从零开始教你打造一个MCP客户端
阿里妹导读
Anthropic开源了一套MCP协议,它为连接AI系统与数据源提供了一个通用的、开放的标准,用单一协议取代了碎片化的集成方式。本文教你从零打造一个MCP客户端。
一、背景
如何让大语言模型与外部系统交互,一直是AI系统需要解决的问题:- Plugins: OpenAI推出ChatGPT Plugins,首次允许模型通过插件与外部应用交互。 插件功能包括实时信息检索(如浏览器访问)、代码解释器(Code Interpreter)执行计算、第三方服务调用(如酒店预订、外卖服务等)
- Function Calling: Function Calling技术逐步成熟,成为大模型与外部系统交互的核心方案。
- Agent框架 Tools: 模型作为代理(Agent),动态选择工具完成任务,比如langchain的Tool。
二、架构
- MCP Hosts: 像 Claude Desktop、Cursor这样的程序,它们通过MCP访问数据。
- MCP Clients: 与服务器保持 1:1 连接的协议客户端。
- MCP Servers: 轻量级程序,每个程序都通过标准化的模型上下文协议公开特定功能。
- StdioTransport
- HTTP SSE
三、实现MCP Server
首先看一个最简单的MCP Server例子:代码头部和底部都是一些样板代码,主要变化的是在tool这块,这个声明了一个做加法的工具。这就是一个最简单的可运行的Server了。 同时也可以使用官方的脚手架,来创建一个完整复杂的Server:import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";// Create an MCP serverconst server = new McpServer({name: "Demo",version: "1.0.0"});// Add an addition toolserver.tool("add",'Add two numbers',{ a: z.number(), b: z.number() },async ({ a, b }) => ({content: [{ type: "text", text: String(a + b) }]}));async function main() {// Start receiving messages on stdin and sending messages on stdoutconst transport = new StdioServerTransport();await server.connect(transport);}main()
npx @modelcontextprotocol/create-server my-server
3.1 使用SDK 从上面代码可以看到很多模块都是从 @modelcontextprotocol/sdk 这个SDK里导出的。
- Resources: 可以由客户端读取的类似文件的数据(例如API响应或文件内容)
- Tools: LLM可以调用的功能(在用户批准下)
- Prompts: 可帮助用户完成特定任务的预先编写的模板
3.2 调试 如果写好了代码,怎么调试这个Server呢?官方提供了一个调试器:
npx @modelcontextprotocol/inspector
1.
连接Server
3.3 在客户端使用 如果运行结果没错,就可以上架到支持MCP协议的客户端使用了,比如Claude、Cursor,这里以Cursor为例:
3.4 HTTP SSE类型Server
核心的差别在于需要提供一个sse服务,对于Tool基本一样,但是sse类型就可以部署在服务端了。上架也和command类型相似:import express from "express";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";import { z } from "zod";const server = new McpServer({name: "demo-sse",version: "1.0.0"});server.tool("exchange",'人民币汇率换算',{ rmb: z.number() },async ({ rmb }) => {// 使用固定汇率进行演示,实际应该调用汇率APIconst usdRate = 0.14; // 1人民币约等于0.14美元const hkdRate = 1.09; // 1人民币约等于1.09港币const usd = (rmb * usdRate).toFixed(2);const hkd = (rmb * hkdRate).toFixed(2);return {content: [{type: "text",text: `${rmb}人民币等于:\n${usd}美元\n${hkd}港币`}]}},);const app = express();const sessions: Record<string, { transport: SSEServerTransport; response: express.Response }> = {}app.get("/sse", async (req, res) => {console.log(`New SSE connection from ${req.ip}`);const sseTransport = new SSEServerTransport("/messages", res);const sessionId = sseTransport.sessionId;if (sessionId) {sessions[sessionId] = { transport: sseTransport, response: res }}await server.connect(sseTransport);});app.post("/messages", async (req, res) => {const sessionId = req.query.sessionId as string;const session = sessions[sessionId];if (!session) {res.status(404).send("Session not found");return;}await session.transport.handlePostMessage(req, res);});app.listen(3001);
3.5 一个复杂一点的例子 操作浏览器执行自动化流程。
3.6 MCP Server资源 有很多写好的Server,可以直接复用。
- https://github.com/modelcontextprotocol/servers
- https://github.com/punkpeye/awesome-mcp-servers/blob/main/README-zh.md
四、实现MCP Client
一般MCP Host以一个Chat box为入口,对话形式去调用。
4.1 配置文件 使用配置文件来标明有哪些MCP Server,以及类型。
const config = [{name: 'demo-stdio',type: 'command',command: 'node ~/code-open/cursor-toolkits/mcp/build/demo-stdio.js',isOpen: true},{name: 'weather-stdio',type: 'command',command: 'node ~/code-open/cursor-toolkits/mcp/build/weather-stdio.js',isOpen: true},{name: 'demo-sse',type: 'sse',url: 'http://localhost:3001/sse',isOpen: false}];export default config;
4.2 确认交互形态 MCP Client主要还是基于LLM,识别到需要调用外部系统,调用MCP Server提供的Tool,所以还是以对话为入口,可以方便一点,直接在terminal里对话,使用readline来读取用户输入。大模型可以直接使用openai,Tool的路由直接使用function calling。
4.3 编写Client 大致的逻辑: 1. 读取配置文件,运行所有Server,获取可用的Tools 2. 用户与LLM对话(附带所有Tools名称描述,参数定义) 3. LLM识别到要执行某个Tool,返回名称和参数 4. 找到对应Server的Tool,调用执行,返回结果 5. 把工具执行结果提交给LLM 6. LLM返回分析结果给用户 使用SDK编写Client代码
import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js";import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";import OpenAI from "openai";import { Tool } from "@modelcontextprotocol/sdk/types.js";import { ChatCompletionMessageParam } from "openai/resources/chat/completions.js";import { createInterface } from "readline";import { homedir } from 'os';import config from "./mcp-server-config.js";// 初始化环境变量const OPENAI_API_KEY = process.env.OPENAI_API_KEY;if (!OPENAI_API_KEY) {throw new Error("OPENAI_API_KEY environment variable is required");}interface MCPToolResult {content: string;}interface ServerConfig {name: string;type: 'command' | 'sse';command?: string;url?: string;isOpen?: boolean;}class MCPClient {static getOpenServers(): string[] {return config.filter(cfg => cfg.isOpen).map(cfg => cfg.name);}private sessions: Map<string, Client> = new Map();private transports: Map<string, StdioClientTransport | SSEClientTransport> = new Map();private openai: OpenAI;constructor() {this.openai = new OpenAI({apiKey: OPENAI_API_KEY});}async connectToServer(serverName: string): Promise<void> {const serverConfig = config.find(cfg => cfg.name === serverName) as ServerConfig;if (!serverConfig) {throw new Error(`Server configuration not found for: ${serverName}`);}let transport: StdioClientTransport | SSEClientTransport;if (serverConfig.type === 'command' && serverConfig.command) {transport = await this.createCommandTransport(serverConfig.command);} else if (serverConfig.type === 'sse' && serverConfig.url) {transport = await this.createSSETransport(serverConfig.url);} else {throw new Error(`Invalid server configuration for: ${serverName}`);}const client = new Client({name: "mcp-client",version: "1.0.0"},{capabilities: {prompts: {},resources: {},tools: {}}});await client.connect(transport);this.sessions.set(serverName, client);this.transports.set(serverName, transport);// 列出可用工具const response = await client.listTools();console.log(`\nConnected to server '${serverName}' with tools:`, response.tools.map((tool: Tool) => tool.name));}private async createCommandTransport(shell: string): Promise<StdioClientTransport> {const [command, ...shellArgs] = shell.split(' ');if (!command) {throw new Error("Invalid shell command");}// 处理参数中的波浪号路径const args = shellArgs.map(arg => {if (arg.startsWith('~/')) {return arg.replace('~', homedir());}return arg;});const serverParams: StdioServerParameters = {command,args,env: Object.fromEntries(Object.entries(process.env).filter(([_, v]) => v !== undefined)) as Record<string, string>};return new StdioClientTransport(serverParams);}private async createSSETransport(url: string): Promise<SSEClientTransport> {return new SSEClientTransport(new URL(url));}async processQuery(query: string): Promise<string> {if (this.sessions.size === 0) {throw new Error("Not connected to any server");}const messages: ChatCompletionMessageParam[] = [{role: "user",content: query}];// 获取所有服务器的工具列表const availableTools: any[] = [];for (const [serverName, session] of this.sessions) {const response = await session.listTools();const tools = response.tools.map((tool: Tool) => ({type: "function" as const,function: {name: `${serverName}__${tool.name}`,description: `[${serverName}] ${tool.description}`,parameters: tool.inputSchema}}));availableTools.push(...tools);}// 调用OpenAI APIconst completion = await this.openai.chat.completions.create({model: "gpt-4-turbo-preview",messages,tools: availableTools,tool_choice: "auto"});const finalText: string[] = [];// 处理OpenAI的响应for (const choice of completion.choices) {const message = choice.message;if (message.content) {finalText.push(message.content);}if (message.tool_calls) {for (const toolCall of message.tool_calls) {const [serverName, toolName] = toolCall.function.name.split('__');const session = this.sessions.get(serverName);if (!session) {finalText.push(`[Error: Server ${serverName} not found]`);continue;}const toolArgs = JSON.parse(toolCall.function.arguments);// 执行工具调用const result = await session.callTool({name: toolName,arguments: toolArgs});const toolResult = result as unknown as MCPToolResult;finalText.push(`[Calling tool ${toolName} on server ${serverName} with args ${JSON.stringify(toolArgs)}]`);console.log(toolResult.content);finalText.push(toolResult.content);// 继续与工具结果的对话messages.push({role: "assistant",content: "",tool_calls: [toolCall]});messages.push({role: "tool",tool_call_id: toolCall.id,content: toolResult.content});// 获取下一个响应const nextCompletion = await this.openai.chat.completions.create({model: "gpt-4-turbo-preview",messages,tools: availableTools,tool_choice: "auto"});if (nextCompletion.choices[0].message.content) {finalText.push(nextCompletion.choices[0].message.content);}}}}return finalText.join("\n");}async chatLoop(): Promise<void> {console.log("\nMCP Client Started!");console.log("Type your queries or 'quit' to exit.");const readline = createInterface({input: process.stdin,output: process.stdout});const askQuestion = () => {return new Promise<string>((resolve) => {readline.question("\nQuery: ", resolve);});};try {while (true) {const query = (await askQuestion()).trim();if (query.toLowerCase() === 'quit') {break;}try {const response = await this.processQuery(query);console.log("\n" + response);} catch (error) {console.error("\nError:", error);}}} finally {readline.close();}}async cleanup(): Promise<void> {for (const transport of this.transports.values()) {await transport.close();}this.transports.clear();this.sessions.clear();}hasActiveSessions(): boolean {return this.sessions.size > 0;}}// 主函数async function main() {const openServers = MCPClient.getOpenServers();console.log("Connecting to servers:", openServers.join(", "));const client = new MCPClient();try {// 连接所有开启的服务器for (const serverName of openServers) {try {await client.connectToServer(serverName);} catch (error) {console.error(`Failed to connect to server '${serverName}':`, error);}}if (!client.hasActiveSessions()) {throw new Error("Failed to connect to any server");}await client.chatLoop();} finally {await client.cleanup();}}// 运行主函数main().catch(console.error);
4.4 运行效果
NODE_TLS_REJECT_UNAUTHORIZED=0 node build/client.js
NODE_TLS_REJECT_UNAUTHORIZED=0 可以忽略(不校验证书)
4.5 时序图
五、总结
总体来说解决了Client和Server数据交互的问题,但是没有解决LLM到Tool的对接:不同模型实现function call支持度不一样,比如DeepSeek R1不支持,那么如何路由到工具就成了问题。 不足: 1. 开源时间不长,目前还不是很完善,语言支持度不够,示例代码不多。 2. Server质量良莠不齐,缺乏一个统一的质量保障体系和包管理工具,很多Server运行不起来,或者经常崩。 3. 本地的Server还是依赖Node.js或者Python环境,远程Server支持的很少。 如果未来都开始接入MCP协议,生态起来了,能力就会非常丰富了,使用的人多了,就会有更多的系统愿意来对接,写一套代码就可以真正所有地方运行了。 个人认为MCP还是有前途的,未来可期!