اكتب MCP Server خاص بك: أنشئ قدرة مخصصة لـ Claude Code

أنشئ MCP Server لإدارة سياق المشروع من الصفر باستخدام TypeScript — تسجيل القرارات وإدارة المهام والبحث عبر الجلسات، مع تغطية تسجيل الأدوات والموارد والتصحيح والنشر.


المقالات الثلاث السابقة عن 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`);
}

النشر والتوزيع

عندما يستقر خادم MCP الخاص بك، هناك عدة طرق للتوزيع:

حزمة 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.