构建 MCP 服务器并非在 hello-world 之上简单加上工具——真正要紧的决定是每种能力应当做成 tool、resource 还是 prompt,以及一台中位数服务器实际长什么样。
每份 MCP 教程都教你注册两个工具、把字符串原样回显,然后宣告胜利;生产环境的服务器完全不是那样。流传中的中位数 MCP 服务器带着 5 个工具、0 个资源、0 个提示、以及无鉴权——一项 1,412 台服务器的调查把这些数字摆到了台面上——而这个形状体现的是多数教程从不提及的一组设计选择。tool、resource、prompt 三者之间的边界一错,下游的每个问题——令牌膨胀、脆弱的测试、看不懂的 agent 轨迹——都会顺着这条错线流下来;而 2026-07-28 修订版新加的那几条义务——server/discover、resultType、缓存提示、逐请求协商——一旦弄错,你写的那些工具压根就没人够得着。
你真正会用到的技术栈:FastMCP(Python)或 Standard Schema(TypeScript)。
Python 的 MCP 服务器都写成 FastMCP 的装饰器风格:你写一个普通函数,加上 @server.tool,库就从你的类型提示推导出 JSON Schema;函数以下的一切——包括参与者模型如今在每一次调用上都要求的逐请求协商——都被藏了起来。Bloomberry 那份对 1,412 台生产服务器的调查发现,被采样的 Python 服务器中 FastMCP 的 SDK 份额最大——这也是为什么那些还在演示裸 Server 类和手动注册处理函数的 2024 年教程已经在带偏读者:它们展示的正是 FastMCP 有意隐藏的那一层。客户端与测试这一侧,Python SDK 的入口是 from mcp import Client,而 Client(server) 默认是跨时代中立的:它会探测服务器、自己挑协议路径,需要把旧语义钉死时再用 mode="legacy"。
TypeScript 这一侧,SDK 如今是两个包而不是一个。@modelcontextprotocol/server 提供 McpServer 与 createMcpHandler;@modelcontextprotocol/client 提供 Client 与 StreamableHTTPClientTransport。McpServer 依旧接受 Standard Schema——传入 Zod、Valibot 或 ArkType 的 schema,SDK 会把它适配成 MCP 的 inputSchema 形状,无需你手写一份 JSON Schema。凡是把两半都从单一的 @modelcontextprotocol/sdk 里导入的示例,都早于这次拆分。两个 SDK 底下的消息层依然可达,那也正是你在需要直接掌控时——实现新型传输、封装非标准宿主、调试协议问题——才下沉过去的地方;但用它来上线一台普通服务器是选错了海拔。
一台最小服务器确实很小:一个装饰器、一个函数、一次 run 调用。2026-07-28 修订版带来的变化是,小与格式正确从此分了家。initialize 请求没有了,确认它的 notifications/initialized 也没有了;那份原本每条连接只谈一次的约定——协议版本、能力、谁在调用——改为在每一次请求上重新陈述,而在你写的任何一个工具被够到之前,先有三条义务绑在你的服务器身上。2026-07-28 修订版那篇讲全了完整的删除清单与迁移路径;下面是你必须亲手敲出来的那部分。
server/discover 不是可选项。服务器 MUST 实现它——它接替握手,成为客户端弄清"自己在跟什么东西说话"的地方。它返回 supportedVersions、capabilities、可选的 instructions 字符串,以及缓存提示 ttlMs 与 cacheScope。坑在 serverInfo:它不是结果的顶层字段,而要放进 _meta、挂在 io.modelcontextprotocol/serverInfo 这个键下。这一条请对着实际报文亲自核,别只信 SDK——TypeScript SDK 就发过顶层那种形状,换来的是直接连不上,而不是一条弃用警告。
{ "jsonrpc": "2.0", "id": 1, "result": { "supportedVersions": ["2026-07-28", "2025-11-25"], "capabilities": { "tools": {} }, "instructions": "Docs server. Search first, fetch by id second.", "resultType": "complete", "ttlMs": 3600000, "cacheScope": "public", "_meta": { // serverInfo lives HERE, not as a sibling of "capabilities". "io.modelcontextprotocol/serverInfo": { "name": "docs-server", "version": "2.1.0" } } } }
协商随每一次请求一起到来,而核对它的人是你。在 Streamable HTTP 上,每个 POST 都带 MCP-Protocol-Version,它 MUST 与 params._meta 里的 io.modelcontextprotocol/protocolVersion 一致——不一致就是一个 400,错误为 HeaderMismatch(-32020)。Mcp-Method 出现在所有请求上,取 method 的值;Mcp-Name 出现在 tools/call、resources/read 与 prompts/get 上,取 params.name 或 params.uri,非 ASCII 的值使用 Base64 哨兵格式。它们被放进请求头而不是消息体,这一点值得服务器作者花一句话留意:网关不必解析你的 JSON 就能完成路由、限流与鉴权——也就是说,你前面早已有东西在读这些头,并且默认它们是真的。在 params._meta 内部,io.modelcontextprotocol/protocolVersion 与 io.modelcontextprotocol/clientCapabilities 都是 REQUIRED,io.modelcontextprotocol/clientInfo 则 SHOULD 出现;缺了任一必填字段的请求就是格式错误的,你的答复是 -32602 配 HTTP 400。
客户端没有声明过的能力,就是你没有的能力。服务器 MUST NOT 依赖它,硬要去用就是 MissingRequiredClientCapabilityError(-32021),HTTP 400。由于 clientCapabilities 现在逐请求到达、而不再是每条连接一次,这就成了处理函数内部的一个分支,而不是你在启动时读一次的开关:同一个工具可能对这个调用方完全可答、对下一个就不行,而对后者诚实的回应是报错——而不是悄悄降级、模型还根本认不出手里的是降级结果。
这些都不是教程止步的地方,也不是设计功夫真正落脚的地方。你刚写下的那个函数签名,其实已经暗含了一整套"哪些东西应该放到服务器上"的选择。
三选一:tool、resource 还是 prompt。
MCP 给你三种原语来暴露一项能力,这个选择不是风格问题。Tools 是由模型主导、带副作用的动作:如果由模型在运行时决定什么时候调用,且调用会改变状态或代表模型执行某种查询,那它就是一个 tool。Resources 是由应用主导的上下文:URI 可寻址、只读的数据,宿主按自己的节奏读取并放入模型上下文——一份文件、一行数据库记录、一次在某个时点冻结的 API 响应。Prompts 是由用户主导的模板:服务器暴露的工作流脚手架,宿主把它呈现为斜杠命令或菜单条目,并用用户提供的参数展开。
能贴在便利贴上的助记:tool = 模型选、resource = 宿主选、prompt = 用户选。这就是全部判据。如果答案是"模型在运行时决定要不要拉取",那即便底层实现只是读一份文件,它也是 tool。如果答案是"宿主在模型还没看到这一轮之前就已经加载了它",那它是 resource。如果答案是"用户从菜单里挑一个",那它是 prompt。
最常见的失败模式是把一切都做成 tool,因为每份教程都在教 tool。Bloomberry 的调查发现,野生环境中的大多数服务器在 tool 上过度加码、在 resource 上使用不足;中位数服务器带的是 0 个资源、0 个提示。其中一部分是合理的——很多服务器封装的就是动作类 API,整个界面都带副作用——但另一部分就是作者从未问过这个问题。一个要求模型每次对话开头都记得调用的 "get_user_profile" tool,多半是一个伪装的 resource:宿主可以读一次、把 profile 放进上下文,模型就永远不必为它花一次调用。注意这如今是"读取并缓存"的故事,不再是"订阅"的故事——resources/subscribe 与 resources/unsubscribe 已在 2026-07-28 中被移除,而你在 resources/read 上返回的 ttlMs,才是告诉宿主这份副本可以留多久的那个东西。
翻成实操。如果"这东西什么时候被加载"的答案是"模型问的时候",写成 tool。如果答案是"总是加载,对话一开始就加载",写成 resource。如果答案是"用户输入 /summarise 时",写成 prompt。反过来做,就等于让模型去做协议本来就替你安排好的事。
一台服务器实际上线的样子:中位数部署的形状。
Bloomberry 2026 年 2 月的调查——1,412 台公开 MCP 服务器,是目前公开数据里最大的一份——给了"真实服务器长什么样"以具体数字。中位数:5 个工具、0 个资源、0 个提示、无鉴权。不是"hello-world 再多几个";生产环境服务器就是会收敛到 5 这个数字。分布有长尾——有些服务器带 30 或 40 个工具——但中段就是小而重副作用的。
这个形状透露了 MCP 实际的使用方式。大多数服务器是在用一小组高价值操作去封装某个 API、CLI 或数据库;它们不是文档库或知识库——那才是 resource 更能发挥作用的场景。如果你的设计里有十五个工具,调查会提示你要么你的能力面确实异常宽,要么——更常见——你把它们拆得太细,用更粗粒度的工具或者干脆把服务器一分为二会更好。
数字暗示的另一种模式,从业者已经开始称之为"八台 MCP 生产栈":与其做一个覆盖整个业务域的大服务器,团队最后往往会落到几台各自专注一个系统的小服务器——文件系统服务器、数据库服务器、监控服务器、项目管理服务器等等。这既是设计决定也是分发决定,且会与宿主的工具选择预算产生交互:当宿主同时跑八台每台 5 个工具的服务器时,模型上下文里就已经有 40 个工具,可发现性问题开始咬人。通常的正解是让服务器保持窄小,让宿主居中调度——而不是不断把某一台喂胖到无所不能。
具体做法:注册一对 search-then-fetch 工具。
典型例子是内容服务器——文档、知识库、代码索引——上面的朴素设计是一个 search 工具,直接返回完整命中的文档。问题是上下文膨胀:一次调用就可能把数兆字节的散文倒进循环,且这一轮无法挽回。search-then-fetch 模式——在微软 Learn MCP 服务器的复盘中被点名——把这一个操作拆成两个工具:search 返回一串 {id, summary},fetch 接受一个 id、只返回单个文档的完整内容。两次往返代替一次,但每次都很小,且模型自己挑哪几篇值得完整读。
# server.py — FastMCP search-then-fetch pair from fastmcp import FastMCP from pydantic import BaseModel mcp = FastMCP("docs-server") class Hit(BaseModel): id: str title: str summary: str @mcp.tool def search(query: str, limit: int = 10) -> list[Hit]: """Search docs for the top matches. Returns id + summary only; call fetch(id) for the full document body.""" return [Hit(id=r.id, title=r.title, summary=r.snippet) for r in index.search(query, k=limit)] @mcp.tool def fetch(id: str) -> str: """Return the full body of one document by id. Use after search() to pull only the documents you actually need.""" return index.get(id).body if __name__ == "__main__": mcp.run()
这里有几件事在做工。docstring 就是模型会读的 tool 描述——它在结构上与系统提示词无异(见面向 agent 的文档),且互相显式引用,好让模型知道 search 与 fetch 是一个工作流的两半。Hit 这个 Pydantic 模型会成为 tool 响应上的结构化输出 schema;调用方可以依赖这个形状。函数签名上的类型提示——包括 limit 的默认值——会流进 tools/list 发出的 inputSchema,让宿主在调用前就完成参数校验。
客户端在 tools/list 上实际看到的是这样:
{
"jsonrpc": "2.0", "id": 2, "result": {
"resultType": "complete",
"ttlMs": 300000,
"cacheScope": "public",
"tools": [
{ "name": "search",
"description": "Search docs for the top matches. Returns id + summary only; call fetch(id) for the full document body.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10}
},
"required": ["query"]
}
},
{ "name": "fetch",
"description": "Return the full body of one document by id. Use after search() to pull only the documents you actually need.",
"inputSchema": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"]
}
}
]
}
}
那份结果上有三个字段是 2026-07-28 新增的,没有一个是装饰。resultType 挂在结果对象上——是 result.resultType,绝不是 JSON-RPC 信封上——取值为 "complete"、"input_required",或者像 Tasks 扩展的 "task" 那样的扩展值。客户端 MUST 把缺失的 resultType 当作 "complete",正是这个枢纽让修订版之前的服务器还能继续工作;但那是一条兼容规则,不是"你可以不写这个字段"的许可。"input_required" 是开启多轮往返交换的信号,而一个工具该在什么时候发出它,是工具设计要回答的问题。
凡是从 tools/list、prompts/list、resources/list、resources/read 与 resources/templates/list 返回、且 resultType: "complete" 的结果,都 MUST 带上 ttlMs 与 cacheScope。ttlMs 是整数毫秒且 MUST >= 0;cacheScope 取 "public" 或 "private"。这是一个长着牙齿的设计决定,不是一个随手填掉的字段。ttlMs 缺失意味着客户端按 0 处理——立刻就算过期——所以省略它,等于在对每一个客户端说"永远别缓存我的工具列表",代价是每一轮都重新拉一次。而你真填下去的那个数字,决定了你把描述改对、把 schema 修好之后,旧版本还会被从缓存里端上来多久——这就把 ttlMs 摆进了下文那个版本管理问题的正中央。cacheScope: "public" 标明这份结果对每个调用方都一样,放在你前面的共享缓存里是安全的;"private" 标明它因调用方而异。如果你的 tools/list 会随调用方的权限变化,那它就是 "private"。
两个工具的全部契约就是这些:两个名字、两条写给模型看的描述、两份宿主可以校验的 schema,外加那三个字段——它们告诉客户端手里这份结果是什么类型、又能留多久。剩下的——传输、分帧、逐请求的 _meta——全部由 SDK 处理。SDK 唯一替不了你的,是替你挑那个 ttlMs。
第一台服务器上你必然会踩的坑。
每一台第一次上手的服务器都会撞上同样几种坑,且一致到可以列成清单。
忘了写 annotations。MCP 的 tool 上有一个可选的 annotations 对象,字段包括 destructiveHint、idempotentHint、openWorldHint 等,宿主用它们来塑造用户同意 UX——运行前要不要问、能不能静默重试、这次调用会不会触及公网。省略 annotations 会迫使宿主回落到保守默认,一般意味着"每次都问用户",而这一般意味着用户会把你的服务器关掉。
为人类写描述。那种读起来像 docstring 的自由文本 tool 描述——"Retrieves user information from the database"——不是 agent 需要的。描述是模型在选工具时读的文本,因此它应该长成指令:以动词开头、点出使用场景,并说清楚什么时候不要用它。"Get a user by id when you need their email, role, or account status. Do not use for listing users." 读起来对人类来说别扭,实际效果却好得多。
schema 不做版本管理。悄悄改一个参数名会打断所有缓存了 schema 的客户端。微软 Learn 服务器的复盘给出了一个具体数字——一次参数改名会导致 2–5% 的客户端出错——因为这些客户端缓存了改名前的 tool 定义——而在 2026-07-28 下,告诉他们能缓存多久的人正是你。如果 schema 变更不是纯加法式的,要么给 tool 换一个新名字,要么把服务器版本号推进;就地改名等同于静默破坏。
上线你从不测试的 resource。常见的做法是"感觉应该有 resource"就注册几个,然后发现你唯一的客户端——那个 agent——其实从来没读过它们,因为你的宿主根本没把 resource 呈现给模型。要么真正使用它们、接入真实代码路径、放进测试覆盖,要么就砍掉。死能力比缺能力更糟,因为它在清单里看起来像是"覆盖到了"。
stdio 对 HTTP 的想当然。大量第一台服务器的活儿都假设 stdio 一定是开发用传输、Streamable HTTP 一定是生产用传输。两个方向都错。本地 stdio 服务器正在生产环境里跑——多数嵌入 IDE 的服务器就是——远程 HTTP 服务器在共享的团队开发环境里也完全合理。传输的选择应该根据服务器要跑在哪里、以什么方式鉴权来定,而不是根据你把这套配置视作"开发"还是"生产"。
发出旧时代的错误码。"资源未找到"已经从 -32002 挪到 -32602,2026-07-28 的服务器 MUST NOT 再发 -32002——不过你客户端那侧的代码 SHOULD 仍然接受老服务器发来的它。比这一次搬家更要紧的是区段:-32000–-32019 是遗留区段,新的错误码 MUST NOT 再分配到那里;-32020–-32099 保留给规范,实现 MUST NOT 发出其中规范未定义的码;而你自己的应用错误码 SHOULD 整个落在 -32768–-32000 之外。在保留区段里随手挑一个"看起来没人用"的号,属于那种一直好用、直到规范把它分配出去才出事的 bug。
把连接当成会话。逐连接状态没有了,也没有任何东西接替它。跨请求的状态"MUST 由客户端在每次请求上传回的一个显式标识符来引用",而规范把话说得很直白:"一条打开的连接——比如一个 STDIO 进程——不是一次对话,也不是一个会话。"把已登录用户挂在进程上的 stdio 服务器,或者拿套接字当缓存键的 HTTP 服务器,依赖的都是协议不再提供的事实。替代物——一个你在工具结果里交出去、模型再当作参数交回来的句柄——对你的代码是个小改动,对你的威胁模型却是个大改动,因此这件事放在工具设计那一侧展开。
贯穿这些的主线:那些看起来像装饰的部分——描述、annotations、版本管理纪律、选对原语——才是决定这台服务器会不会从你的调试队列里消失、还是永久驻留其中的部分。前 5 个工具就做对,下游的一切——从选择准确率到轨迹可读性再到鉴权复杂度——都会变便宜。