1. 为什么我要自己写一个 MCP 客户端
MCP(Model Context Protocol)是 Anthropic 开源的一套协议,它给 AI 系统和数据源之间定了一个通用、开放的标准,用单一协议取代了过去那种碎片化的集成方式。简单说,它让大语言模型能通过一个统一接口去调用外部工具、读取外部资源,而不用为每个框架、每个系统单独写一套适配代码。
MCP 的架构里主要有三个角色:MCP Hosts 是像 Claude Desktop、Cursor 这类程序,它们通过 MCP 访问数据;MCP Clients 是与服务器保持 1:1 连接的协议客户端;MCP Servers 是轻量级程序,每个都通过标准化的模型上下文协议公开特定功能。传输层目前有两类:StdioTransport 和 HTTP SSE。
那既然 Cursor、Claude 这些 Host 已经支持 MCP 了,为什么还要自己从零写一个客户端?我自己的动机有三个。第一,理解通信机制。只有自己把 JSON-RPC 2.0 的握手、listTools、callTool 这些环节跑一遍,才能真正搞懂 MCP 到底在传什么。第二,可调试。官方 Host 是个黑盒,工具调用失败时你只能看到一句报错,自己写的客户端可以把每一帧请求和响应打出来。第三,可定制。我想把 MCP 工具接到自己的业务系统里,而不是被绑死在某个编辑器上。
这篇文章面向的是想理解 MCP 通信机制的开发者。我会交付可复制的客户端初始化配置、工具调用请求构造与响应解析代码,并给出本地联调与错误排查的验证动作,帮你独立跑通一个最小可用 MCP 客户端。整个过程我会用一个加法工具 Server 和一个汇率换算 Server 作为被测对象,最后接上大模型完成一次完整的对话式工具调用。
在开始之前,你需要准备 Node.js 18+ 环境、一个能用的模型 API Key(我用的是 OpenAI 兼容接口),以及对 TypeScript 的基本熟悉。如果你还没配好模型访问入口,可以先用 TaoToken 的模型对话能力验证一下请求链路是否通,再回来写客户端代码。下面进入正题。
2. 前置准备:MCP Server 与 TaoToken 接入配置
在写客户端之前,得先有一个能连的 Server。MCP Server 最简形态就是一个声明了 tool 的进程,通过 stdio 或 SSE 对外暴露。我先写一个最小的 stdio Server,它只提供一个 add 工具。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const server = new McpServer({ name: "Demo", version: "1.0.0" }); server.tool( "add", "Add two numbers", { a: z.number(), b: z.number() }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }], }) ); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main();代码头部和底部都是模板代码,真正变化的是server.tool这一段,它声明了一个做加法的工具。这就是一个最简单、可运行的 Server。你也可以用官方脚手架npx @modelcontextprotocol/create-server my-server生成一个更完整的工程结构。
SDK 已经封装好了协议内部细节(JSON-RPC 2.0),包括架构分层,开发者直接写业务代码就行。MCP 服务器可以提供三种主要功能类型:Resources 是可以由客户端读取的类文件数据;Tools 是 LLM 可以调用的功能(在用户批准下);Prompts 是帮助用户完成特定任务的预编写模板。Resources 和 Prompts 可以让客户端端起来供用户选择,但重点在 Tools,其他很多客户端都不支持。
写完 Server 后,用官方调试器验证一下:npx @modelcontextprotocol/inspector,连接 Server、获取工具、执行调试三步走。确认没问题后,再上到支持 MCP 的客户端里用。
现在说模型侧。我的客户端要调用大模型来做 function calling,所以需要一个 OpenAI 兼容的 API 入口。我用 TaoToken 作为模型访问层,它的 Base URL 是https://taotoken.net/api,Key 在控制台的 API Keys 页面生成。这里要写全三件套,缺一不可:
- Base URL:
https://taotoken.net/api - API Key:在
https://taotoken.net/api-keys生成 - Model ID:比如
gpt-4-turbo-preview或你账号下可用的其他模型
如果你用的是 Claude Code 这类工具做润色或辅助编码,接入方式也是同样的三件套逻辑,把 Base URL 和 Key 填进对应配置即可。想先验证模型能不能通,可以直接在模型对话页面发一条消息试试,确认返回正常再写代码,能省掉很多排查时间。
3. 可复制配置:客户端初始化与工具调用请求构造
这一节是核心,我把客户端的配置文件和主逻辑都拆开讲,你可以直接复制。
先看配置文件。我用一个数组来声明有哪些 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;MCP Client 主要还是基于 LLM,识别到需要调用外部系统时,调用 MCP Server 提供的 Tool,所以还是以对话为入口。为了方便,我直接在 terminal 里对话,用 readline 读取用户输入,大模型直接用 OpenAI 兼容接口,Tool 的路由直接用 function calling。
客户端的大致逻辑是:读取配置文件,运行所有 Server,获取可用的 Tools;用户与 LLM 对话(附带所有 Tools 名称描述、参数定义);LLM 识别到要执行某个 Tool,返回名称和参数;找到对应 Server 的 Tool,调用执行,返回结果;把工具执行结果提交给 LLM;LLM 返回分析结果给用户。
下面是完整的客户端代码,基于 SDK 编写:
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); } const completion = await this.openai.chat.completions.create({ model: "gpt-4-turbo-preview", messages, tools: availableTools, tool_choice: "auto", }); const finalText: string[] = []; 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);这里有几个关键点。工具名我用了${serverName}__${toolName}的拼接方式,因为多个 Server 可能有同名工具,加前缀能避免冲突,路由时再 split 回来。tool.inputSchema直接透传给 OpenAI 的 function parameters,因为 MCP 的 schema 本身就是 JSON Schema,格式兼容。工具执行结果要作为role: "tool"的消息回填,并带上tool_call_id,否则模型无法把结果和调用对应起来。
如果你用的是 SSE 类型的 Server,核心区别在于需要提供一个 sse 服务,对于 Tool 基本一样,但 SSE 类型可以部署在服务端。上传也和 command 类型相似,只是 transport 换成SSEClientTransport。
4. 验证请求:跑通一次完整的工具调用
配置写完了,现在验证。先编译 TypeScript,然后运行:
NODE_TLS_REJECT_UNAUTHORIZED=0 node build/client.jsNODE_TLS_REJECT_UNAUTHORIZED=0可以忽略证书校验,本地调试时用,生产环境别这么干。
启动后你会看到:
Connecting to servers: demo-stdio, weather-stdio Connected to server 'demo-stdio' with tools: [ 'add' ] Connected to server 'weather-stdio' with tools: [ 'get_weather' ] MCP Client Started! Type your queries or 'quit' to exit. Query:输入3 加 5 等于多少,客户端会先把所有工具列表发给模型,模型识别到需要调用demo-stdio__add,返回工具名和参数{"a":3,"b":5}。客户端解析后调用对应 Server 的 add 工具,拿到结果8,再把结果回填给模型,模型最终输出类似「3 加 5 等于 8」。
控制台里你能看到[Calling tool add on server demo-stdio with args {"a":3,"b":5}]这行日志,说明工具调用链路是通的。如果模型返回的是纯文本而没有 tool_calls,说明它没识别出需要调用工具,检查一下工具描述是否清晰、参数 schema 是否正确。
再测一个 SSE 类型的。把配置里demo-sse的isOpen改成true,确保本地 3001 端口的 SSE Server 已经启动。输入100 人民币换多少美元,模型会调用demo-sse__exchange,返回换算结果。SSE 的验证重点是确认/sse端点能建立连接、/messages端点能接收 POST 消息,sessionId 要能正确匹配。
验证成功的标志有三个:连接日志里列出了所有开启 Server 的工具;对话时能看到工具调用日志;模型最终回复里包含了工具执行的结果。三个都满足,说明你的最小可用 MCP 客户端已经跑通了。
5. 常见错误排查:401、local proxy failed 与 reading choices
这一节我列几个实际会撞到的报错,对照着排查。
401 Unauthorized。这个最常见,通常是 API Key 没配或配错。检查OPENAI_API_KEY环境变量是否设置,Key 是否在有效期内。如果你用的是 TaoToken,确认 Base URL 是https://taotoken.net/api,Key 从https://taotoken.net/api-keys生成。注意 Base URL 末尾不要多加/v1或斜杠,SDK 会自己拼路径。401 也可能是 Key 权限不足,去控制台确认这个 Key 有没有对应模型的调用权限。
local proxy failed / connection refused。这个报错说明客户端连不上模型服务或 MCP Server。分两种情况:如果是连模型报错,检查网络是否能到达 Base URL,可以用 curl 直接打一下https://taotoken.net/api看返回;如果是连 MCP Server 报错,检查 Server 进程是否启动、command 路径是否正确、~有没有被正确替换成 home 目录。stdio 类型的 Server 如果路径写错,会直接抛spawn ENOENT。
reading 'choices' of undefined。这个报错出现在解析模型响应时,说明completion.choices是 undefined。原因通常是 API 返回了错误对象而不是正常的 completion,比如模型名写错、参数不合法、额度不足。解决办法是在processQuery里加一层判断,先打印completion的原始返回,确认结构。另一个可能是tool_choice: "auto"在某些模型上不支持,换成"none"或去掉这个参数试试。
OAuth / 认证失败。如果你接的是需要 OAuth 的服务,报错会提示 token 无效或过期。MCP 本身不强制 OAuth,但部分远程 Server 会要求。检查你的 token 是否过期,刷新后重试。如果是 Claude Code 这类工具的接入,确认配置文件里的认证字段填对了,Base URL、Key、Model ID 三件套缺一不可。
工具调用返回空结果。模型返回了 tool_calls,但toolResult.content是空数组。检查 Server 的 tool 实现是否真的返回了 content,有些 Server 在参数不匹配时会静默返回空。在callTool后打印result原始对象,确认结构。
SSE 连接建立后收不到消息。检查/messages端点的 sessionId 是否从 query 参数正确读取,handlePostMessage是否被调用。SSE 的 session 管理容易出错,建议在 Server 端加日志,确认每个请求的 sessionId 能对应上。
排查的核心思路是:先确认模型链路通(用模型对话页面验证),再确认 Server 链路通(用 inspector 验证),最后确认客户端把两者接起来了。分段验证比一次性调试快得多。
6. 把客户端接到你的工作流里
跑通最小客户端之后,你可以往几个方向扩展。一是加更多 Server,把内部系统的 API 包装成 MCP Tool,这样模型就能直接调用你的业务能力。二是把 readline 换成 Web 界面或接入到现有应用,做成一个真正的 Host。三是处理多轮工具调用,现在代码里只处理了一轮 tool_call 后的二次请求,复杂场景可能需要循环直到模型不再请求工具。
如果你打算长期做编码或 Agent 相关的开发,可以考虑用 Coding Plan 来管理模型调用额度,比单次按量更划算。需要查接入细节的时候,接入文档里有完整的参数说明和示例。模型能力验证还是推荐先用模型对话快速试,确认没问题再写进代码。
MCP 生态目前还在早期,Server 质量良莠不齐,本地 Server 还是依赖 Node.js 或 Python 环境,远程 Server 支持得很少。但协议本身的设计是清晰的,自己写一遍客户端之后,再看任何 Host 的实现都能快速理解。这套代码你可以直接拿去改,把 config 换成你自己的 Server 列表,把模型换成你账号下可用的,就能跑起来。