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.
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:
Data disimpan di file JSON lokal, tanpa bergantung pada layanan eksternal apa pun.
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.
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:
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);
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.
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.
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
Setelah menyelesaikan Server ini, berikut beberapa pelajaran praktis yang bisa diambil.
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).
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.
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}` }] };
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.
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.
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.
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`);
}
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.
Contoh di artikel ini adalah MCP Server yang minimal tapi lengkap. Dari dasar ini, kamu bisa mengembangkannya ke banyak arah:
Batas kemampuan MCP ditentukan oleh imajinasimu. Apa pun yang bisa ditulis sebagai kode, bisa menjadi tool untuk Claude.