拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于NodeJs实现一个MCP客户端:TaoToken统一Key接入下的会话模式与无会话模式

基于NodeJs实现一个MCP客户端:TaoToken统一Key接入下的会话模式与无会话模式 1. 从零搭一个 MCP 客户端为什么先要搞懂会话模式和无会话模式MCP 全称 Model Context Protocol你可以把它理解成“大模型和外部工具之间的 USB 接口”。大模型本身不会爬网页、不会查数据库但通过 MCP 协议它可以在需要的时候调用你注册好的工具函数。NodeJs 实现 MCP 客户端就是写一个中间层一边连着大模型一边连着 MCP 服务端把模型吐出来的工具调用请求转发过去再把结果塞回对话。真正动手写的时候很多人会卡在同一个地方官方示例对 Client 端描述很少尤其是 Streamable HTTP 传输下会话模式和无会话模式的差别到底在哪。我一开始也以为只是加不加一个 header 的事实际跑下来才发现它直接决定了你的服务端要不要维护状态、客户端要不要保存 sessionId、以及请求失败后该怎么恢复。这篇就按“本地 AI 工具链集成”的场景来写用 NodeJs 从零搭一个 MCP 客户端分别实现会话模式保持上下文连接和无会话模式每次独立请求给出可复制的 config.toml 与 settings.json 骨架并给出两种模式的连通性验证动作。适合已经会一点 TypeScript、想把自己的工具接进大模型工作流的人。核心检索词先摆在这NodeJs MCP 客户端、会话模式、无会话模式、Streamable HTTP、sessionId。下面所有代码都可以直接跑服务端用 Express客户端用官方modelcontextprotocol/sdk。2. TaoToken 前置统一 Key 接入别让多模型配置拖垮客户端写 MCP 客户端时模型调用这一层很容易变乱今天用 Qwen明天换 Claude后天接个别的每个都要单独配 baseURL 和 apiKey。TaoToken 的价值就在这里——它提供统一的 Key 接入你只需要在客户端里维护一份配置模型切换只改 model 字段不用动业务代码。对 MCP 客户端来说这一点尤其重要。因为客户端里有两套“连接”一套是连 MCP 服务端的 Streamable HTTP一套是连大模型 API 的 OpenAI 兼容接口。前者管工具后者管对话。把后者收敛到 TaoToken 统一 Key 之后你的settings.json里就只剩一个 apiKey 和一个 baseURL排障时能少一半变量。你需要先拿到 Key入口在控制台控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite拿到 Key 之后模型对话调试可以用模型对话https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite如果你后面要做长期编码或 Agent 类任务Coding Plan 更合适Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewriteAPI 地址统一用https://taotoken.net/api注意这个不带 UTM直接写进配置即可。Key 的创建和管理在API Keyshttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite接入文档在接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite如果你用的是 Claude Code 这类工具Anthropic 兼容入口也有ClaudeCodeAnthropichttps://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite3. 可复制配置config.toml 与 settings.json 骨架先把配置文件定下来后面代码直接读它。这样会话模式和无会话模式切换时只改一个字段不用改代码。3.1 config.tomlMCP 服务端与传输模式# config.toml [mcp] # MCP 服务端地址会话模式和无会话模式共用同一个端点 server_url http://127.0.0.1:4002/mcp # 传输模式session 或 stateless # session 会话模式服务端生成并维护 sessionId # stateless 无会话模式每次请求独立服务端不保存状态 transport_mode session # 连接超时毫秒 connect_timeout 10000 # 工具调用超时毫秒 tool_timeout 30000 [model] # TaoToken 统一 Key 接入 base_url https://taotoken.net/api api_key sk-your-taotoken-key model_name qwen-plus enable_thinking true [client] # 客户端标识会随 initialize 请求发给服务端 name mcp-client-cli version 1.0.03.2 settings.json运行时开关与日志{ mcp: { serverUrl: http://127.0.0.1:4002/mcp, transportMode: session, reconnectOn404: true, logLevel: info }, model: { baseUrl: https://taotoken.net/api, apiKey: sk-your-taotoken-key, modelName: qwen-plus, enableThinking: true }, client: { name: mcp-client-cli, version: 1.0.0 } }transportMode是这篇的核心开关。设为session时客户端会从 transport 里读sessionId并在后续请求带上设为stateless时客户端不保存也不发送 sessionId每次请求都是全新的。3.3 项目初始化与依赖mkdir mcp-client-demo cd mcp-client-demo npm init -y npm install modelcontextprotocol/sdk express cors openai npm install -D types/node typescript ts-node nodemonpackage.json里加上 ES 模块声明{ type: module, scripts: { dev: nodemon, build: tsc, start: node dist/index.js } }tsconfig.json{ compilerOptions: { target: ES2022, module: Node16, moduleResolution: Node16, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: dist }, include: [src] }4. 会话模式与无会话模式的实现差异这一节是重点。两种模式在客户端代码上的差别其实集中在三处transport 初始化、sessionId 的读取与携带、以及 404 之后的恢复逻辑。4.1 会话模式sessionId 是上下文唯一标识MCP 的会话指的是客户端与服务端之间存在逻辑关联的交互过程始于初始化阶段。服务端在初始化响应头Mcp-Session-Id里返回 sessionId客户端必须在后续所有请求的Mcp-Session-Id头里带上它。服务端要求 sessionId 全局唯一、加密安全、只含可见 ASCII 字符。如果客户端带了无效 sessionId服务端返回 400如果会话被终止返回 404此时客户端要重新发一次不带 sessionId 的初始化请求。// src/mcpClient.ts import { Client } from modelcontextprotocol/sdk/client/index.js; import { StreamableHTTPClientTransport } from modelcontextprotocol/sdk/client/streamableHttp.js; export class MCPClient { private client: Client; private mcpServerURL: URL; private transport: StreamableHTTPClientTransport | null null; private tools: any[] []; private sessionId?: string; private mode: session | stateless; constructor(mcpServerURL: string, mode: session | stateless session) { this.client new Client({ name: mcp-client-cli, version: 1.0.0 }); this.mcpServerURL new URL(mcpServerURL); this.mode mode; } connectToServer async () { try { this.transport new StreamableHTTPClientTransport(this.mcpServerURL); await this.client.connect(this.transport); if (this.mode session) { this.sessionId this.transport.sessionId; console.log(服务端生成的 sessionId:, this.sessionId); } else { console.log(无会话模式不保存 sessionId); } const toolsResult await this.client.listTools(); this.tools toolsResult.tools.map((tool) ({ type: function, function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, }, })); console.log(已连接工具列表:, JSON.stringify(this.tools)); } catch (error) { console.error(连接失败:, error); } }; getTools () this.tools; callTool async (toolName: string, toolCallArgsStr: string) { if (this.mode session !this.sessionId) { throw new Error(未连接到服务端请先调用 connectToServer()); } const result await this.client.callTool({ name: toolName, arguments: JSON.parse(toolCallArgsStr), }); return result; }; getSessionId () this.sessionId; }4.2 无会话模式每次请求独立服务端不存状态无会话模式在客户端侧更简单不读 sessionId不保存不携带。服务端每次请求都新建一个 transportsessionIdGenerator设为undefined。代价是每次请求都要重新初始化好处是没有状态残留适合无状态部署或短任务。// src/mcpClientStateless.ts import { Client } from modelcontextprotocol/sdk/client/index.js; import { StreamableHTTPClientTransport } from modelcontextprotocol/sdk/client/streamableHttp.js; export class MCPClientStateless { private client: Client; private mcpServerURL: URL; private transport: StreamableHTTPClientTransport | null null; private tools: any[] []; constructor(mcpServerURL: string) { this.client new Client({ name: mcp-client-stateless, version: 1.0.0 }); this.mcpServerURL new URL(mcpServerURL); } connectToServer async () { this.transport new StreamableHTTPClientTransport(this.mcpServerURL); await this.client.connect(this.transport); const toolsResult await this.client.listTools(); this.tools toolsResult.tools.map((tool) ({ type: function, function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, }, })); console.log(无会话模式已连接工具数:, this.tools.length); }; getTools () this.tools; callTool async (toolName: string, toolCallArgsStr: string) { return await this.client.callTool({ name: toolName, arguments: JSON.parse(toolCallArgsStr), }); }; }4.3 服务端对照sessionIdGenerator 决定一切会话模式的服务端关键是sessionIdGenerator返回一个 UUID并在onsessioninitialized里把 transport 按 sessionId 存起来// server-session.ts import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StreamableHTTPServerTransport } from modelcontextprotocol/sdk/server/streamableHttp.js; import express from express; import { z } from zod; import crypto from crypto; import { isInitializeRequest } from modelcontextprotocol/sdk/types.js; const app express(); app.use(express.json()); const transports: { [sessionId: string]: StreamableHTTPServerTransport } {}; app.post(/mcp, async (req, res) { const sessionId req.headers[mcp-session-id] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId transports[sessionId]) { transport transports[sessionId]; } else if (!sessionId isInitializeRequest(req.body)) { transport new StreamableHTTPServerTransport({ sessionIdGenerator: () crypto.randomUUID(), onsessioninitialized: (sid) { transports[sid] transport; }, }); transport.onclose () { if (transport.sessionId) delete transports[transport.sessionId]; }; const server new McpServer({ name: example-server, version: 1.0.0 }); server.tool( crawlWeb, 爬取获取网页内容, { url: z.string().url().describe(需要被爬取的网页链接) }, async ({ url }) { return { content: [{ type: text, text: 已爬取: ${url} }] }; } ); await server.connect(transport); } else { res.status(400).json({ jsonrpc: 2.0, error: { code: -32000, message: Bad Request: No valid session ID provided }, id: null, }); return; } await transport.handleRequest(req, res, req.body); }); app.listen(4002, () console.log(MCP Server on http://localhost:4002/mcp));无会话模式的服务端把sessionIdGenerator设为undefined每个请求新建 transport// server-stateless.ts app.post(/mcp, async (req, res) { const transport new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const server new McpServer({ name: mcp-server-crawl, version: 1.0.0 }); server.tool( crawlWeb, 爬取获取网页内容, { url: z.string().url().describe(需要被爬取的网页链接) }, async ({ url }) { return { content: [{ type: text, text: 已爬取: ${url} }] }; } ); res.on(close, () transport.close()); await server.connect(transport); await transport.handleRequest(req, res, req.body); });5. 验证请求与成功结果配置和代码都齐了接下来做连通性验证。两种模式各有一套验证动作别混着测。5.1 会话模式验证先拿 sessionId再带 sessionId 重连启动服务端和客户端npm run build node dist/server-session.js npm run dev客户端启动后会打印服务端生成的 sessionId类似服务端生成的 sessionId: f94e4537-d016-405a-ba21-fc3811b10877 已连接工具列表: [{type:function,function:{name:crawlWeb,...}}]然后手动带这个 sessionId 重连验证服务端能识别this.transport new StreamableHTTPClientTransport(this.mcpServerURL, { sessionId: f94e4537-d016-405a-ba21-fc3811b10877, });连接成功说明会话被复用。再故意传一个错误 sessionIdthis.transport new StreamableHTTPClientTransport(this.mcpServerURL, { sessionId: error-session-id, });预期报错Error: Error POSTing to endpoint (HTTP 400): {jsonrpc:2.0,error:{code:-32000,message:Bad Request: No valid session ID provided},id:null}看到这个 400说明会话校验生效了。5.2 无会话模式验证连续两次请求都不带 sessionId把config.toml的transport_mode改成stateless重启客户端。日志里不会出现 sessionId。用 curl 连续打两次curl -X POST http://127.0.0.1:4002/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}两次都返回工具列表且响应头里没有Mcp-Session-Id就说明无会话模式跑通了。5.3 端到端对话验证客户端里接上 TaoToken 统一 Key 之后用 Postman 打/chat{ userContent: 帮我看看 https://example.com 这个页面讲了什么 }预期流程模型先输出思考内容判断需要调用crawlWeb客户端收集工具名和参数后调用 MCP 工具把结果塞回对话模型再基于结果生成总结。控制台会依次打印“思考过程”“调用工具后的回复”。6. 本篇常见错排查6.1 400 Bad Request: No valid session ID provided这是最常见的。原因有三种一是会话模式下客户端没带 sessionId二是带了但服务端transports里没有这个 key服务端重启过三是无会话模式的服务端却收到了带 sessionId 的请求。排查顺序先看客户端transportMode和服务端sessionIdGenerator是否匹配再看服务端日志里transports的 key 列表。6.2 404 Not Found 之后客户端卡死会话被服务端终止后会返回 404。按协议客户端必须重新发一次不带 sessionId 的初始化请求。如果你没做这个恢复逻辑客户端会一直用旧 sessionId 重试。在connectToServer外面包一层try { await this.client.connect(this.transport); } catch (err: any) { if (err?.message?.includes(404)) { this.sessionId undefined; await this.connectToServer(); } }6.3 工具调用参数解析失败模型流式返回的tool_calls[0].function.arguments是分片拼接的必须等finish_reason tool_calls之后再JSON.parse。提前解析会拿到半截 JSON。另外toolName也是分片拼接的别只取第一片。6.4 无会话模式下请求 ID 冲突无会话模式每个请求新建 transport如果服务端复用了同一个 server 实例可能出现请求 ID 冲突。正确做法是每个请求都new McpServer(...)并在res.on(close)里transport.close()。6.5 TaoToken 侧 401 或模型名不识别先确认base_url是https://taotoken.net/api不要多加路径。再确认 apiKey 是从控制台新建的、没有多余空格。模型名以模型对话页面列出的为准。如果还是 401去 API Keys 页面重新生成一个再试。7. 接下来怎么选会话还是无会话如果你做的是本地 AI 工具链集成需要多轮对话里保持工具上下文选会话模式客户端保存 sessionId服务端用 UUID 生成并维护transports映射。如果你做的是无状态部署、短任务、或者每次请求本来就独立选无会话模式服务端sessionIdGenerator设为undefined客户端不碰 sessionId。两种模式的代码骨架上面都给全了config.toml和settings.json直接复制改 Key 就能跑。验证动作也给了会话模式看 400 报错无会话模式看响应头有没有Mcp-Session-Id。把这两步跑通MCP 客户端的骨架就立住了后面接更多工具只是往server.tool里加注册而已。
返回列表