כתיבת MCP Server משלך: בניית יכולת מותאמת אישית ל-Claude Code

בניית MCP Server לניהול הקשר פרויקט מאפס ב-TypeScript — רישום החלטות, ניהול משימות, חיפוש חוצה-סשנים, רישום כלים, Resources, דיבאג ופרסום.


שלושת המאמרים הקודמים על MCP עסקו בחיבור לדברים קיימים — שרתי MCP מוכנים, מסדי נתונים, וממשקי 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/**/*"]
}

הוסיפו "type": "module" ב-package.json.

שכבת הנתונים

נתחיל בקריאה וכתיבה של נתונים. כל הנתונים נשמרים ב-.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 משתמש ב-8 התווים הראשונים של UUID, קצר מספיק כדי להתייחס אליו בעל פה (למשל: «עדכן את המשימה 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

אחרי שסיימנו לבנות את השרת הזה, הנה כמה לקחים מעשיים.

טיפול בשגיאות צריך להיות ידידותי

כש-MCP כלי מחזיר שגיאה, Claude קורא את הודעת השגיאה ומציג אותה למשתמש. לכן הודעות השגיאה צריכות להיות כתובות בשפה ברורה:

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

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

isError: true מודיע ל-Claude שהקריאה נכשלה, והוא יתאים את ההתנהגות שלו בהתאם (למשל, ינסה מחדש בדרך אחרת).

אל תגזימו עם מספר הכלים

5 עד 10 כלים לכל MCP Server זה מספר סביר. מעל 15 כלים, הסבירות ש-Claude יבחר בכלי הלא נכון עולה משמעותית. אם יש יותר מדי יכולות, פצלו למספר שרתים.

ערכי החזרה צריכים להיות אינפורמטיביים

להחזיר «הפעולה הצליחה» זה לא מספיק. Claude צריך לדעת מה התוצאה של הפעולה כדי להמשיך את השיחה:

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

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

השתמשו ב-zod לוולידציית פרמטרים

MCP SDK תומך ב-zod באופן מובנה. שימוש בו להגדרת פרמטרים מספק לא רק בדיקת טיפוסים, אלא גם מאפשר לתת ל-Claude הוראות שימוש דרך .describe(). פרמטרים ללא תיאור — Claude יצטרך לנחש.

stdio היא דרך התקשורת הפשוטה ביותר

MCP תומך בשתי דרכי תקשורת: stdio (קלט ופלט סטנדרטי) ו-HTTP+SSE. לשרתי MCP מקומיים לפיתוח, stdio מספיק: Claude Code מפעיל את השרת כתהליך בן, והתקשורת עוברת דרך stdin/stdout.

HTTP+SSE מתאים לשרתים מרוחקים, כמו שירותי MCP מרכזיים בתוך הארגון.

שיטות דיבאג

שימוש ב-MCP Inspector

Anthropic מספקת כלי דיבאג בשם MCP Inspector, שמאפשר לבדוק את שרת ה-MCP ישירות בדפדפן:

npx @modelcontextprotocol/inspector node dist/index.js

הוא מפעיל ממשק ווב שמאפשר לקרוא לכל כלי ידנית, לצפות בקלט ובפלט, ולבדוק שגיאות. הרבה יותר יעיל מבדיקה חוזרת בתוך Claude Code.

הוספת לוגים

stdout בשרת MCP תפוס (משמש לתקשורת 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`);
}

פרסום והפצה

כשהשרת שלכם יציב, יש כמה דרכים להפיץ אותו:

חבילת 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

אם השרת תלוי בשירותים חיצוניים (מסד נתונים, Redis), אריזה ב-Docker נקייה יותר:

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

שימו לב לפרמטר -i — תקשורת stdio דורשת ש-stdin יישאר פתוח.

מה הלאה

הדוגמה במאמר הזה היא MCP Server קטן אך שלם. על בסיסו, אפשר להרחיב בכיוונים רבים:

  • הוספת ציר זמן לפרויקט (תיעוד אוטומטי של מה שנעשה כל יום)
  • אינטגרציה עם git log (קישור בין קומיטים להחלטות)
  • תמיכה בפרויקטים מרובים (שרת אחד שמנהל הקשר של מספר פרויקטים)
  • הוספת תבניות prompt (למשל: פורמט מוגדר מראש ל-«דוח פרויקט יומי»)

גבולות היכולות של MCP תלויים בדמיון שלכם. כל מה שאפשר לכתוב כקוד, אפשר להפוך לכלי ש-Claude משתמש בו.