أنشئ MCP Server لإدارة سياق المشروع من الصفر باستخدام TypeScript — تسجيل القرارات وإدارة المهام والبحث عبر الجلسات، مع تغطية تسجيل الأدوات والموارد والتصحيح والنشر.
المقالات الثلاث السابقة عن MCP كانت تتحدث عن ربط أدوات موجودة — خوادم MCP جاهزة، قواعد بيانات، وواجهات API داخلية. هذه المقالة مختلفة: سنبني MCP Server من الصفر، ليقوم بشيء لا تستطيع الأدوات الحالية فعله.
لن نغلّف API شخص آخر، بل سنكتب المنطق بأنفسنا.
لدى Claude Code نقطة ضعف عملية: لا يملك ذاكرة عبر الجلسات. في كل مرة تبدأ جلسة جديدة، عليك إعادة شرح خلفية المشروع، والقرارات الأخيرة، والمهام الجارية. ملف 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/**/*"]
}
أضف "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;
}
}
بعض خيارات التصميم:
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 على السياق الكامل عند بدء الجلسة:
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، يقرأ 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}` }] };
MCP SDK يدعم zod أصلاً. استخدامه لتعريف المعاملات لا يوفر فقط فحص الأنواع، بل يتيح أيضاً تقديم تعليمات استخدام لـ Claude عبر .describe(). المعاملات بدون وصف يضطر Claude إلى التخمين.
يدعم MCP طريقتين للنقل: stdio (الإدخال والإخراج القياسي) و HTTP+SSE. لخوادم MCP المخصصة للتطوير المحلي، stdio كافية: يقوم Claude Code بتشغيل الخادم كعملية فرعية، والتواصل يتم عبر stdin/stdout.
HTTP+SSE مناسبة للخوادم المنشورة عن بُعد، كخدمات MCP الموحدة داخل الشركة.
توفر 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 صغير لكنه مكتمل. يمكنك التوسع انطلاقاً منه في اتجاهات عديدة:
حدود قدرات MCP تتوقف على خيالك. كل ما يمكن كتابته كشيفرة برمجية، يمكن تحويله إلى أداة يستخدمها Claude.