Tự viết MCP Server: xây dựng khả năng riêng cho Claude Code

Xây dựng MCP Server quản lý ngữ cảnh dự án từ đầu bằng TypeScript — ghi nhận quyết định, quản lý task, tìm kiếm xuyên phiên, bao gồm đăng ký tool, Resource, debug và phát hành.


Ba bài viết MCP trước đều nói về việc kết nối những thứ có sẵn—MCP Server dựng sẵn, database, API nội bộ. Bài này khác: chúng ta sẽ xây dựng một MCP Server từ đầu, để nó làm được điều mà các công cụ hiện có chưa làm được.

Không phải bọc lại API của người khác, mà tự mình viết logic.

Làm gì: Trình quản lý ngữ cảnh dự án

Claude Code có một vấn đề thực tế: nó không có bộ nhớ xuyên phiên. Mỗi lần mở phiên mới, bạn phải giải thích lại bối cảnh dự án, các quyết định gần đây, và những task đang làm dở. CLAUDE.md giải quyết được một phần, nhưng nó là tĩnh, không tự động cập nhật khi dự án tiến triển.

Chúng ta sẽ xây một MCP Server cho phép Claude:

  • Ghi lại quyết định và ngữ cảnh dự án ("Chúng ta quyết định dùng Redis làm cache, lý do là X")
  • Quản lý danh sách việc cần làm và task đang thực hiện
  • Truy xuất ngữ cảnh đã ghi từ các phiên trước

Dữ liệu được lưu trong file JSON cục bộ, không phụ thuộc vào bất kỳ dịch vụ bên ngoài nào.

Khởi tạo dự án

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

Thêm "type": "module" vào package.json.

Tầng dữ liệu

Trước tiên, xử lý việc đọc ghi dữ liệu. Toàn bộ dữ liệu được lưu trong .claude/project-context.json ở thư mục gốc dự án.

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

Một vài lựa chọn thiết kế:

  • ID dùng 8 ký tự đầu của UUID, đủ ngắn để nói miệng được ("update task a3f2b1c9")
  • Mỗi thao tác ghi đều lưu ngay lập tức, không cache trong bộ nhớ
  • Cấu trúc dữ liệu cố tình giữ đơn giản, đủ dùng là được

Đăng ký 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",
});

// ---- Quản lý quyết định ----

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

// ---- Quản lý task ----

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

// ---- Khởi chạy ----

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

Đăng ký Resource

Ngoài tool, MCP còn hỗ trợ Resource—dữ liệu mà Claude có thể chủ động đọc. Chúng ta đăng ký "tổng quan dự án" như một Resource, để Claude có thể lấy được toàn bộ ngữ cảnh khi bắt đầu phiên:

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

Sự khác biệt giữa Resource và Tool: Tool được Claude gọi theo nhu cầu trong cuộc trò chuyện; Resource là ngữ cảnh tĩnh mà Claude có thể đọc, giống CLAUDE.md nhưng có thể được tạo động.

Biên dịch và cấu hình

npx tsc

Cấu hình 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"
      }
    }
  }
}

Khởi động lại Claude Code, gõ /mcp để xác nhận project-context đã xuất hiện trong danh sách.

Sử dụng thực tế

Sau khi cấu hình xong, cuộc trò chuyện với Claude sẽ diễn ra như thế này:

Ghi lại quyết định

Chúng ta vừa thảo luận xong, quyết định dùng Bull + Redis làm task queue, không dùng RabbitMQ.
Lý do chính là team đã dùng Redis rồi, không muốn thêm hạ tầng mới.
Ghi lại giúp mình nhé.

→ Claude gọi record_decision
→ Đã ghi quyết định [a3f2b1c9]: Lựa chọn task queue: Bull + Redis

Tìm kiếm xuyên phiên

Ở phiên mới:

Trước đây mình chọn Bull mà không chọn RabbitMQ vì lý do gì?

→ Claude gọi search_decisions("Bull")
→ Tìm thấy bản ghi trước đó, trình bày đầy đủ lý do quyết định

Theo dõi task

Tiếp theo cần làm ba việc:
1. Viết API đăng ký người dùng
2. Tạo template email đăng ký
3. Thêm luồng xác minh email

→ Claude gọi add_task ba lần
→ Ba task được tạo, trạng thái đều là todo
API đăng ký viết xong rồi, cập nhật trạng thái giúp mình

→ Claude gọi update_task(id, "done")
→ Đồng thời liệt kê các task todo còn lại

Một số điểm quan trọng khi phát triển MCP Server

Sau khi hoàn thành Server này, đây là một vài kinh nghiệm thực tế rút ra được.

Xử lý lỗi phải thân thiện

Khi tool MCP gặp lỗi, Claude sẽ đọc thông báo lỗi cho người dùng nghe. Vì vậy thông báo lỗi phải viết như ngôn ngữ con người:

// Tệ
throw new Error("ENOENT");

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

isError: true cho Claude biết lần gọi này thất bại, và Claude sẽ điều chỉnh hành vi tiếp theo (ví dụ thử lại theo cách khác).

Đừng có quá nhiều tool

Một MCP Server cung cấp 5-10 tool là hợp lý. Vượt quá 15 cái, xác suất Claude chọn sai tool tăng lên rõ rệt. Nếu có quá nhiều khả năng, hãy tách thành nhiều Server.

Giá trị trả về phải có thông tin

Tool mà chỉ trả về "thao tác thành công" là không đủ. Claude cần biết kết quả thao tác là gì để tiếp tục cuộc trò chuyện:

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

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

Dùng zod để validate tham số

MCP SDK hỗ trợ zod sẵn. Dùng nó để định nghĩa tham số không chỉ kiểm tra kiểu dữ liệu, mà còn cung cấp hướng dẫn sử dụng cho Claude thông qua .describe(). Tham số không có mô tả thì Claude chỉ có thể đoán mò.

stdio là phương thức truyền tải đơn giản nhất

MCP hỗ trợ hai phương thức truyền tải: stdio (standard input output) và HTTP+SSE. Với MCP Server dùng cho phát triển cục bộ, stdio là đủ: Claude Code khởi chạy Server như một child process, giao tiếp qua stdin/stdout.

HTTP+SSE phù hợp với Server triển khai từ xa, ví dụ dịch vụ MCP cung cấp tập trung trong nội bộ công ty.

Cách debug

Dùng MCP Inspector

Anthropic cung cấp một công cụ debug tên là MCP Inspector, có thể test MCP Server trực tiếp trên trình duyệt:

npx @modelcontextprotocol/inspector node dist/index.js

Nó sẽ mở giao diện web, cho phép bạn gọi từng tool thủ công, xem input/output, và kiểm tra lỗi. Hiệu quả hơn rất nhiều so với việc test đi test lại trong Claude Code.

Thêm log

stdout của MCP Server đã bị chiếm (dùng cho giao tiếp JSON-RPC), nên không thể dùng console.log. Hãy dùng console.error để xuất thông tin debug, nó sẽ ghi vào stderr:

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

Hoặc ghi ra file:

import { appendFileSync } from "fs";

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

Phát hành và phân phối

Khi MCP Server của bạn đã ổn định, có một vài cách phân phối:

Gói npm

Thêm trường bin và shebang, rồi publish lên npm:

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

Thêm vào đầu dist/index.js:

#!/usr/bin/env node

Cách sử dụng:

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

Docker

Nếu Server có phụ thuộc bên ngoài (database, Redis), dùng Docker đóng gói sẽ gọn gàng hơn:

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

Lưu ý tham số -i—truyền tải stdio cần giữ stdin mở.

Bước tiếp theo

Ví dụ trong bài viết này là một MCP Server tối giản nhưng hoàn chỉnh. Trên nền tảng này, bạn có thể mở rộng theo nhiều hướng:

  • Thêm timeline dự án (tự động ghi lại mỗi ngày làm được gì)
  • Tích hợp git log (liên kết lịch sử commit với các quyết định)
  • Hỗ trợ đa dự án (một Server quản lý ngữ cảnh nhiều dự án)
  • Thêm template prompt (ví dụ định dạng sẵn cho việc tạo "báo cáo ngày dự án")

Giới hạn khả năng của MCP phụ thuộc vào trí tưởng tượng của bạn. Bất cứ thứ gì viết thành code được, đều có thể trở thành công cụ cho Claude.