Lần đầu tôi wire MCP server vào Claude Desktop, tôi mất hai giờ chỉ để Claude nhìn thấy server. Server chạy ngon trong terminal, log “running on stdio” đẹp đẽ, nhưng UI Claude trơ ra không thấy gì. Lỗi nằm ở một dấu ~ trong path config. Path tương đối trong file claude_desktop_config.json không resolve. Hai giờ debug, một ký tự sửa.

Bài 1 của series đứng ở góc concept. Bài này tôi viết để bạn không phải đốt hai giờ như tôi. Đọc xong, bạn có hai MCP server chạy được: một bản TypeScript, một bản Python. Cả hai expose cùng bộ ba Tool, Resource, Prompt, cùng cắm vào Claude Desktop qua stdio. Restart Claude Desktop xong, chat bình thường, Claude tự thấy tool và tự gọi.

Hôm nay làm gì

Ba thứ rất nhỏ, vừa đủ để va chạm với toàn bộ vòng đời một MCP server. Tool get_weather(city) trả về chuỗi thời tiết giả lập, mục đích là thấy LLM chủ động chọn gọi. Resource quotes://daily trả về một câu quote đọc từ file local, mục đích là thấy data được nạp vào context theo cách user chủ động. Prompt summarize(text, style) là template user chọn được từ UI Claude Desktop.

Transport stdio. Đây là transport cho local server, đơn giản nhất, không port, không TLS. Client (Claude Desktop) spawn server như subprocess và nói chuyện qua stdin/stdout. Cá nhân tôi thấy stdio là chỗ nên bắt đầu khi học MCP, kể cả khi mục tiêu cuối là server remote, vì bạn loại bỏ được hết phần network ra khỏi phương trình debug.

Cấu trúc cuối cùng nhìn từ trên xuống:

[Claude Desktop]
       |
       |  spawn subprocess
       v
[node /path/to/server.js]   <-- TS server
       ^
       |  JSON-RPC over stdio
       v
[Claude Desktop]   <-- gọi tool, đọc resource, lấy prompt

Python server cũng vẽ y hệt, chỉ thay node /path/to/server.js bằng python /path/to/server.py.

Phiên bản SDK dùng trong bài: @modelcontextprotocol/sdk v1.x cho TypeScript, package mcp v1.x cho Python. Cả hai đều là branch ổn định khuyến nghị cho production tại thời điểm bài viết. SDK v2 đang pre-alpha, API có thay đổi nhỏ, bài này bám theo v1.

Một message MCP trên dây nhìn ra sao

Trước khi vào code, có một điểm mà tôi luôn cố hình dung trước: khi user chat với Claude, server thực sự nhận message gì? Hiểu cái này thì debug sau dễ hơn rất nhiều.

MCP dùng JSON-RPC 2.0. Mỗi message là một dòng JSON, frame theo newline. Khi Claude Desktop khởi động và spawn server, lần lượt vài message này bay qua stdin của server:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude-desktop","version":"0.x"}}}

Server reply qua stdout với capabilities của nó (tools, resources, prompts có hay không, có hỗ trợ subscribe không). Sau đó client gửi:

{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}

Server trả về list tools với schema. Khi LLM quyết định gọi get_weather, client gửi:

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Hanoi"}}}

Server chạy handler, reply:

{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Weather in Hanoi: 30C, partly cloudy."}]}}

Resource và Prompt cũng theo cùng pattern, chỉ khác method: resources/list, resources/read, prompts/list, prompts/get. SDK ẩn toàn bộ JSON này, bạn chỉ viết handler thuần. Nhưng khi log bị silent hay tool không hiện, biết được “trên dây đang nói gì” giúp đoán đúng chỗ sai.

Setup TypeScript

Yêu cầu Node.js 20 trở lên. Kiểm tra:

node --version
# v20.x trở lên

Khởi tạo project:

mkdir mcp-hello-ts && cd mcp-hello-ts
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Tạo tsconfig.json đơn giản:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}

Thêm vào package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/server.ts"
  }
}

Hai chi tiết quan trọng: "type": "module" (SDK dùng ESM, không CommonJS) và "module": "Node16" (resolve .js extension trong import path đúng cách).

Tạo thư mục src/ và file src/server.ts. Phần code đi ở mục sau.

Code TypeScript server

Toàn bộ file src/server.ts, khoảng 80 dòng:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const QUOTES_PATH = join(__dirname, "..", "quotes.txt");

const server = new McpServer({
  name: "hello-mcp",
  version: "1.0.0",
});

// Tool: get_weather(city) -> mock weather string
server.registerTool(
  "get_weather",
  {
    description: "Get current weather for a city (mocked).",
    inputSchema: {
      city: z.string().describe("City name, e.g. Hanoi"),
    },
  },
  async ({ city }) => {
    const fakeTemp = 28 + Math.floor(Math.random() * 5);
    return {
      content: [
        {
          type: "text",
          text: `Weather in ${city}: ${fakeTemp}C, partly cloudy.`,
        },
      ],
    };
  },
);

// Resource: quotes://daily -> read from local file
server.registerResource(
  "daily-quote",
  "quotes://daily",
  {
    title: "Daily quote",
    description: "A single quote read from quotes.txt",
    mimeType: "text/plain",
  },
  async (uri) => {
    const text = await readFile(QUOTES_PATH, "utf-8");
    const lines = text.split("\n").filter((l) => l.trim().length > 0);
    const pick = lines[Math.floor(Math.random() * lines.length)];
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/plain",
          text: pick,
        },
      ],
    };
  },
);

// Prompt: summarize(text, style)
server.registerPrompt(
  "summarize",
  {
    description: "Summarize a block of text in a chosen style.",
    argsSchema: {
      text: z.string().describe("The text to summarize"),
      style: z.string().describe("Style: short, bullet, formal"),
    },
  },
  ({ text, style }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Summarize the following text in ${style} style:\n\n${text}`,
        },
      },
    ],
  }),
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("hello-mcp running on stdio");
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Tạo thêm file quotes.txt ở root project:

The best way to predict the future is to invent it.
Make it work, make it right, make it fast.
Premature optimization is the root of all evil.
Simplicity is the ultimate sophistication.

Build và chạy thử:

npm run build
node dist/server.js
# Process treo lại, đọc stdin. Đúng. Ctrl+C để thoát.

Server không in gì ra stdout, đó là feature: stdout là kênh JSON-RPC, in bất kỳ thứ gì khác sẽ làm hỏng frame. Dòng console.error("hello-mcp running on stdio") đi ra stderr, an toàn.

Wire vào Claude Desktop

Sửa file config của Claude Desktop. Vị trí khác nhau theo OS:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json
  • Windows: %AppData%\Claude\claude_desktop_config.json

Mở file đó (tạo mới nếu chưa có):

{
  "mcpServers": {
    "hello-mcp": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-hello-ts/dist/server.js"]
    }
  }
}

Ba điểm bắt buộc:

  1. Đường dẫn tuyệt đối, không dùng ~ hoặc relative path. Claude Desktop spawn process từ thư mục khác, relative path sẽ không resolve đúng.
  2. commandnode (đã build sang .js), không phải tsx hay npm. Lý do: Claude Desktop không có npm trong PATH mặc định. Nếu muốn dùng tsx để chạy trực tiếp .ts, phải dùng path tuyệt đối tới binary, ví dụ /Users/you/.nvm/versions/node/v20.x/bin/tsx.
  3. name của server trong key (hello-mcp) là tên Claude Desktop hiển thị, không liên quan đến name trong code, nên đặt sao cũng được.

Restart Claude Desktop hoàn toàn (Cmd+Q rồi mở lại, không chỉ đóng cửa sổ).

Kiểm tra server đã load:

  • Trong Claude Desktop, nhấn icon plug-in / settings, server hello-mcp phải xuất hiện với status xanh.
  • Hoặc xem log: ~/Library/Logs/Claude/mcp-server-hello-mcp.log (macOS) hay %AppData%\Claude\logs\mcp-server-hello-mcp.log (Windows). File này chứa stderr của server. Dòng hello-mcp running on stdio sẽ nằm ở đây.

Nếu server không load, hai nguyên nhân hay gặp: sai path tuyệt đối, hoặc node không có trong PATH mà Claude Desktop nhìn thấy. Hard-code thử command: "/usr/local/bin/node" (hoặc which node cho ra).

Test interactively

Mở chat trong Claude Desktop. Đầu tiên gõ:

Tool hello-mcp có gì? Liệt kê cho tôi.

Claude trả lời sẽ liệt kê tool get_weather, resource quotes://daily, prompt summarize. Đây là Claude tự tools/list, resources/list, prompts/list qua MCP, không phải nó nhớ trước.

Tiếp theo, thử gọi tool:

Thời tiết Hanoi hôm nay thế nào?

Claude Desktop sẽ hiện popup xin permission “use get_weather”. Approve. Sau khi tool chạy, Claude trả lời kiểu:

Theo dữ liệu từ hello-mcp, Hanoi hôm nay 30C, partly cloudy.

Để test resource, Claude Desktop có UI “Attach from MCP”. Click vào, chọn daily-quote từ hello-mcp. Resource được attach vào context. Sau đó hỏi:

Quote tôi vừa attach nói gì?

Claude đọc nội dung resource, trả lời theo câu quote ngẫu nhiên mà server chọn.

Cuối cùng, prompt. Trong Claude Desktop, UI prompt khác nhau theo version, thường là ở menu ”+” hoặc ”/” trong khung chat. Chọn summarize, điền textstyle, gửi. Claude nhận prompt từ server, generate summary.

Một ví dụ session thật trong Claude Desktop, để bạn hình dung cảm giác chạy:

User: Hôm nay tôi nên mặc gì ở Hanoi?

Claude: Để tôi kiểm tra thời tiết Hanoi trước.
        [calls get_weather, city="Hanoi"]
        [tool result: Weather in Hanoi: 30C, partly cloudy.]
        Hanoi hôm nay 30C, trời nhiều mây nhẹ. Bạn nên mặc áo
        thun mỏng, mang theo áo khoác mỏng phòng buổi tối hơi
        mát, không cần ô vì chỉ partly cloudy.

User: Cho tôi một quote truyền cảm hứng để mở đầu ngày.

Claude: [calls quotes://daily resource]
        [resource: Simplicity is the ultimate sophistication.]
        "Simplicity is the ultimate sophistication." Câu này
        rất hợp khi bạn đang debug một codebase phức tạp.

Chú ý: Claude tự diễn giải kết quả, không chỉ paste raw text. Đây là khác biệt giữa “có tool” và “có MCP”: MCP thêm protocol để Claude biết khi nào gọi và diễn giải kết quả tự nhiên, không phải bạn phải orchestrate từng bước.

Mỗi lần tool chạy, server gốc nhận message qua stdin và đáp qua stdout. Bạn không thấy gì vì stdio im lặng. Để theo dõi, mở terminal khác:

tail -f ~/Library/Logs/Claude/mcp-server-hello-mcp.log

Mọi console.error từ server đi vào đây.

So sánh ba entity sau khi chạy thật

Sau khi test xong, sự khác biệt giữa Tool, Resource, Prompt rõ ra theo cách mà đọc concept ở bài 1 không cảm hết:

  • Tool được LLM tự chọn gọi. User chat tự nhiên, không cần biết tên tool. LLM đọc description, quyết định khi nào cần. get_weather chạy mỗi khi câu hỏi liên quan đến thời tiết một thành phố. Permission popup hiện cho user approve trước khi tool thực sự gọi.

  • Resource do user (hoặc app) chủ động attach. LLM không tự quyết định đọc resource nào. Bạn thấy điều này trong UI Claude Desktop: phải click “Attach from MCP” để chọn. Lý do thiết kế: resource có thể rất lớn, nạp tự động sẽ đốt context. Để user quyết, server chỉ expose URI và metadata.

  • Prompt do user chọn từ UI. Đây là template workflow chuẩn hoá. Khác với system prompt hard-code trong app, prompt MCP sống ở server, update mà không deploy lại client. Pattern này hữu ích khi team có workflow lặp lại nhiều lần (“summarize PR theo style A”, “explain error theo style B”), muốn chuẩn hoá mà không nhồi tất cả vào system prompt của app.

Ba entity ba cơ chế trigger khác nhau. Khi design server thật, chọn entity nào phụ thuộc vào “ai khởi xướng”: LLM (Tool), user/app context (Resource), user UI (Prompt).

Setup Python

Yêu cầu Python 3.11 trở lên (SDK dùng async type hints mới). Kiểm tra:

python3 --version
# 3.11 hoặc cao hơn

Khởi tạo project, dùng uv cho nhanh (hoặc venv thuần cũng được):

mkdir mcp-hello-py && cd mcp-hello-py
python3 -m venv .venv
source .venv/bin/activate
pip install mcp

Nếu thích uv:

uv init mcp-hello-py
cd mcp-hello-py
uv add mcp

Tạo file server.py và file quotes.txt cùng nội dung như bên TS:

The best way to predict the future is to invent it.
Make it work, make it right, make it fast.
Premature optimization is the root of all evil.
Simplicity is the ultimate sophistication.

Code Python server

Toàn bộ server.py, khoảng 50 dòng:

import random
from pathlib import Path

from mcp.server.fastmcp import FastMCP

QUOTES_PATH = Path(__file__).parent / "quotes.txt"

mcp = FastMCP("hello-mcp")


@mcp.tool()
def get_weather(city: str) -> str:
    """Get current weather for a city (mocked)."""
    temp = 28 + random.randint(0, 4)
    return f"Weather in {city}: {temp}C, partly cloudy."


@mcp.resource("quotes://daily", mime_type="text/plain")
def daily_quote() -> str:
    """A single quote read from quotes.txt."""
    lines = [
        line.strip()
        for line in QUOTES_PATH.read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]
    return random.choice(lines)


@mcp.prompt()
def summarize(text: str, style: str = "short") -> str:
    """Summarize a block of text in a chosen style."""
    return f"Summarize the following text in {style} style:\n\n{text}"


if __name__ == "__main__":
    mcp.run(transport="stdio")

So với phiên bản TS, Python ngắn hơn rõ rệt. FastMCP đọc type hint và docstring tự động sinh schema, không cần khai báo Zod. Trade-off: ít control trên schema, ví dụ không thêm description cho từng field như TS làm với z.string().describe(...). Cho hello-world thế là đủ, cho tool phức tạp hơn bạn dùng Field của Pydantic, sẽ đào ở bài sau.

Chạy thử:

python3 server.py
# Treo, đọc stdin. Đúng. Ctrl+C.

Thêm vào claude_desktop_config.json:

{
  "mcpServers": {
    "hello-mcp-py": {
      "command": "/absolute/path/to/mcp-hello-py/.venv/bin/python",
      "args": ["/absolute/path/to/mcp-hello-py/server.py"]
    }
  }
}

Lưu ý: command trỏ thẳng tới python trong venv, không phải python3 của hệ thống. Nếu dùng python3 global, package mcp sẽ không tìm thấy. Đây là pitfall hay gặp nhất ở Python side.

Restart Claude Desktop, test lại y như phần TS. Bạn sẽ có hai server song song trong Claude, mỗi cái có cùng bộ ba entity.

Gotchas thường gặp

Cái đầu tiên và đau nhất: một dòng print("debug:", x) làm chết server. stdout là kênh JSON-RPC, mỗi message là một dòng JSON, in bất cứ thứ gì khác là chèn rác vào giữa frame. Client parse fail, kết nối chết, không có error message rõ ràng. TypeScript dùng console.error(...), Python dùng print("...", file=sys.stderr) hoặc tốt hơn module logging configured xuống stderr. Cần log nhiều, ghi ra file qua logging.FileHandler("/tmp/hello-mcp.log") rồi tail -f. Tôi đã debug một server câm im hai mươi phút trước khi nhận ra mình quên đổi console.log thành console.error.

Cái thứ hai là path. Claude Desktop spawn server từ home directory hoặc thư mục app, không phải từ project bạn đang code. Mọi path trong args của config và mọi path trong code phải tuyệt đối, hoặc relative tới __file__ (Python) / import.meta.url (TS). Trong code TS phía trên tôi dùng fileURLToPath(import.meta.url) rồi join để có path tới quotes.txt, không hard-code cwd. Đây là chỗ tôi đốt hai giờ ở đoạn opening.

Cái thứ ba subtle hơn: schema validation strict. SDK validate input theo schema trước khi gọi handler. LLM gửi city: 42 (number) cho tool nhận z.string(), handler không chạy, client nhận error. Nhìn LLM có vẻ “thông minh” nhưng nó sai schema thường xuyên ở edge case. Cách phòng là viết description rõ ràng cho từng field, dùng .describe() (TS) hoặc docstring (Python). LLM đọc description khi quyết định format input, không phải khi nhìn raw type.

Cuối cùng, async vs sync. TS SDK luôn async, không có lựa chọn khác. Python FastMCP cho phép cả def lẫn async def. Handler I/O nặng (gọi API, query DB) phải dùng async def, không thì block event loop và mọi request khác queue lại. Sync handler chỉ hợp cho compute ngắn, không I/O.

Còn một bẫy nữa mà tôi xếp riêng vì nó liên quan tới mental model chứ không phải syntax: server stateless. Đừng giữ state trong module-level variable (visited = set(), cache = {}) ở server. Claude Desktop có thể spawn lại process bất kỳ lúc nào, một client thứ hai connect cùng server, state share lung tung. State thuộc về client, server chỉ là function pure nhận input ra output.

TypeScript hay Python, tôi chọn theo runtime

Sau khi viết cả hai, hai SDK có character rất khác dù protocol y hệt. TypeScript SDK đi hướng explicit: khai báo Zod schema cho từng input, return object { content: [...] } đúng format SDK yêu cầu. Verbose hơn, nhưng schema và type signature đi cùng nhau, IDE bắt lỗi compile-time khi return sai shape. Hợp khi server sẽ phình to với nhiều tool, hoặc khi team đông cần type safety.

Python SDK đi hướng implicit qua FastMCP. Type hint và docstring chính là schema. Code ngắn hơn rõ rệt, có khi chỉ bằng nửa TS cho cùng tool. Trade-off là ít control trên schema chi tiết (description từng field, validation phức tạp), và runtime error thay vì compile-time.

Câu hỏi mà tôi từng hỏi chính mình lần đầu: client thấy khác không? Không. Cả hai đều JSON-RPC qua stdio, Claude Desktop không phân biệt được sau khi serialize. Vậy chọn theo gì? Theo môi trường runtime của tool bạn wrap, không theo MCP. Tool wrap API HTTP đơn giản thì Python ngắn hơn nhiều, không lý do build TS server chỉ để gọi curl rồi return string. Tool wrap library Node native (Puppeteer, Playwright, Sharp) thì TS hợp lý hơn vì binding sẵn có.

Debug khi tool không gọi được

Flow debug tôi quen dùng đi qua 4 chỗ kiểm tra theo độ “sâu” tăng dần.

Đầu tiên là kiểm xem server có load được không. Mở log file mcp-server-<name>.log. File rỗng hoặc không tồn tại nghĩa là Claude Desktop chưa spawn được process. Check command path và args path bằng cách chạy thẳng trong terminal (node /absolute/path/dist/server.js hoặc /path/to/venv/bin/python /path/server.py). Process phải treo, không exit. Exit ngay là code có exception hoặc thiếu dependency.

Server load được nhưng tool không list? Log file có dòng “running on stdio” mà tool/resource/prompt không xuất hiện trong Claude. Trường hợp này gần như chắc là code đăng ký entity chạy sau connect(), hoặc throw exception trong handler khi server warm-up. Move tất cả registerTool/registerResource/registerPrompt lên trước server.connect(transport).

Tool list được nhưng gọi fail? Approve permission, popup hiện rồi tool error. Đọc stack trace trong log. Schema validation fail thì sửa schema cho khớp với input LLM gửi. Exception trong handler thì wrap try/catch, return error message dạng text trong content. LLM nhìn thấy lỗi rõ ràng có khả năng retry hoặc báo user; nuốt exception thì LLM bị mù.

Và cuối cùng, lần nào tôi cũng quên: restart Claude Desktop hoàn toàn. Đóng cửa sổ không đủ. Cmd+Q (macOS) hoặc kill process tray (Linux/Windows). Server chỉ reload khi app khởi động lại từ đầu. Sửa code mà quên restart Claude là nguyên nhân tốn thời gian nhất, và là lý do tôi luôn dùng MCP Inspector trong dev loop (mục dưới).

Mẹo nhỏ trong lúc dev: đặt một console.error("tool called with:", args) ở đầu mỗi handler, tail -f log file ở terminal khác. Mọi lần Claude gọi tool, dòng log hiện ra. Vừa thấy latency, vừa biết LLM gửi args gì. Lần đầu thấy LLM gọi tool sai schema, bạn sẽ thầm cảm ơn dòng log này.

MCP Inspector, cách test không cần Claude Desktop

Restart Claude Desktop sau mỗi thay đổi code rất tốn thời gian. Anthropic ship một tool gọi là MCP Inspector, một web UI chạy local, đóng vai trò MCP client tương tự Claude Desktop nhưng nhanh hơn nhiều cho dev.

Chạy Inspector trỏ vào server TS của bạn:

npx @modelcontextprotocol/inspector node dist/server.js

Hoặc Python server:

npx @modelcontextprotocol/inspector /path/to/.venv/bin/python server.py

Inspector mở browser ở http://localhost:5173 (port mặc định), hiển thị UI ba tab: Tools, Resources, Prompts. Bạn click vào tool, điền input, gọi thẳng. Không cần Claude, không cần restart app, không cần permission popup. Response hiện ngay, kèm raw JSON-RPC traffic ở panel bên.

Workflow dev tôi quen dùng: code server, restart Inspector (chỉ tốn 1 giây, Ctrl+C rồi chạy lại lệnh), test tool, sửa, lặp lại. Khi tool hoạt động ổn trên Inspector, mới wire vào Claude Desktop để test integration thật. Cách này tiết kiệm hàng chục lần restart Claude trong một buổi.

Inspector cũng là nơi tốt để xem schema server expose. Tab Tools list ra description, input schema, output preview cho từng tool. Nếu LLM gọi sai args, đối chiếu với schema ở Inspector là cách nhanh nhất tìm ra chỗ schema mô tả mơ hồ.

Hết hello-world

Hai server chạy được, ba entity nói chuyện ổn với Claude Desktop. Phần hello-world tới đây là đủ. Quan điểm của tôi sau khi build vài chục MCP server lớn nhỏ: 80% pain ở MCP nằm trong 100 dòng đầu tiên này. Một khi server hello-world chạy được, mọi thứ phía sau là thêm tool, thêm resource, thêm complexity. Mental model đã đúng.

stdio chỉ chạy local là giới hạn rõ ràng nhất hiện tại. Bài kế đến mở rộng phần đó, kèm cảnh báo về SSE đã deprecate mà rất nhiều tutorial cũ vẫn dạy.

Nếu bạn mới bắt đầu series, đọc bài 1 MCP là gì trước. Concept bài 1 và code bài 2 nối thẳng với nhau.