TypeScript ile sıfırdan bir proje bağlam yöneticisi MCP Server oluşturun — karar kaydı, görev yönetimi, oturumlar arası arama; araç kaydı, Resource, hata ayıklama ve yayınlama dahil.
Önceki üç MCP makalesi hep mevcut şeyleri bağlamaktan bahsediyordu—hazır MCP Server'lar, veritabanları, dahili API'ler. Bu makale farklı: sıfırdan bir MCP Server inşa edecek ve mevcut araçların yapamadığı bir şeyi yaptıracağız.
Başkasının API'sini sarmak değil, kendi mantığımızı yazmak.
Claude Code'un somut bir sıkıntısı var: oturumlar arası hafızası yok. Her yeni oturum açtığınızda proje geçmişini, son kararları ve devam eden görevleri baştan anlatmanız gerekiyor. CLAUDE.md bunun bir kısmını çözer ama statiktir, proje ilerledikçe otomatik güncellenmez.
Bir MCP Server yapacağız ve Claude'un şunları yapabilmesini sağlayacağız:
Veriler yerel bir JSON dosyasında saklanacak, herhangi bir harici servise bağımlılık yok.
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 dosyasına "type": "module" ekleyin.
Önce veri okuma-yazma işini halledelim. Tüm veriler projenin kök dizinindeki .claude/project-context.json dosyasında saklanacak.
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;
}
}
Birkaç tasarım tercihi:
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",
});
// ---- Karar Yönetimi ----
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) }],
};
}
);
// ---- Görev Yönetimi ----
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) }],
};
}
);
// ---- Başlat ----
const transport = new StdioServerTransport();
server.connect(transport);
Tool'ların yanı sıra, MCP ayrıca Resource'ları da destekler—Claude'un aktif olarak okuyabileceği veriler. "Proje özeti"ni bir Resource olarak kaydediyoruz, böylece Claude oturum başladığında tam bağlamı elde edebilir:
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 ile Tool arasındaki fark: Tool, Claude tarafından konuşma sırasında ihtiyaç duyulduğunda çağrılır; Resource ise Claude'un okuyabileceği statik bağlamdır, CLAUDE.md gibi ama dinamik olarak üretilebilir.
npx tsc
Claude Code yapılandırması:
{
"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'u yeniden başlatın ve /mcp yazarak project-context'in listede göründüğünü doğrulayın.
Yapılandırma tamamlandıktan sonra Claude ile sohbet şu şekilde ilerleyecek:
Karar Kaydetme
Az önce tartışmayı bitirdik, görev kuyruğu için Bull + Redis kullanmaya karar verdik, RabbitMQ değil.
Ana sebep ekibin zaten Redis kullandığı, yeni altyapı eklemek istemiyoruz.
Bunu kaydet lütfen.
→ Claude record_decision'ı çağırır
→ Karar kaydedildi [a3f2b1c9]: Görev kuyruğu seçimi: Bull + Redis
Oturumlar Arası Arama
Yeni bir oturumda:
Daha önce neden RabbitMQ yerine Bull'u seçmiştik?
→ Claude search_decisions("Bull") çağırır
→ Önceki kaydı bulur, karar gerekçesini eksiksiz aktarır
Görev Takibi
Sırada yapılacak üç şey var:
1. Kullanıcı kayıt API'sini oluştur
2. Kayıt e-postası şablonunu yaz
3. E-posta doğrulama akışını ekle
→ Claude add_task'ı üç kez çağırır
→ Üç görev oluşturulur, hepsinin durumu todo
Kayıt API'si bitti, durumunu güncelle
→ Claude update_task(id, "done") çağırır
→ Aynı zamanda kalan todo görevleri listeler
Bu Server'ı tamamladıktan sonra, birkaç pratik deneyimi özetleyelim.
MCP tool'u hata verdiğinde, Claude hata mesajını kullanıcıya okur. Bu yüzden hata mesajları insan diline uygun yazılmalı:
// Kötü
throw new Error("ENOENT");
// İyi
return {
content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
isError: true,
};
isError: true, Claude'a bu çağrının başarısız olduğunu söyler ve Claude sonraki davranışını buna göre ayarlar (mesela farklı bir yöntemle tekrar dener).
Bir MCP Server'ın 5-10 tool sunması makul bir sayıdır. 15'i aşınca, Claude'un yanlış tool seçme olasılığı belirgin şekilde artar. Yetenekler çok fazlaysa, birden çok Server'a bölün.
Sadece "işlem başarılı" döndüren bir tool yeterli değildir. Claude'un sohbete devam edebilmesi için işlemin sonucunun ne olduğunu bilmesi gerekir:
// Kötü
return { content: [{ type: "text", text: "OK" }] };
// İyi
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };
MCP SDK doğrudan zod'u destekler. Parametreleri tanımlamak için kullanmak yalnızca tür kontrolü sağlamakla kalmaz, aynı zamanda .describe() ile Claude'a kullanım talimatları da verir. Açıklaması olmayan parametreleri Claude ancak tahmin edebilir.
MCP iki iletişim yöntemini destekler: stdio (standart giriş çıkış) ve HTTP+SSE. Yerel geliştirme için kullanılan MCP Server'larda stdio yeterlidir: Claude Code, Server'ı doğrudan bir alt süreç olarak başlatır, iletişim stdin/stdout üzerinden gerçekleşir.
HTTP+SSE, uzaktan dağıtılan Server'lar için uygundur, örneğin şirket içinde merkezi olarak sunulan MCP hizmetleri.
Anthropic, MCP Inspector adında bir hata ayıklama aracı sunuyor. MCP Server'ınızı doğrudan tarayıcıda test edebilirsiniz:
npx @modelcontextprotocol/inspector node dist/index.js
Bir web arayüzü açılır, her tool'u manuel olarak çağırmanıza, giriş/çıkışları görmenize ve hataları kontrol etmenize olanak tanır. Claude Code içinde tekrar tekrar test etmekten çok daha verimlidir.
MCP Server'ın stdout'u meşguldür (JSON-RPC iletişimi için kullanılır), bu yüzden console.log kullanılamaz. Hata ayıklama bilgilerini çıkarmak için console.error kullanın, stderr'e yazılır:
console.error("[debug] processing request:", JSON.stringify(params));
Ya da dosyaya yazın:
import { appendFileSync } from "fs";
function log(msg: string) {
appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}
MCP Server'ınız kararlı hale geldiğinde, birkaç dağıtım yöntemi var:
npm paketi
bin alanı ve shebang ekleyip npm'e yayınlayın:
{
"name": "@yourcompany/mcp-project-context",
"bin": {
"mcp-project-context": "./dist/index.js"
}
}
dist/index.js dosyasının başına ekleyin:
#!/usr/bin/env node
Kullanım:
{
"mcpServers": {
"project-context": {
"command": "npx",
"args": ["-y", "@yourcompany/mcp-project-context"]
}
}
}
Docker
Server'ın harici bağımlılıkları varsa (veritabanı, Redis), Docker ile paketlemek daha temizdir:
{
"mcpServers": {
"project-context": {
"command": "docker",
"args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
}
}
}
-i parametresine dikkat edin—stdio iletişimi stdin'in açık kalmasını gerektirir.
Bu makaledeki örnek, minimal ama eksiksiz bir MCP Server'dır. Bu temel üzerine birçok yöne genişletebilirsiniz:
MCP'nin yetenek sınırı hayal gücünüze bağlıdır. Kodla yazılabilen her şey, Claude'un bir aracına dönüştürülebilir.