Model Context Protocol (MCP) 实战:从零构建一个支持 Stdio 与 SSE 的 MCP Client
1. 为什么我要自己写一个 MCP ClientModel Context Protocol简称 MCP这两年在 AI 工具链里出现得越来越频繁它本质上是一套让大语言模型和外部数据源、命令行工具、远程服务打通的协议。你可以把它理解成「AI 世界的 USB-C 接口」只要工具按 MCP 规范暴露能力任何支持 MCP 的客户端都能即插即用。而 MCP Client 就是那个「插头」——它负责启动或连接 MCP Server把 Server 暴露的 tools、resources、prompts 拉过来再交给大模型去调用。我最初接触 MCP 是因为手头有一堆本地脚本和几个内部 HTTP 服务想让模型直接调用它们而不是每次手动复制粘贴结果。市面上的现成客户端要么只支持 Stdio要么只支持 SSE配置项还藏在各种 GUI 里排查问题很痛苦。于是我决定从零写一个同时支持 Stdio 与 SSE 的 MCP Client把两种传输方式的差异彻底搞清楚。这篇文章面向的是希望把本地工具或远程服务接入 MCP 生态的开发者。读完之后你应该能在本地跑通一个可调用 MCP Server 的客户端理解 Stdio 和 SSE 在接入方式上的核心区别并且知道怎么用统一的 Key/API 通道完成鉴权与调用。我会给出可复制的配置片段和双通道验证步骤尽量让每一步都能跟着做。先说结论Stdio 适合本地命令行工具、脚本、可执行文件Client 负责拉起子进程通过标准输入输出通信SSE 适合已经跑起来的远程服务Client 通过 HTTP 长连接订阅事件流。两者在 MCP 协议层是一致的差异集中在传输层和生命周期管理上。理解这一点后面的代码就好写了。2. TaoToken 前置准备统一 Key 与 API 通道在写 Client 之前得先把「模型调用」这条链路准备好。MCP Client 本身只负责和 MCP Server 通信真正把工具调用结果喂给模型、让模型决定调哪个工具的是背后的 LLM。所以你需要一个能稳定调用的模型 API 通道。我这边用的是 TaoToken 作为统一的 Key/API 通道。它的好处是把模型调用收敛到一个 Base URL 和一个 API Key 上Client 里不用为每个模型厂商写不同的鉴权逻辑。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。你需要准备三样东西我把它叫做「三件套」配置项说明示例Base URL模型 API 的入口地址https://taotoken.net/apiAPI Key鉴权凭证放在请求头里sk-xxxxxxxxModel ID具体调用的模型标识按你订阅的模型填写拿到 Key 的路径是先登录控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 然后在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 创建一个新的 Key。创建时建议给它起个能认出来的名字比如 mcp-client-local方便后面排查是哪个客户端在调用。注意API Key 只在创建时完整显示一次复制后立刻存到本地环境变量或配置文件里不要硬编码进会提交到 Git 的源码。我习惯把 Key 放到环境变量里这样 Client 代码里只读环境变量不碰明文。Linux/macOS 下可以这样export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/apiWindows PowerShell 下$env:TAOTOKEN_API_KEYsk-你的key $env:TAOTOKEN_BASE_URLhttps://taotoken.net/api如果你用的是 Claude Code 这类工具它的配置文件和 MCP Client 是分开的但可以共用同一个 Key。Claude Code 的接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有 Base URL 和 Key 的填写位置说明。我实测下来把同一个 Key 同时给 Claude Code 和自建 MCP Client 用是没问题的额度是共享的注意别把并发打满。这一步做完模型通道就通了。接下来才是 MCP Client 本身。很多人卡住是因为把「模型调用」和「MCP 通信」混在一起其实它们是两条独立的链路Client 用 TaoToken 的 Key 调模型用 MCP 协议调 Server两者通过工具调用function calling串起来。3. 可复制的 MCP Client 配置与代码这一节是核心。我会给出一个能同时处理 Stdio 和 SSE 的 Client 结构配置片段可以直接抄。为了让你能跟做我用 Node.js 写示例因为 MCP 官方 SDK 对 JS/TS 支持比较完整依赖也好装。先初始化项目并装依赖mkdir mcp-client-demo cd mcp-client-demo npm init -y npm install modelcontextprotocol/sdk openai dotenv然后在项目根目录建一个.env文件把上一节的三件套写进去TAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODEL你的模型ID接着建一个mcp.config.json把两种传输方式的 Server 定义都放进去。这个文件就是你要复制的配置片段{ mcpServers: { local-stdio-server: { transport: stdio, command: node, args: [./servers/local-tools.js], env: { NODE_ENV: production } }, remote-sse-server: { transport: sse, url: http://localhost:5266/sse, headers: { Authorization: Bearer ${TAOTOKEN_API_KEY} } } } }注意 SSE 那段里的headers如果你的远程 MCP Server 需要鉴权就把 TaoToken 的 Key 或者 Server 自己的 Token 放进去。这里用${TAOTOKEN_API_KEY}占位读取时替换成真实值避免明文落盘。下面是 Client 主逻辑client.js。它做三件事根据配置建立 Stdio 或 SSE 连接、拉取工具列表、把工具注册给模型并处理调用。import dotenv/config; import { Client } from modelcontextprotocol/sdk/client/index.js; import { StdioClientTransport } from modelcontextprotocol/sdk/client/stdio.js; import { SSEClientTransport } from modelcontextprotocol/sdk/client/sse.js; import { OpenAI } from openai; import fs from fs; const config JSON.parse(fs.readFileSync(./mcp.config.json, utf-8)); function resolveEnv(value) { return value.replace(/\$\{(\w)\}/g, (_, key) process.env[key] ?? ); } async function createTransport(serverConf) { if (serverConf.transport stdio) { return new StdioClientTransport({ command: serverConf.command, args: serverConf.args, env: { ...process.env, ...serverConf.env } }); } if (serverConf.transport sse) { const headers {}; for (const [k, v] of Object.entries(serverConf.headers ?? {})) { headers[k] resolveEnv(v); } return new SSEClientTransport(new URL(serverConf.url), { requestInit: { headers } }); } throw new Error(未知传输类型: ${serverConf.transport}); } async function connectServer(name, serverConf) { const transport await createTransport(serverConf); const client new Client({ name: mcp-client-${name}, version: 1.0.0 }, { capabilities: {} }); await client.connect(transport); const tools await client.listTools(); console.log([${name}] 已连接工具数: ${tools.tools.length}); return { client, tools: tools.tools }; } const llm new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL }); async function main() { const connections {}; for (const [name, conf] of Object.entries(config.mcpServers)) { connections[name] await connectServer(name, conf); } const allTools []; for (const [name, conn] of Object.entries(connections)) { for (const tool of conn.tools) { allTools.push({ type: function, function: { name: ${name}__${tool.name}, description: tool.description, parameters: tool.inputSchema } }); } } const messages [ { role: system, content: 你可以调用已注册的 MCP 工具来完成任务。 }, { role: user, content: 帮我查一下本地工具里有哪些可用能力。 } ]; const response await llm.chat.completions.create({ model: process.env.TAOTOKEN_MODEL, messages, tools: allTools }); const choice response.choices[0].message; if (choice.tool_calls?.length) { for (const call of choice.tool_calls) { const [serverName, toolName] call.function.name.split(__); const args JSON.parse(call.function.arguments || {}); const result await connections[serverName].client.callTool({ name: toolName, arguments: args }); console.log(工具 ${call.function.name} 返回:, result.content); } } else { console.log(模型直接回复:, choice.content); } } main().catch((err) { console.error(运行失败:, err); process.exit(1); });这段代码的关键点在于工具名做了serverName__toolName的前缀拼接避免多个 Server 工具重名SSE 的 headers 支持环境变量替换Stdio 的 env 把当前进程环境透传下去子进程才能读到需要的变量。如果你用的是 Cline 或 CC Switch 这类工具它们的 MCP 配置格式和上面的mcp.config.json基本一致把mcpServers那段贴进去即可。Cline 的 MCP 配置在设置面板里CC Switch 则是独立的配置文件三件套Base URL、Key、Model ID填法相同。4. 双通道验证Stdio 与 SSE 分别跑通配置写好了得验证两条通道都能通。我建议分开验证先 Stdio 再 SSE这样出问题容易定位。先准备一个最小的 Stdio MCP Server放在servers/local-tools.jsimport { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { ListToolsRequestSchema, CallToolRequestSchema } from modelcontextprotocol/sdk/types.js; const server new Server({ name: local-tools, version: 1.0.0 }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () ({ tools: [{ name: get_time, description: 返回当前服务器时间, inputSchema: { type: object, properties: {} } }] })); server.setRequestHandler(CallToolRequestSchema, async (req) { if (req.params.name get_time) { return { content: [{ type: text, text: new Date().toISOString() }] }; } throw new Error(未知工具); }); const transport new StdioServerTransport(); await server.connect(transport);跑 Stdio 通道node client.js预期输出里会看到[local-stdio-server] 已连接工具数: 1然后模型可能会调用get_time打印出 ISO 时间。如果这一步成功说明 Stdio 链路通了。再验证 SSE。你需要一个已经跑起来的 SSE MCP Server。假设它监听在http://localhost:5266/sse把mcp.config.json里的remote-sse-server保留重新跑node client.js。预期输出里会多一行[remote-sse-server] 已连接工具数: N。我实测下来SSE 连接最容易出问题的地方是 URL 路径。有些 Server 的 SSE 端点是/sse有些是/mcp/sse还有的用/events。如果连不上先确认 Server 实际暴露的路径。另外 SSE 是长连接Client 进程退出时要记得关闭否则 Server 端可能残留会话。验证成功的标志是两条通道都能listTools成功并且模型能通过工具调用拿到返回结果。你可以故意问一个需要调用工具的问题比如「现在服务器时间是多少」看模型是否触发get_time。如果你更想先验证模型通道本身是否通可以打开模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 发一条消息确认 Key 和 Base URL 没问题再回来跑 Client。这样能把「模型不通」和「MCP 不通」两类问题分开。5. 常见报错排查401、local proxy failed、reading choices这一节列几个我踩过的坑都是真实报错对照着查能省不少时间。401 Unauthorized。这个基本是 Key 的问题。先确认.env里的TAOTOKEN_API_KEY有没有被正确加载Node 里dotenv/config要在最顶部导入。然后确认 Base URL 是https://taotoken.net/api不要多加斜杠或路径。如果 Key 是从控制台复制的注意有没有把首尾空格带进去。还有一种情况是 Key 被禁用或额度耗尽去控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 看一下状态。local proxy failed。这个报错通常出现在 SSE 连接阶段意思是 Client 尝试连本地地址失败了。先确认 MCP Server 真的在跑用curl http://localhost:5266/sse看有没有响应。如果 Server 没起来Client 当然连不上。另外检查端口有没有被占用lsof -i :5266能看出来。还有一种可能是 Server 只监听了127.0.0.1而 Client 用了localhost解析到 IPv6改成127.0.0.1试试。reading choices of undefined。这个报错来自模型调用返回结构不对。常见原因是baseURL配错了请求打到了非兼容端点返回的不是标准 OpenAI 格式。确认TAOTOKEN_BASE_URL是https://taotoken.net/api并且model字段填的是你订阅里真实存在的 Model ID。如果 Model ID 写错有些网关会返回错误结构导致解析choices时崩掉。打印一下原始 response 就能看出来。OAuth 相关报错。如果你接的远程 MCP Server 要求 OAuth 鉴权而你在 headers 里只放了 API Key就会报鉴权失败。这种情况要么让 Server 支持 Bearer Token要么按 Server 文档走 OAuth 流程。我一般优先选支持静态 Token 的 Server省去刷新 token 的麻烦。工具调用参数解析失败。模型返回的arguments有时不是合法 JSONJSON.parse会抛异常。加一层 try/catch解析失败时把原始字符串打出来通常能看出是模型多加了注释还是引号没转义。排查顺序建议是先确认模型通道用模型对话页面测再确认 MCP Server 单独能跑用官方 inspector 或 curl最后才看 Client 代码。这样能把问题范围快速缩小。6. 把 MCP Client 用起来长期编码与 Agent 场景跑通之后你会发现 MCP Client 真正的价值在于把一堆零散能力统一成模型可调用的工具集。本地脚本、数据库查询、内部 API只要包一层 MCP Server就能被同一个 Client 调度。如果你打算把 MCP Client 用在长期编码或 Agent 场景里比如让模型持续调用工具完成多步任务建议关注 Coding Plan 这类订阅方案 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它在并发和额度上更适合长时间运行的 Agent。我自己的做法是把 MCP Client 常驻在一个进程里工具列表启动时拉一次缓存起来模型每轮对话只传工具定义不重复拉取这样响应更快。另外Claude Code 的 Anthropic 接入方式 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 和自建 Client 可以互补日常编码用 Claude Code需要自定义工具链时用自建 Client。两者共用同一个 TaoToken Key管理起来不费劲。最后给一个实用技巧把mcp.config.json里的 Server 按用途分组比如local-前缀放本地工具remote-前缀放远程服务工具名前缀拼接时就能一眼看出调用的是哪类。工具多了之后这个命名习惯能帮你省很多排查时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →