本文参考官方案例,使用 Node.js 语言,从零开始构建一个 MCP Server 和一个 MCP Client,并基于标准 IO 实现本地调用。
官方文档:https://modelcontextprotocol.io/quickstart/server
一、开发 MCP Server
基于美国国家气象局(NWS)API 构建一个可以查询天气预报和查询天气警报的 MCP Server。
(一)检查开发环境
node --version
npm --version
需要 Node.js 版本为 16 或者更高。
(二)创建项目
工程初始化
# 创建并进入工程目录
mkdir mcp-server-weather
cd mcp-server-weather
# 使用 npm 初始化项目(可自行选择其他构建工具,例如 pnpm yarn)
npm init -y
# 安装依赖
npm install @modelcontextprotocol/sdk zod
npm install -D @types/node typescript
# 创建代码文件
mkdir src
touch src/index.ts
修改 package.json
增加 "type": "module"
和构建脚本 "build": "tsc && chmod 755 build/index.js"
。
{
"name": "mcp-server-weather",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc && chmod 755 build/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.7.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2"
}
}
在项目根目录新增 tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
(三)编写源码
在 src/index.ts
中编写源码。
导入依赖包
// 导入 McpServer 类,用于创建 MCP Server 的实例
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// 导入 StdioServerTransport 类,用于实现 Client 和 Server 的标准通信
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// 从 zod 模块导入 z 对象,用于数据验证
import { z } from "zod";
定义常量
// 定义美国国家气象局(NWS)API 的基础 URL
const NWS_API_BASE = "https://api.weather.gov";
// 定义用户代理,用于标识请求来源
const USER_AGENT = "weather-app/1.0";
创建一个 McpServer 实例
// 创建服务器实例
const server = new McpServer({
// 服务器名称
name: "mcp-server-weather",
// 服务器版本
version: "1.0.0",
});
封装 NWS 接口请求函数
// 辅助函数,用于向 NWS API 发起请求
async function makeNWSRequest<T>(url: string): Promise<T | null> {
// 设置请求头
const headers = {
// 用户代理
"User-Agent": USER_AGENT,
// 接受的响应类型
Accept: "application/geo+json",
};
try {
// 发起请求
const response = await fetch(url, { headers });
// 检查响应状态
if (!response.ok) {
// 若响应状态不是 200,抛出错误
throw new Error(`HTTP error! status: ${response.status}`);
}
// 解析响应为 JSON 并返回
return (await response.json()) as T;
} catch (error) {
// 捕获请求过程中的错误并打印
console.error("Error making NWS request:", error);
// 返回 null 表示请求失败
return null;
}
}
// 定义警报特征的接口
interface AlertFeature {
properties: {
// 事件名称
event?: string;
// 受影响区域描述
areaDesc?: string;
// 严重程度
severity?: string;
// 状态
status?: string;
// 标题
headline?: string;
};
}
// 格式化警报数据
function formatAlert(feature: AlertFeature): string {
// 获取警报属性
const props = feature.properties;
return [
`Event: ${props.event || "Unknown"}`,
`Area: ${props.areaDesc || "Unknown"}`,
`Severity: ${props.severity || "Unknown"}`,
`Status: ${props.status || "Unknown"}`,
`Headline: ${props.headline || "No headline"}`,
"---",
].join("\n");
}
// 定义预报周期的接口
interface ForecastPeriod {
// 周期名称
name?: string;
// 温度
temperature?: number;
// 温度单位
temperatureUnit?: string;
// 风速
windSpeed?: string;
// 风向
windDirection?: string;
// 简短预报
shortForecast?: string;
}
// 定义警报响应的接口
interface AlertsResponse {
// 警报特征数组
features: AlertFeature[];
}
// 定义点数据响应的接口
interface PointsResponse {
properties: {
// 预报 URL
forecast?: string;
};
}
// 定义预报响应的接口
interface ForecastResponse {
properties: {
// 预报周期数组
periods: ForecastPeriod[];
};
}
注册获取天气报警的工具
// 注册获取天气警报的工具
server.tool(
// 工具名称
"get-weather-alerts",
// 工具描述
"根据美国洲代码获取天气警报信息",
// 工具参数
{
// 州代码,必须为两个字符
state: z.string().length(2).describe("两个字母的州代码 (例如: CA, NY)"),
},
// 工具的异步处理函数
async ({ state }) => {
// 将州代码转换为大写
const stateCode = state.toUpperCase();
// 构建警报请求的 URL
const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`;
// 发起警报请求
const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);
// 若未获取到警报数据
if (!alertsData) {
return {
content: [
{
type: "text",
text: "Failed to retrieve alerts data",
},
],
};
}
// 获取警报特征数组
const features = alertsData.features || [];
// 若没有活动警报
if (features.length === 0) {
return {
content: [
{
type: "text",
text: `No active alerts for ${stateCode}`,
},
],
};
}
// 格式化警报数据
const formattedAlerts = features.map(formatAlert);
// 拼接警报文本
const alertsText = `Active alerts for ${stateCode}:\n\n${formattedAlerts.join("\n")}`;
return {
content: [
{
type: "text",
text: alertsText,
},
],
};
},
);
注册获取天气预报的工具
// 注册获取天气预报的工具
server.tool(
// 工具名称
"get-weather-forecast",
// 工具描述
"根据经纬度获取天气信息",
// 工具参数
{
// 纬度,范围在 -90 到 90 之间
latitude: z.number().min(-90).max(90).describe("纬度"),
// 经度,范围在 -180 到 180 之间
longitude: z.number().min(-180).max(180).describe("经度"),
},
// 工具的异步处理函数
async ({ latitude, longitude }) => {
// 获取网格点数据的 URL
const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`;
// 发起网格点数据请求
const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl);
// 若未获取到网格点数据
if (!pointsData) {
return {
content: [
{
type: "text",
text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`,
},
],
};
}
// 获取预报 URL
const forecastUrl = pointsData.properties?.forecast;
// 若未获取到预报 URL
if (!forecastUrl) {
return {
content: [
{
type: "text",
text: "Failed to get forecast URL from grid point data",
},
],
};
}
// 发起预报数据请求
const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
// 若未获取到预报数据
if (!forecastData) {
return {
content: [
{
type: "text",
text: "Failed to retrieve forecast data",
},
],
};
}
// 获取预报周期数组
const periods = forecastData.properties?.periods || [];
// 若没有预报周期
if (periods.length === 0) {
return {
content: [
{
type: "text",
text: "No forecast periods available",
},
],
};
}
// 格式化预报周期数据
const formattedForecast = periods.map((period: ForecastPeriod) =>
[
`${period.name || "Unknown"}:`,
`Temperature: ${period.temperature || "Unknown"}°${period.temperatureUnit || "F"}`,
`Wind: ${period.windSpeed || "Unknown"} ${period.windDirection || ""}`,
`${period.shortForecast || "No forecast available"}`,
"---",
].join("\n"),
);
// 拼接预报文本
const forecastText = `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join("\n")}`;
return {
content: [
{
type: "text",
text: forecastText,
},
],
};
},
);
编写主函数启动服务
// 主函数
async function main() {
// 创建标准输入输出服务器传输实例
const transport = new StdioServerTransport();
// 连接服务器
await server.connect(transport);
// 打印服务器运行信息
console.error("Weather MCP Server running on stdio");
}
// 执行主函数并捕获错误
main().catch((error) => {
// 打印致命错误信息
console.error("Fatal error in main():", error);
// 退出进程
process.exit(1);
});
(四)构建工程
npm run build
执行构建脚本后,可以在项目中找到 build/index.js
。后续如果修改了源码,记得重新构建。
二、开发 MCP Client
(一)检查开发环境
node --version
npm --version
需要 Node.js 版本为 16 或者更高。
(二)创建项目
工程初始化
# 创建并进入工程目录
mkdir mcp-client-typescript
cd mcp-client-typescript
# 使用 npm 初始化工程
npm init -y
# 安装依赖(这里安装了 openai,可兼容 deepseek、moonshot 等接口)
npm install openai @modelcontextprotocol/sdk dotenv
npm install -D @types/node typescript
# 创建源码文件
touch index.ts
修改 package.json
增加 "type": "module"
和构建脚本 "build": "tsc && chmod 755 build/index.js"
。
{
"name": "mcp-client-typescript",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc && chmod 755 build/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.7.0",
"dotenv": "^16.4.7",
"openai": "^4.87.4"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2"
}
}
在项目根目录新增 tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["index.ts"],
"exclude": ["node_modules"]
}
在项目根目录新增环境变量文件 .env
LLM_API_KEY=sk-xxxxxxxxxxxxxxxx
LLM_BASE_URL=https://api.deepseek.com
LLM_MODEL=deepseek-chat
本文使用 deepseek 官方 API,使用 openai 的 sdk 接入。使用支持函数调用(Function Calling)的 deepseek-chat 模型。
DeepSeek API 官方文档:https://api-docs.deepseek.com/zh-cn/
(三)编写源码
在 index.ts
中编写代码。
导入依赖包
// 引入 OpenAI 库,可兼容很多支持 openai 接口规范的大模型 API,本文使用 deepseek
import OpenAI from 'openai'
// 引入 Client 类
import {Client} from "@modelcontextprotocol/sdk/client/index.js";
// 引入 StdioClientTransport 类
import {StdioClientTransport} from "@modelcontextprotocol/sdk/client/stdio.js";
// 引入 readline/promises 模块,用于处理命令行输入
import readline from "readline/promises";
// 引入 dotenv 库,用于加载环境变量
import dotenv from "dotenv";
加载并引入环境变量
// 加载环境变量
dotenv.config();
// 从环境变量中获取 LLM_API_KEY
const LLM_API_KEY = process.env.LLM_API_KEY;
// 从环境变量中获取 LLM_BASE_URL
const LLM_BASE_URL = process.env.LLM_BASE_URL;
// 从环境变量中获取 LLM_MODEL
const LLM_MODEL = process.env.LLM_MODEL;
// 检查 LLM_API_KEY 是否设置,如果未设置则抛出错误
if (!LLM_API_KEY) {
throw new Error("LLM_API_KEY is not set");
}
// 检查 LLM_BASE_URL 是否设置,如果未设置则抛出错误
if (!LLM_BASE_URL) {
throw new Error("LLM_BASE_URL is not set");
}
// 检查 LLM_MODEL 是否设置,如果未设置则抛出错误
if (!LLM_MODEL) {
throw new Error("LLM_MODEL is not set");
}
编写客户端类的基本结构
// 定义 MCPClient 类
class MCPClient {
// 定义私有属性 mcp,类型为 Client
private mcp: Client;
// 定义私有属性 openai,类型为 OpenAI
private openai: OpenAI;
// 定义私有属性 transport,类型为 StdioClientTransport 或 null,初始值为 null
private transport: StdioClientTransport | null = null;
// 定义私有属性 tools,类型为数组,初始值为空数组
private tools: OpenAI.ChatCompletionTool[] = [];
// 构造函数
constructor() {
// 初始化 openai 实例,传入 API 密钥和基础 URL
this.openai = new OpenAI({
apiKey: LLM_API_KEY,
baseURL: LLM_BASE_URL
});
// 初始化 mcp 实例,传入客户端名称和版本
this.mcp = new Client({name: "mcp-client-cli", version: "1.0.0"});
}
// 接下来的功能函数写在这里...
}
在 MCPClient 类中编写连接到服务器的方法
// 连接到服务器的方法
async connectToServer(serverScriptPath: string) {
try {
// 检查服务器脚本是否为 .js 文件
const isJs = serverScriptPath.endsWith(".js");
// 检查服务器脚本是否为 .py 文件
const isPy = serverScriptPath.endsWith(".py");
// 如果不是 .js 或 .py 文件,则抛出错误
if (!isJs && !isPy) {
throw new Error("Server script must be a .js or .py file");
}
// 根据不同情况选择命令
const command = isPy
? process.platform === "win32"
? "python"
: "python3"
: process.execPath;
// 初始化 transport 实例
this.transport = new StdioClientTransport({
command,
args: [serverScriptPath],
});
// 连接到服务器
await this.mcp.connect(this.transport);
// 获取工具列表
const toolsResult = await this.mcp.listTools();
// 处理工具列表,转换为特定格式
this.tools = toolsResult.tools.map((tool) => {
return {
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
}
};
});
// 打印连接成功信息和工具名称
console.log(
"Connected to server with tools:",
this.tools.map((item) => item.function.name)
);
} catch (e) {
// 打印连接失败信息和错误
console.log("Failed to connect to MCP server: ", e);
// 抛出错误
throw e;
}
}
在 MCPClient 类中编写处理查询的方法
// 处理查询的方法
async processQuery(query: string) {
// 初始化消息数组
const messages: OpenAI.ChatCompletionMessageParam[] = [
{
role: "user",
content: query,
},
];
// 调用 OpenAI 的聊天完成接口
const response = await this.openai.chat.completions.create({
model: LLM_MODEL!,
max_tokens: 1000,
messages,
tools: this.tools,
});
// 存储最终文本的数组
const finalText = [];
// 存储工具调用结果的数组
const toolResults = [];
// 遍历响应的选择
for (const choice of response.choices) {
// 如果没有工具调用
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
// 将消息内容添加到最终文本数组
finalText.push(choice.message.content);
} else {
// 获取工具名称
const toolName = choice.message.tool_calls[0].function.name;
// 获取工具参数
const toolArgs = JSON.parse(choice.message.tool_calls[0].function.arguments);
// 调用工具
const result = await this.mcp.callTool({
name: toolName,
arguments: toolArgs,
});
// 将工具调用结果添加到工具结果数组
toolResults.push(result);
// 将工具调用信息添加到最终文本数组
finalText.push(
`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`
);
// 将工具调用结果添加到消息数组
messages.push({
role: "user",
content: result.content as string,
});
// 再次调用 OpenAI 的聊天完成接口
const response = await this.openai.chat.completions.create({
model: LLM_MODEL!,
max_tokens: 1000,
messages,
});
// 将响应内容添加到最终文本数组
finalText.push(
response.choices[0].message.content
);
}
}
// 返回最终文本,用换行符连接
return finalText.join("\n");
}
在 MCPClient 类中编写聊天循环方法
// 聊天循环方法
async chatLoop() {
// 创建 readline 接口
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
// 打印客户端启动信息
console.log("\nMCP Client Started!");
// 打印提示信息
console.log("Type your queries or 'quit' to exit.");
// 循环接收用户输入
while (true) {
// 获取用户输入
const message = await rl.question("\nQuery: ");
// 如果用户输入为 'quit',则退出循环
if (message.toLowerCase() === "quit") {
break;
}
// 处理用户输入
const response = await this.processQuery(message);
// 打印响应结果
console.log("\n" + response);
}
} catch (e) {
// 打印错误信息
console.error(e)
} finally {
// 关闭 readline 接口
rl.close();
}
}
在 MCPClient 类中编写清理资源的方法
// 清理资源的方法
async cleanup() {
// 关闭 mcp 连接
await this.mcp.close();
}
编写主函数启动服务
// 主函数
async function main() {
// 检查命令行参数是否足够
if (process.argv.length < 3) {
// 打印使用说明
console.log("Usage: node index.ts <path_to_server_script>");
return;
}
// 创建 MCPClient 实例
const mcpClient = new MCPClient();
try {
// 连接到服务器
await mcpClient.connectToServer(process.argv[2]);
// 启动聊天循环
await mcpClient.chatLoop();
} finally {
// 清理资源
await mcpClient.cleanup();
// 退出进程
process.exit(0);
}
}
// 调用主函数
main();
(四)构建工程
npm run build
执行构建脚本后,可以在项目中找到 build/index.js
。后续如果修改了源码,记得重新构建。
(五)什么是 Function Calling
可能有的朋友还没接触过 Function Calling。
Function Calling 简单来说就是:
- 先告诉大模型我们本地有哪些函数(函数名称、用途、入参的数据类型和名称),同时把用户的提问也发给大模型。
- 大模型判断到用户的提问需要调用某个函数时,会返回函数名称和入参的具体值;如果不需要使用函数,大模型会直接回答用户提问。
- 此时我们使用大模型给的入参调用指定的函数,并得到执行结果。
- 再将函数执行结果和上文中的消息发送给大模型。
- 最后大模型会根据上下文整理并返回回答内容。
以本文中的天气预报查询工具来举例:
- 用户提问:硅谷天气如何。
- 我们在将用户提问发给大模型的同事,也通过 tools 参数告诉了大模型我们本地有一个查询天气预报的函数。
- 大模型判断用户的提问需要用到天气预报查询函数。会按照要求返回函数名 get-weather-forecast 以及参数 latitude(纬度)、longitude(经度)的数值。
- 我们调用本地函数查询到天气信息。并将结果再次发送给大模型。
- 大模型通过上下文,整理并回答用户硅谷的天气。
三、Client 和 Server 联调
(一)启动 MCP Client 并接入 MCP Server
在 Client 项目执行脚本:
node build/index.js 路径省略.../mcp-server-weather/build/index.js
运行 client 的启动脚本,后面的参数是 server 的脚本路径。
启动后输出内容如下:
Weather MCP Server running on stdio
Connected to server with tools: [ 'get-alerts', 'get-forecast' ]
MCP Client Started!
Type your queries or 'quit' to exit.
Query:
(二)大模型闲聊
Query: 你是谁
我是一个人工智能助手,旨在帮助你获取信息、解答问题以及执行各种任务。如果你有任何问题或需要帮助,请随时告诉我!
闲聊不会调用已注册的两个工具。
本文使用的是同步对话,没有开启流式对话,输出等待时间可能较长(需等待大模型输出完所有内容后一次性显示结果)。
(三)查询天气预报
Query: 硅谷天气如何
[Calling tool get-forecast with args {"latitude":37.3875,"longitude":-122.0575}]
硅谷(37.3875, -122.0575)的天气预报如下:
- **今晚**:
温度:39°F(约4°C)
风速:2 mph SSE(东南偏南风)
天气:局部有雾
- **周三**:
温度:63°F(约17°C)
风速:2 to 7 mph SW(西南风)
天气:局部有雾,随后可能有小雨
- **周三晚**:
温度:43°F(约6°C)
风速:2 to 7 mph SW(西南风)
天气:可能有小雨,随后多云
- **周四**:
温度:63°F(约17°C)
风速:2 to 12 mph NW(西北风)
天气:大部分时间晴朗
- **周四晚**:
温度:42°F(约6°C)
风速:3 to 12 mph NW(西北风)
天气:局部多云
- **周五**:
温度:64°F(约18°C)
风速:3 to 12 mph NW(西北风)
天气:大部分时间晴朗
- **周五晚**:
温度:46°F(约8°C)
风速:2 to 12 mph NW(西北风)
天气:大部分时间多云
- **周六**:
温度:67°F(约19°C)
风速:2 to 12 mph NW(西北风)
天气:局部晴朗
- **周六晚**:
温度:48°F(约9°C)
风速:2 to 12 mph NW(西北风)
天气:局部多云
- **周日**:
温度:72°F(约22°C)
风速:2 to 8 mph NW(西北风)
天气:局部有雾,随后大部分时间晴朗
- **周日晚**:
温度:49°F(约9°C)
风速:2 to 8 mph NNE(东北偏北风)
天气:大部分时间晴朗
- **周一**:
温度:79°F(约26°C)
风速:2 to 6 mph NNE(东北偏北风)
天气:晴朗
- **周一晚**:
温度:54°F(约12°C)
风速:2 to 6 mph NNE(东北偏北风)
天气:大部分时间晴朗
- **周二**:
温度:78°F(约26°C)
风速:2 to 7 mph N(北风)
天气:局部有雾,随后大部分时间晴朗
总体来看,硅谷未来几天的天气以晴朗为主,气温逐渐回升,周末和下周初气温较高,适合户外活动。注意早晚温差较大,局部可能有雾或小雨。
(四)查询天气报警
Query: 使用中文总结回答,德克萨斯洲最近的天气报警
>
[Calling tool get-alerts with args {"state":"TX"}]
德克萨斯州最近的天气报警情况如下:
1. **红色预警(Red Flag Warning)**:多个地区发布了红色预警,主要涉及火灾高风险区域。这些地区包括Harper、Woods、Alfalfa、Grant、Kay、Ellis、Woodward、Major、Garfield、Noble、RoPayne、Beckham、Washita、Caddo、Canadian、Oklahoma、Lincoln、Grady、McClain、Cleveland、Pottawatomie、Harmon、Greer、Kiowa、Jackson、Tillman、Comanche、Stephens、Garvin、Cottonove、Hardeman、Foard、Wilbarger、Wichita、Knox、Baylor、Archer、Clay等。这些预警通常是由于高温、低湿度和强风导致的火灾风险增加。
2. **大风预警(Wind Advisory)**:多个地区发布了大风预警,风速可能达到危险水平。受影响地区包括Eddy County Plains、Northern Lea County、Central Lea County、Southern Lea County、 County、Davis Mountains、Davis Mountains Foothills等。风速可能达到每小时40至50英里,可能导致树木倒塌和电力中断。
3. **高风预警(High Wind Warning)**:部分地区发布了高风预警,风速可能达到每小时60英里以上。受影响地区包括Guadalupe Mountains of Eddy County、Guadalupe Mountains Above 7000 Fee能导致严重的破坏和交通问题。
4. **吹尘预警(Blowing Dust Advisory)**:部分地区发布了吹尘预警,强风可能导致尘土飞扬,影响能见度。受影响地区包括Tillman、Hardeman、Foard、Wilbarger、Knox、Baylor等。
5. **洪水预警(Flood Warning)**:部分地区发布了洪水预警,主要涉及Beauregard、Calcasieu、Newton、Orange等地区。这些预警通常是由于持续降雨或河流水位上升导致的。
6. **火灾预警(Fire Warning)**:部分地区发布了火灾预警,主要涉及Hutchinson等地区。这些预警通常是由于极端天气条件导致的火灾风险增加。
7. **强流预警(Rip Current Statement)**:沿海地区发布了强流预警,主要涉及Kenedy Island、Willacy Island、Cameron Island等地区。这些预警提醒游泳者注意强流可能带来的危险。
总结来说,德克萨斯州近期面临多种天气威胁,包括火灾、大风、吹尘、洪水和强流等。居民应密切关注当地气象部门的更新,并采取适当的预防措施。