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_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite
拿到 Key 之后,模型对话调试可以用:
模型对话:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite
如果你后面要做长期编码或 Agent 类任务,Coding Plan 更合适:
Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite
API 地址统一用https://taotoken.net/api,注意这个不带 UTM,直接写进配置即可。Key 的创建和管理在:
API Keys:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite
接入文档在:
接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite
如果你用的是 Claude Code 这类工具,Anthropic 兼容入口也有:
ClaudeCodeAnthropic:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude-code-anthropic&utm_campaign=rewrite
3. 可复制配置:config.toml 与 settings.json 骨架
先把配置文件定下来,后面代码直接读它。这样会话模式和无会话模式切换时,只改一个字段,不用改代码。
3.1 config.toml:MCP 服务端与传输模式
# 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.0"3.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,不保存,不携带。服务端每次请求都新建一个 transport,sessionIdGenerator设为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', });连接成功说明会话被复用。再故意传一个错误 sessionId:
this.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里加注册而已。