自己写一个 MCP Server:给 Claude Code 造一个专属能力

从零构建一个项目上下文管理器 MCP Server,用 TypeScript 实现决策记录、任务管理和跨会话检索,覆盖工具注册、Resource、调试和发布的完整流程。


前三篇 MCP 文章讲的都是接入已有的东西——现成的 MCP Server、数据库、内部 API。这篇不一样:我们要从零构建一个 MCP Server,让它做一件现有工具做不了的事。

不是包装别人的 API,而是自己实现逻辑。

做什么:项目上下文管理器

Claude Code 有一个实际痛点:它没有跨会话记忆。每次开新会话,你得重新告诉它项目背景、最近的决策、进行中的任务。CLAUDE.md 能解决一部分,但它是静态的,不会随项目推进自动更新。

我们要做一个 MCP Server,让 Claude 可以:

  • 记录项目决策和上下文(「我们决定用 Redis 做缓存,原因是 X」)
  • 管理待办事项和进行中的任务
  • 在新会话里检索之前记下的上下文

数据存在本地 JSON 文件里,不依赖任何外部服务。

初始化项目

mkdir mcp-project-context && cd mcp-project-context
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

tsconfig.json

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

package.json 里加上 "type": "module"

数据层

先把数据的读写搞定。所有数据存在项目根目录的 .claude/project-context.json 里。

src/store.ts

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { dirname } from "path";

export interface Decision {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
}

export interface Task {
  id: string;
  title: string;
  status: "todo" | "in-progress" | "done";
  notes: string;
  createdAt: string;
  updatedAt: string;
}

export interface ProjectContext {
  decisions: Decision[];
  tasks: Task[];
}

export class Store {
  private data: ProjectContext;

  constructor(private filePath: string) {
    this.data = this.load();
  }

  private load(): ProjectContext {
    if (!existsSync(this.filePath)) {
      return { decisions: [], tasks: [] };
    }
    return JSON.parse(readFileSync(this.filePath, "utf-8"));
  }

  private save(): void {
    const dir = dirname(this.filePath);
    if (!existsSync(dir)) {
      mkdirSync(dir, { recursive: true });
    }
    writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
  }

  // Decisions
  addDecision(title: string, content: string, tags: string[]): Decision {
    const decision: Decision = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      content,
      tags,
      createdAt: new Date().toISOString(),
    };
    this.data.decisions.push(decision);
    this.save();
    return decision;
  }

  searchDecisions(query: string): Decision[] {
    const q = query.toLowerCase();
    return this.data.decisions.filter(
      (d) =>
        d.title.toLowerCase().includes(q) ||
        d.content.toLowerCase().includes(q) ||
        d.tags.some((t) => t.toLowerCase().includes(q))
    );
  }

  listDecisions(): Decision[] {
    return this.data.decisions;
  }

  // Tasks
  addTask(title: string): Task {
    const task: Task = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      status: "todo",
      notes: "",
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    this.data.tasks.push(task);
    this.save();
    return task;
  }

  updateTask(
    id: string,
    updates: Partial<Pick<Task, "status" | "notes" | "title">>
  ): Task | null {
    const task = this.data.tasks.find((t) => t.id === id);
    if (!task) return null;
    Object.assign(task, updates, { updatedAt: new Date().toISOString() });
    this.save();
    return task;
  }

  listTasks(status?: Task["status"]): Task[] {
    if (status) {
      return this.data.tasks.filter((t) => t.status === status);
    }
    return this.data.tasks;
  }
}

几个设计选择:

  • ID 用 UUID 前 8 位,短到可以口头引用(「更新一下任务 a3f2b1c9」)
  • 每次写操作都立刻持久化,不做内存缓存
  • 数据结构故意保持简单,够用就行

注册工具

src/index.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Store } from "./store.js";

const CONTEXT_FILE =
  process.env.CONTEXT_FILE || ".claude/project-context.json";
const store = new Store(CONTEXT_FILE);

const server = new McpServer({
  name: "project-context",
  version: "1.0.0",
});

// ---- 决策管理 ----

server.tool(
  "record_decision",
  "记录一条项目决策。当团队做出技术选型、架构决定、或重要的设计取舍时使用",
  {
    title: z.string().describe("决策标题,如「缓存方案选型」"),
    content: z.string().describe("决策内容和原因"),
    tags: z
      .array(z.string())
      .optional()
      .default([])
      .describe("标签,便于后续检索,如 [\"cache\", \"architecture\"]"),
  },
  async ({ title, content, tags }) => {
    const decision = store.addDecision(title, content, tags);
    return {
      content: [
        {
          type: "text",
          text: `已记录决策 [${decision.id}]: ${decision.title}`,
        },
      ],
    };
  }
);

server.tool(
  "search_decisions",
  "搜索已记录的项目决策。当需要回顾之前为什么做某个选择时使用",
  {
    query: z.string().describe("搜索关键词,会匹配标题、内容和标签"),
  },
  async ({ query }) => {
    const results = store.searchDecisions(query);
    if (results.length === 0) {
      return {
        content: [{ type: "text", text: `没有找到与「${query}」相关的决策` }],
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }
);

server.tool(
  "list_decisions",
  "列出所有已记录的项目决策",
  {},
  async () => {
    const decisions = store.listDecisions();
    return {
      content: [{ type: "text", text: JSON.stringify(decisions, null, 2) }],
    };
  }
);

// ---- 任务管理 ----

server.tool(
  "add_task",
  "添加一个待办任务",
  {
    title: z.string().describe("任务标题"),
  },
  async ({ title }) => {
    const task = store.addTask(title);
    return {
      content: [
        { type: "text", text: `已添加任务 [${task.id}]: ${task.title}` },
      ],
    };
  }
);

server.tool(
  "update_task",
  "更新任务的状态或备注",
  {
    id: z.string().describe("任务 ID"),
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("新状态"),
    notes: z.string().optional().describe("备注信息"),
    title: z.string().optional().describe("更新标题"),
  },
  async ({ id, status, notes, title }) => {
    const task = store.updateTask(id, { status, notes, title });
    if (!task) {
      return {
        content: [{ type: "text", text: `找不到任务 ${id}` }],
        isError: true,
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
    };
  }
);

server.tool(
  "list_tasks",
  "列出任务。可以按状态筛选",
  {
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("按状态筛选,不传则列出全部"),
  },
  async ({ status }) => {
    const tasks = store.listTasks(status);
    return {
      content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
    };
  }
);

// ---- 启动 ----

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

注册 Resource

除了工具,MCP 还支持 Resource——让 Claude 可以主动读取的数据。我们把「项目概况」注册为一个 Resource,这样 Claude 在会话开始时就能拿到完整上下文:

server.resource("project-summary", "project://summary", async (uri) => {
  const decisions = store.listDecisions();
  const tasks = store.listTasks();
  const activeTasks = tasks.filter((t) => t.status !== "done");

  const summary = {
    totalDecisions: decisions.length,
    recentDecisions: decisions.slice(-5),
    activeTasks,
    completedTasks: tasks.filter((t) => t.status === "done").length,
  };

  return {
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(summary, null, 2),
      },
    ],
  };
});

Resource 和 Tool 的区别:Tool 是 Claude 在对话中按需调用的;Resource 是 Claude 可以读取的静态上下文,类似于 CLAUDE.md 但可以是动态生成的。

编译和配置

npx tsc

配置 Claude Code:

{
  "mcpServers": {
    "project-context": {
      "command": "node",
      "args": ["/path/to/mcp-project-context/dist/index.js"],
      "env": {
        "CONTEXT_FILE": "/path/to/your-project/.claude/project-context.json"
      }
    }
  }
}

重启 Claude Code,输入 /mcp 确认 project-context 出现在列表里。

实际使用

配好之后,和 Claude 的对话会变成这样:

记录决策

我们刚讨论完,决定用 Bull + Redis 做任务队列,不用 RabbitMQ。
主要原因是团队已经在用 Redis,不想引入新的基础设施。
帮我记一下。

→ Claude 调用 record_decision
→ 已记录决策 [a3f2b1c9]: 任务队列选型:Bull + Redis

跨会话检索

新会话里:

之前我们为什么选了 Bull 而不是 RabbitMQ?

→ Claude 调用 search_decisions("Bull")
→ 找到之前的记录,完整复述决策原因

任务跟踪

接下来要做三件事:
1. 实现用户注册接口
2. 写注册邮件的模板
3. 加上邮箱验证流程

→ Claude 调用 add_task 三次
→ 三个任务分别创建,状态都是 todo
注册接口写完了,帮我更新状态

→ Claude 调用 update_task(id, "done")
→ 同时列出剩余的 todo 任务

MCP Server 开发的几个要点

做完这个 Server 之后,总结几条实践经验。

错误处理要友好

MCP 工具报错时,Claude 会把错误信息读给用户看。所以错误信息要写得像人话:

// 差
throw new Error("ENOENT");

// 好
return {
  content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
  isError: true,
};

isError: true 告诉 Claude 这次调用失败了,它会据此调整后续行为(比如换一种方式重试)。

工具数量不要太多

一个 MCP Server 暴露 5-10 个工具是合理的。超过 15 个,Claude 选错工具的概率就明显上升了。如果能力太多,拆成多个 Server。

返回值要有信息量

工具返回「操作成功」是不够的。Claude 需要知道操作的结果是什么,才能继续对话:

// 差
return { content: [{ type: "text", text: "OK" }] };

// 好
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };

用 zod 做参数校验

MCP SDK 原生支持 zod,用它定义参数不仅能做类型检查,还能通过 .describe() 给 Claude 提供使用说明。没有描述的参数,Claude 只能靠猜。

stdio 是最简单的传输方式

MCP 支持两种传输方式:stdio(标准输入输出)和 HTTP+SSE。对于本地开发用的 MCP Server,stdio 就够了:Claude Code 直接把 Server 当子进程启动,通信走 stdin/stdout。

HTTP+SSE 适合远程部署的 Server,比如公司内部统一提供的 MCP 服务。

调试方法

用 MCP Inspector

Anthropic 提供了一个调试工具叫 MCP Inspector,可以直接在浏览器里测试你的 MCP Server:

npx @modelcontextprotocol/inspector node dist/index.js

它会启动一个 Web 界面,让你手动调用每个工具、查看输入输出、检查错误。比在 Claude Code 里反复测试高效得多。

加日志

MCP Server 的 stdout 被占用了(用于 JSON-RPC 通信),所以不能用 console.log。用 console.error 输出调试信息,它会写到 stderr:

console.error("[debug] processing request:", JSON.stringify(params));

或者写到文件:

import { appendFileSync } from "fs";

function log(msg: string) {
  appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}

发布和分发

当你的 MCP Server 稳定了,有几种分发方式:

npm 包

加上 bin 字段和 shebang,发布到 npm:

{
  "name": "@yourcompany/mcp-project-context",
  "bin": {
    "mcp-project-context": "./dist/index.js"
  }
}

dist/index.js 顶部加上:

#!/usr/bin/env node

使用时:

{
  "mcpServers": {
    "project-context": {
      "command": "npx",
      "args": ["-y", "@yourcompany/mcp-project-context"]
    }
  }
}

Docker

如果 Server 有外部依赖(数据库、Redis),用 Docker 打包更干净:

{
  "mcpServers": {
    "project-context": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
    }
  }
}

注意 -i 参数——stdio 传输需要保持 stdin 打开。

下一步能做什么

这篇的例子是一个最小但完整的 MCP Server。在它的基础上,你可以往很多方向扩展:

  • 加上项目 timeline(自动记录每天做了什么)
  • 集成 git log(把提交记录和决策关联起来)
  • 支持多项目(一个 Server 管理多个项目的上下文)
  • 加上 prompt 模板(比如预定义一个「项目日报」的生成格式)

MCP 的能力边界取决于你的想象力。只要能写成代码的事情,都能变成 Claude 的工具。