本文面向:想理解 MCP 协议内部原理并动手实现一个 MCP Server 的开发者。
预计阅读时间:12 分钟
最终效果:掌握 MCP Server 的创建、Tool 注册、Zod 参数定义、stdio 通信的完整流程。

什么是 MCP

MCP(Model Context Protocol)是 Anthropic 提出的开放协议,让 AI 助手能够连接外部工具和数据源。你可以把它理解为 AI 世界的 USB 接口——定义一套标准,任何工具只要实现这个协议,就能被 Claude、Cursor 等客户端调用。

协议核心只有三个角色:

  • Server:提供能力的一方,暴露若干 Tool、Resource、Prompt
  • Client:发起调用的一方(Claude Code、Cursor 等)
  • Transport:通信层,负责在 Client 和 Server 之间传递 JSON-RPC 消息

本文聚焦最常见的用法:用 TypeScript 写一个 MCP Server,通过 stdio 传输层暴露自定义工具,让 Claude Code 能够调用你写的函数。

安装依赖

npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

@modelcontextprotocol/sdk 是官方 TypeScript SDK,内部封装了 JSON-RPC 通信、协议握手、能力协商等细节。zod 用于定义工具参数的类型约束——MCP 协议要求工具参数必须有 JSON Schema,zod 能自动生成。

创建 MCP Server 实例

整个 Server 的初始化只需要三行:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new McpServer({
  name: 'my-first-mcp',
  version: '1.0.0',
});

McpServer 构造函数接收两个参数:nameversion。这两个值会在协议握手阶段发送给 Client,用于标识你的 Server。

注册 Tool

Tool 是 MCP 最核心的能力。每个 Tool 有四个要素:

要素 说明
name 工具名称,Client 通过这个名字调用
description 自然语言描述,告诉 AI 什么时候该用这个工具
parameters 参数定义,用 zod schema 描述
handler 处理函数,收到参数后执行业务逻辑

来看一个最简单的例子:

import { z } from 'zod';

server.tool(
  'add',
  'Add two numbers together.',
  {
    a: z.number().describe('First number'),
    b: z.number().describe('Second number'),
  },
  async ({ a, b }) => {
    return {
      content: [{
        type: 'text',
        text: String(a + b),
      }],
    };
  },
);

server.tool() 的签名是:

server.tool(name, description, parameters, handler)

parameters 对象的每个字段都是一个 zod schema。zod 不仅做运行时校验,SDK 还会自动将其转换为 JSON Schema 传给 Client。.describe() 方法写的描述非常重要——AI 会根据这些描述来理解每个参数的含义。

handler 函数必须返回一个对象,包含 content 数组。每个 content 项有 type 和对应的值:

  • { type: 'text', text: '...' } —— 文本结果
  • { type: 'image', data: '...', mimeType: '...' } —— 图片(base64)
  • { type: 'resource', ... } —— 资源引用

大多数场景用 text 就够了。

用 Zod 定义复杂参数

简单参数用 z.string()z.number() 即可。实际项目中经常遇到复杂结构,zod 都能表达:

// 可选参数 + 默认值
{
  query: z.string().describe('Search query'),
  limit: z.number().optional().default(10).describe('Max results'),
}

// 枚举
{
  source: z.enum(['claude-code', 'cursor', 'codex']).describe('Data source'),
}

// 嵌套对象
{
  task: z.object({
    goal: z.string(),
    files: z.array(z.string()).optional(),
  }),
}

// 数组
{
  tags: z.array(z.string()).describe('Tag list'),
}

ChatCrystal 的 recall_for_task 工具展示了复杂参数的实际用法。实际代码直接复用独立定义的 RecallForTaskRequestShape(来自 services/memory/schemas.ts),而不是在注册时内联展开:

import { RecallForTaskRequestShape } from '../../services/memory/schemas.js';

server.tool(
  'recall_for_task',
  'Recall project-first and global-supplement memories for a task.',
  RecallForTaskRequestShape,
  async (input) => {
    const data = await client.recallForTask(input);
    return {
      content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }],
    };
  },
);

RecallForTaskRequestShape 是一个 zod shape 对象,包含 mode(枚举)、task(嵌套对象,含 goaltask_kindproject_key 等字段)、options(可选,含 project_limitglobal_limit 等)。将 schema 提取到独立文件的好处是:MCP 注册和 HTTP route handler 可以共用同一套校验逻辑,避免重复定义。

参数复杂度没有上限,zod 支持 .transform().superRefine() 等高级用法,可以在校验阶段就完成数据转换。

StdioServerTransport:标准输入输出通信

MCP 协议支持多种 Transport。最常用的是 stdio——通过标准输入输出传递 JSON-RPC 消息:

const transport = new StdioServerTransport();
await server.connect(transport);

为什么用 stdio?因为 Claude Code 启动 MCP Server 时,会把它当作子进程启动,通过 stdin/stdout 与之通信。这意味着:

  1. Server 不需要监听端口,没有端口冲突问题
  2. Client(Claude Code)控制 Server 的生命周期,退出时自动关闭
  3. 通信内容是 JSON-RPC 2.0 格式,每行一个完整的消息

server.connect(transport) 会完成协议握手:Client 发送 initialize 请求,Server 回复自己的能力(支持哪些 Tool),然后进入正常的消息循环。

在 ChatCrystal 中,整个启动流程被封装为一个函数:

export async function startMcpServer(baseUrl: string) {
  const client = new CrystalClient(baseUrl);
  const server = new McpServer({
    name: 'chatcrystal',
    version: '0.2.0',
  });

  // 注册 7 个工具...
  server.tool('search_knowledge', /* ... */);
  server.tool('get_note', /* ... */);
  server.tool('list_notes', /* ... */);
  server.tool('get_relations', /* ... */);
  server.tool('recall_for_task', /* ... */);
  server.tool('validate_task_memory', /* ... */);
  server.tool('write_task_memory', /* ... */);

  // 启动传输层
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

注意 handler 是 async 函数,可以做任何异步操作:调 HTTP API、查数据库、读文件。ChatCrystal 的 MCP Server 本身不直接访问数据库,而是通过 CrystalClient 调用已有的 REST API,这样 MCP 层只是一个薄薄的适配层。

配置 Claude Code 连接

Server 写好了,下一步是让 Claude Code 能找到它。在项目根目录或全局配置中创建 .claude/settings.json

{
  "mcpServers": {
    "chatcrystal": {
      "command": "npx",
      "args": ["tsx", "src/mcp-server.ts"]
    }
  }
}

如果已经发布为 npm 包(如 ChatCrystal 的 crystal CLI),可以直接用命令名:

{
  "mcpServers": {
    "chatcrystal": {
      "command": "crystal",
      "args": ["mcp"]
    }
  }
}

配置完成后,重启 Claude Code,输入 /mcp 可以看到已连接的 Server 和它暴露的 Tool 列表。在对话中,Claude 会根据你的问题自动判断是否需要调用这些工具。

调试技巧

查看原始 JSON-RPC 消息

stdio 通信是双向的:Client 通过 stdin 发请求,Server 通过 stdout 回响应。调试时可以在 Server 代码中加日志,但要注意 日志必须写到 stderr,写到 stdout 会干扰 JSON-RPC 通信:

console.error('Received:', JSON.stringify(message));

MCP Inspector

官方提供了 @modelcontextprotocol/inspector 工具,可以可视化调试 MCP Server:

npx @modelcontextprotocol/inspector npx tsx src/mcp-server.ts

它会打开一个 Web 界面,列出所有 Tool,可以手动输入参数调用,查看请求和响应的原始 JSON。

常见问题

Tool 不出现在 Claude Code 中:检查 server.connect() 是否被调用,以及 settings.json 的路径和 command 是否正确。

参数校验失败:zod schema 的 .describe() 不是可选的——AI 依赖这些描述来正确填写参数。没有描述的参数,AI 可能传入错误的值。

超时:handler 函数不要做太重的操作。如果需要长时间运行,考虑先返回一个"处理中"的状态,用轮询或其他机制获取结果。

从 ChatCrystal 学到的设计模式

回看 ChatCrystal 的 MCP 实现,有几个值得学习的设计决策:

1. MCP 层不持有状态。 CrystalClient 是一个纯 HTTP 客户端,所有状态都在 Fastify Server 端管理。MCP Server 进程随时可以重启,不会丢失数据。

2. 参数 schema 复用。 RecallForTaskRequestShape 这样的 zod shape 对象被提取到独立文件,MCP 注册和 HTTP route handler 共用同一套校验逻辑。

3. 返回值统一格式。 所有工具都返回 { content: [{ type: 'text', text: JSON.stringify(data) }] }。虽然 MCP 支持多种 content type,但 text 是最通用的——AI 能直接读 JSON 文本。

4. 用 .describe() 替代注释。 zod 字段的 .describe() 是给 AI 看的文档,不是给人看的注释。写得越清楚,AI 调用的准确率越高。

完整最小示例

把上面的内容合在一起,一个可运行的 MCP Server:

// mcp-server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({
  name: 'hello-mcp',
  version: '1.0.0',
});

server.tool(
  'greet',
  'Greet someone by name.',
  {
    name: z.string().describe('Person name'),
    language: z.enum(['zh', 'en']).optional().default('zh').describe('Language'),
  },
  async ({ name, language }) => {
    const greeting = language === 'zh'
      ? `你好,${name}`
      : `Hello, ${name}!`;
    return {
      content: [{ type: 'text' as const, text: greeting }],
    };
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);

运行:npx tsx mcp-server.ts

在 Claude Code 的 settings.json 中添加配置后,你就可以在对话中说"用 greet 工具跟张三打个招呼",Claude 会自动调用你的 Server。

下一步

到这里你已经掌握了 MCP Server 的核心:创建实例、注册工具、定义参数、启动通信。接下来可以探索:

  • Resource:让 Server 暴露数据文件,Client 可以主动读取
  • Prompt:预定义的 prompt template,Client 可以列举和选用
  • SSE Transport:用 HTTP Server-Sent Events 替代 stdio,适合远程部署
  • 多工具协作:参考 ChatCrystal 的 7 个工具如何配合,形成知识检索 + 记忆写入的闭环

MCP 协议规范在 spec.modelcontextprotocol.io 持续更新,SDK 文档见 GitHub 仓库


项目地址:github.com/ZengLiangYi/ChatCrystal

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐