MCP 协议设计与实现

第20章 从零构建一个生产级 MCP Server

作者 杨艺韬 · 12,298 字

第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 SDKPython SDK 的开源实现。特别感谢:

  • Anthropic 发起并开源 MCP 协议
  • MCP 社区的早期贡献者——提供大量 reference server 实例
  • Claude Desktop / Cursor / Continue.dev 等早期 client 实现者——让协议被真实验证

本书中的所有代码示例在撰写时基于当时的 SDK 版本可运行。但 SDK 在演进——部分 API 可能在后续版本有变化。请以官方文档为准——本书提供的是设计意图和架构理解、不是 API reference。

如有错误或建议——欢迎通过 GitHub Issue 反馈——所有反馈都会认真考虑并在再版中修正

20.18.5 再深入——关于 registerTool 的类型细节

打开 @modelcontextprotocol/sdkpackages/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 提供 protocolschema 库用户自选——各司其职。

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-894registerTool

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>
    );
}

几个工程细节值得注意

① 泛型参数 InputArgsOutputArgs——保证 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

registerResourceregisterPrompt 结构类似 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_changednotifications/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/sdkTS/JS官方 Tier-1Zod schema、Express 友好
mcp-python-sdkPython官方 Tier-1Pydantic、Starlette ASGI、装饰器
mcp-rust-sdkRust社区性能、类型安全
mcp-go-sdkGo社区轻量、部署简单
mcp-java-sdkJava社区企业 Java 项目集成
mcp-kotlin-sdkKotlin社区JVM + 语法更简洁
mcp-csharp-sdkC#社区.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 McpServerServer 的两层 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)的必选字段。

第二步——保存 clientCapabilitiesclientInfo——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 bodybody-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——同样的问题复用同样的解法。