发布一个 MCP Server:TypeScript 与 Python 实测对比
同一个 server 写两遍:三个工具、一个资源、一个 prompt。94 个包对 28 个,冷启动 145 ms 对 709 ms。
本页内容
在开始争论语言之前,先把完整结论量出来。
node ./incidents.js 144.5 ms
python incidents.py 709.4 ms
npx incidents-mcp 712.6 ms前两行是所有人都想看的对比。第三行是第一行里同一个 TypeScript server,但按它实际会被分发的方式启动——结果距离 Python 只差三毫秒。
第 26 章用原始 JSON-RPC 对照 Model Context Protocol 自身规范来读它,因为原始 JSON-RPC 没有语言。本章有两种语言,而论证的重量就在这里:同一个 server,写两遍。 三个工具,一个资源,一个 prompt,两个 SDK,两边都不走捷径。然后是 transports、Inspector、401,以及没人发表过的数字。
这个 server,以及为什么里面有这五样东西
链接到此部分:这个 server,以及为什么里面有这五样东西一个事故日志。三个工具,因为第 18 章里对读写的拆分必须可见:search_incidents 负责读取,open_incident 负责写入并返回一个 handle,resolve_incident 接收这个 handle 并关闭。一个资源,incidents://open,因为读取当前列表是由应用附加的东西。一个 prompt,postmortem,因为“把这个写出来”是人的 slash command。这就是第 26 章的控制层级——model、application、person——变成了五个注册项。
这个 handle 比看上去更重要。第 26 章曾经让一个玩具日历崩掉,因为它把状态保存在模块级数组里:协议没有 session,所以创建工具返回一个不透明标识符,之后每一次调用都把它当作普通参数传入。任一文件里都没有假设调用方就是打开它的那个进程。
下面是同一个工具在两种语言里的并排注册:
server.registerTool(
"resolve_incident",
{
description:
"Close an incident by handle and record its cause.",
inputSchema: {
id: z.string().describe(
"The handle returned by open_incident, e.g. INC-3."),
cause: z.string().describe(
"One sentence. What actually broke."),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
async ({ id, cause }) => {
const at = OPEN.findIndex((i) => i.id === id);
if (at < 0) {
return { isError: true, content: [{ type: "text",
text: `No open incident ${id}. ` +
`Call search_incidents first.` }] };
}
const [done] = OPEN.splice(at, 1);
return { content: [{ type: "text",
text: JSON.stringify({ ...done, cause }) }] };
},
);@server.tool(
description=
"Close an incident by handle and record its cause.",
annotations=ToolAnnotations(
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
),
)
def resolve_incident(
id: Annotated[str, Field(description=
"The handle returned by open_incident, e.g. INC-3.")],
cause: Annotated[str, Field(description=
"One sentence. What actually broke.")],
) -> Incident:
for at, i in enumerate(OPEN):
if i["id"] == id:
done = OPEN.pop(at)
return {**done, "cause": cause}
raise ValueError(
f"No open incident {id}. Call search_incidents first.")先读相同的部分,因为这就是发现。两者都声明了一个名称、一个描述、两个带描述的字符串参数和三个 annotations;两者都是一个函数;两者都没有提到 JSON-RPC、framing、stdout 或协议版本。两个 SDK 收敛到同一种形状,这才是“Tier 1”应该意味着的东西。1
两个差异是真实的,而且后面都会回来。TypeScript 用 schema library 描述参数——这里是 Zod——这个 schema 是你写出来的一个值。Python 用函数自己的类型提示来描述参数,并在 import 时读取它们,所以它知道一些 TypeScript 文件从未告诉它的函数信息。还有错误路径:TypeScript 返回带有 isError 的工具结果,Python 则 raise。记住这一点。
其他四个注册项在结构上没有差别。资源是 server.registerResource("open-incidents", "incidents://open", …) 对 @server.resource("incidents://open", …);prompt 是 registerPrompt 对 @server.prompt。每个文件的最后一行是 transport:await server.connect(new StdioServerTransport()) 对 server.run()。
完整文件:TypeScript 是 81 行非空行、3,060 字节,Python 是 63 行、2,555 字节。这个数字该带着应有的保留来看——行数衡量的既是语言,也是 formatter,所以它们没有出现在下面的 headline table 里。
一个 client,两个 servers
链接到此部分:一个 client,两个 servers证明语言不可见的方法,就是同一个 client 跑两次,十一行:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const client = new Client({ name: "incident-cli", version: "1.0.0" });
await client.connect(new StdioClientTransport({
command: process.argv[2], args: process.argv.slice(3) }));
const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));
const opened = await client.callTool({ name: "open_incident",
arguments: { title: "Queue backed up", severity: "sev2" } });
console.log("open_incident ->", JSON.stringify(opened.content));依次指向两个 server。真实输出,已裁剪:
$ node client.ts node incidents.ts
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\"id\":\"INC-3\"}"}]
$ node client.ts ./py/.venv/bin/python ./py/incidents.py
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\n \"id\": \"INC-3\"\n}"}]同样的工具,同样的顺序,同样的 handle。TypeScript client 看不出 server 是用什么写的,它也从来不问。这就是一个协议的全部承诺,而且兑现了。
现在看第二个结果里的空白,因为这不是外观问题:Python SDK 用 pydantic_core.to_json(result, fallback=str, indent=2) 序列化 payload。在列表里有两条事故的资源读取中,TypeScript body 是 136 个字符、37 个 o200k_base token;Python body 是 185 个字符、62 个 token。相同的行多出 68% 的 token,每一次把这个资源读进 prompt 时,都由读取者付费。
目录也讲着同一个故事,只是原因更大。两个 servers,同样三个工具,tools/list 按 key 称重:
| key | TypeScript | Python |
|---|---|---|
name | 21 | 21 |
description | 46 | 46 |
annotations | 46 | 46 |
inputSchema | 211 | 192 |
outputSchema | — | 187 |
execution | 27 | — |
| total | 342 | 480 |
Python 的 input schemas 更便宜——TypeScript 的 Zod bridge 会在每一个上盖一个 $schema 和一个 additionalProperties。整个 138-token 的差距,是一个没人手写的 output schema。resolve_incident 被标注为 -> Incident,所以 SDK 为返回类型推导出了一个 JSON Schema 并把它一起发出。它确实有用——正是它让 client 可以验证 structuredContent——而它也是 187 个 token,因为一个类型提示进入了你的 context window。第 24 章关于定义会挤占重要材料的规则,同样适用于你不知道自己拥有的 schemas。
故意弄坏它:泄漏出去的错误消息
链接到此部分:故意弄坏它:泄漏出去的错误消息上面的两条错误路径并不是风格选择。给每个 server 一个会像真实 integration 那样失败的工具,然后读读有什么传到了 model。
TypeScript {"content":[{"type":"text","text":
"connect ECONNREFUSED 10.0.3.7:5432 (db-prod-eu, user=reporting)"}],
"isError":true}
Python {"content":[{"text":"Error executing tool boom","type":"text"}],
"isError":true}TypeScript SDK 把一个内部地址、一个端口、一个数据库名和一个 service account 放进了 model 的 context。Python SDK 没把这些放进去;traceback 进了 stderr,并留在 server 上。
两者都不是 bug。两者都是决策,而 Python 的那一个写在它自己的 docstring 里:ToolError 是“你预料到的失败”,它的消息会“在 content 中返回给 model 阅读”;其他任何东西“都会被当作 crash:model 只看到 Error executing tool <name>,server 在 ERROR 记录 traceback”。crash case 的类把剩下的话说得很明白——“原始内容没有任何东西会到达 client”。
两种行为各有一半时间是错的。第 18 章曾论证,validation error 应该作为 model 能读并能修正的工具结果返回,因为那通常是大多数 integrations 里杠杆最高的一行;在 Python 侧,这要求显式 raise ToolError,而一个裸 ValueError 会把有用的句子扔掉。第 30 章的论点则朝另一个方向:工具返回的一切都会落入一个 context,后续的 prompt injection 可能试图把它读出来,而未经审查的 exception 字符串,是你系统里审计最少的文本。
能同时经受两边考验的规则是:按工具逐个决定失败允许说什么,并自己写下那段字符串。 在任一语言里,都不要让 exception 的默认文本替你决定。
故意弄坏它:standard output 上的一行
链接到此部分:故意弄坏它:standard output 上的一行官方教程毫不含糊地说明了规则:“For STDIO-based servers: Never write to stdout. Writing to stdout will corrupt the JSON-RPC messages and break your server. The print() function writes to stdout by default, so keep it out of a STDIO server entirely.”1 第 26 章引用了规范版本——server “MUST NOT write anything to its stdout that is not a valid MCP message”。2
给每个 server 加一行,然后读取原始 stream:
TypeScript incidents server starting
{"result":{"protocolVersion":"2025-11-25", … },"jsonrpc":"2.0","id":1}
Python {"jsonrpc":"2.0","id":1,"result":{ … }}
incidents server startingPython 这个更糟,原因并不是 MCP。一个 stdout 是 pipe 而不是 terminal 的进程,会得到 block-buffered stream,所以这行杂音会在 buffer 自己决定的时候 flush——这里是在退出时,晚于一个它本来写在其前面的 response。污染并不会出现在 bug 所在的位置。加上 flush=True,或者换一个会 flush 的 library,它就会移动。
然后就是解释为什么这种东西会被发出去的部分。把坏掉的 server 喂给三个 clients:
naive parser, dirty server SyntaxError: Unexpected token 'i',
"incidents "... is not valid JSON
SDK client, dirty server tools: search_incidents, open_incident, resolve_incident
MCP Inspector, dirty server full catalogue, no warning七行 parser 立刻死掉。官方 client 和 Inspector 都耸耸肩——跳过那一行,继续运行。一个只会弄坏没人使用的 clients 的规则,会原封不动地进入生产环境,所以值得在这里故意弄坏,而不是在客户日志里才发现。
Inspector 的 CLI 模式是常被遗忘的那一半:npx @modelcontextprotocol/inspector --cli <command> --method tools/list 会打印目录并退出,这让它能被脚本化,而 browser UI 做不到这一点。3
两个 SDK 都干净安装在各自目录中,没有任何共享:
| TypeScript | Python | |
|---|---|---|
| package | @modelcontextprotocol/sdk 1.30.0 + zod 3.25.76 | mcp 2.1.1 |
| latest protocol revision implemented | 2025-11-25 | 2026-07-28 |
| transitive packages installed | 94 | 28 |
| installed size | 13.9 MiB | 44.3 MiB |
| files on disk | 3,386 | 2,018 |
| third-party packages loaded to serve stdio | 8 of 94 | 18 of 28 |
| bare interpreter start, median | 19.4 ms | 11.1 ms |
spawn → tools/list answered, median of 25 | 144.5 ms | 709.4 ms |
tools/list catalogue, o200k_base tokens | 342 | 480 |
每一行都以不同方向令人意外,所以这组对比值得真正跑一遍,而不是凭直觉假设。
TypeScript 安装的包超过三倍,字节数却不到三分之一。 94 个 dependencies 是 npm ecosystem 的本色——fast-deep-equal、es-errors、dunder-proto。Python 的 28 个更少,但非常庞大:cryptography、pydantic-core 和 uvicorn 都是 compiled artefacts。如果你的直觉是 dependency count 才是该担心的事,这一行就是反例。
Python 的 interpreter 启动比 Node 更快,而且差距明显——空程序 11.1 ms 对 19.4 ms。所以冷启动行里的 565 ms 不是语言造成的。它是 SDK,而 loaded-packages 行说明了原因:
TypeScript 8 of 94 sdk, zod, zod-to-json-schema, ajv, ajv-formats,
fast-deep-equal, fast-uri, json-schema-traverse
Python 18 of 28 mcp, mcp_types, pydantic, pydantic_core, anyio,
starlette, uvicorn, sse_starlette, httpx2,
cryptography, _cffi_backend, opentelemetry, click, …一个唯一 I/O 是 pipe 的 server,在读取第一行之前 import 了 ASGI web server、HTTP client 和 TLS library。TypeScript SDK 也带着 Express、Hono、jose 和 eventsource——它们躺在磁盘上没有被读到,因为 package boundary 把它们挡在 server/stdio.js import 之外。Python 的 package 是一个 import graph,所以 import mcp 就是全部:python -X importtime 把 727 ms 归因于 import mcp.server.mcpserver——这个数字是在 import profiler 下测得的,所以会高于未 profiling 时从 spawn 到 answer 的 709 ms——其中 269 ms 仅归因于 mcp.types subtree——wire types 是 Pydantic models,每个协议消息、每个 revision 一个 class,构建它们就是 import 时完成的工作。这是设计取舍,不是粗心——eager imports 正是 Python SDK 能在下一行不需要第二次安装就把 run(transport="streamable-http") 交给你的原因。
然后开头代码块的最后一行又推翻了这个论点。 正确打包 TypeScript server——一个 bin entry、一个 shebang、npm link、无需下载任何东西——并通过带 --no-install 的 npx 启动它,这正是已发布 stdio server 的实际启动方式:
node ./incidents.js 144.5 ms
npx incidents-mcp 712.6 ms (+568.1 ms of launcher)
python incidents.py 709.4 mslauncher 每次启动花费 568 ms——是整个 TypeScript SDK import 的四倍半——而且每次 launch 都要付,因为 MCP host 启动 stdio server 的方式就是运行这条 command。所以“TypeScript 启动快五倍”的诚实表述是:确实快,直到你按正常方式分发它。 同样的 caveat 大概也适用于 uvx;这台机器没有安装 uv,所以没有那一行。没测的东西不上表。
两种 transports,也只有两种
链接到此部分:两种 transports,也只有两种第 26 章讲过 stdio 的 framing。还有两件事留到这里。
第一:用 npx 或 uvx 运行 server 就是 stdio transport。 没有什么单独的“package mode”。host 的配置指定 command 和 arguments;host spawn 它,并通过 pipes 交谈。所以在本地,“我该怎么分发它”和“它说哪种 transport”是同一个问题,也因此 launcher 的成本属于关于 shipping 的一章。
第二:stdio 完全没有 authorization section,规范用一句话说明了这一点——使用 stdio 的实现“SHOULD NOT follow this specification, and instead retrieve credentials from the environment”。4 它的安全模型就是操作系统的安全模型,它的限制也是如此:一个本地 subprocess 精确服务于一台机器和一个用户。
另一个 live transport 是 Streamable HTTP:一个接受 POST 的单一 endpoint,每条 JSON-RPC message 对应一个 HTTP request,并且 Accept header 必须同时列出 application/json 和 text/event-stream,因为 server 会按 request 选择用两者中的哪一个来回答。5 第 14 章曾手动解析那个 event stream,所以 wire format 里没有新东西——新的只是包在外面的东西。当前 revision 有三个容易错过的义务,而且三个都可测试:
version header 必须与 body 一致
链接到此部分:version header 必须与 body 一致每个 POST 都带着 MCP-Protocol-Version,它的值必须匹配 request 自己的 _meta 里面的 protocolVersion。不匹配时是带 header-mismatch error 的 400,不是耸肩放过。5
还需要两个 headers 才算合规
链接到此部分:还需要两个 headers 才算合规Mcp-Method 在每个 request 上镜像 method;Mcp-Name 在 tools/call、resources/read 和 prompts/get 上镜像 params.name 或 params.uri。它们存在的目的,是让 proxy 不解析 body 也能 route。5
旧形状已经消失,并且应以拒绝作答
链接到此部分:旧形状已经消失,并且应以拒绝作答GET stream、Mcp-Session-Id 和 Last-Event-ID resumption 都被移除了。只支持这个 revision 的 server,应该对 GET 或 DELETE 回答 405 Method Not Allowed,忽略 session header 而不生成 session,并忽略 Last-Event-ID。5
现在是重新框定整章的测量。通过 HTTP 给每个 server 发送当前 revision 的 request。
Python 200 {"result":{"resultType":"complete","cacheScope":"private","ttlMs":0,
"tools":[…],"_meta":{"io.modelcontextprotocol/serverInfo":{…}}}}
TypeScript {"error":{"code":-32000,"message":"Bad Request: Unsupported protocol
version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18,
2025-03-26, 2024-11-05, 2024-10-07)"}}常量与行为一致:Python SDK 的 LATEST_PROTOCOL_VERSION 读到 2026-07-28,TypeScript SDK 的读到 2025-11-25。发送上一步里的 header-mismatch request,Python server 回答带 error -32020 的 400,消息是“mcp-protocol-version header does not match the request envelope's protocol version”;TypeScript SDK 没有这样的代码,因为它没有实现定义它的那个 revision。
把两者都列为 Tier 1 的页面也说“Each SDK provides the same functionality”。1 在下面这个日期,对于当前 revision,这句话还是愿景。检查你准备安装的 SDK 里的 LATEST_PROTOCOL_VERSION;它只有一行,而且是本章唯一一年后仍然重要的 claim。
401,以及该引用的那句话
链接到此部分:401,以及该引用的那句话把 server 从你的笔记本上搬出去,陌生人的 client 就会带着 token 出现。这是第 26 章没有处理的一半,也是多用户产品不能跳过的一半。
规范把 MCP server 放在 OAuth 2.1 角色里,并给它命名:受保护的 MCP server 是 resource server,client 是 OAuth client,而 authorization server 是别人的问题。4 从这个角色出发,有四条 mandatory clauses,完整引用,因为转述正是错误产生的方式:
MCP servers, acting in their role as an OAuth 2.1 resource server, MUST validate access tokens as described in OAuth 2.1 Section 5.2. MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. […] MCP clients MUST NOT send tokens to the MCP server other than ones issued by the MCP server's authorization server. MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.4
“Must not accept or transit”就是反 passthrough 规则,也正是整个 audience 机制存在的原因。一个 server 如果把交给它的 bearer token 重放到 third-party API,就是 confused deputy:它把自己的信任借给了调用它的人。规则禁止的是复用,而不只是存储。
要让这件事可执行,需要四个 RFC,各司其职。6 RFC 9728 是 client 最初找到 authorization server 的方式:MCP server 提供 protected-resource-metadata document,并用 401 指向它。RFC 8707 是 resource parameter——client 必须在 authorization request 和 token request 中都发送 server 的 canonical URI,“regardless of whether authorization servers support it”,这样签发出来的 token 才会命名其 audience。RFC 9207 从另一侧闭环:client 在 redirect 前记录 issuer,并以精确字符串比较返回的 iss,不做 normalisation——不折叠大小写、不省略默认端口、不处理尾随斜杠。RFC 7591,Dynamic Client Registration,现在已被弃用,改用 Client ID Metadata Documents,“retained for backwards compatibility with authorization servers that do not support” 它们。4
在两个 servers 上接入一个只检查 audience 的 token verifier。TypeScript 的 ladder:
no token 401 WWW-Authenticate: Bearer error="invalid_token",
error_description="Missing Authorization header",
scope="incidents:read",
resource_metadata="…/.well-known/oauth-protected-resource/mcp"
aud=other server 401 error_description="token audience is not this server"
no exp claim 401 error_description="Token has no expiration time"
right aud, no scope 403 error="insufficient_scope", scope="incidents:read"
right aud + scope 200 {"result":{"tools":[…]}}{"resource":"http://127.0.0.1:8931/mcp",
"authorization_servers":["https://auth.example.com/"],
"scopes_supported":["incidents:read","incidents:write"],
"resource_name":"Incidents"}两个 SDK 都提供这个 document,并且都用 401 指向它,这就是完整的 discovery story:一个从未见过你 server 的 client,会从一次拒绝中学会去哪里认证。403 是另一回事——token 没问题,scope 不对——challenge 会命名缺失的内容,所以 client 可以升级权限,而不是从头开始。
两个 rungs 有差异,而两者都不在规范里。TypeScript SDK 会拒绝没有 expiry claim 的 token;Python 返回 200,因为 expires_at 在它的 AccessToken 上是 optional,而 None 表示“没有意见”。并且 Python 的 403 带着 error_description="Required scope: incidents:read",却没有规范说 server 应该包含的 scope parameter。verifier 不是接受 library default 的地方:audience check 在任一语言里都要由你写,expiry 也是。
同一次运行里还有一个诚实的小瑕疵。对 endpoint 发 GET 时,Express wiring 回答 404,Python 回答 400 Bad Request: Missing session ID;规范要求的是 405 Method Not Allowed,而且“session ID”是这个 revision 已经移除的词汇。两者都不危险;两者都是 ecosystem 正在迁移途中的形状。
Servers 实际住在哪里
链接到此部分:Servers 实际住在哪里shipping 的最后一块是你发布到哪里,而这个问题有一个带数字的答案。今天抓取了官方 registry 中每个 server 的最新版本:7
| servers | |
|---|---|
| total (latest version, not deleted) | 28,170 |
| active / deprecated | 27,853 / 317 |
| ship at least one installable package | 13,065 |
| remote only — a URL, nothing to install | 14,696 |
| npm | 8,275 |
| PyPI | 3,603 |
| OCI images | 867 |
mcpb bundles | 706 |
| NuGet / Cargo | 107 / 43 |
两种读法,指向相反方向。按已发布 servers 计,npm 以 2.3 比 1 领先——这是人们说 ecosystem 是 TypeScript 时引用的数字。按 downloads 计,Python 领先:过去三十天 mcp 是 286.7 million,@modelcontextprotocol/sdk 是 194.7 million,还没加上 fastmcp 的 72.1 million。7 两者都是 Tier 1,normative schema 是 schema.ts,官方“Build an MCP server”教程打开时停在 Python tab。1 无论你脑子里记着哪一半,另一半也是真的。
而比任一行都更重要的是这一行:超过半数 registry——28,170 中的 14,696——没有任何可安装的东西。 它们是 web services。transport 统计从另一侧印证:在 14,290 个 package entries 中,13,787 个声明 stdio;在 16,640 个 remote entries 中,15,570 个声明 Streamable HTTP,1,070 个仍声明已弃用的 HTTP+SSE。所以“一个 MCP server 就是你笔记本上的 subprocess”描述的是一个正在缩小的少数,而这 14,696 个中的每一个都需要上面那一节,而不是一个 environment variable。
查看详情
有意双语,以及它的先例。
这是本课程唯一的双语章节,因为诚实答案本身就是分裂的:今天,同一时间,registry 是 npm-first,而 downloads 是 Python-first。只写其中一种会放弃问题的一半,并在描述 ecosystem 时失真。公开材料里有先例——Hugging Face MCP Course 在先修条件中列出“Experience with at least one programming language (Python or TypeScript examples will be shown)”,并且两者都教。一个价值完全来自实现数量的协议,不适合单语。
带日期的部分:上面所有有保质期的东西
链接到此部分:带日期的部分:上面所有有保质期的东西阅读和测量日期为 2026 年 9 月 7 日,对应 protocol revision 2026-07-28。
| value | |
|---|---|
@modelcontextprotocol/sdk | 1.30.0, published 27 July 2026; 4,322,438 bytes unpacked, 693 files, 17 direct dependencies |
| latest revision it implements | 2025-11-25 |
mcp (PyPI) | 2.1.1, published 25 August 2026; 357,912-byte wheel, plus mcp-types 2.1.1 at 69,656 bytes |
| latest revision it implements | 2026-07-28 |
| SDK tiers | TypeScript, Python, C#, Go, Rust at Tier 1; Java, Ruby at Tier 2; Swift, PHP, Kotlin at Tier 3 |
| registry servers | 28,170 |
| downloads, last 30 days | mcp 286,653,871 · fastmcp 72,097,269 · @modelcontextprotocol/sdk 194,679,333 |
还有一条不是数字的迁移说明。在 mcp 2.x 中,FastMCP 改名为 MCPServer,而网上几乎所有教程开头仍然是旧 import。SDK 附带了一个唯一目的就是解释这一点的模块,这是本章最体贴的一次 deprecation:
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import
MCPServer) and other APIs changed; see the migration guide … or pin 'mcp<2'
to keep running v1 code.那么选哪个
链接到此部分:那么选哪个把表格摆在面前,建议就会变得无聊,而这是好迹象。
如果 server 住在你已经运行的 web application 里,就用 TypeScript 写。 同一个进程,同一次 deploy,同一个 request handler;Streamable HTTP 就是你加在其他 endpoints 旁边的一个 endpoint;而 13.9 MiB 和 145 ms 是免费的,因为 runtime 本来就已经启动了。14,696 个 remote servers 里大多数都是这种情况。
如果 server 包装的是 data tooling,就用 Python 写。 你暴露出去的是 pandas、warehouse client、一整个 notebook 的 transforms;如果 server 用另一种语言写,那就是一次披着 schema 的 subprocess call。一个启动一次的 service 里,700 ms import 不算成本;一个 host 整天反复重启的 subprocess 里,它就是成本。
而目前,revision 行优先于两者。 如果你需要 2026-07-28——multi-round-trip requests、resultType、cache hints、server/discover——今天两个 SDK 里只有一个有它。
接下来去哪里
链接到此部分:接下来去哪里现在,你可以用任一语言发布同一个 server,用表格而不是偏好来捍卫选择,在两种 live transports 上运行它,并交给它一个它会拒绝的 token。
你构建的仍然是一个函数:一个 schema、一个 endpoint、一个由 model 调用的 deterministic thing。有一整类知识不适合这种形状——我们如何写 postmortem、我们的 incident reports 需要哪些字段、我们做事的顺序以及原因。它是 procedure,是 prose,把它硬塞进工具描述,正是 system prompts 长到两千 token 的方式,而且每一轮都要付费,无论对话是否关于 incidents。
第 28 章是另一个答案:一个里面有 SKILL.md 的文件夹,model 读取它,而不是调用它;它按三层加载,所以 reference material 在需要的那一轮之前几乎不花成本。它没有主语言,而这正是它教的第一件事。
来源与方法
链接到此部分:来源与方法这里的一切都测量于 2026 年 9 月 7 日,环境为 Node 22.22.3 和 Python 3.14.4,针对 @modelcontextprotocol/sdk 1.30.0 搭配 zod 3.25.76,以及 mcp 2.1.1;每个都安装到自己的临时目录中。timings 是 25 次启动的 median,wall clock 从 spawn 到携带 tools/list response 的那一行;token counts 是通过 tiktoken 对每个 definition 的 JSON 运行 o200k_base 得到的。没有调用任何 paid API:这里没有任何东西需要 model。
两个 servers 分别是 81 和 63 行非空行;它们三个工具中的一个已在上面用两种语言复现,另外四个注册项的差异仅如文中所述。Python SDK 的 error-disclosure policy 引自 mcp/server/mcpserver/exceptions.py 中 ToolError 和 UnexpectedToolError 的 docstrings;pretty-printing default 是 mcp/server/mcpserver/resources/types.py 和 utilities/func_metadata.py 中的 pydantic_core.to_json(result, fallback=str, indent=2)。protocol-version constants 是 mcp_types/version.py 中的 LATEST_PROTOCOL_VERSION,以及 TypeScript SDK 的 types.js,两者都直接读取自已安装的 packages,而不是 changelog。
参考资料
链接到此部分:参考资料-
SDKs,
modelcontextprotocol.io/docs/sdk, and Build an MCP server,modelcontextprotocol.io/docs/develop/build-server, both read 7 September 2026. tier table、句子“Each SDK provides the same functionality but follows the idioms and best practices of its language”、教程 language-tab 顺序(Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, Go),以及关于print()和stdout的 logging rule 引文来源。 ↩ ↩2 ↩3 ↩4 -
stdio transport,
.../basic/transports/stdio. newline framing 和stdoutpurity rule 的来源。第 26 章完整阅读了这个页面;这里引用它,是为了指出 broken server 违反的那一行。 ↩ -
MCP Inspector,
modelcontextprotocol.io/docs/2026-07-28/tools/inspector, read 7 September 2026. 一个 package,一个 binary 后面有三个 clients——web、--cli和--tui——共享一个 core、一组 transports 和磁盘上的一个 OAuth state。这里的 catalogue traces 由 CLI 生成。 ↩ -
Authorization,
modelcontextprotocol.io/specification/2026-07-28/basic/authorization, read 7 September 2026. resource-server 角色的来源;完整引用的四条 token-handling clauses;server 必须实现 RFC 9728 且 client 使用它进行 discovery 的要求;resourceparameter 规则和 canonical-URI 定义;issuer-validation table;Dynamic Client Registration 的弃用;401/403/400table 和insufficient_scopechallenge;以及 stdio exemption:“Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.” ↩ ↩2 ↩3 ↩4 -
Streamable HTTP,
.../basic/transports/streamable-http, and Transports overview,.../basic/transports. 单一 endpoint POST 规则、双Accept要求、MCP-Protocol-Versionheader 及其必须匹配 body 的规则、被描述为“REQUIRED for compliance”的Mcp-Method和Mcp-Nameheaders、GET stream、sessions 和Last-Event-ID的移除、405guidance、mandatoryOriginvalidation,以及 2024-11-05 HTTP+SSE transport 在 SEP-2596 下被归类为 Deprecated 的来源。 ↩ ↩2 ↩3 ↩4 -
规范依赖的四个 RFC,以及它 profile 的 draft:The OAuth 2.1 Authorization Framework,
draft-ietf-oauth-v2-1-13. Campbell, B., Bradley, J. and Tschofenig, H., Resource Indicators for OAuth 2.0, RFC 8707, February 2020 —resourceparameter 以及它绑定的 audience。Jones, M.B., Hunt, P. and Parecki, A., OAuth 2.0 Protected Resource Metadata, RFC 9728, April 2025 —401指向的 document。Meyer zu Selhausen, K. and Fett, D., OAuth 2.0 Authorization Server Issuer Identification, RFC 9207, March 2022 —issparameter 和 exact-string comparison。Richer, J. (ed.) et al., OAuth 2.0 Dynamic Client Registration Protocol, RFC 7591, July 2015, deprecated for this use. 以及 Jones, M. and Hardt, D., The OAuth 2.0 Authorization Framework: Bearer Token Usage, RFC 6750, October 2012, section 3, for theWWW-Authenticatechallenge shape above. ↩ -
Official MCP registry,
registry.modelcontextprotocol.io/v0/servers, crawled 7 September 2026 withversion=latest: 282 pages, 28,170 servers, tallied byregistryTypeover distinct server names. Download figures:api.npmjs.org/downloads/point/last-monthfor@modelcontextprotocol/sdk(194,679,333 for 8 August – 6 September 2026) andpypistats.org/api/packages/<name>/recentformcpandfastmcp, both read the same day. Package sizes come from the npm registry document and the PyPI JSON API. ↩ ↩2