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.
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:
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.
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.
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ế:
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);
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.
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.
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
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.
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).
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.
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}` }] };
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ò.
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.
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.
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`);
}
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ở.
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:
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.