自己寫一個 MCP Server:給 Claude Code 打造專屬能力

從零打造一個專案上下文管理器 MCP Server,用 TypeScript 實作決策記錄、任務管理和跨會話檢索,涵蓋工具註冊、Resource、除錯和發布的完整流程。


前三篇 MCP 文章講的都是接入已有的東西——現成的 MCP Server、資料庫、內部 API。這篇不一樣:我們要從零打造一個 MCP Server,讓它做一件現有工具做不到的事。

不是包裝別人的 API,而是自己實作邏輯。

做什麼:專案上下文管理器

Claude Code 有一個實際痛點:它沒有跨 Session 的記憶。每次開新 Session,你得重新告訴它專案背景、最近的決策、進行中的任務。CLAUDE.md 能解決一部分,但它是靜態的,不會隨著專案推進自動更新。

我們要做一個 MCP Server,讓 Claude 可以:

  • 記錄專案決策和上下文(「我們決定用 Redis 做快取,原因是 X」)
  • 管理待辦事項和進行中的任務
  • 在新 Session 裡檢索之前記下的上下文

資料存在本地 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 在 Session 開始時就能拿到完整上下文:

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

跨 Session 檢索

新 Session 裡:

之前我們為什麼選了 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 裡反覆測試高效得多。

加 Log

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 的工具。