從零打造一個專案上下文管理器 MCP Server,用 TypeScript 實作決策記錄、任務管理和跨會話檢索,涵蓋工具註冊、Resource、除錯和發布的完整流程。
前三篇 MCP 文章講的都是接入已有的東西——現成的 MCP Server、資料庫、內部 API。這篇不一樣:我們要從零打造一個 MCP Server,讓它做一件現有工具做不到的事。
不是包裝別人的 API,而是自己實作邏輯。
Claude Code 有一個實際痛點:它沒有跨 Session 的記憶。每次開新 Session,你得重新告訴它專案背景、最近的決策、進行中的任務。CLAUDE.md 能解決一部分,但它是靜態的,不會隨著專案推進自動更新。
我們要做一個 MCP Server,讓 Claude 可以:
資料存在本地 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;
}
}
幾個設計選擇:
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);
除了工具,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 任務
做完這個 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}` }] };
MCP SDK 原生支援 zod,用它定義參數不僅能做型別檢查,還能透過 .describe() 給 Claude 提供使用說明。沒有描述的參數,Claude 只能靠猜。
MCP 支援兩種傳輸方式:stdio(標準輸入輸出)和 HTTP+SSE。對於本地開發用的 MCP Server,stdio 就夠了:Claude Code 直接把 Server 當子程序啟動,通訊走 stdin/stdout。
HTTP+SSE 適合遠端部署的 Server,例如公司內部統一提供的 MCP 服務。
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。在它的基礎上,你可以往很多方向擴充:
MCP 的能力邊界取決於你的想像力。只要能寫成程式碼的事情,都能變成 Claude 的工具。