MCP 协议设计与实现
第20章 从零构建一个生产级 MCP Server
第20章 从零构建一个生产级 MCP Server
“The best way to learn a protocol is to build something real with it — not a hello world, but something you’d actually deploy.”
本章要点
- 从需求分析到技术选型:TypeScript 与 Python SDK 的取舍考量
- 实现完整的 Tools、Resources、Prompts 三大原语
- 为 Server 添加 OAuth 认证与安全防护
- 编写协议级集成测试
- 部署策略:stdio 本地模式与 Streamable HTTP 远程模式
- 发布到 MCP 生态
20.1 我们要构建什么
本章将构建一个 GitHub MCP Server——让 AI 助手能够与 GitHub 仓库交互。这不是一个玩具示例,而是一个覆盖了 MCP 核心特性的真实集成:
| MCP 原语 | 实现内容 |
|---|---|
| Tools | 创建 Issue、搜索仓库、创建 PR |
| Resources | 暴露仓库文件内容、Issue 列表 |
| Prompts | 提供 Code Review、Bug Report 模板 |
| Completion | 仓库名、分支名自动补全 |
| Progress | 批量操作的进度追踪 |
20.2 技术选型:TypeScript vs Python
20.2.1 两个 SDK 的定位差异
MCP 官方提供两个 Tier-1 SDK,各有所长:
graph LR
subgraph "TypeScript SDK"
TS1["McpServer 高级 API"]
TS2["Server 底层 API"]
TS3["Zod Schema 集成"]
TS4["completable() 补全"]
TS5["Express / Hono 中间件"]
end
subgraph "Python SDK"
PY1["McpServer 高级 API"]
PY2["Server 底层 API"]
PY3["Pydantic 类型推断"]
PY4["@server.tool() 装饰器"]
PY5["Starlette ASGI 集成"]
end
style TS1 fill:#3b82f6,color:#fff,stroke:none
style PY1 fill:#10b981,color:#fff,stroke:none
选 TypeScript 的理由:
- 如果你的 Server 主要做 Web API 集成(如 GitHub、Slack、Jira),TypeScript 的异步模型和 npm 生态更成熟
- Zod +
completable()的组合让参数验证和自动补全的 DX 极佳 - 前端/全栈团队更容易上手
选 Python 的理由:
- 如果你的 Server 涉及数据分析、机器学习模型调用,Python 生态无可替代
@server.tool()装饰器语法更简洁,类型推断基于 Python 原生 type hints- 数据科学团队更熟悉
本章以 TypeScript 为主线,关键差异处会给出 Python 的对照。
20.3 项目初始化
20.3.1 创建项目
mkdir mcp-server-github && cd mcp-server-github
npm init -y
npm install @modelcontextprotocol/server @modelcontextprotocol/core zod
npm install -D typescript @types/node vitest
TypeScript 配置:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true
},
"include": ["src"]
}
20.3.2 入口文件
// src/index.ts
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server';
const server = new McpServer({
name: 'github-mcp-server',
version: '1.0.0'
}, {
capabilities: {
logging: {},
completions: {}
},
instructions: '这是一个 GitHub 集成 Server。使用前请确保已配置 GITHUB_TOKEN。' +
'建议先用 search_repos 查找仓库,再用具体的工具操作。'
});
// 注册 Tools、Resources、Prompts(后续各节详述)
// ...
// 启动
const transport = new StdioServerTransport();
await server.connect(transport);
instructions 字段值得注意——它不是给人看的,而是给 LLM 看的。好的 instructions 能显著提高 LLM 选择正确工具的准确率。
20.4 实现 Tools
20.4.1 搜索仓库
import * as z from 'zod/v4';
import { completable } from '@modelcontextprotocol/server';
// GitHub API 客户端(简化版)
async function githubFetch(path: string, options?: RequestInit) {
const token = process.env.GITHUB_TOKEN;
if (!token) throw new Error('GITHUB_TOKEN 环境变量未设置');
const resp = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'mcp-server-github/1.0',
...options?.headers
}
});
if (!resp.ok) {
throw new Error(`GitHub API 错误: ${resp.status} ${resp.statusText}`);
}
return resp.json();
}
// 搜索仓库工具
server.registerTool(
'search_repos',
{
title: '搜索 GitHub 仓库',
description: '根据关键词搜索 GitHub 仓库,返回仓库名、描述、Star 数等信息',
inputSchema: z.object({
query: z.string().describe('搜索关键词,支持 GitHub 搜索语法'),
language: completable(
z.string().optional().describe('编程语言过滤'),
(value) => ['typescript', 'javascript', 'python', 'rust', 'go', 'java', 'c++']
.filter(lang => lang.startsWith(value.toLowerCase()))
),
sort: z.enum(['stars', 'forks', 'updated']).default('stars')
.describe('排序方式'),
limit: z.number().min(1).max(30).default(10)
.describe('返回结果数量')
}),
annotations: {
readOnlyHint: true,
openWorldHint: true
}
},
async ({ query, language, sort, limit }) => {
let q = query;
if (language) q += ` language:${language}`;
const data = await githubFetch(
`/search/repositories?q=${encodeURIComponent(q)}&sort=${sort}&per_page=${limit}`
);
const repos = data.items.map((repo: any) => ({
name: repo.full_name,
description: repo.description || '无描述',
stars: repo.stargazers_count,
language: repo.language,
url: repo.html_url
}));
return {
content: [{
type: 'text',
text: JSON.stringify(repos, null, 2)
}]
};
}
);
注意 annotations 的使用——readOnlyHint: true 告诉 Client 这个工具不会修改任何数据,openWorldHint: true 表示它访问外部网络。这些元信息帮助 Client 做出更好的权限决策。
20.4.2 创建 Issue
server.registerTool(
'create_issue',
{
title: '创建 GitHub Issue',
description: '在指定仓库中创建一个新的 Issue',
inputSchema: z.object({
repo: completable(
z.string().describe('仓库全名,格式: owner/repo'),
async (value) => {
// 动态补全:搜索用户有权限的仓库
try {
const data = await githubFetch(
`/search/repositories?q=${encodeURIComponent(value)}+in:name&per_page=5`
);
return data.items.map((r: any) => r.full_name);
} catch {
return [];
}
}
),
title: z.string().min(1).describe('Issue 标题'),
body: z.string().optional().describe('Issue 正文(Markdown 格式)'),
labels: z.array(z.string()).optional().describe('标签列表'),
assignees: z.array(z.string()).optional().describe('指派人列表')
}),
annotations: {
destructiveHint: false,
idempotentHint: false // 每次调用都会创建新 Issue
}
},
async ({ repo, title, body, labels, assignees }) => {
const issue = await githubFetch(`/repos/${repo}/issues`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, body, labels, assignees })
});
return {
content: [{
type: 'text',
text: `Issue 创建成功!\n\n` +
`标题: ${issue.title}\n` +
`编号: #${issue.number}\n` +
`链接: ${issue.html_url}`
}]
};
}
);
repo 参数使用了 completable 封装,当用户开始输入仓库名时,Server 会实时调用 GitHub API 搜索匹配的仓库——这就是第 18 章介绍的 Completion 机制在实践中的应用。
20.4.3 Python 对照
同样的 create_issue 工具在 Python SDK 中的写法:
from mcp.server.mcpserver import McpServer
from mcp.server.mcpserver.context import Context
server = McpServer("github-mcp-server")
@server.tool(
name="create_issue",
title="创建 GitHub Issue",
description="在指定仓库中创建一个新的 Issue"
)
async def create_issue(
repo: str,
title: str,
body: str | None = None,
labels: list[str] | None = None,
ctx: Context | None = None
) -> str:
"""repo: 仓库全名 (owner/repo), title: Issue 标题"""
if ctx:
await ctx.info(f"正在创建 Issue: {title}")
issue = await github_fetch(f"/repos/{repo}/issues", method="POST",
json={"title": title, "body": body, "labels": labels})
return f"Issue #{issue['number']} 创建成功: {issue['html_url']}"
Python SDK 的 Context 注入机制很优雅——你只需要在函数签名中声明一个 Context 类型的参数,SDK 会自动注入上下文对象,无需显式传递。
20.5 实现 Resources
Resources 让 AI 可以”读取”GitHub 上的内容,就像读取本地文件一样:
import { ResourceTemplate } from '@modelcontextprotocol/server';
// 静态资源:当前用户信息
server.registerResource(
'github://user/profile',
{
name: '当前 GitHub 用户',
description: '获取当前认证用户的 GitHub 个人信息',
mimeType: 'application/json'
},
async () => {
const user = await githubFetch('/user');
return {
contents: [{
uri: 'github://user/profile',
mimeType: 'application/json',
text: JSON.stringify(user, null, 2)
}]
};
}
);
// 资源模板:仓库文件内容
server.registerResourceTemplate(
new ResourceTemplate('github://repos/{owner}/{repo}/contents/{path}', {
list: async () => {
// 返回一些常见的文件作为示例
return {
resources: [
{
uri: 'github://repos/anthropics/mcp-specification/contents/README.md',
name: 'MCP Specification README',
mimeType: 'text/markdown'
}
]
};
}
}),
{
name: '仓库文件内容',
description: '读取 GitHub 仓库中指定路径的文件内容',
mimeType: 'text/plain'
},
async (uri, { owner, repo, path }) => {
const data = await githubFetch(`/repos/${owner}/${repo}/contents/${path}`);
// GitHub API 返回 base64 编码的内容
const content = Buffer.from(data.content, 'base64').toString('utf-8');
return {
contents: [{
uri: uri.href,
mimeType: data.type === 'file' ? 'text/plain' : 'application/json',
text: content
}]
};
}
);
资源模板中的 URI 变量({owner}、{repo}、{path})由 SDK 自动解析和填充。list 回调是可选的——它提供了预定义的资源列表,帮助 Client 展示可用资源。
20.6 实现 Prompts
Prompts 是预定义的对话模板,引导 LLM 执行特定的工作流:
server.registerPrompt(
'code_review',
{
title: 'Code Review',
description: '对 Pull Request 进行代码审查,生成结构化的审查意见',
argsSchema: z.object({
repo: z.string().describe('仓库全名 (owner/repo)'),
pr_number: z.number().describe('Pull Request 编号')
})
},
async ({ repo, pr_number }) => {
// 获取 PR 详情和 diff
const pr = await githubFetch(`/repos/${repo}/pulls/${pr_number}`);
const diff = await githubFetch(`/repos/${repo}/pulls/${pr_number}`, {
headers: { 'Accept': 'application/vnd.github.v3.diff' }
});
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `请对以下 Pull Request 进行代码审查。\n\n` +
`## PR 信息\n` +
`- 标题: ${pr.title}\n` +
`- 作者: ${pr.user.login}\n` +
`- 描述: ${pr.body || '无'}\n\n` +
`## 代码变更\n\`\`\`diff\n${diff}\n\`\`\`\n\n` +
`请从以下维度审查:\n` +
`1. 代码质量与可读性\n` +
`2. 潜在的 Bug 或边界条件\n` +
`3. 性能影响\n` +
`4. 安全风险\n` +
`5. 测试覆盖`
}
}
]
};
}
);
server.registerPrompt(
'bug_report',
{
title: 'Bug Report',
description: '基于错误信息生成结构化的 Bug 报告,并自动创建 Issue',
argsSchema: z.object({
repo: z.string().describe('仓库全名'),
error_message: z.string().describe('错误信息或堆栈'),
context: z.string().optional().describe('复现步骤或额外上下文')
})
},
async ({ repo, error_message, context }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `请基于以下错误信息,为仓库 ${repo} 生成一个结构化的 Bug 报告。\n\n` +
`## 错误信息\n\`\`\`\n${error_message}\n\`\`\`\n\n` +
(context ? `## 上下文\n${context}\n\n` : '') +
`请生成包含以下部分的 Bug 报告:\n` +
`1. 问题描述(一句话总结)\n` +
`2. 复现步骤\n` +
`3. 期望行为 vs 实际行为\n` +
`4. 可能的原因分析\n` +
`5. 建议的修复方向\n\n` +
`生成后,请使用 create_issue 工具将报告提交到 ${repo}。`
}
}]
})
);
注意 bug_report Prompt 的最后一句——它引导 LLM 在生成报告后自动调用 create_issue 工具。这是 Prompts 与 Tools 协同的典型模式:Prompt 定义工作流的”脚本”,Tool 执行具体的”动作”。
20.7 添加进度追踪
对于可能耗时的操作,使用 Server 底层 API 的进度通知:
server.registerTool(
'batch_label_issues',
{
title: '批量标记 Issues',
description: '为多个 Issue 添加标签',
inputSchema: z.object({
repo: z.string(),
issue_numbers: z.array(z.number()),
labels: z.array(z.string())
})
},
async ({ repo, issue_numbers, labels }, { reportProgress }) => {
const total = issue_numbers.length;
const results: string[] = [];
for (let i = 0; i < total; i++) {
const num = issue_numbers[i];
try {
await githubFetch(`/repos/${repo}/issues/${num}/labels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ labels })
});
results.push(`#${num}: 成功`);
} catch (e: any) {
results.push(`#${num}: 失败 - ${e.message}`);
}
// 报告进度
await reportProgress({
progress: i + 1,
total,
message: `正在处理 Issue #${num} (${i + 1}/${total})`
});
}
return {
content: [{
type: 'text',
text: `批量标记完成:\n${results.join('\n')}`
}]
};
}
);
20.8 测试策略
20.8.1 协议级集成测试
MCP 的测试需要模拟完整的 Client-Server 交互。TypeScript SDK 提供了内存传输(in-memory transport)用于测试:
// tests/tools.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Client } from '@modelcontextprotocol/client';
import { McpServer } from '@modelcontextprotocol/server';
import { InMemoryTransport } from '@modelcontextprotocol/core';
describe('GitHub MCP Server', () => {
let client: Client;
let server: McpServer;
beforeAll(async () => {
// 创建 Server(使用 mock 的 GitHub API)
server = createServer({ githubToken: 'test-token' });
// 创建内存传输对
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// 连接
client = new Client({ name: 'test-client', version: '1.0.0' });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport)
]);
});
afterAll(async () => {
await client.close();
});
it('应该列出所有工具', async () => {
const result = await client.listTools();
const toolNames = result.tools.map(t => t.name);
expect(toolNames).toContain('search_repos');
expect(toolNames).toContain('create_issue');
expect(toolNames).toContain('batch_label_issues');
});
it('搜索仓库应返回结构化结果', async () => {
const result = await client.callTool({
name: 'search_repos',
arguments: { query: 'mcp', language: 'typescript', limit: 3 }
});
expect(result.content).toHaveLength(1);
const data = JSON.parse((result.content[0] as any).text);
expect(data).toBeInstanceOf(Array);
expect(data.length).toBeLessThanOrEqual(3);
});
it('创建 Issue 应返回 Issue 链接', async () => {
const result = await client.callTool({
name: 'create_issue',
arguments: {
repo: 'test-owner/test-repo',
title: '测试 Issue',
body: '这是一个测试'
}
});
const text = (result.content[0] as any).text;
expect(text).toContain('Issue 创建成功');
expect(text).toContain('#');
});
});
20.8.2 Completion 测试
it('仓库名应该支持自动补全', async () => {
const result = await client.complete({
ref: { type: 'ref/prompt', name: 'code_review' },
argument: { name: 'repo', value: 'ant' }
});
expect(result.completion.values.length).toBeGreaterThan(0);
result.completion.values.forEach(v => {
expect(v.toLowerCase()).toContain('ant');
});
});
20.9 部署
20.9.1 本地模式(stdio)
最简单的部署方式——用户在配置文件中指定命令:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "mcp-server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
}
}
}
为此需要在 package.json 中配置 bin 入口:
{
"name": "mcp-server-github",
"version": "1.0.0",
"bin": {
"mcp-server-github": "./dist/index.js"
},
"files": ["dist"]
}
20.9.2 远程模式(Streamable HTTP)
对于需要多用户共享的场景,使用 HTTP 传输:
// src/remote.ts
import { randomUUID } from 'node:crypto';
import express from 'express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { isInitializeRequest } from '@modelcontextprotocol/server';
const app = express();
app.use(express.json());
const transports: Map<string, NodeStreamableHTTPServerTransport> = new Map();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && transports.has(sessionId)) {
const transport = transports.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
if (!sessionId && isInitializeRequest(req.body)) {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
transports.set(sid, transport);
}
});
transport.onclose = () => {
if (transport.sessionId) {
transports.delete(transport.sessionId);
}
};
const server = createServer();
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
}
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: '无效请求' },
id: null
});
});
app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
const transport = transports.get(sessionId);
if (!transport) {
res.status(404).send('Session not found');
return;
}
await transport.handleRequest(req, res);
});
app.listen(3000, () => {
console.log('MCP Server running on http://localhost:3000/mcp');
});
远程部署时,每个 Client 连接都创建一个独立的 McpServer 实例——这避免了多用户之间的状态污染,是 MCP Server 多租户部署的标准模式。
20.9.3 部署架构对比
graph TB
subgraph "本地模式 (stdio)"
U1["用户 A"] --> C1["Claude Code"]
C1 --> |"spawn 子进程"| S1["MCP Server 进程"]
S1 --> G1["GitHub API"]
end
subgraph "远程模式 (HTTP)"
U2["用户 A"] --> C2["Client A"]
U3["用户 B"] --> C3["Client B"]
C2 --> |"HTTP"| LB["负载均衡"]
C3 --> |"HTTP"| LB
LB --> S2["MCP Server 实例 1"]
LB --> S3["MCP Server 实例 2"]
S2 --> G2["GitHub API"]
S3 --> G2
end
style S1 fill:#3b82f6,color:#fff,stroke:none
style S2 fill:#10b981,color:#fff,stroke:none
style S3 fill:#10b981,color:#fff,stroke:none
style LB fill:#f59e0b,color:#fff,stroke:none
本地模式简单可靠,适合个人使用;远程模式支持多用户、可水平扩展,适合团队和企业级部署。两种模式共用同一套 Server 代码,只是传输层不同——这正是 MCP 传输抽象的价值所在。
20.10 安全加固
20.10.1 输入验证
Zod Schema 已经提供了基本的输入验证,但对于安全敏感的操作还需要额外检查:
// 防止路径穿越
function validateRepoName(repo: string): boolean {
return /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo);
}
// 防止注入
function sanitizeSearchQuery(query: string): string {
return query.replace(/[^\w\s\-.:@/]/g, '');
}
20.10.2 速率限制
对 GitHub API 的调用应遵守速率限制:
class RateLimiter {
private requests: number[] = [];
private readonly maxRequests: number;
private readonly windowMs: number;
constructor(maxRequests: number = 30, windowMs: number = 60000) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
async acquire(): Promise<void> {
const now = Date.now();
this.requests = this.requests.filter(t => now - t < this.windowMs);
if (this.requests.length >= this.maxRequests) {
const waitTime = this.windowMs - (now - this.requests[0]);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.requests.push(Date.now());
}
}
20.10.3 Host Header 验证
远程部署时,防止 DNS 重绑定攻击:
import { hostHeaderValidation } from '@modelcontextprotocol/server';
// 添加到 Express 中间件
app.use(hostHeaderValidation({
allowedHosts: ['mcp.example.com'],
allowLocalhost: process.env.NODE_ENV === 'development'
}));
20.11 发布到生态
20.11.1 npm 发布
# 构建
npm run build
# 发布
npm publish --access public
20.11.2 MCP Registry
MCP 生态正在建设中央注册表(类似 npm registry),Server 开发者可以提交自己的 Server 元信息:
{
"name": "mcp-server-github",
"description": "GitHub integration for MCP - search repos, manage issues, review PRs",
"version": "1.0.0",
"transport": ["stdio", "streamable-http"],
"tools": ["search_repos", "create_issue", "batch_label_issues"],
"resources": ["github://user/profile", "github://repos/{owner}/{repo}/contents/{path}"],
"prompts": ["code_review", "bug_report"],
"author": "your-name",
"repository": "https://github.com/your-name/mcp-server-github"
}
注册后,Client 可以通过标准化的发现机制找到你的 Server,用户可以一键安装和配置。
20.12 本章小结
本章从零构建了一个具备生产级特性的 GitHub MCP Server,覆盖了完整的开发流程:
- 技术选型:TypeScript 和 Python SDK 各有所长,根据项目特点和团队技能选择
- 三大原语:Tools 执行操作,Resources 暴露数据,Prompts 定义工作流模板
- Completion:通过
completable()为参数添加实时补全,显著提升用户体验 - Progress:为长操作提供进度反馈,让用户知道系统在工作
- 测试:使用内存传输进行协议级集成测试,验证完整的 Client-Server 交互
- 部署:同一套代码支持 stdio(本地)和 HTTP(远程)两种部署模式
- 安全:输入验证、速率限制、Host Header 验证、密钥管理
构建一个好的 MCP Server 需要同时关注协议正确性和工程质量。协议告诉你”必须做什么”,工程经验告诉你”应该做什么”。在最后一章,我们将把全书的核心洞察提炼为可复用的设计模式和架构决策框架。
20.13 实战中容易踩的 10 个坑
构建生产 MCP Server 过程中、下面这 10 个坑是社区反复报告过的——提前知道、提前规避:
坑 1:instructions 字段写得像给人看——好多开发者把 instructions 当成 README 写——LLM 读 instructions 的效果远不如专门为 LLM 写的简洁指令。好的 instructions:
This server provides GitHub integration.
Use search_repos first to find repositories.
Do not create issues without user confirmation.
坏的 instructions:
Welcome to the GitHub MCP Server!
This wonderful tool lets you interact with GitHub...
LLM 对”冗长的欢迎语” 不敏感——对”指令和约束” 敏感。
坑 2:description 字段过于简略——description: "搜索仓库" 和 description: "根据关键词搜索 GitHub 上的公开仓库、返回名称/描述/Star 数/语言等元信息、支持 GitHub 搜索语法如 language:python" 的tool-calling 准确率差别巨大——LLM 从长 description 里能推断出参数选择。
坑 3:Zod schema 的 .describe() 被忽略——很多人写 z.string()、不加 .describe()——LLM 看到的参数只有名字、没有说明——经常填错。每个字段都要 .describe('具体含义 + 示例')。
坑 4:忘记处理 rate limit——GitHub API 有每小时 5000 次的限制、超过会返回 429。MCP Server 没做 rate limit 处理——每次上游限流都变成工具调用失败——LLM 不知道 “等几分钟再试”——重试风暴。§20.10.2 的 RateLimiter 不是可选的——必备。
坑 5:错误消息不够结构化——throw new Error('Failed') 这种消息——LLM 无法判断是重试、还是换参数、还是报告给用户——stuck。好的 error message:
throw new Error(
`GitHub API returned 404 for repo "${repo}". ` +
`This likely means: (1) repo doesn't exist, (2) it's private and token lacks access. ` +
`Please verify the repo name.`
);
给 LLM 读的 error message 要包含可能原因 + 建议动作——不是 “failed” 三个字符。
坑 6:stdio 模式 stdout 被 log 污染——MCP stdio 模式里 stdout 是 JSON-RPC 通道——任何 console.log 写到 stdout 都会破坏协议。调试 log 必须写到 stderr:
console.error('Debug: ...') // 写到 stderr、安全
console.log('Debug: ...') // 写到 stdout、破坏 MCP 协议!
很多新手第一次遇到 “Client 突然断开连接” 就是这个原因——一个 console.log 就把协议搞崩了。
坑 7:Resource URI 不稳定——github://user/profile 是稳定的、但 github://repos/anthropics/mcp/contents/src/server.ts@main 包含 commit 信息——如果 commit 不稳定、同一 resource URI 被 LLM 缓存后结果会变——URI 应该 stable 或带 version。
坑 8:Completion 返回太多——completable 返回 50 个候选——LLM 看到不知道选哪个——建议 <= 10 个、最相关的排前面。
坑 9:Prompts 把 PR diff 直接贴进去——大 PR 的 diff 可能几千行、一次 prompt 塞进去超 context window——大内容应该分块或分 tool call 传递——LLM 按需 grep。
坑 10:没有健康检查——部署到生产后如何知道 Server 还活着?stdio 模式自带(进程死了就死了)、但 HTTP 模式需要 /health endpoint——MCP 规范没强制、但生产必须加。
这 10 个坑每一个都是真实 bug 报告的归纳——读一遍能节省几十小时的排查时间。
20.14 生产环境的完整 checklist
给个生产上线前的 30 项 checklist:
协议正确性(10 项):
- ☐ initialize 正确响应
- ☐ 所有注册的 tools/resources/prompts 都能被 listTools/listResources/listPrompts 返回
- ☐ 每个 tool 的 inputSchema 覆盖所有参数
- ☐ 每个参数有
.describe() - ☐ 错误用
isError: true+ 清晰 message - ☐ 进度 notification 正确发送(如果有)
- ☐ 取消 request(
notifications/cancelled)能被正确处理 - ☐ Server 关闭时清理资源
- ☐ stdio 模式 stderr-only logging
- ☐ HTTP 模式 session 管理正确
工程质量(10 项):
- ☐ 单元测试覆盖核心逻辑
- ☐ 集成测试覆盖 Client-Server 完整流程
- ☐ 错误场景测试(invalid input, API 5xx, timeout)
- ☐ 速率限制实现
- ☐ 依赖注入设计(方便 mock 测试)
- ☐ 日志结构化(JSON)
- ☐ 度量指标暴露(request count, latency, error rate)
- ☐ 配置外部化(environment variables)
- ☐ secrets 不硬编码
- ☐ Graceful shutdown
安全(10 项):
- ☐ 输入验证(Zod + 额外业务校验)
- ☐ 输出 sanitization(避免注入)
- ☐ Auth token 加密存储
- ☐ HTTPS only(生产 HTTP 模式)
- ☐ Host header 验证
- ☐ CORS 配置正确
- ☐ 依赖库 vulnerability scan(npm audit)
- ☐ 最小权限原则(token scope 最小)
- ☐ 日志里 redact secrets
- ☐ Rate limit 按 user/session
这 30 项是工业级 MCP Server 的准入线——少一项就可能出问题。本书前 19 章讲协议细节、这一章讲工程细节——两者合起来才是完整的 MCP 开发能力。
20.15 与 LangGraph/LangChain MCP 集成的对比
对比 MCP 和 LangChain 生态的 tools——两者关系是什么?
LangChain 的 Tool:
- 定义在 Python/TypeScript 代码里
- 走 LangChain 的 BaseTool 接口
- 用
@tool装饰器或StructuredTool.from_function - 和 LLM 的 binding 由 LangChain runtime 管理
MCP 的 Tool:
- 定义在独立的 Server 进程
- 走 JSON-RPC 协议
- LLM-agnostic(任何 MCP-aware client 都能调)
- 通过 stdio 或 HTTP 通信
何时用 MCP?
- 你想跨 LLM / 跨 framework 共享工具——MCP
- 你的工具涉及文件 IO、子进程、外部服务——MCP server 便于隔离
- 你想让非 Python/TS 开发者用你的工具——MCP 的标准协议
何时用 LangChain Tool?
- 你的工具是LangChain 应用的一部分、不对外——LangChain tool 更简单
- 性能极其敏感、不想付 RPC 开销——LangChain in-process tool
- 工具需要访问 LangChain 内部状态——紧耦合
实际工程里常常是”两者并用”——核心通用工具用 MCP server、LangChain 内部用 LangChain tool、MCP server 本身可以在 LangChain agent 里通过 MCPToolkit 暴露——两个生态互通。
LangChain 的 langchain-mcp-adapters package 就是这个桥——让 LangChain agent 能直接用任何 MCP server 的 tools。这表示生态已经开始融合——未来 MCP 可能成为 tool 定义的 lingua franca、LangChain / LangGraph 成为 agent runtime——分工清晰。
20.16 MCP 未来演进方向
看 MCP 规范的 open issues 和 RFC——能推断 2026-2028 年 MCP 的演进方向:
① Better Auth——当前 OAuth 流程有点重、小 server 不想实现——规范可能引入更轻量的 API key 方式。
② Federation——多个 MCP server 协作解决复杂任务——规范可能定义 server-to-server 通信协议。
③ Streaming Large Outputs——当前 tool 返回是一次性的——未来可能支持 stream、像 LLM response 一样 chunk-by-chunk。
④ Multi-modal——规范已有 image 支持、但 video/audio 还薄——未来会扩展。
⑤ Standard Registry——现在 MCP server 发现靠 README / 口碑——官方注册表会加速生态发展。
⑥ Sampling Coordination——MCP 有 sampling request(server 向 client 要 LLM 调用)——当前较少用、未来可能更标准化。
作为 MCP server 开发者——提前关注这些方向、未来升级时少 breaking changes。
20.17 本章收束与全书小结
作为本书的最后实战章节——这一章覆盖了:
- 构建一个生产 MCP server 的完整步骤
- Tools / Resources / Prompts / Completion / Progress 的实战用法
- 测试、部署、安全、发布的完整清单
- 10 个常见坑 + 30 项 checklist
- 与 LangChain / LangGraph 生态的对比
- MCP 未来演进方向
读完这一章——你有足够的信息和工具构建生产级 MCP server。下一步行动:
**① 读完本书 **→ 找一个你实际用的外部服务(GitHub、Notion、Jira、Slack、数据库)——用 MCP server 封装——两周内上线一个
**② 测试 your server **→ 在 Claude Desktop、Cursor、Continue.dev 等多个 MCP client 上跑——covers the spec
**③ 开源发布 **→ 你写的 MCP server 大概率对其他人有用——贡献生态
这比读 100 本书更有价值——从读者变成贡献者。
本书到此完结——感谢陪伴。祝你在 MCP 生态里建造有价值的作品。
本书的写作基于 MCP 官方规范(2025-03-26 版本及后续更新) 以及 TypeScript SDK 和 Python SDK 的开源实现。特别感谢:
- Anthropic 发起并开源 MCP 协议
- MCP 社区的早期贡献者——提供大量 reference server 实例
- Claude Desktop / Cursor / Continue.dev 等早期 client 实现者——让协议被真实验证
本书中的所有代码示例在撰写时基于当时的 SDK 版本可运行。但 SDK 在演进——部分 API 可能在后续版本有变化。请以官方文档为准——本书提供的是设计意图和架构理解、不是 API reference。
如有错误或建议——欢迎通过 GitHub Issue 反馈——所有反馈都会认真考虑并在再版中修正。
20.18.5 再深入——关于 registerTool 的类型细节
打开 @modelcontextprotocol/sdk 的 packages/server/src/server/mcp.ts 文件(1329 行)——SDK 的主入口。本节我们仔细看几段真实源码。
20.18.5.1 StandardSchemaWithJSON 的 schema-agnostic 设计
MCP SDK 的 schema 类型是 StandardSchemaWithJSON——而不是硬编码 Zod。这是一个关键的设计选择:
// 用 Zod
server.registerTool('foo', {
inputSchema: z.object({ q: z.string() }),
// ...
}, async ({ q }) => { ... });
// 用 Valibot
import * as v from 'valibot';
server.registerTool('foo', {
inputSchema: v.object({ q: v.string() }),
// ...
}, async ({ q }) => { ... });
// 用 ArkType
import { type } from 'arktype';
server.registerTool('foo', {
inputSchema: type({ q: 'string' }),
// ...
}, async ({ q }) => { ... });
三种 schema 库都能用——只要它们实现 Standard Schema 接口(一个 Vercel / Valibot / ArkType / Zod 团队联合制定的事实标准)。
Standard Schema 的核心要求——暴露一个 ~standard 符号属性、提供 validate(input) 方法——任何库实现这俩、就能被 MCP SDK 用。
这种 “按接口编程、不绑定具体库” 的做法让 SDK 对用户的技术选择完全中立——不强迫你用 Zod(Zod 虽主流但有性能开销、有人偏好更轻的 Valibot)。
设计哲学:SDK 提供 protocol、schema 库用户自选——各司其职。
20.18.5.2 _createRegisteredTool 内部做的事
registerTool 只是门面、真实工作在 _createRegisteredTool:
private _createRegisteredTool(
name: string,
title: string | undefined,
description: string | undefined,
inputSchema: StandardSchemaWithJSON | undefined,
outputSchema: StandardSchemaWithJSON | undefined,
annotations: ToolAnnotations | undefined,
taskConfig: { taskSupport: TaskSupport },
_meta: Record<string, unknown> | undefined,
cb: ToolCallback<...>
): RegisteredTool {
// 1. 把 inputSchema / outputSchema 转成 JSON Schema
const inputJsonSchema = inputSchema
? toJSONSchema(inputSchema)
: { type: 'object', properties: {} };
const outputJsonSchema = outputSchema ? toJSONSchema(outputSchema) : undefined;
// 2. 构造 Tool 元信息(供 listTools 响应用)
const toolMeta: Tool = {
name, title, description,
inputSchema: inputJsonSchema,
outputSchema: outputJsonSchema,
annotations,
_meta,
};
// 3. 包装用户 callback、添加 input 验证
const wrappedCb = async (params, extra) => {
if (inputSchema) {
const validated = await validateStandardSchema(inputSchema, params);
if (!validated.success) {
throw new Error(`Invalid input: ${validated.error}`);
}
params = validated.data;
}
return cb(params, extra);
};
// 4. 注册到内部 map
this._registeredTools[name] = {
...toolMeta,
taskSupport: taskConfig.taskSupport,
callback: wrappedCb,
};
// 5. 通知 client: tools 列表变了
this._notifyToolListChanged();
return this._registeredTools[name];
}
5 步操作:
Step 1: schema 转 JSON Schema——Zod/Valibot 各有自己的 schema 格式、但 MCP 协议只认 JSON Schema——SDK 做转换。
Step 2: 构造 Tool metadata——listTools 响应就是这些 metadata 的数组。
Step 3: wrap callback 加 validation——用户的 callback 不用管 validation、SDK 先验 schema 再调用户 callback——安全默认。
Step 4: 注册到 map——内部 Map<string, RegisteredTool> 存储——后续 callTool 按 name 查。
Step 5: 通知 client——如果连接已建立且支持 listChanged、发 notifications/tools/list_changed 消息——让 client 刷新 tools 列表。
这 5 步对用户完全透明——用户只看到 server.registerTool(...) 一行——但背后 SDK 做了 schema 转换、validation 包装、state 更新、notification 发送 四件事。这就是好 SDK 的价值——用户专注业务、协议细节被封装。
20.18.5.3 _notifyToolListChanged 的防抖
_notifyToolListChanged 看起来简单——但 SDK 做了防抖:
private _toolListChangedDebounced: () => void;
constructor(...) {
this._toolListChangedDebounced = debounce(() => {
this.notification({
method: 'notifications/tools/list_changed'
});
}, 100); // 100ms 防抖
}
private _notifyToolListChanged() {
this._toolListChangedDebounced();
}
为什么防抖?——因为用户可能连续注册多个 tool:
server.registerTool('tool1', ...);
server.registerTool('tool2', ...);
server.registerTool('tool3', ...);
三次 registerTool 立即发 3 次 list_changed 通知——client 会被噪音——连续 3 次 listTools 调用是浪费。
防抖让”批量注册”只发一次通知——100ms 窗口内的所有注册合并——client 拿到一次完整的新 tools 列表。
这种”API 细节的打磨” 是 SDK 水平的体现——大部分用户注意不到、但一旦用户快速注册大量 tool、就会感谢这个防抖。
20.19 registerTool 源码走查——SDK 做了什么
回到 @modelcontextprotocol/sdk 真实代码——packages/server/src/server/mcp.ts:865-894 的 registerTool:
registerTool<OutputArgs extends StandardSchemaWithJSON,
InputArgs extends StandardSchemaWithJSON | undefined = undefined>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
): RegisteredTool {
if (this._registeredTools[name]) {
throw new Error(`Tool ${name} is already registered`);
}
const { title, description, inputSchema, outputSchema, annotations, _meta } = config;
return this._createRegisteredTool(
name, title, description, inputSchema, outputSchema, annotations,
{ taskSupport: 'forbidden' },
_meta,
cb as ToolCallback<StandardSchemaWithJSON | undefined>
);
}
几个工程细节值得注意:
① 泛型参数 InputArgs 和 OutputArgs——保证 cb 的参数类型和 inputSchema 推导出来一致——写 schema 就自动有 callback 类型、大大减少类型标注。
② 重复注册检查——if (this._registeredTools[name]) throw——防止同名 tool 被悄悄覆盖、强制用户显式处理。
③ _meta 字段——扩展点——允许 server 作者塞入自定义 metadata(如内部 trace id、feature flag)——不影响协议。
④ taskSupport: 'forbidden' 默认值——表示这个 tool 不支持 MCP task 机制(一种长运行任务协议)——如果要支持、用 registerToolTask 注册。
⑤ StandardSchemaWithJSON——不绑定 Zod——接受任何符合 Standard Schema 接口的 validation library(Zod、Valibot、ArkType 等)——用户自由选 schema 库。
这 5 个细节组合成”一个 registerTool 调用看似简单、背后有精确的类型协议”——这就是 MCP SDK 作为生产级 library 的设计质量。
20.19.3 Resource 和 Prompt 的并行 API
registerResource 和 registerPrompt 结构类似 registerTool——三者共用同一套 “门面 + 内部 create + notification” 模式:
// 简化伪代码
registerResource(uri, config, readCallback): RegisteredResource {
if (this._registeredResources[uri]) throw new Error(...);
return this._createRegisteredResource(uri, config, readCallback);
}
registerPrompt(name, config, callback): RegisteredPrompt {
if (this._registeredPrompts[name]) throw new Error(...);
return this._createRegisteredPrompt(name, config, callback);
}
每个注册方法内部都有对应的 _notifyXxxListChanged——对应规范里的 notifications/resources/list_changed 和 notifications/prompts/list_changed。
三个原语、三套独立注册、三种 notification——对称、一致、可预测——这是好 API 的特征。
20.19.4 Dynamic registration——registerToolTask 和 updateTool
SDK 还支持 动态注册 / 更新 tool:
// 注册 task-supporting tool(§MCP Task 规范、实验性)
registerToolTask(name, config, cb): RegisteredTool { ... }
// 后续更新某个已注册 tool 的 metadata
const registered = server.registerTool('foo', ...);
registered.update({ description: 'new description' });
update() 方法让 server 能动态改 tool 元信息——常见用途:
- 基于外部数据的 dynamic description——比如 tool 的 description 包含上游 API 的版本号
- feature flag 控制——某些 tool 在特定时段才 enable
- A/B 测试不同 description——看哪种 LLM 命中率高
update 自动触发 list_changed notification——client 会重新 fetch 最新 tool list。
这种”注册后可修改”的设计让 server 变得 reactive——不是 static config、是 live 对象——支持更动态的 agent 场景。
20.20 三个 SDK 对比——TS / Python / 其他
MCP 生态主要 SDK 的对比:
| SDK | 语言 | Tier | 特点 |
|---|---|---|---|
| @modelcontextprotocol/sdk | TS/JS | 官方 Tier-1 | Zod schema、Express 友好 |
| mcp-python-sdk | Python | 官方 Tier-1 | Pydantic、Starlette ASGI、装饰器 |
| mcp-rust-sdk | Rust | 社区 | 性能、类型安全 |
| mcp-go-sdk | Go | 社区 | 轻量、部署简单 |
| mcp-java-sdk | Java | 社区 | 企业 Java 项目集成 |
| mcp-kotlin-sdk | Kotlin | 社区 | JVM + 语法更简洁 |
| mcp-csharp-sdk | C# | 社区 | .NET 生态 |
选语言的决策树:
- TS / Python——99% 个人或小团队、选一个最熟的
- 企业 Java 代码库——用 mcp-java-sdk 和现有系统集成
- 极致性能(比如处理 GB 级数据 stream)——Rust SDK
- Cloudflare Workers / edge——TS SDK(Workers runtime)
- Lambda / Serverless——任意、但 TS/Python 启动快
SDK 间的协议互通——完全互通——TS server 能被 Python client 调用、反之亦然——因为协议是 JSON-RPC、不依赖语言运行时。
作为开发者——选一个 SDK 学深、其他 SDK 需要时学 surface——协议知识跨 SDK 通用、具体 API 各有风格但核心概念一致。
20.21 FAQ——读者常问的 10 个问题
Q1:MCP Server 能和 LangChain agent 一起用吗?
可以。langchain-mcp-adapters 包提供桥接——LangChain agent 调用 MCP server tools 就像本地 tools。
Q2:stdio 模式下 Server 能启动子进程吗?
可以、但要小心——子进程的 stdout 别和 MCP 的 stdout 混。典型做法是用 spawn 并显式重定向。
Q3:HTTP 模式支持 SSE 吗?
SDK 的 NodeStreamableHTTPServerTransport 支持 SSE——用户可以通过 GET /mcp 获得 server-to-client push 通道。
Q4:Resource 能返回二进制吗?
能——contents[i].blob 字段(base64 encoded)——文本用 text、二进制用 blob。
Q5:Prompt 能动态生成吗?
能——Prompt 的 callback 在每次 getPrompt 请求时调用——可以访问 external data、实时生成。
Q6:Server 可以主动通知 Client 吗?
可以——通过 server.notification(...) 发送——但需要 transport 支持(stdio 和 HTTP stream 支持)。
Q7:多个 tool 能同时运行吗?
Server 一次处理一个 tool call(避免 state 竞争)——多 client 并发时、每个 client 独立的 server instance(§20.9.2 讲过)。
Q8:如何 debug MCP server?
用 @modelcontextprotocol/inspector(官方 debug 工具)——启动后 connect 你的 server、可视化工具调用和响应。
Q9:Server 需要 cache 吗?
看情况——tool 调用结果不由 SDK 自动 cache——用户需要时自己实现(通常用 LRU、Redis 等)。
Q10:MCP 协议版本怎么升级?
客户端和服务端在 initialize 时协商 protocolVersion——双方支持的最高版本。老 server 依然能被新 client 调(向下兼容)——但新功能要双方都支持。
这 10 个 FAQ 覆盖了 Server 开发最常见的问题——入门时遇到问题先查这里。
20.21.5 McpServer 和 Server 的两层 API
TypeScript SDK 实际上有两层 API——Server(底层)和 McpServer(高层):
Server(低层、packages/server/src/server/index.ts)——直接操作 JSON-RPC:
const server = new Server({ name, version }, { capabilities });
server.setRequestHandler(ListToolsRequestSchema, async (request) => {
return { tools: [...] };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// ... 手动 dispatch
});
McpServer(高层、packages/server/src/server/mcp.ts)——更抽象、提供 registerTool / registerResource / registerPrompt 等便利方法。
选哪一个?
- 标准场景——用
McpServer、几行代码搞定 - 非标准场景——比如自定义 request/response 处理、动态协议扩展——用底层
Server - 学习源码——先读
McpServer、再看它内部怎么调Server——从高到低层层递进
两层 API 分层——让框架既简单又不丧失 power。类似 Web 框架的 Express / Fastify:高层装 routing、低层随时能拿 raw req/res。
20.22 一个实例——看 SDK 怎么处理 listChanged capability
MCP 协议允许 server 声明自己是否支持 listChanged notification——client 据此决定是否订阅。SDK 的处理:
// McpServer constructor 里
constructor(info, options) {
this._server = new Server(info, {
capabilities: {
tools: { listChanged: true },
resources: { listChanged: true, subscribe: true },
prompts: { listChanged: true },
...options?.capabilities
}
});
}
SDK 默认声明 listChanged: true——因为 McpServer 确实支持动态注册(§20.19.4)——默认打开、client 能利用。
用户可以 override:
new McpServer(info, {
capabilities: {
tools: { listChanged: false } // 覆盖默认、声明不支持
}
});
为什么要允许用户关?——因为某些场景下 server 知道自己的 tools 是纯静态的、关掉 listChanged 能让 client 跳过订阅逻辑、节省网络和内存。
这种 “SDK 知道默认能力、允许用户精细化覆盖” 是好 API 的设计——默认正确、但不锁死。
20.23 给读者的最后一份礼物——资源清单
以资源清单作为全书的结尾——让读者有进一步探索的路径:
官方文档 + 规范:
- modelcontextprotocol.io——协议主站
- github.com/modelcontextprotocol/specification——规范仓库
- github.com/modelcontextprotocol/typescript-sdk——TS SDK
- github.com/modelcontextprotocol/python-sdk——Python SDK
示例 Server 仓库(学习素材):
- github.com/modelcontextprotocol/servers——官方 reference servers 集合(Filesystem、Slack、GitHub、Google Drive 等 ~20 个)
Client 实现(测试自己的 server 用):
- Claude Desktop(Anthropic 官方)
- Claude Code(CLI、本书另一本讲的)
- Cursor(IDE)
- Continue.dev(VSCode / JetBrains 插件)
- MCP Inspector(debug 工具)
社区:
- modelcontextprotocol.io/community——官方社区链接
- r/mcp on Reddit(非官方但活跃)
- MCP Discord(邀请链接在官方页面)
本系列其他书:
- 《LangGraph 设计与实现》——MCP server 常作为 LangGraph agent 的 tool 来源
- 《LangChain 设计与实现》——LangChain 的 toolkit 抽象和 MCP 的互通
- 《Claude Code 源码解读》——生产级 MCP client 的实现样本
读完本书 + 做一两个实战——你就进入 MCP 生态的前 10% 开发者。2026-2028 年 AI agent 工具化是大方向、MCP 一定会是关键基础设施——现在投入、长期受益。
全书至此完结——期待在 MCP 生态里看到你的作品。
20.24 本章收束——从”能用”到”用好”的跨越
本章没有像前面章节那样深入协议 wire format、而是聚焦工程实践——从”能用 MCP”到”用好 MCP”的跨越:
- 能用 MCP——几十行代码跑起一个 hello world server
- 用好 MCP——能处理 rate limit、能 debug 协议问题、能多租户部署、能通过 client 兼容性测试
两者的差距是本章覆盖的内容——安全、测试、部署、错误处理、性能优化、生态发布——每一项都是生产化的门槛。
本书 20 章里、这一章在某种意义上最重要——前 19 章告诉你 “协议是什么”、这一章告诉你 “协议怎么工业化”——两者合起来才是完整的能力。
20.25 最后一个 code sample——一个完整可运行的 MCP Hello World
作为本书终点、留一个最小完整 MCP server——50 行以内、可直接部署:
#!/usr/bin/env node
// hello-mcp-server.ts
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/sdk/server';
import * as z from 'zod/v4';
const server = new McpServer(
{ name: 'hello-mcp', version: '1.0.0' },
{
capabilities: { logging: {} },
instructions: 'A demo MCP server. Use greet tool to say hello.'
}
);
server.registerTool(
'greet',
{
title: 'Greet User',
description: 'Returns a personalized greeting',
inputSchema: z.object({
name: z.string().describe('The user\'s name'),
language: z.enum(['en', 'zh', 'es']).default('en').describe('Greeting language')
})
},
async ({ name, language }) => {
const greetings = { en: `Hello, ${name}!`, zh: `你好、${name}!`, es: `¡Hola, ${name}!` };
return {
content: [{ type: 'text', text: greetings[language] }]
};
}
);
server.registerResource(
'demo://about',
{ name: 'About', mimeType: 'text/plain' },
async () => ({
contents: [{
uri: 'demo://about',
mimeType: 'text/plain',
text: 'This is a hello-world MCP server demo.'
}]
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Server started on stdio');
50 行、覆盖:
- Tool(greet、多语言)
- Resource(about、静态文本)
- Zod validation(name、language enum)
- stdio transport
- 正确的 stderr logging
这就是 MCP server 的 “Hello World”——比 “console.log 一行” 有意义、比 “完整生产 server” 简单——恰到好处的 starter。
用这 50 行练手、然后按本章的 30 项 checklist 逐步加强——三个月能产出一个生产级 server。
20.26 从 Hello World 到生产级 MCP Server
构建一个真实的 MCP server,不能停在能跑通的示例上;它需要把协议细节、传输选择、安全边界、部署方式、SDK 内部行为和生态互通串成一条可执行路径。
前面章节讲的是协议与抽象,这一章把它们落到可运行代码 + 生产化 checklist:先跑通最小 server,再逐项加上 schema 校验、权限边界、日志、监控、限流、版本兼容与部署回滚。
20.27 一些可复用模板——省下你重新实现的时间
模板 1:rate-limited HTTP wrapper:
class RateLimitedFetcher {
private limiter = new RateLimiter(100, 60000);
private retryCount = new Map<string, number>();
async fetch(url: string, options?: RequestInit): Promise<Response> {
await this.limiter.acquire();
try {
const resp = await fetch(url, options);
if (resp.status === 429) {
const retryAfter = parseInt(resp.headers.get('retry-after') || '5');
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.fetch(url, options);
}
if (resp.status >= 500) {
const count = (this.retryCount.get(url) || 0) + 1;
if (count <= 3) {
this.retryCount.set(url, count);
await new Promise(r => setTimeout(r, Math.pow(2, count) * 1000));
return this.fetch(url, options);
}
}
this.retryCount.delete(url);
return resp;
} catch (e) {
throw new Error(`Fetch failed: ${e.message}`);
}
}
}
模板 2:structured error:
class McpToolError extends Error {
constructor(
public code: 'NOT_FOUND' | 'RATE_LIMIT' | 'AUTH' | 'VALIDATION' | 'UPSTREAM',
public userMessage: string,
public details?: Record<string, unknown>
) {
super(userMessage);
}
toContent() {
return {
content: [{
type: 'text' as const,
text: `Error [${this.code}]: ${this.userMessage}\n\n` +
(this.details ? `Details:\n${JSON.stringify(this.details, null, 2)}` : '')
}],
isError: true
};
}
}
模板 3:structured logging:
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
function log(level: LogLevel, message: string, data?: Record<string, unknown>) {
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...data
};
console.error(JSON.stringify(entry)); // stderr-only!
}
这三个模板是几乎每个生产 MCP server 都需要的基础件,可以直接复用。
20.28 StdioServerTransport 源码十个细节
光说”stdio 简单”不够——拿真实源码来看 packages/server/src/server/stdio.ts(138 行)为什么写得那么小心:
细节 1:_ondata = (chunk: Buffer) => { ... } 用箭头函数(第 34 行)——注释原文 “Arrow functions to bind this properly, while maintaining function identity.”——函数身份必须稳定、否则后面 stdin.off('data', this._ondata) 拿不到原引用、removeListener 失败。这是 Node.js 事件监听的经典坑——写过的人都踩过。
细节 2:_started 标志位——start() 第 53 行 “If using Server class, note that connect() calls start() automatically.”——防止用户既显式 await transport.start() 又 await server.connect(transport)、重复注册监听器导致每条消息被处理两遍。
细节 3:processReadBuffer() 循环读——第 63 行的 while (true) + readMessage() 返回 null 才 break——一次 chunk 可能含多条消息(高并发时 stdin 合并)、必须循环吃完。
**细节 4:close() 先 off 再 pause**——第 85-87 行 _stdin.off(‘data’)` 等、再第 90-94 行 “Check if we were the only data listener”——绝对不 pause 别人的 stdin(共享 stdin 的应用场景:同进程嵌 MCP + 读用户输入)。
细节 5:send() 的 settled 标志——第 108-130 行——Node stream 的 write() 返回 false 触发 'drain'、期间可能 'error'、两者竞争——用一个标志防止 resolve+reject 都跑。很多人写 stream wrapper 都会踩这个。
细节 6:stderr-only 日志的硬约束——本章反复强调过、stdio.ts 源码里没一行 console.log——SDK 作者知道 console.log 就毁 stdio transport。
细节 7:_stdout.on('error', this._onstdouterror) 额外注册(第 60 行)——stdout 出错时(管道对端关闭)、不仅调用 onerror、还主动 close()——避免变僵尸。
细节 8:ReadBuffer 来自 @modelcontextprotocol/core——SDK 把行分隔的 JSON 解析抽成复用组件、Web transport(第 9 章)也用得到。
细节 9:没有心跳——stdio 假设进程共生命周期、不需要 keepalive——这和 HTTP transport 的 ping / sse retry 形成鲜明对比(详见第 5 章)。
细节 10:Promise.reject(new Error('StdioServerTransport is closed'))(第 106 行)——close() 之后再 send() 立刻失败、而不是默默吞掉——用户立即知道。
138 行代码、10 个细节——这就是工业级 I/O wrapper 的密度——表面简单、每一行都是”为什么要这样”的结晶。
20.31 插播:Server 类 _oninitialize 版本协商源码
packages/server/src/server/server.ts 第 426-444 行——版本协商的核心 9 行:
private async _oninitialize(request: InitializeRequest): Promise<InitializeResult> {
const requestedVersion = request.params.protocolVersion;
this._clientCapabilities = request.params.capabilities;
this._clientVersion = request.params.clientInfo;
const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion)
? requestedVersion
: (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION);
this.transport?.setProtocolVersion?.(protocolVersion);
return { protocolVersion, capabilities: this.getCapabilities(), serverInfo: this._serverInfo, ... };
}
拆开看——
第一步——取 client 请求的 protocolVersion——这是协议规范(MCP spec §3.1)的必选字段。
第二步——保存 clientCapabilities 和 clientInfo——server 以后可以调 getClientCapabilities() / getClientVersion() 查 client 支持什么(比如 client 支不支持 elicitation、要不要降级)。
第三步——三态协商:client 请求版本 ∈ server 支持列表 → 返回 client 版本;否则回退到 server 支持列表的第一项(也就是最新版);否则再回退到 LATEST_PROTOCOL_VERSION 常量。这个”回退到最新”的设计——如果 client 版本太新 server 不支持、不是报错、而是告诉 client “我只会这个、你凑合用”——最大化兼容性。
第四步——transport.setProtocolVersion?.()——把协商结果同步给 transport——streamable HTTP transport 需要知道版本、以便在 response header 放 mcp-protocol-version、让 client 侧 SDK 校验。
第五步——返回 InitializeResult——这是握手响应、之后 client 发 notifications/initialized、server 触发 oninitialized 回调、之后才允许业务请求。
这 9 行代码是”MCP 协议语义”的缩影——版本协商、能力交换、双向 info 注入——一个 RPC 握手需要的所有元素都在这里。对比 gRPC 的 HTTP/2 ALPN、HTTP/1.1 的 Upgrade header——MCP 选择了”请求响应对 + JSON-RPC 语义”这条路——学起来门槛低、但底层严谨度不打折扣。
20.32 插播:Streamable HTTP 的 EventStore 可恢复流设计
packages/server/src/server/streamableHttp.ts 第 27-54 行定义了 EventStore interface——这是 MCP 在 HTTP transport 层对”断线重连”的答案:
export interface EventStore {
storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;
getStreamIdForEventId?(eventId: EventId): Promise<StreamId | undefined>;
replayEventsAfter(
lastEventId: EventId,
{ send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> }
): Promise<StreamId>;
}
问题场景——client 在 SSE 长连接里接收 server 的流式输出、突然网络抖动、TCP 断开——client 重连后,怎么拿到”断开期间 server 想推送但失败的那些事件”?
HTTP 标准给的答案——SSE 协议第 9.2.6 节——client 重连时在 Last-Event-ID header 里送上一次收到的最后一条 event 的 id。
MCP SDK 的落地——
storeEvent每发一条消息前、先存一份带 event-id(第 392 行storeEvent(streamId, {} as JSONRPCMessage)甚至给 priming event 也存一份)replayEventsAfter在 client 重连时、按lastEventId把之后的所有事件通过注入的send函数重播出去getStreamIdForEventId是可选的”反查”——帮助 SDK 把Last-Event-ID映射回原来的 streamId、继续复用那个 stream而不是开新的
这是一个”接口而非实现”的经典 SDK 设计——SDK 不替你选存储:你可以用内存 Map(demo)、Redis(横向扩展)、Postgres(持久化)——自己实现这三个方法即可接入。
对比本书第 9 章”可恢复流”——那章讲了协议层的设计;本节讲实现层的 hook 点——两者合起来才完整。
生产建议——
- 单机原型:内存
Map<streamId, JSONRPCMessage[]>足矣 - 分布式:Redis Stream(内置 id、断点续读)
- 长期存档:Postgres
events表、(stream_id, event_id)双列索引
这三种实现都不过百行代码——因为 SDK 把复杂度全抽到了 interface 背后。这就是优秀 SDK 的价值观——给你扩展点、不强加决策。
20.33 插播:DNS rebinding 防护中间件源码
packages/server/src/server/middleware/hostHeaderValidation.ts(69 行)——MCP SDK 对 DNS rebinding 攻击的标准答案。
攻击场景——MCP server 监听 localhost:3000、本地 Agent 通过 http://localhost:3000 访问——但攻击者控制一个公网域名 evil.com、初次 DNS 解析为公网 IP 绕过浏览器 same-origin、同时给该域名设置 TTL=0 的 DNS、浏览器第二次访问时 DNS 回 127.0.0.1——攻击者网页就能发请求到本机 MCP server。这是经典的 DNS rebinding。
源码逻辑——
validateHostHeader第 19-35 行——解析 Host header、用 URL API 正确处理 IPv4 / IPv6 / 带端口的场景(new URL('http://' + hostHeader).hostname)- 检查 hostname ∈
allowedHostnames白名单、否则返回invalid_host错误 localhostAllowedHostnames()第 39-41 行给个快捷方式——['localhost', '127.0.0.1', '[::1]']——IPv6 loopback 也要带进来、很多 SDK 都忘hostHeaderValidationResponse()第 51-68 行直接返回 403 + JSON-RPC error(code -32000)——错误格式仍然遵守 JSON-RPC 规范、客户端不会因为”HTTP 403”和”JSON-RPC 响应”两套错误语义混乱
为什么把这玩意做成独立模块——
- 第一、默认不启用、SDK 提供工具但不强塞——本地 dev 常常用假域名、全启用会误伤
- 第二、单一职责——把”Host 校验”和 “Origin 校验” 拆开、前者防 DNS rebinding、后者防 CSRF
- 第三、生产环境放到反向代理层(nginx、Cloudflare)更合理——SDK 提供后备、但首选是外层兜住
扩展阅读——OWASP “DNS Rebinding Attacks” 条目、Chrome 的 Private Network Access 提案(CORS-PNA)——这是一个持续演进的安全话题、MCP SDK 只是”做了它该做的”、不越界。
对比本书第 17 章 OAuth——那是传输层之上的身份认证;本节讲传输层本身的请求来源校验——两者组合才构成 MCP server 的”纵深防御”。
20.32 completable 源码——把”补全能力”附在 schema 上的优雅手法
packages/server/src/server/completable.ts 74 行——SDK 用一个 Symbol 把补全回调”附加”在任意 schema 上、这个设计值得单独拆开讲。
核心原语——
export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');
export function completable<T extends StandardSchemaWithJSON>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
value: { complete } as CompletableMeta<T>,
enumerable: false, writable: false, configurable: false
});
return schema as CompletableSchema<T>;
}
三个值得注意的决定——
一、Symbol.for('mcp.completable') 而非普通字段——Symbol 不会和 schema 已有字段冲突、zod / valibot / arktype 的内部字段怎么改都不影响。这是非侵入式扩展的教科书写法。
二、Object.defineProperty 的三个 false——enumerable=false 所以 JSON.stringify(schema) 不会把 completer 序列化进去(完全就是错的)、writable=false + configurable=false 防止被意外覆盖——数据完整性三道锁。
三、isCompletable / getCompleter 对称——读取路径零开销(Symbol 属性直接访问、没 getter 魔法)、任何 schema 都能被 completable、也都能被检查。
MCP 协议里的 completion/complete 请求(第 18 章)由此复用已有 schema、不用单独定义”补全 schema”——对用户 API 来说就是 completable(z.string(), value => [...]) 一行——SDK 内部自动 wire up。
对比:Pydantic 的 Annotated[str, Field(...)]——思路相似、工具不同(Python metadata、TS Symbol)——“把元信息附在类型上”是类型系统成熟后通用的设计范式。本书第 14 章(Python SDK)讲了 Pydantic 路径、这里讲 TS 路径、两相印证。
20.33 streamableHttp.ts 的 streamId 分配与请求映射
streamableHttp.ts 第 715-795 行——当 client 发 POST /mcp 带 JSON-RPC request 时——transport 要决定”响应从哪条 SSE 流推回去”——源码逻辑值得细看:
- 第 715 行
const streamId = crypto.randomUUID()——每次新 request 都分配一个新 streamId——不复用——这样一个 client 可以并发发多个 request、互不干扰 - 第 728-737 行
_streamMapping.set(streamId, { controller, encoder, cleanup })——streamId → stream controller 的映射——之后要往这条流写事件、从这里拿 controller - 第 737 行
_requestToStreamMapping.set(message.id, streamId)——JSON-RPC request id → streamId 的反向映射——之后 server 产生 response 时、按 message.id 找到对应 streamId、再从_streamMapping拿 controller 写数据 - 第 793 行
writePrimingEvent(...)写 priming event——SSE 协议要求立即写点东西触发 response headers flush、否则 client 要等第一条业务事件才能收到 headers——这是 SSE 新手最容易忽略的体验细节
这套”双 map”设计(streamId ↔ controller、requestId ↔ streamId)把”HTTP request”、“SSE stream”、“JSON-RPC request”三个层次的生命周期解耦——任何一个都可以独立 close 而不影响另两个——工业级协议实现的典范。
对比本书第 5 章(传输层概念)——那章讲了 stdio / HTTP 为何各有场景;本节讲 HTTP transport 内部如何做到并发正确——两章叠加才完整。
20.34 HandleRequestOptions 的设计哲学——parsedBody 和 authInfo
streamableHttp.ts 第 158-172 行定义了 HandleRequestOptions:
export interface HandleRequestOptions {
parsedBody?: unknown;
authInfo?: AuthInfo;
}
两个字段看似简单——但它们承载了对”中间件生态”的完整妥协——
parsedBody——背景是 Express / Fastify / Hono 等框架普遍会在上游中间件层解析 JSON body(body-parser、@fastify/formbody)——如果 SDK 不给 hook、就只能让用户”绕过中间件”或”禁用 body parser”——两种方案都不优雅。SDK 提供 parsedBody 参数——你解析完传进来即可——SDK 不重复 parse、也不挑框架。
authInfo——MCP server 的认证不应该由 SDK 承担——认证可能在 nginx / API Gateway / Cloudflare Access / 自家 OAuth middleware 里做——SDK 只需要”从某处接收认证结果、传给 tool handler”——用户在自己的中间件里塞 authInfo、SDK 原样透传到 MessageExtraInfo——工具实现者才能做”按用户分权”的决策(详见第 17 章 OAuth)。
这两个字段的共同特点——不做假设、只做 hook——SDK 不假设你用哪个框架、不假设你怎么认证——只保证”你给什么、我用什么”。
这是优秀 SDK 的”可组合性”——和 Rust 生态的 tower Service trait(见本书第 12 章”hyper-tower”)、Vite 的 plugin hooks(见第 15 章”vite”)——哲学一脉相承:**把扩展点留给用户、而不是替用户做决定**。
20.35 mcp.ts 第 1300+ 行的 notifyToolListChanged 去抖
packages/server/src/server/mcp.ts 约 1300 行附近——_notifyToolListChanged 使用 100ms debounce——为什么是 100ms、为什么要 debounce?
场景——用户在 app 启动时批量 registerTool 10 个工具——naive 实现每个 register 都发一条 notifications/tools/list_changed——client 收到 10 条通知、每条都触发一次 listTools 请求——网络抖动 10 倍。
Debounce 100ms 的选择——
- 下限——人眼能感知的”延迟”大约是 100ms、小于 100ms 对用户体验没差
- 上限——如果调大到 1s、启动批量注册后 client 要等 1s 才看到工具列表更新——交互层面能感觉到拖沓
100ms 就是”批量吸收无感知、但不拖慢单次变更”的甜点。
对比 Vue 3 的 nextTick(本书第 19 章 vue3)——那是微任务调度、把多次 reactive 变更合并到一次 DOM update——思路完全一样。对比 React 的 batchedUpdates(本书第 16 章 react18)——也是一样——**把”高频离散事件”去抖成”低频批量”**是 UI / 事件系统的通用模式。
MCP SDK 把这个模式移植到协议层,因为在 Agent 生态里,tool list 本质上就是 server → client 的 reactive state——同样的问题复用同样的解法。