Kendi MCP Server'ınızı Yazın: Claude Code İçin Özel Bir Yetenek Oluşturun

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.

Ne Yapacağız: Proje Bağlam Yöneticisi

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:

  • Proje kararlarını ve bağlamı kaydetmek ("Cache için Redis kullanmaya karar verdik, sebebi X")
  • Yapılacaklar listesini ve devam eden görevleri yönetmek
  • Yeni oturumda önceden kaydedilmiş bağlamı geri getirmek

Veriler yerel bir JSON dosyasında saklanacak, herhangi bir harici servise bağımlılık yok.

Projeyi Başlatma

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.

Veri Katmanı

Ö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:

  • ID olarak UUID'nin ilk 8 karakteri kullanılıyor, sözlü olarak söylenebilecek kadar kısa ("a3f2b1c9 görevini güncelle")
  • Her yazma işlemi anında kalıcı hale getiriliyor, bellekte cache yapılmıyor
  • Veri yapısı bilerek basit tutuluyor, işe yarıyorsa yeter

Tool Kaydetme

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);

Resource Kaydetme

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.

Derleme ve Yapılandırma

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.

Gerçek Kullanım

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

MCP Server Geliştirmenin Bazı Önemli Noktaları

Bu Server'ı tamamladıktan sonra, birkaç pratik deneyimi özetleyelim.

Hata Yönetimi Kullanıcı Dostu Olmalı

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).

Çok Fazla Tool Olmasın

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.

Dönüş Değerleri Bilgi İçermeli

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}` }] };

Parametre Doğrulaması İçin zod Kullanın

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.

stdio En Basit İletişim Yöntemidir

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.

Hata Ayıklama Yöntemleri

MCP Inspector Kullanın

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.

Log Ekleyin

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`);
}

Yayınlama ve Dağıtım

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.

Sonraki Adımlar

Bu makaledeki örnek, minimal ama eksiksiz bir MCP Server'dır. Bu temel üzerine birçok yöne genişletebilirsiniz:

  • Proje zaman çizelgesi ekleyin (her gün ne yapıldığını otomatik kaydedin)
  • git log entegrasyonu (commit geçmişini kararlarla ilişkilendirin)
  • Çoklu proje desteği (tek bir Server ile birden çok projenin bağlamını yönetin)
  • Prompt şablonları ekleyin (örneğin önceden tanımlanmış bir "günlük proje raporu" oluşturma formatı)

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.