MCP TypeScript SDK开发必看:理解协议边界,避开集成深坑

发布时间:2026/9/10 19:53:00
MCP TypeScript SDK开发必看:理解协议边界,避开集成深坑 最近在不少技术交流群里总能看到同一类求助有人把一个 MCP Server 的仓库 clone 下来npm install、npm run build 一路畅通服务也正常起来了但接到 Claude Code 或者 Cursor 里面板上死活看不到工具或者调用时一直报 Method not found。折腾一圈重装依赖、换 SDK 版本、翻 issue最后发现代码一点问题没有问题出在大家默认“先跑示例再理解原理”这条路放到 MCP 上恰好是最绕远的路。我一直持有这个观点学 MCP TypeScript SDK不要急着跑示例先把协议边界看清楚。协议边界这四个字听起来抽象实际上决定了你写的 Server 能不能被主流客户端正常识别、工具能不能被模型正确调用、以及报错时你能不能在五分钟内定位根因。这篇内容就围绕边界展开把 SDK 怎么映射协议、示例按什么顺序跑、跑挂了从哪排查一次性讲透。适合刚接触 MCP 想做工具接入的开发者也适合已经在用 TypeScript 写 Server 但总觉得“能跑但不太懂为什么”的同学。1. 为什么我建议先看协议边界再跑示例学习一个普通 SDK 的正常路径通常是拉一个 example 起来看到输出获得正反馈再往里面加自己的逻辑。这个路径对绝大多数库都没问题但放到 MCP 上反而容易让人原地打转。原因在于 MCP 不是“你调我”的单机库它定义的是两个独立进程之间怎么协作的规则。你写的 Server 是一个进程宿主应用Claude Code、Cursor、自研 Agent是另一个进程两者通过协议交换消息。任何一个环节越过了边界表现都不是编译报错而是“看起来正常但就是不通”。我见过一个真实案例。有人写了一个 MCP Server想在初始化时连数据库、拉配置、做一堆预加载然后把这些逻辑直接放在模块顶层执行。本地测试时一切正常因为进程还在跑但接到宿主应用里客户端发来了 initialize 请求服务端却因为还没进入消息循环压根没响应表现就是连接超时或工具列表为空。这就是典型的生命周期边界没搞清楚宿主拉起子进程后第一件事是握手而不是等你完成自己的业务初始化。再比如传输层边界。半年多以前很多 MCP 示例还基于“HTTP SSE”的旧传输模式但那版传输已经进入废弃通道。如果照抄老示例再配一个只支持新版 Streamable HTTP 的客户端请求路径对不上报错信息又不会直接提示“传输版本不匹配”排查成本非常高。所以我把协议边界拆成四层生命周期边界谁先发消息、谁响应、连接建立后能做什么能力边界双方各自声明支持什么未声明的能力不能调用原语边界资源、工具、提示词三条通道各自管什么传输边界消息走什么通道通道决定了部署形态。把这四层在脑子里过一遍再回头看 SDK 的 Server、Client、Transport 这些类你会瞬间觉得代码变简单了。跑示例也不再是“照着敲一遍碰运气”而是每一步都知道在验证什么。2. MCP 协议的三个标准原语资源、工具、提示词边界各自在哪协议层最核心的东西就是三个原语Resources资源、Tools工具、Prompts提示词。新手经常混淆它们写 Server 时不知道一个数据接口该注册成资源还是工具。这里从边界角度拆开讲。2.1 Resources只读上下文类似“让 AI 打开一个文件”Resources 的语义是只读的数据访问。它用 URI 来寻址比如sqlite:///users、file:///logs/app.log客户端会调用resources/list或resources/read来获取内容。它适合什么适合给模型提供背景资料、数据库 Schema、文档片段这类不改变外部状态的数据。边界特征很明显服务端读出数据返回给客户端客户端或模型决定如何使用但服务端不负责“执行动作”。如果你提供一个接口前端一调里面的核心逻辑是把数据写入数据库、改配置、发请求那么这个接口就不应该做成 Resource它的语义边界在“读”之外。另外Resources 支持subscribe和listChanged这类可选能力用于数据变更时通知客户端。如果宿主支持订阅资源变化可以实时通知模型。但注意这是可选能力不是必须实现注册了但没有正确处理订阅请求反而会在调试时引入额外噪音。2.2 Tools可执行操作类似“给模型加一个函数”Tools 是 MCP 里最常用、讨论也最多的原语。它的语义是一个可以被模型自主调用的函数包含名称、描述、输入 JSON Schema。模型根据用户诉求和工具描述来决定“现在该调用这个工具了”然后传入符合 Schema 的参数拿到执行结果。边界在于模型负责决定要不要调而工具负责“一定执行成功或返回明确失败”。一个工具内部不能假设还有另一个工具存在更不能依赖调用顺序。比如你写了工具 A 要在工具 B 执行后才能用这听起来合理但协议本身不保证这一点。模型完全可能绕过 B 直接调 A或者并行调用多个工具。工具之间必须互相独立这是设计 MCP Server 时最容易忽略的边界约束。注册工具时描述和输入 Schema 不是可有可无的装饰。模型选工具基本靠名字和描述理解意图Schema 不清晰传参就乱。实际经验是描述里写上“什么时候用、参数是什么含义、典型示例”比多写几十行代码还有用。2.3 Prompts可复用模板介于工具与资源之间的第三条路Prompts 的语义是可复用的提示词模板。你可以把它理解成一类预制指令比如“总结这个 PR”“生成周报”。客户端可以拉取模板列表再把用户的选择展开成实际的提示消息交给模型念诵。边界上Prompts 不像 Resources 那样只读也不像 Tools 那样直接执行动作它更像是“给模型一段组织好的输入”。如果你用 MCP 做企业内部助手把常用的分析指令固化到 Prompts 里配合客户端 UI用户体验会很顺手。但如果你的目标场景是“让模型自动触发某个动作”该用工具还是得用工具用 Prompts 硬撑会把语义搞拧。2.4 三个原语怎么选原语语义典型场景模型触发方式Resources只读数据访问URI 寻址数据库 Schema、日志文件、文档片段模型/用户主动读取Tools可执行操作受参数约束查询订单、创建工单、调用第三方 API模型根据场景自主调用Prompts提示词模板固化分析步骤、预置指令用户选择后展开一句话判断只是“取数据给 AI 看”用 Resources要“让 AI 触发一个动作”用 Tools要“规范化 AI 的交互方式”用 Prompts。三者各管一摊别混。3. 传输层的边界决策stdio、HTTP 与 Streamable HTTP 各自适合谁MCP 的传输层经历了从本地到远程的演进选型直接影响部署形态和客户端兼容性。很多示例失败不是逻辑问题而是传输方式没选对。3.1 stdio本地进程的标准输入输出宿主私有的子进程通道stdio 传输是最简单也最常用的一种。宿主应用Claude Code、Cursor、自研 CLI以子进程方式启动你的 MCP Server通过标准输入stdin发送 JSON-RPC 请求服务端把响应写到标准输出stdout。协议消息在进程间流动边界就是进程边界。它适合什么场景本地工具、开发者本机的 CLI 集成、对延迟敏感的操作。因为不用走网络启动快、调试直观主流客户端对 stdio 的支持最成熟。但 stdio 有几个边界特征必须记住一个 stdio Server 只能服务一个宿主进程无法多客户端共享Server 进程的生命周期跟着宿主走宿主退出Server 也就结束千万不要在 Server 里用 console.log 输出普通日志因为 stdout 已经被协议消息占用任何非 JSON-RPC 的文本都会污染协议流导致客户端解析失败表现就是连接异常、消息永远等不到响应。要调试就往 process.stderr 写。3.2 HTTP SSE属于历史版本新项目别走回头路早期 MCP 支持“HTTP SSE”模式客户端通过 HTTP POST 发请求服务端通过 SSEServer-Sent Events单向推送事件。这套方案的问题也很明显双向通道不对称客户端要建额外连接接收服务端消息部署和调试都比较别扭。官方后来把它标记为废弃主推 Streamable HTTP。如果你搜示例时遇到 2024 年底或 2025 年初、基于modelcontextprotocol/sdk早期版本的代码很可能就是这套老传输。除非你在维护存量项目否则不要在新代码里用。3.3 Streamable HTTP当前推荐一个 POST 入口走天下Streamable HTTP 是当前推荐使用的 HTTP 形态设计上非常简洁服务端暴露一个 HTTP 端点客户端用POST发送 JSON-RPC 消息如果需要流式接收服务端消息再用 GET 建立 SSE 流会话结束用 DELETE 收尾。传输方式通信形态部署形态适用场景当前状态stdiostdin/stdout进程内通信本地子进程本机工具、CLI 集成推荐HTTP SSEPOST 单向 SSE远程服务早期远程接入已废弃Streamable HTTPPOST 可选 GET SSE DELETE远程服务多人共享、服务端部署推荐选择逻辑其实很清晰只在本地用、给某个宿主私用走 stdio要把 MCP Server 部署到服务器、让多个客户端共用走 Streamable HTTP。不要为了“看起来高级”硬上 HTTP本地场景里 stdio 的稳定性和调试体验是 HTTP 比不了的。3.4 生命周期边界连接不是一上来就能调工具不管哪种传输客户端连上服务端之后第一件事永远是initialize 握手。这一步交换协议版本号紧接着客户端会发notifications/initialized通知之后才进入正常业务请求。SDK 会自动处理这段握手你不需要手动发消息但要理解工具列表、资源列表都是握手之后才可能出现的。如果一个宿主应用接上你的 Server 后工具列表为空排查顺序是握手有没有成功能力声明有没有匹配再往后才是工具注册代码有没有执行。很多人第一步就去看注册代码方向反了。4. 能力协商机制客户端不声明的能力服务端调用就是越界MCP 不是“双方默认什么都能做”。恰恰相反它要求双方在握手阶段把各自支持的能力说清楚之后只允许调用双方声明过的能力。这个机制叫能力协商Capability Negotiation是协议边界里最微妙的一层。4.1 initialize 握手具体换了什么客户端发来 initialize 请求时协议版本、客户端能力列表就跟着过来了。类似下面这段伪代码{ protocolVersion: 2025-06-18, capabilities: { roots: { listChanged: true }, sampling: {}, experimental: {} } }服务端要响应自己支持的能力比如是否支持 resources、tools、prompts以及每个原语下面是否支持listChanged、subscribe等子能力。一旦响应结束这份能力清单就是双方约定的边界。如果服务端没有声明支持某原语客户端不会调用也不会展示对应的列表反过来如果客户端没有声明支持 sampling服务端却尝试通过createMessage发请求客户端就会返回 method not found 之类的错误。4.2 用 TypeScript SDK 时能力声明大都是自动的在modelcontextprotocol/sdk里你注册了server.tool()SDK 会自动在 ServerCapabilities 里声明 Tools 支持注册了server.resource()自动声明 Resources。高层封装的McpServer类会把这件事处理得干净利落。只有当你用底层Server类手写 handler 时才可能需要手动维护 capabilities 对象。问题往往出在自定义实验能力上。SDK 留了一个experimental字段给自定义能力我见过有人用它封装内部 RPC然后发现不知情的客户端永远不调用最后还得回到标准原语。如果客户端不声明、你也说不清业务必须走自定义扩展大概率是设计方向没对准协议边界。4.3 实战教训从“方法找不到”反推动能边界错位有次我给一个内部工具加 MCP 支持本地用一个比较新的客户端测步步正常。换到某个企业级客户端上树起来就报错错误信息大概是 method not foundmethod 是sampling/createMessage。查了半天才发现这个客户端没有实现 sampling 能力服务端却在某个工具里想通过 sampling 扩充上下文。边界错位不是代码错误是双方能力清单没有交集。所以排查这类问题时请记住一个顺序先看握手时客户端声明的 capabilities再看服务端响应的 capabilities最后才是业务逻辑。这个顺序能省下大量试错时间。5. 协议边界在 TypeScript SDK 里长什么样Server、Client、Transport 的关键职责SDK 存在的意义就是帮你把协议这层细节藏起来让你专注业务。但不懂协议边界的开发者用了 SDK踩坑后往往连根因都找不到。这一节把关键类在边界上的位置理清楚。5.1 SDK 包结构与两类写法modelcontextprotocol/sdk现在提供两类使用方式。一类是偏底层的 RPC 风格Server类、Client类方法名和协议里基本对应如果你要实现非常精细的控制可以走这条路。另一类是高层封装McpServer它屏蔽了能力协商细节替你维护注册表直观地把tool、resource、prompt暴露给你日常项目里我更推荐这个。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; const server new McpServer({ name: demo-server, version: 0.1.0, }); server.tool(get-server-time, { timezone: { type: string } }, async ({ timezone }) { const now new Date(); const text timezone ? time in ${timezone}: ${now.toLocaleString()} : now.toISOString(); return { content: [{ type: text, text }] }; }); const transport new StdioServerTransport(); await server.connect(transport);这段代码就是一个能跑的最小 Server。McpServer构造函数里传的名字、版本会成为握手时服务端信息的一部分。server.tool()注册一个工具传入名称、参数 Schema 和执行函数SDK 自动处理好注册和调用路由。变量的边界非常清楚Server 负责声明和调用Transport 负责消息搬运业务函数只关心自己的输入输出。5.2 Client 侧验证为什么你不需要先有一个“完整宿主”才能测试在接 Claude Code 或 Cursor 之前我强烈建议你先用 SDK 自己写一个最小 Client 做自测。这样能精确控制请求序列快速验证 Server 行为。import { Client } from modelcontextprotocol/sdk/client/index.js; import { StdioClientTransport } from modelcontextprotocol/sdk/client/stdio.js; const transport new StdioClientTransport({ command: node, args: [dist/server.js], }); const client new Client({ name: demo-client, version: 0.1.0, }); await client.connect(transport); const tools await client.listTools(); console.log(server tools:, tools.tools.map((t) t.name)); const result await client.callTool({ name: get-server-time, arguments: { timezone: Asia/Shanghai }, }); console.log(call result:, result);这个 Client 会启动子进程跑dist/server.js然后走一遍真正的握手和工具发现流程。跑完你就知道注册的工具能不能被发现、参数 Schema 对不对、执行函数有没有报错。这是本地验证边界的最短路径。5.3 SDK 版本的选择与“隐式边界”modelcontextprotocol/sdk迭代很快不同版本内部 API 有差异。我在项目中遇到过一个典型场景锁定^1.0.0之后升级到某个 minor 版本McpServer的某个方法签名变化编译通过但运行时行为不同。这类问题最隐蔽因为它发生在“SDK 与协议实现”的那条隐式边界上。我的建议是示例代码里看到 import 路径带/server/mcp.js还是/server/index.js留意一下版本和文档同一套代码不要盲目跨大版本升级。签发锁文件、跟进 SDK 发布说明比临时“已解决”要稳妥。6. 按协议顺序跑通一个真实示例以 SQLite 数据库操作 Server 为例这一节我们把上面所有边界知识串起来实际做一遍。示例场景让 Claude Code 能通过 MCP Server 查询本地 SQLite 数据库的表结构和数据。6.1 环境准备与项目初始化需要安装 Node.js 18 和 TypeScript。初始化项目mkdir mcp-sqlite-demo cd mcp-sqlite-demo npm init -y npm install modelcontextprotocol/sdk sqlite3 npm install -D typescript tsx types/nodesqlite3的 API 是回调风格这里为了示例简单用sqlite3包。建一个tsconfig.json核心是module和target保持 Node 兼容{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: dist, rootDir: src, strict: true } }6.2 实现 Server先注册只读工具不碰其他原语边界原则数据库查询属于“取数据给 AI 看”但为了演示工具调用我们用工具实现。两个工具list-tables列出所有表query-table查询某张表的前 N 行。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import sqlite3 from sqlite3; const db new sqlite3.Database(process.env.DB_PATH ?? ./app.db); const server new McpServer({ name: sqlite-helper, version: 1.0.0, }); server.tool(list-tables, {}, async () { const rows await new Promiseany[]((resolve, reject) { db.all(SELECT name FROM sqlite_master WHERE typetable, (err, rows) { if (err) reject(err); else resolve(rows); }); }); const text rows.map((r) r.name).join(\n); return { content: [{ type: text, text }] }; }); server.tool( query-table, { table: { type: string }, limit: { type: number, default: 10 } }, async ({ table, limit }) { const rows await new Promiseany[]((resolve, reject) { db.all(SELECT * FROM ${table} LIMIT ${limit}, (err, rows) { if (err) reject(err); else resolve(rows); }); }); const text JSON.stringify(rows, null, 2); return { content: [{ type: text, text }] }; } ); const transport new StdioServerTransport(); await server.connect(transport);这里有几个边界设计的细节两个工具必须完全独立各自打开连接各自返回结果不能互相依赖表名和列名不能直接用字符串拼接进 SQL示例里为了简洁没有做白名单校验。真要用在生产环境必须做标识符白名单校验否则就是 SQL 注入风险limit虽然标注了default但模型依然可能不传所以执行时要有兜底。6.3 用最小 Client 跑通自测链路按 5.2 节的 Client 写法连上这个 Server依次调用client.listTools()和client.callTool()。预期输出server tools: [ list-tables, query-table ] call result: { content: [ { type: text, text: [...] } ] }到这一步说明握手、能力协商、工具发现、工具调用整条链路是通的。不要跳过这个自测环节它把问题圈定在 Server 自身不用去宿主应用里找晦涩的日志。6.4 接入 Claude Code用 mcp add 指向本地命令在 Claude Code 里运行claude mcp add sqlite-helper -- node dist/server.js或者直接写配置文件。Claude Code 会用 stdio 拉起这个命令并走协议握手。配置成功后让模型“列出数据库里有哪些表”它会自动调用list-tables再让它“查一下 users 表前五条”自动调query-table。如果接入后看不到工具不用急着怀疑注册代码。按这个排查顺序来能否用最小 Client 自测通过配置里命令路径是不是绝对路径或可被 PATH 正确解析Server 有没有往 stderr 写可见日志DB_PATH环境变量是否正确。这里每一步查的都是“边界上的某个环节”而不是盲目改代码。实际跑一遍你就会发现例子里最复杂的工作其实是数据库查询逻辑本身MCP 相关的协议部分基本被 SDK 消化掉了。7. 跑示例后的常见报错与边界错位排查从错误堆栈反推协议边界错位是 MCP 开发最重要的实战能力。经验不足时看到英文报错容易慌先把常见错误和“对应哪条边界”对照起来。7.1 常见错误速查表现象常见原因边界错位点Method not found / Unknown method客户端/服务端调用了对方没有声明的能力能力协商边界工具列表为空握手未完成或能力注册失败生命周期/能力声明连接立即断开Server 进程启动即退出或往 stdout 打了非协议内容进程/传输边界Input validation failed工具输入校验失败原语边界请求长期无响应Server 在初始化里做耗时操作、阻塞了事件循环生命周期边界Server 工具报错但客户端看不到细节工具内部抛异常未包装原语边界7.2 从“工具列表为空”还原完整排查链路有一次远程帮同学排查他的 MCP Server 在本地测试里工具齐全但接到某个 IDE 插件里列表为空。第一反应先问“最小 Client 能连上吗”他说能。于是范围缩小到“宿主应用与 Server 之间的边界”。再看配置他用的是npx tsx src/server.ts直接拉起。这里的问题在于宿主应用以子进程方式运行命令时PATH 环境可能不完整npx解析失败进程直接退出。他本地终端里有 PATH 所以跑得通但宿主环境里没有。改成node /absolute/path/dist/server.js问题立刻解决。这类问题不是协议层的错而是“进程拉起边界”的错。但它恰恰是初学者最容易忽略的地方协议层通了不代表真实部署环境通了。7.3 工具内部异常不要把内部错误膨胀成协议错误工具执行函数里如果抛出异常SDK 会把它转成 JSON-RPC 错误返回给客户端。这是正确做法。但要注意错误消息不要太长、不要包含堆栈明细因为接口调用方模型可能拿这些信息直接做决策暴露内部路径和数据库细节反而有风险。合理做法是包装一层给模型一个可读的错误说明同时把详细堆栈记录到服务端自己的日志里。server.tool(query-table, schema, async ({ table, limit }) { try { // 执行查询 } catch (err) { console.error(query-table failed:, err); return { content: [{ type: text, text: query failed: ${(err as Error).message} }], isError: true, }; } });这样客户端能看到“查询失败”的明确信号服务端也有完整堆栈可追溯。isError: true这个字段也很重要它能告诉模型这次调用没有成功避免模型把异常输出当成有效结果继续推理。7.4 错误排查的通用顺序无论遇到哪种报错通用的排查顺序是能否最小复现用 SDK Client 做自测排除宿主因素握手日志确认双方能力清单有没有交集传输日志检查有没有非协议内容污染数据流部署差异进程路径、环境变量、工作目录是否与本地一致SDK 版本是否与协议版本、宿主版本匹配。这一步一步走下来95% 的问题都能在十分钟内定位。8. 我的实际体会与后续可以怎么扩展按“边界先行”的方式重学 MCP 之后我最大的体会是MCP 的代码量真的不多但理解边界需要花的时间不少。刚开始接触时我也习惯性拉一个 README 里有 gif 的仓库跑起来再说结果遇到一堆“玄学报错”。后来老老实实把官方协议文档的客户端/服务端小节读了一遍再回来写 SDK才发现那些报错背后全是有规律的边界错位。一个很实用的调试技巧开发时把MCP_LOG_LEVELdebug设上SDK 会输出协议层握手和消息流转的日志你自己的业务日志统一写到process.stderr。把“协议日志”和“业务日志”分开排查效率能翻倍。后续如果想把 Server 部署成远程服务除了把 stdio 换成 Streamable HTTP还要额外考虑几个协议边界之外的问题鉴权不能让任何人连上就调工具、限流模型可能并发调用多个工具、会话管理DELETE 终止后清理资源、以及工具的超时控制。这些不属于协议本身的边界但属于工程化的边界迟早要面对。从一个小工具入手跑通一条调用链再逐步加资源、加提示词、换传输层——这是我认为最扎实的 MCP TypeScript 学习路径。先把边界立住示例怎么跑都是顺风局。