Sổ tay kỹ thuật

Viết MCP server bằng TypeScript SDK

how-to#mcp#typescript#claude-code· cập nhật 2026-11-06

Dùng khi cần viết MCP server bằng Node.js/TypeScript với @modelcontextprotocol/sdk, khai báo tool bằng zod schema, build rồi gắn vào Claude Code.

Sửa trang này
Mục lục

Tóm tắt: Cài @modelcontextprotocol/sdk và zod. Tạo new McpServer({ name, version }), gọi server.registerTool(name, { description, inputSchema: { field: z... } }, handler), sau đó server.connect(new StdioServerTransport()). Handler trả về { content: [{ type: "text", text }] }, và thêm isError: true khi lỗi. Bản Python tương đương xem FastMCP.

Ví dụ dưới đây đã chạy thử với @modelcontextprotocol/sdk 1.29.0, zod 3.25 và Node 22. Ở bản này server.tool(...) đã bị đánh dấu deprecated, nên dùng registerTool. SDK đang được phát triển nhanh, có thể có major version mới đổi tên package hoặc import path. Nâng cấp thì đọc README/changelog trước.

Luồng hoạt động

flowchart LR A["src/index.ts"] -->|"tsc"| B["build/index.js"] B --> C["MCP Inspector"] C -->|OK| D["claude mcp add"] D --> E[Claude Code] E -->|"stdio JSON-RPC"| B B -->|"ping / inventory"| F[(Router)]

Cấu hình

Tạo project

mkdir netops-mcp-ts && cd netops-mcp-ts
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npm pkg set type=module
npm pkg set scripts.build="tsc"
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Server

src/index.ts
import { execFile } from "node:child_process";
import { isIP } from "node:net";
import { promisify } from "node:util";
 
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
const run = promisify(execFile);
 
const DEVICES: Record<string, { mgmt: string; role: string; os: string }> = {
  "pe1-hni": { mgmt: "198.51.100.11", role: "pe", os: "iosxr" },
  "pe2-hcm": { mgmt: "198.51.100.12", role: "pe", os: "junos" },
};
 
const server = new McpServer({ name: "netops-ts", version: "0.1.0" });
 
server.registerTool(
  "get_device",
  {
    title: "Tra inventory",
    description: "Tra thiết bị theo tên, trả về IP quản trị, role, OS. Chỉ đọc.",
    inputSchema: { name: z.string().describe("Tên trong inventory, vd pe1-hni") },
    annotations: { readOnlyHint: true },
  },
  async ({ name }) => {
    const dev = DEVICES[name];
    if (!dev) {
      const valid = Object.keys(DEVICES).join(", ");
      return { content: [{ type: "text", text: `Không có ${name}. Hợp lệ: ${valid}` }], isError: true };
    }
    return { content: [{ type: "text", text: JSON.stringify(dev) }] };
  },
);
 
server.registerTool(
  "ping",
  {
    title: "Ping",
    description: "Ping một IPv4/IPv6 từ máy chạy server. Chỉ nhận IP, không nhận hostname.",
    inputSchema: {
      target: z.string().refine((s) => isIP(s) !== 0, "target phải là địa chỉ IP"),
      count: z.number().int().min(1).max(10).default(3),
    },
    annotations: { readOnlyHint: true, openWorldHint: true },
  },
  async ({ target, count }) => {
    try {
      // execFile không qua shell, không có nguy cơ chèn lệnh
      const { stdout } = await run("ping", ["-c", String(count), "-W", "1", target], { timeout: 30_000 });
      return { content: [{ type: "text", text: stdout.slice(-1500) }] };
    } catch (err: any) {
      // ping trả exit code khác 0 khi mất gói: vẫn trả output cho model đọc
      return { content: [{ type: "text", text: String(err.stdout || err.message).slice(-1500) }], isError: true };
    }
  },
);
 
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("netops-ts MCP server đang chạy trên stdio"); // stderr, KHÔNG dùng console.log

Những điểm cần nhớ:

  • inputSchema là object gồm các field zod (raw shape). SDK tự sinh JSON Schema cho tools/list và validate input trước khi gọi handler. Input sai thì client nhận lỗi Input validation error kèm message của refine.
  • .describe() trên từng field thành description trong schema. Model dựa vào đó để điền tham số, nên viết rõ (xem thiết kế tool).
  • annotations.readOnlyHint chỉ là gợi ý cho client, không thay được việc kiểm tra quyền trong code.
  • Import phải có đuôi .js (.../server/mcp.js) vì project dùng ESM với moduleResolution: Node16.

Build và test

npm run build
npx @modelcontextprotocol/inspector node build/index.js
 
# test nhanh không cần UI: gửi initialize + tools/list qua stdin
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node build/index.js

Gắn vào Claude Code

claude mcp add netops-ts -- node /opt/netops-mcp-ts/build/index.js
claude mcp add netops-ts -s project -- node build/index.js   # ghi vào .mcp.json cho cả team
claude mcp list

Nếu cần chạy server qua HTTP, SDK có StreamableHTTPServerTransport (@modelcontextprotocol/sdk/server/streamableHttp.js) để gắn vào Express hoặc node:http. Trước khi mở server ra mạng, đọc transport và xác thực.

Lỗi thường gặp

Triệu chứng Nguyên nhân Cách xử lý
Cannot find module '@modelcontextprotocol/sdk/server/mcp' Import thiếu đuôi .js Dùng .../server/mcp.js, .../server/stdio.js
SyntaxError: Cannot use import statement outside a module package.json chưa có "type": "module" npm pkg set type=module
tsc báo lỗi top-level await module trong tsconfig quá cũ (CommonJS) module/moduleResolution = Node16 (hoặc NodeNext), target từ ES2022
Claude Code báo server failed / parse error Dùng console.log nên làm bẩn stdout Mọi log dùng console.error
Lỗi type ở inputSchema Truyền z.object({...}) ở chỗ SDK cũ chỉ nhận raw shape, hoặc lệch major version zod Truyền { field: z.string() }, kiểm tra peer dependency zod của SDK
Tool không xuất hiện trong /mcp Đường dẫn build/index.js tương đối nhưng Claude Code chạy ở cwd khác Dùng đường dẫn tuyệt đối, chạy claude mcp get netops-ts để kiểm tra
Model gọi tool liên tục rồi fail Handler throw exception Bắt lỗi, trả isError: true kèm gợi ý sửa

Tham khảo