Membuat MCP Server Sendiri: Bangun Kemampuan Khusus untuk Claude Code

Bangun MCP Server pengelola konteks proyek dari nol dengan TypeScript — mencatat keputusan, mengelola tugas, dan pencarian lintas sesi, mencakup registrasi tool, Resource, debugging, dan publikasi.


Tiga artikel MCP sebelumnya membahas cara menghubungkan hal-hal yang sudah ada—MCP Server yang sudah jadi, database, API internal. Artikel ini berbeda: kita akan membangun MCP Server dari nol, agar ia bisa melakukan sesuatu yang belum bisa dilakukan tool yang ada.

Bukan membungkus API orang lain, tapi mengimplementasikan logika sendiri.

Apa yang Akan Dibuat: Pengelola Konteks Proyek

Claude Code memiliki satu masalah nyata: ia tidak punya memori lintas sesi. Setiap kali membuka sesi baru, kamu harus menjelaskan ulang latar belakang proyek, keputusan terbaru, dan tugas yang sedang berjalan. CLAUDE.md bisa mengatasi sebagian, tapi sifatnya statis dan tidak otomatis ter-update seiring perkembangan proyek.

Kita akan membuat MCP Server yang memungkinkan Claude untuk:

  • Mencatat keputusan proyek dan konteks ("Kita memutuskan pakai Redis untuk cache, alasannya X")
  • Mengelola daftar tugas dan tugas yang sedang dikerjakan
  • Mengambil kembali konteks yang sudah dicatat di sesi sebelumnya

Data disimpan di file JSON lokal, tanpa bergantung pada layanan eksternal apa pun.

Inisialisasi Proyek

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/**/*"]
}

Tambahkan "type": "module" di package.json.

Lapisan Data

Pertama, kita selesaikan baca-tulis data. Semua data disimpan di .claude/project-context.json di root proyek.

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

Beberapa pilihan desain:

  • ID menggunakan 8 karakter pertama UUID, cukup pendek untuk disebut secara lisan ("update task a3f2b1c9")
  • Setiap operasi tulis langsung di-persist, tanpa cache di memori
  • Struktur data sengaja dibuat sederhana, cukup untuk kebutuhan

Mendaftarkan Tool

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",
});

// ---- Manajemen Keputusan ----

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) }],
    };
  }
);

// ---- Manajemen Tugas ----

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) }],
    };
  }
);

// ---- Mulai ----

const transport = new StdioServerTransport();
server.connect(transport);

Mendaftarkan Resource

Selain tool, MCP juga mendukung Resource—data yang bisa dibaca Claude secara aktif. Kita daftarkan "ringkasan proyek" sebagai Resource, sehingga Claude bisa mendapatkan konteks lengkap saat sesi dimulai:

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),
      },
    ],
  };
});

Perbedaan Resource dan Tool: Tool dipanggil Claude sesuai kebutuhan selama percakapan; Resource adalah konteks statis yang bisa dibaca Claude, mirip CLAUDE.md tapi bisa di-generate secara dinamis.

Kompilasi dan Konfigurasi

npx tsc

Konfigurasi 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"
      }
    }
  }
}

Restart Claude Code, ketik /mcp untuk memastikan project-context muncul di daftar.

Penggunaan Nyata

Setelah dikonfigurasi, percakapan dengan Claude akan terasa seperti ini:

Mencatat Keputusan

Kita baru selesai diskusi, memutuskan pakai Bull + Redis untuk task queue, bukan RabbitMQ.
Alasan utamanya karena tim sudah pakai Redis, tidak mau menambah infrastruktur baru.
Tolong catat.

→ Claude memanggil record_decision
→ Keputusan tercatat [a3f2b1c9]: Pemilihan task queue: Bull + Redis

Pencarian Lintas Sesi

Di sesi baru:

Kenapa dulu kita pilih Bull dan bukan RabbitMQ?

→ Claude memanggil search_decisions("Bull")
→ Menemukan catatan sebelumnya, menyampaikan alasan keputusan secara lengkap

Pelacakan Tugas

Selanjutnya ada tiga hal yang perlu dikerjakan:
1. Implementasi API registrasi pengguna
2. Buat template email registrasi
3. Tambahkan alur verifikasi email

→ Claude memanggil add_task tiga kali
→ Tiga tugas dibuat masing-masing dengan status todo
API registrasi sudah selesai, tolong update statusnya

→ Claude memanggil update_task(id, "done")
→ Sekaligus menampilkan tugas todo yang tersisa

Beberapa Poin Penting dalam Pengembangan MCP Server

Setelah menyelesaikan Server ini, berikut beberapa pelajaran praktis yang bisa diambil.

Penanganan Error Harus Ramah

Ketika tool MCP mengalami error, Claude akan membacakan pesan error tersebut kepada pengguna. Jadi pesan error harus ditulis seperti bahasa manusia:

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

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

isError: true memberi tahu Claude bahwa pemanggilan ini gagal, dan Claude akan menyesuaikan perilaku selanjutnya (misalnya mencoba cara lain).

Jangan Terlalu Banyak Tool

Satu MCP Server yang menyediakan 5-10 tool sudah cukup wajar. Lebih dari 15, probabilitas Claude salah memilih tool meningkat secara signifikan. Jika kemampuannya terlalu banyak, pecah menjadi beberapa Server.

Nilai Kembalian Harus Informatif

Tool yang mengembalikan "operasi berhasil" saja tidak cukup. Claude perlu tahu apa hasil operasinya agar bisa melanjutkan percakapan:

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

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

Gunakan zod untuk Validasi Parameter

MCP SDK secara native mendukung zod. Menggunakannya untuk mendefinisikan parameter tidak hanya memberikan pengecekan tipe, tapi juga bisa memberikan panduan penggunaan untuk Claude melalui .describe(). Parameter tanpa deskripsi hanya bisa ditebak oleh Claude.

stdio adalah Cara Transportasi Paling Sederhana

MCP mendukung dua cara transportasi: stdio (standard input output) dan HTTP+SSE. Untuk MCP Server yang digunakan dalam pengembangan lokal, stdio sudah cukup: Claude Code langsung menjalankan Server sebagai child process, komunikasi lewat stdin/stdout.

HTTP+SSE cocok untuk Server yang di-deploy secara remote, misalnya layanan MCP yang disediakan secara terpusat di internal perusahaan.

Cara Debugging

Gunakan MCP Inspector

Anthropic menyediakan tool debugging bernama MCP Inspector, yang bisa langsung menguji MCP Server-mu di browser:

npx @modelcontextprotocol/inspector node dist/index.js

Tool ini akan membuka antarmuka web, memungkinkanmu memanggil setiap tool secara manual, melihat input/output, dan memeriksa error. Jauh lebih efisien dibanding testing berulang kali di Claude Code.

Tambahkan Log

stdout MCP Server sudah dipakai (untuk komunikasi JSON-RPC), jadi tidak bisa pakai console.log. Gunakan console.error untuk output informasi debug, yang akan ditulis ke stderr:

console.error("[debug] processing request:", JSON.stringify(params));

Atau tulis ke file:

import { appendFileSync } from "fs";

function log(msg: string) {
  appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}

Publikasi dan Distribusi

Ketika MCP Server-mu sudah stabil, ada beberapa cara distribusi:

Paket npm

Tambahkan field bin dan shebang, lalu publikasikan ke npm:

{
  "name": "@yourcompany/mcp-project-context",
  "bin": {
    "mcp-project-context": "./dist/index.js"
  }
}

Tambahkan di bagian atas dist/index.js:

#!/usr/bin/env node

Cara penggunaannya:

{
  "mcpServers": {
    "project-context": {
      "command": "npx",
      "args": ["-y", "@yourcompany/mcp-project-context"]
    }
  }
}

Docker

Jika Server memiliki dependensi eksternal (database, Redis), menggunakan Docker untuk packaging lebih rapi:

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

Perhatikan parameter -i—transportasi stdio memerlukan stdin tetap terbuka.

Langkah Selanjutnya

Contoh di artikel ini adalah MCP Server yang minimal tapi lengkap. Dari dasar ini, kamu bisa mengembangkannya ke banyak arah:

  • Tambahkan timeline proyek (otomatis mencatat apa yang dikerjakan setiap hari)
  • Integrasikan git log (menghubungkan catatan commit dengan keputusan)
  • Dukung multi-proyek (satu Server mengelola konteks beberapa proyek)
  • Tambahkan template prompt (misalnya format pembuatan "laporan harian proyek" yang sudah terdefinisi)

Batas kemampuan MCP ditentukan oleh imajinasimu. Apa pun yang bisa ditulis sebagai kode, bisa menjadi tool untuk Claude.