สร้าง MCP Server ของคุณเอง: สร้างความสามารถเฉพาะทางให้ Claude Code

สร้าง MCP Server จัดการบริบทโปรเจกต์ตั้งแต่ศูนย์ด้วย TypeScript — บันทึกการตัดสินใจ จัดการ task ค้นหาข้ามเซสชัน ครอบคลุมการลงทะเบียน tool, Resource, การ debug และการเผยแพร่


สามบทความ MCP ก่อนหน้านี้พูดถึงการเชื่อมต่อสิ่งที่มีอยู่แล้ว—MCP Server สำเร็จรูป, ฐานข้อมูล, API ภายใน บทความนี้ต่างออกไป: เราจะสร้าง MCP Server ตั้งแต่ศูนย์ ให้มันทำในสิ่งที่เครื่องมือที่มีอยู่ทำไม่ได้

ไม่ใช่การ wrap API ของคนอื่น แต่เขียน logic เอง

จะทำอะไร: ตัวจัดการบริบทโปรเจกต์

Claude Code มีปัญหาจริง ๆ อยู่อย่างหนึ่ง: มันไม่มีหน่วยความจำข้ามเซสชัน ทุกครั้งที่เปิดเซสชันใหม่ คุณต้องอธิบายพื้นหลังโปรเจกต์ การตัดสินใจล่าสุด และงานที่กำลังทำอยู่ใหม่ทั้งหมด CLAUDE.md ช่วยได้บางส่วน แต่มันเป็นไฟล์คงที่ ไม่อัปเดตอัตโนมัติตามความคืบหน้าของโปรเจกต์

เราจะสร้าง MCP Server ที่ให้ Claude สามารถ:

  • บันทึกการตัดสินใจและบริบทของโปรเจกต์ ("เราตัดสินใจใช้ Redis ทำ cache เหตุผลคือ X")
  • จัดการรายการสิ่งที่ต้องทำและงานที่กำลังดำเนินอยู่
  • ดึงบริบทที่เคยบันทึกไว้ในเซสชันก่อนหน้า

ข้อมูลเก็บในไฟล์ JSON บนเครื่อง ไม่ต้องพึ่งพาบริการภายนอกใด ๆ

เริ่มต้นโปรเจกต์

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

เพิ่ม "type": "module" ใน package.json

ชั้นข้อมูล

ก่อนอื่น จัดการเรื่องอ่านเขียนข้อมูลให้เรียบร้อย ข้อมูลทั้งหมดเก็บใน .claude/project-context.json ที่ root ของโปรเจกต์

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

การตัดสินใจด้านการออกแบบบางประการ:

  • ID ใช้ 8 ตัวอักษรแรกของ UUID สั้นพอที่จะพูดอ้างอิงได้ ("อัปเดต task a3f2b1c9")
  • ทุกการเขียนจะบันทึกลงไฟล์ทันที ไม่ cache ในหน่วยความจำ
  • โครงสร้างข้อมูลตั้งใจทำให้เรียบง่าย ใช้งานได้ก็พอ

ลงทะเบียน 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",
});

// ---- จัดการการตัดสินใจ ----

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

// ---- จัดการงาน ----

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

// ---- เริ่มทำงาน ----

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

ลงทะเบียน Resource

นอกจาก Tool แล้ว MCP ยังรองรับ Resource—ข้อมูลที่ Claude สามารถอ่านได้เอง เราจะลงทะเบียน "สรุปโปรเจกต์" เป็น Resource เพื่อให้ Claude ได้บริบทครบถ้วนตั้งแต่เริ่มเซสชัน:

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 กับ Tool: Tool ถูก Claude เรียกใช้ตามความจำเป็นระหว่างการสนทนา; Resource เป็นบริบทคงที่ที่ Claude สามารถอ่านได้ คล้ายกับ CLAUDE.md แต่สามารถสร้างแบบไดนามิกได้

คอมไพล์และตั้งค่า

npx tsc

ตั้งค่า 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"
      }
    }
  }
}

รีสตาร์ท Claude Code แล้วพิมพ์ /mcp เพื่อยืนยันว่า project-context ปรากฏในรายการ

การใช้งานจริง

หลังจากตั้งค่าเรียบร้อย การสนทนากับ Claude จะเป็นแบบนี้:

บันทึกการตัดสินใจ

เราเพิ่งคุยกันเสร็จ ตัดสินใจใช้ Bull + Redis เป็น task queue ไม่ใช้ RabbitMQ
เหตุผลหลักคือทีมใช้ Redis อยู่แล้ว ไม่อยากเพิ่ม infrastructure ใหม่
ช่วยจดให้หน่อย

→ Claude เรียก record_decision
→ บันทึกการตัดสินใจแล้ว [a3f2b1c9]: เลือก task queue: Bull + Redis

ค้นหาข้ามเซสชัน

ในเซสชันใหม่:

ก่อนหน้านี้เราเลือก Bull แทน RabbitMQ เพราะอะไร?

→ Claude เรียก search_decisions("Bull")
→ พบบันทึกก่อนหน้า อธิบายเหตุผลการตัดสินใจอย่างครบถ้วน

ติดตามงาน

ต่อไปต้องทำสามอย่าง:
1. สร้าง API ลงทะเบียนผู้ใช้
2. ทำ template อีเมลลงทะเบียน
3. เพิ่มขั้นตอนยืนยันอีเมล

→ Claude เรียก add_task สามครั้ง
→ สร้างสาม task แต่ละอันมีสถานะเป็น todo
API ลงทะเบียนเสร็จแล้ว ช่วยอัปเดตสถานะให้หน่อย

→ Claude เรียก update_task(id, "done")
→ พร้อมกับแสดงรายการ task ที่ยังเป็น todo

ข้อควรรู้ในการพัฒนา MCP Server

หลังจากทำ Server นี้เสร็จ สรุปบทเรียนจากประสบการณ์จริงไว้ดังนี้

การจัดการ Error ต้องเป็นมิตร

เมื่อ MCP tool เกิด error Claude จะอ่านข้อความ error ให้ผู้ใช้ฟัง ดังนั้นข้อความ error ต้องเขียนเหมือนภาษาคน:

// แย่
throw new Error("ENOENT");

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

isError: true บอก Claude ว่าการเรียกครั้งนี้ล้มเหลว Claude จะปรับพฤติกรรมต่อไปตามนั้น (เช่น ลองวิธีอื่น)

อย่ามี Tool มากเกินไป

MCP Server หนึ่งตัวมี 5-10 tool ถือว่าเหมาะสม เกิน 15 ตัว โอกาสที่ Claude จะเลือก tool ผิดจะเพิ่มขึ้นอย่างชัดเจน ถ้ามีความสามารถมากเกินไป ให้แยกเป็นหลาย Server

ค่าที่ส่งกลับต้องมีข้อมูลเพียงพอ

Tool ที่ส่งกลับแค่ "ดำเนินการสำเร็จ" นั้นไม่เพียงพอ Claude ต้องรู้ว่าผลลัพธ์ของการดำเนินการคืออะไร จึงจะสนทนาต่อได้:

// แย่
return { content: [{ type: "text", text: "OK" }] };

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

ใช้ zod ตรวจสอบพารามิเตอร์

MCP SDK รองรับ zod โดยตรง การใช้มันกำหนดพารามิเตอร์ไม่เพียงแค่ตรวจสอบชนิดข้อมูล แต่ยังให้คำอธิบายการใช้งานกับ Claude ผ่าน .describe() ได้ด้วย พารามิเตอร์ที่ไม่มีคำอธิบาย Claude ก็ได้แต่เดาเอา

stdio คือวิธีสื่อสารที่ง่ายที่สุด

MCP รองรับสองวิธีสื่อสาร: stdio (standard input output) และ HTTP+SSE สำหรับ MCP Server ที่ใช้พัฒนาบนเครื่อง stdio ก็เพียงพอแล้ว: Claude Code จะเริ่ม Server เป็น child process โดยตรง สื่อสารผ่าน stdin/stdout

HTTP+SSE เหมาะกับ Server ที่ deploy แบบ remote เช่น บริการ MCP ที่จัดให้ส่วนกลางภายในบริษัท

วิธีดีบัก

ใช้ MCP Inspector

Anthropic มีเครื่องมือดีบักชื่อ MCP Inspector สามารถทดสอบ MCP Server ได้โดยตรงในเบราว์เซอร์:

npx @modelcontextprotocol/inspector node dist/index.js

มันจะเปิดหน้าเว็บให้คุณเรียกแต่ละ tool ด้วยตัวเอง ดู input/output และตรวจสอบ error ได้ มีประสิทธิภาพกว่าการทดสอบซ้ำ ๆ ใน Claude Code มาก

เพิ่ม Log

stdout ของ MCP Server ถูกใช้งานอยู่แล้ว (สำหรับการสื่อสาร JSON-RPC) จึงใช้ console.log ไม่ได้ ให้ใช้ console.error แทนสำหรับข้อมูลดีบัก ซึ่งจะเขียนลง stderr:

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

หรือเขียนลงไฟล์:

import { appendFileSync } from "fs";

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

การเผยแพร่และแจกจ่าย

เมื่อ MCP Server ของคุณเสถียรแล้ว มีหลายวิธีในการแจกจ่าย:

แพ็กเกจ npm

เพิ่มฟิลด์ bin และ shebang แล้ว publish ขึ้น npm:

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

เพิ่มบรรทัดนี้ที่ด้านบนของ dist/index.js:

#!/usr/bin/env node

วิธีใช้งาน:

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

Docker

ถ้า Server มี dependency ภายนอก (ฐานข้อมูล, Redis) ใช้ Docker แพ็กจะสะอาดกว่า:

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

สังเกตพารามิเตอร์ -i—การสื่อสารแบบ stdio ต้องการให้ stdin เปิดอยู่

ขั้นตอนถัดไป

ตัวอย่างในบทความนี้เป็น MCP Server ที่เล็กแต่ครบถ้วน จากพื้นฐานนี้ คุณสามารถขยายไปได้หลายทิศทาง:

  • เพิ่ม timeline โปรเจกต์ (บันทึกอัตโนมัติว่าแต่ละวันทำอะไรบ้าง)
  • เชื่อมต่อกับ git log (เชื่อมโยงประวัติ commit กับการตัดสินใจ)
  • รองรับหลายโปรเจกต์ (Server เดียวจัดการบริบทหลายโปรเจกต์)
  • เพิ่ม template prompt (เช่น ฟอร์แมตสำเร็จรูปสำหรับสร้าง "รายงานประจำวันโปรเจกต์")

ขีดจำกัดของ MCP ขึ้นอยู่กับจินตนาการของคุณ อะไรก็ตามที่เขียนเป็นโค้ดได้ ก็เปลี่ยนเป็นเครื่องมือให้ Claude ได้ทั้งนั้น