MCP 协议设计与实现

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

作者 杨艺韬 · 17,826 字 · 发布于 · 更新于

学一份协议最快的路子,是拿它做一个真会部署上线的东西,而不是一个 hello world。

本章要点

  • 从需求分析到技术选型:TypeScript 与 Python SDK 的取舍考量
  • 实现完整的 Tools、Resources、Prompts 三大原语
  • 为 Server 添加安全防护:输入校验、限流、Host 头校验(OAuth 认证见第 15 章)
  • 编写协议级集成测试
  • 部署策略:stdio 本地模式与 Streamable HTTP 远程模式
  • 发布到 MCP 生态

20.1 我们要构建什么

本章将构建一个 GitHub MCP Server——让 AI 助手能够与 GitHub 仓库交互。这不是一个玩具示例,而是一个覆盖了 MCP 核心特性的真实集成:

MCP 原语 实现内容
Tools 搜索仓库、创建 Issue、批量给 Issue 打标签
Resources 暴露当前用户信息、仓库文件内容
Prompts 提供 Code Review、Bug Report 模板
Completion Prompt 参数与资源 URI 变量的自动补全(ref/prompt / ref/resource)
Progress 批量操作的进度追踪

20.2 技术选型:TypeScript vs Python

20.2.1 两个 SDK 的定位差异

官方 SDK 有多个(Tier 1 就有 TypeScript、Python、C#、Go 四个,见 §20.20),但本专栏全程对照的是最成熟、生态最大的两个,它们各有所长:

graph LR
    subgraph "TypeScript SDK"
        TS1["McpServer 高级 API"]
        TS2["Server 底层 API"]
        TS3["Standard Schema<br/>Zod / Valibot / ArkType"]
        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:#2563eb,color:#fff,stroke:none
    style PY1 fill:#10b981,color:#333,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 pkg set type=module scripts.build=tsc
# 锁死到与本专栏 pin 最接近的发布版(原因见下);node + express 供 §20.9.2 的远程模式用
npm install --save-exact @modelcontextprotocol/server@2.0.0-alpha.2 @modelcontextprotocol/node@2.0.0-alpha.2 @cfworker/json-schema
npm install zod express
npm install -D --save-exact @modelcontextprotocol/client@2.0.0-alpha.2
npm install -D typescript @types/node @types/express vitest

这几行里每一处都是照抄本章代码实跑时踩出来的(2026-09-25,node v24):

  • 版本要锁死。 本专栏对照的 TS SDK 是 b8886e7,npm 上与它最近的发布是 2.0.0-alpha.2(pin 比它多 13 个提交)。不写版本会装到之后的 2.x 正式版;只写版本不加 --save-exact,package.json 里记下的是 ^2.0.0-alpha.2,别人 npx 你的包时同样会解析到新版——实测解析到的 2.1.0,server 包根入口已不再导出 StdioServerTransport,本章代码一启动就报错。
  • @cfworker/json-schema:alpha.2 的构建产物静态 import 了这个只标为可选 peer 的依赖,不装就是 ERR_MODULE_NOT_FOUND。pin 上已把它挪到按需的 ./validators/cf-worker 子路径(提交 7ba58dac),主入口不再引用。
  • 不装 @modelcontextprotocol/core:它在 pin 上是 private 的内部包,server / client 已把它打进各自的包里,公开类型也由这两个包重新导出(docs/migration.md 第 60 行)。
  • "type": "module":SDK 只提供 ESM,入口文件还用了顶层 await。

TypeScript 配置:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}

最后两项也不是装饰:TypeScript 6 起 types 默认为空、不再自动带上 @types/node(用 5.x 可省);而 server 包的类型声明 import 了 ajv、json-schema-typed,这两个包却不在它的依赖里(pin 上同样如此),不开 skipLibCheck 编译会报 TS2307。

20.3.2 入口文件

#!/usr/bin/env node
// mcp-server-github/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);

第一行的 #!/usr/bin/env node 要留着:tsc 会把它原样带进编译出的 index.js,§20.9.1 用 bin 发布后,npx 靠它知道用 node 执行;去掉它,shell 会把这个 JS 文件当脚本跑,直接报 import: not found(实测)。

instructions 字段值得注意——它不是给人看的,而是给 LLM 看的:Server 只在它非空时才把它放进 InitializeResult,客户端拿到后通常拼进系统提示。写得好不好会影响模型选工具的表现,但具体提升多少必须用你自己的评测集量,本专栏不给百分比。

20.4 实现 Tools

20.4.1 搜索仓库

import * as z from 'zod/v4';

// 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}`);
  }
  // §20.6 会用 diff 格式的 Accept 头取 PR diff,那时 GitHub 回的是纯文本,不能一律 resp.json()
  const type = resp.headers.get('content-type') ?? '';
  return type.includes('json') ? resp.json() : resp.text();
}

// 搜索仓库工具
server.registerTool(
  'search_repos',
  {
    title: '搜索 GitHub 仓库',
    description: '根据关键词搜索 GitHub 仓库,返回仓库名、描述、Star 数等信息',
    inputSchema: z.object({
      query: z.string().describe('搜索关键词,支持 GitHub 搜索语法'),
      language: z.string().optional()
        .describe('编程语言过滤,如 typescript / python / rust'),
      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: z.string().describe('仓库全名,格式: owner/repo'),
      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}`
      }]
    };
  }
);

这里要澄清一个很多人踩过的误区:completable() 不能用在 Tool 的 inputSchema 上。

completion/complete 请求的 ref 只有两种类型——ref/prompt 和 ref/resource。规范里没有 ref/tool,SDK 的补全处理器也只对这两种引用分发。给工具参数包一层 completable() 不会报错,但那个回调永远不会被调用——因为工具参数是模型填的,不是用户在输入框里敲的,本来也没有"补全"这个交互位置。

Completion 的正确落点是 Prompt 的参数和资源模板的 URI 变量——§20.6 的 code_review Prompt 会给出一个真正生效的例子。补全机制本身在第 8 章 §8.4 讲过。

20.4.3 Python 对照

同样的 create_issue 工具在 Python SDK 中的写法:

from mcp.server.mcpserver import MCPServer, 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']}"

注意类名是 MCPServer(三个大写字母),不是 TypeScript 那边的 McpServer——两个 SDK 在这个名字上不一致,跨语言对照时很容易写错。MCPServer 和 Context 都直接从 mcp.server.mcpserver 导出。github_fetch 是与 TS 版 githubFetch 对应的请求函数,这里省略;末尾加一句 server.run() 就能以 stdio 启动(传 "streamable-http" 则起 HTTP 服务,默认 127.0.0.1:8000/mcp,两种都已实跑)。

还有一处和 TS 版不对等:函数里那行 docstring 不会变成参数说明(显式传了 description 时它连工具描述都不是),实测生成的 inputSchema 里各参数只有 title、没有 description。要达到 TS 里 .describe() 的效果,写成 repo: Annotated[str, Field(description="仓库全名,格式 owner/repo")](Field 来自 pydantic)。

Python SDK 的 Context 注入机制很优雅——你只需要在函数签名中声明一个 Context 类型标注的参数(参数名任意,ctx、context 都行),SDK 会自动注入上下文对象,无需显式传递。除了 ctx.info() 这类日志方法,它还提供 ctx.report_progress(progress, total, message) 做进度上报。

20.5 实现 Resources

Resources 让 AI 可以"读取"GitHub 上的内容,就像读取本地文件一样:

import { ResourceTemplate } from '@modelcontextprotocol/server';

// 静态资源:当前用户信息
// 注意签名是 registerResource(name, uriOrTemplate, config, readCallback) —— 四个参数,
// 第一个是资源名、第二个才是 URI(或 ResourceTemplate)
server.registerResource(
  'github-user-profile',
  'github://user/profile',
  {
    title: '当前 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)
      }]
    };
  }
);

// 资源模板:仓库文件内容 —— 同一个 registerResource,第二个参数换成 ResourceTemplate
server.registerResource(
  'github-repo-file',
  new ResourceTemplate('github://repos/{owner}/{repo}/contents/{path}', {
    // list 是必填项(值可以是 undefined,但字段不能省),
    // 这是 SDK 有意为之:强迫你显式表态"这个模板要不要可枚举"
    list: async () => {
      // 返回一些常见的文件作为示例
      return {
        resources: [
          {
            uri: 'github://repos/modelcontextprotocol/modelcontextprotocol/contents/README.md',
            name: 'MCP Specification README',
            mimeType: 'text/markdown'
          }
        ]
      };
    },
    // URI 模板变量的补全在这里声明——这是 completable 之外的另一条补全路径
    complete: {
      owner: async (value) => ['modelcontextprotocol', 'anthropics']
        .filter(o => o.startsWith(value))
    }
  }),
  {
    title: '仓库文件内容',
    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 字段必须写,但值可以是 undefined——源码注释把理由说得很直白:"even if undefined, to avoid accidentally forgetting resource listing"。把"我不提供列举"变成一次显式表态,而不是一次遗漏,这是个值得抄的 API 设计习惯。

模板回调的签名是 (uri: URL, variables, ctx)——第二个参数才是解析出来的模板变量。

20.6 实现 Prompts

Prompts 是预定义的对话模板,引导 LLM 执行特定的工作流:

import { completable } from '@modelcontextprotocol/server';

server.registerPrompt(
  'code_review',
  {
    title: 'Code Review',
    description: '对 Pull Request 进行代码审查,生成结构化的审查意见',
    argsSchema: z.object({
      // completable 用在 Prompt 参数上才会生效(ref/prompt)
      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 [];
          }
        }
      ),
      pr_number: z.string().describe('Pull Request 编号')
    })
  },
  async ({ repo, pr_number }) => {
    // 提醒:prompts/get 的 arguments 在协议里是 { [key: string]: string },
    // 所有参数都以字符串到达,需要数字时自己转
    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 添加进度追踪

对于可能耗时的操作,用工具回调第二个参数 ctx 发进度通知。TypeScript SDK 没有 reportProgress 这样的封装方法——进度是一条普通的 notifications/progress 通知,你要自己从请求元数据里取出 progressToken:

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 }, ctx) => {
    // 客户端只有在请求里带了 progressToken 时才想要进度;没带就别发
    const progressToken = ctx.mcpReq._meta?.progressToken;
    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}`);
      }

      if (progressToken !== undefined) {
        // ctx.mcpReq.notify 会把通知正确关联到当前这条请求,
        // 这对 Streamable HTTP 这类需要"往哪条流写"的传输是必需的
        await ctx.mcpReq.notify({
          method: 'notifications/progress',
          params: {
            progressToken,
            progress: i + 1,
            total,
            message: `正在处理 Issue #${num} (${i + 1}/${total})`
          }
        });
      }
    }

    return {
      content: [{
        type: 'text',
        text: `批量标记完成:\n${results.join('\n')}`
      }]
    };
  }
);

ctx 上还有几个同样走"关联到当前请求"这条路的能力:ctx.mcpReq.log() 发日志、ctx.mcpReq.elicitInput() 向用户提问、ctx.mcpReq.requestSampling() 请求 LLM 采样、ctx.mcpReq.signal 拿到取消信号。

Python SDK 这边则做了封装:注入的 Context 直接提供 await ctx.report_progress(i + 1, total, message)。同一个协议动作,两个 SDK 的抽象层级不同——这是本专栏反复出现的对照。

20.8 测试策略

20.8.1 协议级集成测试

MCP 的测试需要模拟完整的 Client-Server 交互。先做一处重构:测试和 §20.9.2 的远程模式都要反复创建 Server,所以把 §20.3–§20.7 的注册代码(连同 githubFetch)收进 server.ts 里一个导出的 createServer() 工厂函数,返回配好的 McpServer;入口文件只剩 createServer() 加 stdio 连接那几行。

两端用内存传输直连,不起子进程。SDK 仓库自己的测试用的是 core 里的 InMemoryTransport,但它已移出公开 API(docs/migration.md 的 "InMemoryTransport removed from public API" 一节),core 又是不随 alpha.2 发布的内部包,读者项目里拿不到——自己写一对二十来行的即可。GitHub API 用 vitest 的 vi.stubGlobal 换成假的,测试不联网、不需要真 token:

// mcp-server-github/tests/tools.test.ts
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/client';
import type { JSONRPCMessage, McpServer, Transport } from '@modelcontextprotocol/server';
import { createServer } from '../src/server.js';

// 一对首尾相连的内存传输:一端 send(),另一端的 onmessage 收到
function createLinkedPair(): [Transport, Transport] {
  const make = () => {
    const t: Transport & { peer?: Transport } = {
      async start() {},
      async send(message: JSONRPCMessage) {
        queueMicrotask(() => t.peer?.onmessage?.(message));
      },
      async close() {
        const peer = t.peer;
        t.peer = undefined;
        await peer?.close();
        t.onclose?.();
      }
    };
    return t;
  };
  const a = make(), b = make();
  a.peer = b;
  b.peer = a;
  return [a, b];
}

// 假的 GitHub API:只认本测试会打到的几个路径
function fakeGitHub(url: string | URL | Request, init?: RequestInit): Promise<Response> {
  const { pathname } = new URL(String(url));
  const json = (body: unknown) =>
    new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
  if (pathname === '/search/repositories') {
    return Promise.resolve(json({ items: [{
      full_name: 'anthropics/claude-code', description: null, stargazers_count: 1,
      language: 'TypeScript', html_url: 'https://github.com/anthropics/claude-code'
    }] }));
  }
  if (pathname.endsWith('/issues') && init?.method === 'POST') {
    return Promise.resolve(json({
      number: 1, title: '测试 Issue', html_url: 'https://github.com/test-owner/test-repo/issues/1'
    }));
  }
  return Promise.resolve(new Response('Not Found', { status: 404 }));
}

describe('GitHub MCP Server', () => {
  let client: Client;
  let server: McpServer;

  beforeAll(async () => {
    process.env.GITHUB_TOKEN = 'test-token';
    vi.stubGlobal('fetch', vi.fn(fakeGitHub));

    server = createServer();
    const [clientTransport, serverTransport] = 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();
    vi.unstubAllGlobals();
  });

  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('#');
  });
});

npx vitest run 实测通过(连同下面 §20.8.2 那条共 4 条),测试文件在 strict 下也能过 tsc。换用 pin 树本地构建的 server / client 包重跑,结果相同。

20.8.2 Completion 测试

放进上面同一个 describe 里。补全回调会去搜仓库,走的仍是那个假 fetch:

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", "@my-username/mcp-server-github"],
      "env": {
        "GITHUB_TOKEN": "${GITHUB_TOKEN}"
      }
    }
  }
}

${GITHUB_TOKEN} 这种写法要客户端肯替你展开:Claude Code 的 .mcp.json 支持在 command、args、env、url、headers 里写 ${VAR} 与 ${VAR:-默认值}(官方文档,核对日期 2026-09-24);不展开的客户端会把这串字面量原样传给 Server,那就得直接填值。

为此需要在 package.json 中配置 bin 入口(与 §20.3.1 生成的那份合并):

{
  "name": "@my-username/mcp-server-github",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mcp-server-github": "./dist/index.js"
  },
  "files": ["dist"]
}

包名用自己 npm 账号下的 scope:不带 scope 的 mcp-server-github 在 npm 上已被占用(现在是 npm 的安全占位包,版本号 0.0.1-security,2026-09-25 查询),npx -y mcp-server-github 拉到的不是你的代码。用 npm pack 打出 tgz、在空目录里安装后让客户端经 bin 启动,工具调用走通(实测)。

20.9.2 远程模式(Streamable HTTP)

对于需要多用户共享的场景,使用 HTTP 传输:

// mcp-server-github/src/remote.ts
import { randomUUID } from 'node:crypto';
import express from 'express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { isInitializeRequest } from '@modelcontextprotocol/server';
import { createServer } from './server.js';   // §20.8.1 抽出的工厂函数

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

// GET 打开服务端推送流,DELETE 结束会话——两者都只认已有会话
const handleSessionRequest = async (req: express.Request, res: express.Response) => {
  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.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);

app.listen(3000, () => {
  console.log('MCP Server running on http://localhost:3000/mcp');
});

远程部署时,每个 Client 连接都创建一个独立的 McpServer 实例——这避免了多用户之间的状态污染,SDK 官方示例 examples/server/src/simpleStreamableHttp.ts 也是这么组织的。

实跑(SDK 的 StreamableHTTPClientTransport 连上来):initialize 响应头带回 mcp-session-id,batch_label_issues 的进度通知和最终结果在同一条 POST 的 SSE 流里依次到达,terminateSession() 发的 DELETE 成功结束会话。DELETE 那条路由不能省:没有它 Express 回 404,客户端的 terminateSession() 会抛 Failed to terminate session,服务端的会话也永远留在 transports 里。另外两点照抄前要知道:

  • app.listen(3000) 监听所有网卡,这段代码也不校验 Host 头——实测带 Host: evil.example.com 的 initialize 照样 200。本机使用时至少按 §20.10.3 加上 Host 校验,或改用 @modelcontextprotocol/express 的 createMcpExpressApp():它默认按 127.0.0.1 配置,并自动挂上 localhost 的 Host 校验(packages/middleware/express/src/express.ts:62-86);实际监听地址仍要在 listen 时自己给出。
  • 会话表 transports 在进程内存里。下面架构图里的多实例部署,负载均衡必须按 Mcp-Session-Id 做会话粘滞,否则后续请求落到另一个实例上,只会得到上面那条 400 或 404。

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:#2563eb,color:#fff,stroke:none
    style S2 fill:#10b981,color:#333,stroke:none
    style S3 fill:#10b981,color:#333,stroke:none
    style LB fill:#f59e0b,color:#333,stroke:none

本地模式简单可靠,适合个人使用;远程模式支持多用户、可水平扩展,适合团队和企业级部署。两种模式共用同一套 Server 代码,只是传输层不同——这正是 MCP 传输抽象的价值所在。

20.10 安全加固

20.10.1 输入验证

Zod Schema 已经提供了基本的输入验证,但对于安全敏感的操作还需要额外检查:

// 防止路径穿越:字符集之外,还得排除 "." 与 ".." 这两个路径段——
// 只靠正则的话 "../.." 能通过,拼进 URL 后 /repos/../../issues 会被规范化成 /issues
function validateRepoName(repo: string): boolean {
  return /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)
    && !repo.split('/').some(seg => seg === '.' || seg === '..');
}

// 搜索词不需要再做"防注入"清洗:search_repos 已对 q 做 encodeURIComponent,拼不出额外的 URL 参数。
// 反过来,query.replace(/[^\w\s\-.:@/]/g, '') 这类字符白名单会把 stars:>100 变成含义不同的
// stars:100、剥掉短语引号,JS 的 \w 也不含中文——与 query 描述里的"支持 GitHub 搜索语法"相矛盾。

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 重绑定攻击:

SDK 在 @modelcontextprotocol/server 里导出的是三个函数,不是一个中间件工厂——你自己决定在哪一层调用它:

import {
  validateHostHeader,
  localhostAllowedHostnames,
  hostHeaderValidationResponse
} from '@modelcontextprotocol/server';

const allowed = process.env.NODE_ENV === 'development'
  ? [...localhostAllowedHostnames(), 'mcp.example.com']  // ['localhost','127.0.0.1','[::1]', ...]
  : ['mcp.example.com'];

// Node / Express 风格:自己判断、自己回响应
app.use((req, res, next) => {
  const result = validateHostHeader(req.headers.host, allowed);
  if (!result.ok) {
    res.status(403).json({
      jsonrpc: '2.0',
      error: { code: -32000, message: result.message },
      id: null
    });
    return;
  }
  next();
});

// Web 标准 Request/Response 风格(Workers、Hono 等):一行搞定
const rejection = hostHeaderValidationResponse(request, allowed);
if (rejection) return rejection;   // 403 + JSON-RPC 错误体

两点值得注意:白名单里只写主机名、不带端口(validateHostHeader 用 new URL('http://' + host).hostname 解析,是端口无关的),IPv6 要写成带方括号的 [::1];拒绝时返回的是 403 + JSON-RPC 错误体,而不是一个裸 HTTP 错误——客户端不用同时处理两套错误语义。

Host 校验只是一半。2025-11-25 规范要求服务端对所有连接校验 Origin 头(MUST,带了非法 Origin 的回 403,docs/specification/2025-11-25/basic/transports.mdx 第 78-79 行),而 packages/middleware 下的中间件只校验 Host、不校验 Origin(第 13 章 §13.3),这一步要自己补。

20.11 发布到生态

20.11.1 npm 发布

# 构建
npm run build

# 发布(带 scope 的包默认私有,首次发布要 --access public)
npm publish --access public

20.11.2 MCP Registry

官方的 MCP Registry(https://registry.modelcontextprotocol.io)已经上线:2025-09-08 进入 preview,2025-10-24 起 API 冻结在 v0.1;截至 2026-09-25,官方 registry 仓库的 README 与文档仍标注为 preview,GA 前可能有破坏性变更或数据重置。它只存元数据、不托管制品——包还是发到 npm / PyPI,Registry 里放一份 server.json 指过去:

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
  "name": "io.github.my-username/weather",
  "description": "An MCP server for weather information.",
  "repository": {
    "url": "https://github.com/my-username/mcp-weather-server",
    "source": "github"
  },
  "version": "1.0.1",
  "packages": [
    {
      "registryType": "npm",
      "identifier": "@my-username/mcp-weather-server",
      "version": "1.0.1",
      "transport": { "type": "stdio" },
      "environmentVariables": [
        {
          "name": "YOUR_API_KEY",
          "description": "Your API key for the service",
          "isRequired": true,
          "isSecret": true,
          "format": "string"
        }
      ]
    }
  ]
}

三个容易漏掉的点:

  1. 命名空间要经过验证。名字形如 io.github.<用户名>/<server 名>,采用 GitHub 认证时前缀必须与你的 GitHub 用户名一致(或是你担任 Owner 的组织名);也支持用 DNS 或 HTTP 验证自有域名,前缀写成反向域名,如 com.example/<server 名>。
  2. 包侧要留一个指纹。npm 包的 package.json 里要加 "mcpName": "io.github.my-username/weather",且必须与 server.json 的 name 一致,Registry 用它确认"这个包确实属于这条元数据";PyPI 包则是在 README(即 PyPI 上的包描述)里写一行 mcp-name: io.github.my-username/weather,可以藏在注释里。
  3. 密钥是声明出来的,不是写死的。environmentVariables 里用 isSecret / isRequired 描述需求,客户端据此提示用户填——这正是 §20.9.1 那份 ${GITHUB_TOKEN} 配置的另一半。

上面这份 server.json 取自官方 registry 仓库的发布教程(docs/modelcontextprotocol-io/quickstart.mdx),用 $schema 指向的 2025-12-11 版 JSON Schema 校验通过。提交走官方的 mcp-publisher 命令行:mcp-publisher init 生成 server.json 模板,mcp-publisher login github 认证,mcp-publisher publish 发布;顺序上要先把包发到 npm,再发元数据。

注册后,Client 与聚合器可以通过 Registry 的 REST API(如 GET /v0.1/servers?search=...)发现你的 Server,用户拿到标准化的安装与配置信息。

20.12 走查小结:从零到生产的完整链路

本章从零构建了一个具备生产级特性的 GitHub MCP Server,覆盖了完整的开发流程:

  • 技术选型:TypeScript 和 Python SDK 各有所长,根据项目特点和团队技能选择
  • 三大原语:Tools 执行操作,Resources 暴露数据,Prompts 定义工作流模板
  • Completion:通过 completable() 为 Prompt 参数、通过 ResourceTemplate 的 complete 为 URI 变量添加实时补全——工具参数不在补全的适用范围内
  • 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...

欢迎语对模型没有信息量,指令和约束才有。

坑 2:description 字段过于简略——description: "搜索仓库" 和 description: "根据关键词搜索 GitHub 上的公开仓库、返回名称/描述/Star 数/语言等元信息、支持 GitHub 搜索语法如 language:python",模型能从后者推断出参数怎么填、什么时候该用这个工具。差多少要看你的评测,但方向是确定的:description 是模型唯一能读到的使用说明。

坑 3:Zod schema 的 .describe() 被忽略——很多人写 z.string()、不加 .describe()——LLM 看到的参数只有名字、没有说明——经常填错。每个字段都要 .describe('具体含义 + 示例')。

坑 4:忘记处理 rate limit——GitHub 认证后的 REST API 有每小时 5000 次的主配额,超限时返回 403 或 429(并带 Retry-After / x-ratelimit-reset 头,以官方文档为准)。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 协议!

后果往往比报错更隐蔽:一整行非 JSON 的日志,TS 客户端会静默跳过;但不带换行的输出会和紧随其后的响应粘成一行、被整行丢弃,表现为 connect() 或某次调用一直等到超时(第 12 章 §12.9 实测)。

坑 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 返回太多——规范给的硬上限是 100 条(CompleteResult.completion.values "must not exceed 100 items"),但那是上限不是目标:补全是给人在输入框里挑的,一次甩 50 条没人看得完——建议 ≤ 10 个、最相关的排前面。

坑 9:Prompts 把 PR diff 直接贴进去——大 PR 的 diff 可能几千行、一次 prompt 塞进去超 context window——大内容应该分块或分 tool call 传递——LLM 按需 grep。

坑 10:没有健康检查——部署到生产后如何知道 Server 还活着?stdio 模式自带(进程死了就死了)、但 HTTP 模式需要 /health endpoint——MCP 规范没强制、但生产必须加。

这 10 个坑提前知道,能省下不少排查时间。

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 项可以当作上线前的自检清单——不是每一项都对所有 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——两个生态互通。

LangChain 的 langchain-mcp-adapters package 就是这个桥——把 MCP server 的 tools 转成 LangChain / LangGraph 能直接绑定的 tool 对象(具体导出的类名以该包当前版本的文档为准,本专栏不锚定它的版本)。这表示生态已经开始融合——未来 MCP 可能成为 tool 定义的 lingua franca、LangChain / LangGraph 成为 agent runtime——分工清晰。

20.16 MCP 未来演进方向

先把已经落地的和还是猜测的分开——把两者混在一起写"未来方向",是这类章节最容易骗到读者的地方。

已经在 2025-11-25 规范里的(不是预测):

  • 长任务:tasks 能力已进入规范(docs/specification/2025-11-25/basic/utilities/tasks.mdx),把长操作从"一次请求一次响应"改成"创建任务 → 轮询状态 → 取结果"。TypeScript SDK 里对应 registerToolTask 与 Experimental*Tasks 这组实验性 API。它不计入任何 SDK Tier 的要求,说明官方自己也把它当作尚未稳定的部分。
  • 官方注册表:MCP Registry 已上线(preview),见 §20.11.2。所以"Server 发现靠 README 和口碑"这句话已经过期了。
  • URL 模式 Elicitation:把凭证收集移出 Client 的那条路径,也是 2025-11-25 才定下来的(第 18 章)。

2026-07-28 版规范也已发布(本专栏仍锚定 2025-11-25):去掉了 initialize 握手与协议级会话,Tasks 移出核心、成为官方扩展(SEP-2663),Roots、Sampling、Logging 与 HTTP+SSE 传输被标为 Deprecated——这些同样是已落地的规范文本,不是预测。

仍属推测,没有规范文本支撑的(下面这些本专栏不提供出处,读者当作作者的判断即可,不要当结论引用):

  • 更轻量的认证形态——现在的 OAuth 2.1 流程对小 server 偏重
  • Server 之间的协作 / 联邦通信
  • 工具结果的分块流式返回——目前 tools/call 的返回是一次性的
  • 视频、实时音频等多模态内容类型的扩展

作为 MCP server 开发者——盯规范仓库的 SEP(Specification Enhancement Proposal)编号,而不是盯传闻:docs/seps/ 下每个提案都有编号和状态,这是唯一能核实"某个方向到底走到哪一步"的地方。

20.17 本章收束:从这里开始动手

作为本专栏的最后一个实战章节(收尾的方法论总结在第 21 章)——这一章覆盖了:

  • 构建一个生产 MCP server 的完整步骤
  • Tools / Resources / Prompts / Completion / Progress 的实战用法
  • 测试、部署、安全、发布的完整清单
  • 10 个常见坑 + 30 项 checklist
  • 与 LangChain / LangGraph 生态的对比
  • MCP 未来演进方向

读完这一章——你有足够的信息和工具构建生产级 MCP server。下一步行动:

① 读完本专栏 → 找一个你实际用的外部服务(GitHub、Notion、Jira、Slack、数据库)——用 MCP server 封装——两周内上线一个

② 测试你的 server → 在 Claude Desktop、Cursor、Continue.dev 等多个 MCP client 上跑一遍——这是覆盖规范最省力的方式

③ 开源发布 → 你写的 MCP server 大概率对其他人有用——贡献生态

从读者变成贡献者——这一步比再多读几篇文章都值。

实战部分到此收尾——方法论的总结在第 21 章。

本专栏的写作基于 MCP 官方规范 2025-11-25 版本 以及 TypeScript SDK b8886e7、Python SDK 3d7b311 这两棵锚定的源码树(口径见前言《源码版本与环境》一节与专栏首页《源码版本》一节)。特别感谢:

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

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

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

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

打开 TypeScript SDK 仓库的 packages/server/src/server/mcp.ts(1329 行)——@modelcontextprotocol/server 这个包的主体。(2.0 起单包 @modelcontextprotocol/sdk 已拆成 core / client / server 加几个框架中间件包,本章一律用拆分后的包名。)本节我们仔细看几段真实源码。

20.18.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 接口(由 Zod、Valibot、ArkType 三个库的作者联合发起的事实标准)。

Standard Schema 的核心要求——暴露一个名为 ~standard 的属性,其中带版本号、厂商名和 validate(input) 方法——任何库实现这俩、就能被 MCP SDK 用。

这种 "按接口编程、不绑定具体库" 的做法让 SDK 对用户的技术选择完全中立——不强迫你用 Zod(Zod 虽主流但有性能开销、有人偏好更轻的 Valibot)。

设计哲学:SDK 提供 protocol、schema 库用户自选——各司其职。

20.18.2 _createRegisteredTool 内部做的事

registerTool 只是门面、真实工作在 _createRegisteredTool:

// packages/server/src/server/mcp.ts:767-837(节选:省略了工具名校验 validateAndWarnToolName、
// currentHandler 变量和 update 的函数体)
private _createRegisteredTool(
    name: string,
    title: string | undefined,
    description: string | undefined,
    inputSchema: StandardSchemaWithJSON | undefined,
    outputSchema: StandardSchemaWithJSON | undefined,
    annotations: ToolAnnotations | undefined,
    execution: ToolExecution | undefined,
    _meta: Record<string, unknown> | undefined,
    handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
): RegisteredTool {
    const registeredTool: RegisteredTool = {
        title, description, inputSchema, outputSchema, annotations,
        execution, _meta,
        handler,
        executor: createToolExecutor(inputSchema, handler),   // 校验器在注册期就编译好
        enabled: true,
        disable: () => registeredTool.update({ enabled: false }),
        enable:  () => registeredTool.update({ enabled: true }),
        remove:  () => registeredTool.update({ name: null }),
        update: updates => {
            // …改名时从 map 里删旧键、写新键;改 schema 或 callback 时重建 executor…
            this.sendToolListChanged();          // 每次改动都通知
        }
    };
    this._registeredTools[name] = registeredTool;

    this.setToolRequestHandlers();               // 幂等:首次注册时才真正挂 handler
    this.sendToolListChanged();

    return registeredTool;
}

真正值得注意的是这几点:

1. registerTool 本身只做重名检查。 它先 if (this._registeredTools[name]) throw new Error(\Tool ${name} is already registered`),然后把参数原样转交 _createRegisteredTool,并写死 { taskSupport: 'forbidden' }`——任务型工具走的是另一个入口。

2. 校验器在注册期编译,不在调用期。 createToolExecutor(inputSchema, handler) 在注册时就把 schema 和用户 callback 编译成一个 executor 存起来。每次 tools/call 直接用它,省掉重复的 schema 解析。改 schema 或改 callback 时,update 里会置 needsExecutorRegen 重新编译——状态与派生物同步更新,这是注册表类设计最容易出 bug 的地方。

3. 返回的 RegisteredTool 是一个活句柄。 enabled / disable() / enable() / remove() / update() 都挂在上面,而且三个便捷方法都是 update() 的薄封装——remove() 就是 update({ name: null })。单一写入口,意味着任何改动都必然经过同一处 sendToolListChanged(),不会漏发通知。

4. setToolRequestHandlers() 是幂等的(内部用 _toolHandlersInitialized 守卫,与第 7 章讲 prompts 时的 _promptHandlersInitialized 同一套路)——注册第 100 个工具时不会重复挂 handler。

20.18.3 服务端默认不防抖——去抖在客户端

这里要破除一个很自然的直觉:既然连续注册会连发通知,SDK 是不是在服务端做了防抖?

默认没有。 服务端这条链路是直通的:

// packages/server/src/server/mcp.ts:991
sendToolListChanged() {
    if (this.isConnected()) {
        this.server.sendToolListChanged();
    }
}

// packages/server/src/server/server.ts:670
async sendToolListChanged() {
    return this.notification({ method: 'notifications/tools/list_changed' });
}

只有一个 isConnected() 判断——未连接时不发,已连接就立即发。默认配置下连续注册三个工具,就是实实在在三条 notifications/tools/list_changed。

服务端合并是可选的:构造时传 debouncedNotificationMethods(例如 new McpServer(info, { debouncedNotificationMethods: ['notifications/tools/list_changed'] })),Protocol.notification() 会把同一轮事件循环里同步连发的同类通知合并成一条——前提是通知不带 params、也不关联某个请求(packages/core/src/shared/protocol.ts:965-997;第 3 章 §3.6 实测)。

去抖的默认落点在客户端:Client 构造时配置了 listChanged 选项,_setupListChangedHandler 就会按 debounceMs 去抖,默认 300ms,可配,设 0 关闭。完整机制与这样分工的理由见 §20.37。


以下为源码附录。 §20.1–20.18 是从零构建一台生产级 MCP Server 的完整走查,读到 §20.17 就已经具备动手的全部信息。 从这里开始的各节是独立的源码深挖——每节针对 SDK 里一处值得单独看的实现(注册流程、版本协商、EventStore、 DNS rebinding 防护、补全机制、去抖……),彼此不构成阅读顺序,按需查阅即可。

20.19 registerTool 源码走查——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.1 Resource 和 Prompt 的并行 API

registerResource 和 registerPrompt 结构类似 registerTool——三者共用同一套 "门面 + 内部 create + notification" 模式:

// 简化伪代码
registerResource(name, uriOrTemplate, config, readCallback) {
    // 静态 URI 走 _registeredResources,ResourceTemplate 走 _registeredResourceTemplates
    if (typeof uriOrTemplate === 'string') {
        if (this._registeredResources[uriOrTemplate]) throw new Error(...);
        return this._createRegisteredResource(name, config.title, uriOrTemplate, config, readCallback);
    }
    if (this._registeredResourceTemplates[name]) throw new Error(...);   // 模板按 name 查重
    return this._createRegisteredResourceTemplate(name, ..., uriOrTemplate, config, readCallback);
}

registerPrompt(name, config, callback): RegisteredPrompt {
    if (this._registeredPrompts[name]) throw new Error(...);
    return this._createRegisteredPrompt(name, config, callback);
}

三条链路各有一个对称的通知方法——sendToolListChanged() / sendResourceListChanged() / sendPromptListChanged(),都定义在 packages/server/src/server/server.ts(第 670 / 664 / 674 行),实现只有一行,各自发出规范里对应的 notifications/tools/list_changed、notifications/resources/list_changed、notifications/prompts/list_changed。

三个原语、三套独立注册、三种 notification——对称、一致、可预测——这是好 API 的特征。注意 registerResource 是四个参数(多一个 name),而且用重载同时吃字符串 URI 和 ResourceTemplate——这是三条链路里唯一不对称的地方。

20.19.2 Dynamic registration——registerToolTask 和 update

SDK 还支持 动态注册 / 更新 tool:

// 注册 task-supporting tool —— 挂在实验性命名空间下,不在 McpServer 顶层
server.experimental.tasks.registerToolTask(name, config, handler);

// 后续更新某个已注册 tool 的 metadata
const registered = server.registerTool('foo', ...);
registered.update({ description: 'new description' });

注意入口是 server.experimental.tasks.registerToolTask(...),不是 server.registerToolTask(...)——实验性 API 被显式隔离在 experimental 这个命名空间下,升级时一眼就能看出哪些代码踩在不稳定面上。

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,标准是一致性测试通过率 + 维护承诺,而不是"官方 / 社区":Tier 1 要求一致性测试 100% 通过、新协议特性在新版规范发布前跟上、issue 两个工作日内分诊;Tier 2 要求 80% 通过、6 个月内跟上新特性;Tier 3 无最低要求(SEP-1730 §Tier 1/2/3 与 Compliance Scoring 一节)。

下表是 2025-11-25 版规范配套文档给出的分级(全部托管在 modelcontextprotocol 组织下):

SDK 语言 Tier 特点
typescript-sdk TS/JS Tier 1 Standard Schema(Zod / Valibot / ArkType 皆可)、拆成 core/client/server + Express / Fastify / Hono / Node 中间件包
python-sdk Python Tier 1 Pydantic、Starlette ASGI、装饰器
csharp-sdk C# Tier 1 .NET 生态
go-sdk Go Tier 1 轻量、部署简单
java-sdk Java Tier 2 企业 Java 项目集成
rust-sdk Rust Tier 2 性能、类型安全
kotlin-sdk / swift-sdk / ruby-sdk / php-sdk Kotlin / Swift / Ruby / PHP Tier 3 覆盖各自生态,成熟度差异较大

Tier 会变——分级依据的是持续跑的一致性测试,引用时请回查官方 SDK 页面。

选语言的决策树:

  • TS / Python——个人或小团队绝大多数情况下选一个最熟的就好
  • 企业 Java 代码库——用 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(...) 能发任意通知(用 McpServer 时写 server.server.notification(...)),常用的几类 McpServer 已有封装:sendLoggingMessage() 与三个 send*ListChanged()。stdio 和 Streamable HTTP 都能承载;HTTP 下与请求无关的通知走客户端用 GET 打开的独立 SSE 流。

Q7:多个 tool 能同时运行吗?

能。SDK 的请求处理是异步的,不会替你串行化——同一个连接上并发到达的多个 tools/call 会并发执行,返回顺序也不保证与请求顺序一致(JSON-RPC 靠 id 配对)。所以共享可变状态要自己加锁。多用户场景下的隔离靠"每个连接一个 Server 实例"(§20.9.2 讲过),而不是靠串行执行。

Q8:如何 debug MCP server?

用 @modelcontextprotocol/inspector(官方 debug 工具)——启动后 connect 你的 server、可视化工具调用和响应。

Q9:Server 需要 cache 吗?

看情况——tool 调用结果不由 SDK 自动 cache——用户需要时自己实现(通常用 LRU、Redis 等)。

Q10:MCP 协议版本怎么升级?

客户端和服务端在 initialize 时协商 protocolVersion:客户端报上自己支持的(通常是最新的)版本,服务端支持就原样回这个版本,不支持就回一个自己支持的版本(规范要求 SHOULD 是其最新版),客户端不支持该版本则应断开(2025-11-25 规范 lifecycle 一节;源码见 §20.31)。老 server 依然能被新 client 调(只要新 client 还支持老版本)——但新功能要双方都支持。

这 10 个 FAQ 覆盖了 Server 开发最常见的问题——入门时遇到问题先查这里。

20.21.1 McpServer 和 Server 的两层 API

TypeScript SDK 实际上有两层 API——Server(底层)和 McpServer(高层):

Server(低层、packages/server/src/server/server.ts)——直接操作 JSON-RPC:

import { Server } from '@modelcontextprotocol/server';

const server = new Server({ name, version }, { capabilities });

// 注意 2.0 的签名是 setRequestHandler(method: RequestMethod, handler)——
// 第一个参数是**方法名字符串**,不再是 v1 时代的 `ListToolsRequestSchema` 这类 schema 对象
server.setRequestHandler('tools/list', async (request, ctx) => {
  return { tools: [...] };
});

server.setRequestHandler('tools/call', async (request, ctx) => {
  const { name, arguments: args } = request.params;
  // ... 手动 dispatch
});

网上 v1 时代的示例普遍写成 setRequestHandler(CallToolRequestSchema, handler),照抄到 2.0 上编译不过。 方法名与返回类型的绑定改由 RequestMethod / ResultTypeMap 这两个类型完成—— 写 'tools/call' 就自动得到 CallToolResult 的返回类型约束(见 packages/server/src/server/server.ts:225)。

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 的构造函数干干净净,只是把 options 原样透传:

// packages/server/src/server/mcp.ts:65 / 75
public readonly server: Server;

constructor(serverInfo: Implementation, options?: ServerOptions) {
    this.server = new Server(serverInfo, options);
}

listChanged 的默认值出现在第一次注册该类原语时。以工具为例,在 setToolRequestHandlers() 里(由 _toolHandlersInitialized 守卫,只跑一次):

this.server.registerCapabilities({
    tools: {
        listChanged: this.server.getCapabilities().tools?.listChanged ?? true
    }
});

resources 和 prompts 各有一份对称的写法,分别在它们自己的 setXxxRequestHandlers() 里。

这个"懒声明"设计有两个好处:

  1. 只声明你真正提供的能力。 一个只注册了工具的 server,压根不会向客户端声明 resources 或 prompts 能力——客户端也就不会去发 resources/list。能力声明与实际实现自动保持一致,不存在"声明了却没实现"的空头支票。
  2. ?? true 而不是硬编码 true。 用户在 new McpServer(info, { capabilities: { tools: { listChanged: false } } }) 里显式关掉时,这个选择会被保留;没表态才默认打开。

顺带纠正一个常见误解:SDK 不会自动给 resources 加上 subscribe: true。上面那段只设 listChanged 一个字段——资源订阅是需要你自己声明并实现的能力。

用户可以 override:

new McpServer(info, {
    capabilities: {
        tools: { listChanged: false }  // 覆盖默认、声明不支持
    }
});

为什么要允许用户关?——因为某些场景下 server 知道自己的 tools 是纯静态的、关掉 listChanged 能让 client 跳过订阅逻辑、节省网络和内存。

这种 "SDK 知道默认能力、允许用户精细化覆盖" 是好 API 的设计——默认正确、但不锁死。

20.23 给读者的最后一份礼物——资源清单

以一份资源清单收尾本章——让读者有进一步探索的路径:

官方文档 + 规范:

  • modelcontextprotocol.io——协议主站
  • github.com/modelcontextprotocol/modelcontextprotocol——规范仓库(原名 specification,已重命名)
  • github.com/modelcontextprotocol/typescript-sdk——TS SDK
  • github.com/modelcontextprotocol/python-sdk——Python SDK

示例 Server 仓库(学习素材):

  • github.com/modelcontextprotocol/servers——官方 reference servers 集合(现存 Everything、Fetch、Filesystem、Git、Memory、Sequential Thinking、Time 7 个;GitHub、Slack、Google Drive 等早期实现已归档到 servers-archived 仓库,2026-09-25 查询)

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 的理解会远超"照着 README 跑通一个 server"的程度。至于 MCP 在未来几年的地位,那是判断不是事实,本专栏不替你下结论。

本章的资源清单就到这里——最后一章我们把本专栏的设计决策收成六个模式。

20.24 这一章在本专栏中的位置:从"能用"到"用好"

本章没有像前面章节那样深入协议 wire format、而是聚焦工程实践——从"能用 MCP"到"用好 MCP"的跨越:

  • 能用 MCP——几十行代码跑起一个 hello world server
  • 用好 MCP——能处理 rate limit、能 debug 协议问题、能多租户部署、能通过 client 兼容性测试

两者的差距是本章覆盖的内容——安全、测试、部署、错误处理、性能优化、生态发布——每一项都是生产化的门槛。

20.25 最后一个 code sample——一个完整可运行的 MCP Hello World

作为本章的收尾、留一个最小完整 MCP server——几十行、可直接部署:

#!/usr/bin/env node
// hello-mcp-server.ts
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/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(
  'about',
  'demo://about',
  { title: 'About', mimeType: 'text/plain' },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      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');

这几十行覆盖了:

  • Tool(greet、多语言)
  • Resource(about、静态文本)
  • Standard Schema 校验(这里用 Zod:name、language enum)
  • stdio transport
  • 正确的 stderr logging

这就是 MCP server 的 "Hello World"——比 "console.log 一行" 有意义、比 "完整生产 server" 简单——恰到好处的 starter。

20.26 从 Hello World 到生产级 MCP Server

从 §20.25 的几十行出发,按 §20.14 的 30 项 checklist 依次补上:参数 schema 与描述(§20.4)、进度与取消(§20.7)、协议级测试(§20.8)、远程部署(§20.9)、输入校验 / 限流 / Host 与 Origin 校验(§20.10),最后发布(§20.11)。

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 as Error).message}`);  // strict 下 catch 变量是 unknown
    }
  }
}

模板 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-55 行)写着 "If using Server class, note that connect() calls start() automatically."——防止用户既显式 await transport.start() 又 await server.connect(transport)、重复注册监听器导致每条消息被处理两遍。

细节 3:processReadBuffer() 循环读——第 65 行的 while (true) + readMessage() 返回 null 才 break——一次 chunk 可能含多条消息(高并发时 stdin 合并)、必须循环吃完。

细节 4:close() 先 off 再 pause——第 86-88 行 _stdin.off('data') 等、再第 90-95 行 "Check if we were the only data listener"——绝对不 pause 别人的 stdin(共享 stdin 的应用场景:同进程嵌 MCP + 读用户输入)。

细节 5:send() 的 settled 标志——第 107-136 行——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) 额外注册(第 61 行)——stdout 出错时(管道对端关闭)、不仅调用 onerror、还主动 close()——传输及时进入关闭状态、触发 onclose,不再对着一条写不出去的管道收发消息。注意 close() 不结束进程:客户端退出后,Server 是靠读到 stdin EOF、事件循环清空而自行退出的(StdioServerTransport 并不监听 stdin 的 end 事件);若还挂着定时器之类的活跃句柄,TS Server 会被 PID 1 收养成孤儿进程继续运行——孤儿进程不是僵尸(zombie)进程(第 12 章 §12.8 实测)。

细节 8:ReadBuffer 来自 @modelcontextprotocol/core——SDK 把换行分隔的 JSON 解析抽成复用组件、客户端侧的 packages/client/src/client/stdio.ts 用的是同一个(第 12 章)。

细节 9:没有心跳——stdio 假设进程共生命周期、不需要 keepalive——这和 HTTP transport 的 SSE retry 与 Last-Event-ID 断线续传形成鲜明对比(详见第 13 章;ping 是协议层的方法,两种传输都有)。

细节 10:Promise.reject(new Error('StdioServerTransport is closed'))(第 105 行)——close() 之后再 send() 立刻失败、而不是默默吞掉——用户立即知道。

138 行代码、10 个细节——这就是工业级 I/O wrapper 的密度——表面简单、每一行都是"为什么要这样"的结晶。

20.31 插播:Server 类 _oninitialize 版本协商源码

packages/server/src/server/server.ts 第 426-444 行——版本协商的核心:

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,
        ...(this._instructions && { instructions: this._instructions })
    };
}

拆开看——

第一步——取 client 请求的 protocolVersion——这是 initialize 请求的必选字段(2025-11-25 规范 basic/lifecycle.mdx)。

第二步——保存 clientCapabilities 和 clientInfo——server 以后可以调 getClientCapabilities() / getClientVersion() 查 client 支持什么(比如 client 支不支持 elicitation、要不要降级)。

第三步——三态协商:client 请求版本 ∈ server 支持列表 → 返回 client 版本;否则回退到 server 支持列表的第一项(默认列表里就是最新版 2025-11-25);列表为空时再回退到 LATEST_PROTOCOL_VERSION 常量。这个"回退到最新"不是 SDK 自选的策略,而是规范的要求:不支持请求的版本时 MUST 回一个自己支持的版本、SHOULD 是最新的,不是报错;接不接受由客户端决定,不支持就应断开。

第四步——transport.setProtocolVersion?.()——把协商结果同步给 transport。这是 Transport 接口上的可选方法:pin 版里实现它的只有客户端侧的 Streamable HTTP 与 SSE transport(之后每个请求头都带上 mcp-protocol-version),服务端自带的 transport 都没实现,这一行对它们是空操作——服务端 Streamable HTTP 是从每个请求的 mcp-protocol-version 头读版本、不支持就回 400(packages/server/src/server/streamableHttp.ts:890-893)。

第五步——返回 InitializeResult——这是握手响应、之后 client 发 notifications/initialized、server 触发 oninitialized 回调。但这并不是"之后才允许业务请求":规范只约束客户端在收到 initialize 响应之前不应发 ping 以外的请求,TS Server 本身也没有初始化门控,notifications/initialized 到达之前的业务请求照常处理(第 8 章 §8.8.6)。另外,这套握手是本专栏锚定的 2025-11-25 版的做法:2026-07-28 版已去掉 initialize / notifications/initialized,版本与能力改由每个请求的 _meta 携带。

这十几行代码是"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 想推送但失败的那些事件"?

Web 标准给的答案——HTML 标准的 Server-Sent Events 一节——client 重连时在 Last-Event-ID header 里送上一次收到的最后一条 event 的 id。

MCP SDK 的落地——

  • storeEvent 每发一条消息前、先存一份带 event-id(第 392 行 storeEvent(streamId, {} as JSONRPCMessage) 甚至给 priming event 也存一份)。但 TS pin 版只在 POST 流的映射还在时才存:客户端断开后产生的消息既不存也不发,重连也补不回来(第 13 章 §13.5 实测)
  • replayEventsAfter 在 client 重连时、按 lastEventId 把之后的所有事件通过注入的 send 函数重播出去
  • getStreamIdForEventId 是可选的"反查"——帮助 SDK 把 Last-Event-ID 映射回原来的 streamId、继续复用那个 stream而不是开新的

这是一个"接口而非实现"的经典 SDK 设计——SDK 不替你选存储:你可以用内存 Map(demo)、Redis(横向扩展)、Postgres(持久化)——自己实现这三个方法即可接入。

对比本专栏第 13 章"可恢复流"——那章讲了协议层的设计;本节讲实现层的 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 第 17-34 行——解析 Host header、用 URL API 正确处理 IPv4 / IPv6 / 带端口的场景(new URL('http://' + hostHeader).hostname)
  • 检查 hostname ∈ allowedHostnames 白名单、否则返回 invalid_host 错误
  • localhostAllowedHostnames() 第 40-42 行给个快捷方式——['localhost', '127.0.0.1', '[::1]']——IPv6 loopback 也带进来了
  • hostHeaderValidationResponse() 第 51-69 行直接返回 403 + JSON-RPC error(code -32000)——错误格式仍然遵守 JSON-RPC 规范、客户端不会因为"HTTP 403"和"JSON-RPC 响应"两套错误语义混乱

为什么把这玩意做成独立模块——

  • 第一、这几个函数本身不会自动生效,要你自己挂——本地 dev 常常用假域名、全启用会误伤。不过框架适配包替你挂了一层:createMcpExpressApp() 等在 host 为 localhost 类地址(默认 127.0.0.1)时会自动启用 localhost 白名单校验(packages/middleware/express/src/express.ts:62-86)
  • 第二、单一职责——这个模块只管 Host。规范要求的 Origin 校验(MUST,同样是为了防 DNS rebinding)packages/middleware 下的中间件并没有做;pin 版里只有 transport 上已标 @deprecated 的 allowedOrigins 选项(默认关闭),要自己补(第 13 章 §13.3)
  • 第三、生产环境放到反向代理层(nginx、Cloudflare)更合理——SDK 提供后备、但首选是外层兜住

扩展阅读——OWASP "DNS Rebinding Attacks" 条目、Chrome 的 Private Network Access 提案(CORS-PNA)——这是一个持续演进的安全话题、MCP SDK 只是"做了它该做的"、不越界。

对比本专栏第 15 章 OAuth——那是传输层之上的身份认证;本节讲传输层本身的请求来源校验——两者组合才构成 MCP server 的"纵深防御"。

20.34 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 请求(第 8 章 §8.4)由此复用已有 schema、不用单独定义"补全 schema"——对用户 API 来说就是 completable(z.string(), value => [...]) 一行——SDK 内部自动 wire up。

对比:Pydantic 的 Annotated[str, Field(...)]——思路相似、工具不同(Python metadata、TS Symbol)——"把元信息附在类型上"是类型系统成熟后通用的设计范式。本专栏第 10 章(Python Server 实现剖析)讲了 Pydantic 路径、这里讲 TS 路径、两相印证。

20.35 streamableHttp.ts 的 streamId 分配与请求映射

packages/server/src/server/streamableHttp.ts 第 715-795 行——当 client 发 POST /mcp 带 JSON-RPC request 时——transport 要决定"响应从哪条 SSE 流推回去"——源码逻辑值得细看:

  • 第 715 行 const streamId = crypto.randomUUID()——每个带请求的 POST 分配一个新 streamId(一个 POST 里若批量带了多条请求,它们共用这条流)——不复用——这样一个 client 可以并发发多个 POST、互不干扰
  • 第 776-788 行 _streamMapping.set(streamId, { controller, encoder, cleanup })——streamId → stream controller 的映射——之后要往这条流写事件、从这里拿 controller(JSON 响应模式走 725-745 行的分支,存的是 resolveJson)
  • 第 788 行 _requestToStreamMapping.set(message.id, streamId)——JSON-RPC request id → streamId 的反向映射——之后 server 产生 response 时、按 message.id 找到对应 streamId、再从 _streamMapping 拿 controller 写数据
  • 第 793 行 writePrimingEvent(...) 写 priming event——只在配置了 EventStore、且客户端协议版本不低于 2025-11-25 时才写(第 380-399 行):一条只有事件 ID、data 为空的事件,让客户端在任何业务消息之前就拿到一个可以当 Last-Event-ID 用的 ID(2025-11-25 规范的 SHOULD,见第 13 章 §13.4)

这套"双 map"设计(streamId ↔ controller、requestId ↔ streamId)把"HTTP request"、"SSE stream"、"JSON-RPC request"三个层次的对应关系拆成了两张表——响应到来时按请求 id 两跳就能找到该写的那条流。

对比本专栏第 12、13 章(stdio 与 Streamable HTTP)——那章讲了 stdio / HTTP 为何各有场景;本节讲 HTTP transport 内部如何做到并发正确——两章叠加才完整。

20.36 HandleRequestOptions 的设计哲学——parsedBody 和 authInfo

packages/server/src/server/streamableHttp.ts 第 160-171 行定义了 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——工具实现者才能做"按用户分权"的决策(详见第 15 章 OAuth)。

这两个字段的共同特点——不做假设、只做 hook——SDK 不假设你用哪个框架、不假设你怎么认证——只保证"你给什么、我用什么"。

这是优秀 SDK 的"可组合性"——和 Rust 生态的 tower Service trait(见《Hyper 与 Tower:工业级 HTTP 栈》专栏)、Vite 的 plugin hooks(见《Vite 设计与实现》专栏)——哲学一脉相承:把扩展点留给用户,而不是替用户做决定。

20.37 list_changed 通知的去抖:SDK 默认把它放在了客户端

批量注册会引出一个真实问题。设想用户在启动时连续 registerTool 十个工具—— 如果每次注册都发一条 notifications/tools/list_changed,客户端就会收到十条通知, naive 的客户端每收一条就发一次 tools/list——一次启动放大成十轮往返。

有意思的是 SDK 解决这个问题的位置:默认的去抖不在 server 侧(server 侧只有需要显式开启的 debouncedNotificationMethods,见 §20.18.3),而在 client 侧。 实现是 packages/client/src/client/client.ts 的 Client._setupListChangedHandler——构造 Client 时传了 listChanged 选项、且服务端声明了对应的 listChanged 能力,才会挂上这个处理器:

const handler = () => {
    if (debounceMs) {
        // 同一个 listType 的待处理定时器先清掉
        const existingTimer = this._listChangedDebounceTimers.get(listType);
        if (existingTimer) {
            clearTimeout(existingTimer);
        }
        const timer = setTimeout(refresh, debounceMs);
        this._listChangedDebounceTimers.set(listType, timer);
    } else {
        // debounceMs 为 0:不去抖,立即刷新
        refresh();
    }
};

参数定义在 packages/core/src/types/schemas.ts 的 ListChangedOptionsBaseSchema:

autoRefresh: z.boolean().default(true),
debounceMs: z.number().int().nonnegative().default(300)

三个值得注意的设计决定:

1. 默认 300ms,且可由使用方覆盖,0 表示关闭。 SDK 没有把这个常量焊死。原因很实际——一次 tools/list 往返的代价,本地 stdio server 和跨公网的远程 server 差着数量级,只有客户端知道刷新一次有多贵。

2. 去抖默认放在消费侧,而不是生产侧。 server 无从知道 client 收到通知后要做多重的活,甚至不知道 client 是否在意这条通知。 把节流交给真正承担成本的一方,是比"server 替所有人决定"更干净的分工。 同时通知本身仍然是即时、如实发出的——协议语义没有被 SDK 的性能优化篡改。

3. 计时器按 listType 分桶。 _listChangedDebounceTimers 是一个 Map<string, Timeout>,tools / resources / prompts 各自独立去抖,互不干扰——工具列表的频繁变动不会拖慢提示词列表的刷新。

还有一个容易被忽略的开关:autoRefresh 为 false 时,SDK 不会替你去拉新列表, 而是用 onChanged(null, null) 通知你"变了,自己决定要不要拉"。 这条路径给了调用方完全的控制权——去抖窗口内合并事件,但是否真的发起请求由你说了算。

把"高频离散事件合并成低频批量"是 UI 和事件系统里的通用模式。MCP SDK 的做法值得学的 不是模式本身,而是它把决策权和成本承担方对齐了:谁付代价,谁配置阈值。