Lộ trình học›MCP›MCP: Advanced Topics Mở bản đồ

10.2 · Transport STDIO — Kênh giao tiếp đơn giản nhất

Bài viếtCơ bản → Trung c25 phút đọc

Ở bài 10.1 bạn đã học về JSON message — nội dung trao đổi. Nhưng message cần một con đường để đi từ A sang B. Con đường đó gọi là transport.

Sau bài này, bạn sẽ
  1. Giải thích được transport là gì và vì sao MCP tách riêng khái niệm này ra khỏi message format.
  2. Mô tả được cách STDIO transport hoạt động: subprocess, stdin, stdout.
  3. Tự chạy một MCP server bằng stdio không cần client — chỉ dùng terminal.
  4. Liệt kê được 4 tình huống giao tiếp mà mọi transport phải hỗ trợ (client↔server, request/response).
  5. Hiểu được vì sao stdio là baseline — các transport khác đều chỉ cố gắng đạt được năng lực của stdio.

Mở đầu: Transport là đường ống, message là nước

Transport có thể là bất kỳ kênh nào miễn là nó có thể chuyển byte qua lại:

  • HTTP request/response
  • WebSocket connection
  • gRPC stream
  • ... thậm chí viết JSON lên bưu thiếp và gửi bằng đường bưu điện (không khuyến khích cho production, nhưng đúng về mặt kỹ thuật)

MCP không ép bạn chọn transport nào. Hiện tại spec chuẩn hóa 2 cái: stdio (bài này) và streamable-http (bài 10.3 → 10.6). Bạn có thể tự viết transport riêng nếu muốn — miễn là 2 bên đều đồng ý dùng chung.

Bài này tập trung vào stdio — transport đơn giản nhất, sạch nhất, và là "case lý tưởng" để các transport khác so sánh.

Stdio là gì — Nói bằng ngôn ngữ Unix cổ điển

Từ năm 1970, Unix đã dạy chúng ta một ý tưởng thiên tài: mọi chương trình đều có 3 file mặc định — stdin, stdout, stderr. Dữ liệu vào qua stdin, kết quả ra qua stdout, lỗi ra stderr. Đó là lý do bạn viết được:

cat file.txt | grep "error" | wc -l

STDIO transport của MCP dùng đúng ý tưởng đó cho client-server MCP:

┌────────────────────────────────────────────────────────────┐
│                                                            │
│   CLIENT PROCESS (vd: Claude Desktop)                      │
│                                                            │
│    ┌────────────────────────────┐                          │
│    │                            │                          │
│    │     1. spawn subprocess    │                          │
│    │            │               │                          │
│    │            ▼               │                          │
│    │    ┌───────────────┐       │                          │
│    │    │ SERVER PROCESS│       │                          │
│    │    │   (your code) │       │                          │
│    │    └───────────────┘       │                          │
│    │            ▲     │         │                          │
│    │            │     │         │                          │
│    │    write   │     │  read   │                          │
│    │            │     ▼         │                          │
│    │         stdin   stdout     │                          │
│    │                            │                          │
│    └────────────────────────────┘                          │
│                                                            │
└────────────────────────────────────────────────────────────┘

Ba điều cần nhớ:

  1. Client khởi động server như subprocess — ví dụ uv run server.py hoặc npx mcp-server-xyz.
  2. Client ghi vào stdin của server — đó là cách gửi request.
  3. Server ghi ra stdout — đó là cách trả response.

Mỗi message là 1 dòng JSON, kết thúc bằng \n. Đơn giản đến mức bạn có thể tự test bằng tay.

Stdio trong thực tế — Test server không cần client

Đây là sức mạnh của stdio: bạn không cần Claude Desktop, không cần Python client code. Chỉ cần terminal.

Bước 1: Chạy server

Giả sử bạn có server.py với 1 tool add:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP(name="calculator")

@mcp.tool()
def add(a: int, b: int) -> int:
    return a + b

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

Chạy nó:

uv run server.py

Terminal sẽ im lặng — không print gì. Đó là vì server đang lắng nghe stdin, đợi bạn gửi JSON.

Bước 2: Gõ message initialize vào stdin

Paste JSON sau (1 dòng, enter để gửi):

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}}

Server sẽ in ngay ra một dòng JSON response:

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"calculator","version":"..."}}}

Bước 3: Hoàn tất handshake

Gửi notification initialized (không id vì là notification):

{"jsonrpc":"2.0","method":"notifications/initialized"}

Server không trả về gì (đúng như spec — notification không có response).

Bước 4: Gọi tool

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}

Response:

{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"5"}]}}

Bạn vừa chạy MCP bằng tay, không cần Claude, không cần framework. Đây là cái đẹp của stdio — protocol lộ ra thật rõ, không bị che bởi SDK magic.

Handshake bắt buộc — 3 message, không thể thiếu

Mọi session stdio (và cả HTTP) đều bắt đầu bằng handshake 3 bước:

    CLIENT                    SERVER
      │                         │
      │───── initialize ───────▶│
      │                         │  (học về client, lưu capabilities)
      │◀──── InitializeResult ──│
      │                         │
      │── notifications/        │
      │     initialized ────────▶│
      │                         │  (giờ mới có thể phục vụ tools/call)
      │                         │
      │────── tools/call ──────▶│
      │◀──── CallToolResult ────│
      │          ...            │

Quy tắc vàng:

  • Client phải gửi initialize đầu tiên, không được gửi gì khác trước đó.
  • Server phải trả InitializeResult với capabilities nó support (tools, prompts, resources, sampling, roots...).
  • Client phải gửi notification initialized trước khi gọi bất kỳ request nào khác.

Nếu bạn bỏ bước 3, server sẽ reject mọi request sau đó. Đây là pattern "tôi đã nhận xong capabilities, giờ mới nói chuyện được".

Bốn kịch bản giao tiếp — Và vì sao stdio dễ

Bất kỳ MCP transport nào cũng phải xử lý được 4 hướng message dưới đây:

┌───────────────────────────────────────────────────────────┐
│                                                           │
│   (1) Client  →  Server  REQUEST                          │
│       Ví dụ: tools/call, resources/read                   │
│       Stdio: client ghi stdin                             │
│                                                           │
│   (2) Server  →  Client  RESPONSE                         │
│       Ví dụ: CallToolResult, ReadResourceResult           │
│       Stdio: server ghi stdout                            │
│                                                           │
│   (3) Server  →  Client  REQUEST                          │
│       Ví dụ: sampling/createMessage, roots/list           │
│       Stdio: server ghi stdout (bất cứ lúc nào)           │
│                                                           │
│   (4) Client  →  Server  RESPONSE                         │
│       Ví dụ: CreateMessageResult, ListRootsResult         │
│       Stdio: client ghi stdin                             │
│                                                           │
└───────────────────────────────────────────────────────────┘

Stdio xử lý tất cả 4 case một cách thanh lịch — vì stdin và stdout là 2 kênh full-duplex, bất kỳ bên nào cũng có thể gửi bất cứ lúc nào.

Nhưng hãy giữ 4 case này trong đầu, vì ở bài 10.3 bạn sẽ thấy HTTP gặp khó khăn với case (3) và (4): server không có URL của client, làm sao gửi request tới nó?

So sánh: Stdio vs cái gì sau đó?

Tiêu chíStdioHTTP thôStreamable HTTP
Full bidirectional?CóKhôngCó (qua SSE)
Client & Server cùng máy?Bắt buộcKhông cầnKhông cần
Dễ debug?Gõ bằng tay đượcCần curl/PostmanSSE hơi phức tạp
Scale được?1 subprocess / sessionStateless scale tốtCần session affinity
Dùng khi?Dev & local user app-Production remote

Nhìn bảng, bạn thấy stdio thắng tuyệt đối trong dev — dễ debug, full bidirectional, gõ tay được. Nhược điểm duy nhất: client và server phải cùng máy. Không có cách nào Claude Desktop trên máy bạn giao tiếp stdio với server trên Cloud Run được.

Đây là lý do chúng ta cần HTTP transport cho production remote — với cái giá phải trả mà bạn sẽ thấy ở bài sau.

Stdio trong hệ sinh thái Anthropic

Khi bạn cài MCP server cho Claude Desktop, file config claude_desktop_config.json trông như:

{
  "mcpServers": {
    "my-server": {
      "command": "uv",
      "args": ["run", "/path/to/server.py"]
    }
  }
}

chỉ có command và args. Không có URL, không có port. Claude Desktop sẽ spawn** subprocess uv run /path/to/server.py và dùng stdio của subprocess đó. Y hệt như khi bạn tự chạy uv run server.py trong terminal — nhưng Claude Desktop đóng vai trò "terminal".

Tương tự với Cursor, Cline, mcp-cli — tất cả đều dùng stdio cho local development.

Ví dụ theo ngành

Solo developer build personal automation

Pain: Muốn Claude access Obsidian vault, nhưng không muốn deploy server lên cloud.

Giải pháp:

  • Viết MCP server Python ~100 dòng, expose tool search_notes, read_note, create_note.
  • Config Claude Desktop trỏ tới uv run obsidian-mcp.py.
  • Run local, không cần auth, không cần HTTPS.
  • Kết quả: 30 phút từ ý tưởng đến dùng được. Zero infrastructure cost.

DevRel engineer chạy demo workshop

Pain: Workshop có 50 developer, muốn ai cũng thử MCP server mẫu mà không cần setup server riêng.

Giải pháp:

  • Publish server dưới dạng npx mcp-demo-xyz lên npm.
  • Attendee chỉ copy-paste config vào Claude Desktop, Claude tự npx install và chạy stdio.
  • Kết quả: Thời gian setup trung bình 3 phút/người, không ai bị stuck vì server down.

Enterprise data engineer prototype internal MCP

Pain: Muốn POC MCP để Claude query internal data warehouse, nhưng security team chưa duyệt network deployment.

Giải pháp:

  • Viết server local chạy stdio.
  • Server chạy trên máy cá nhân engineer với VPN sẵn có.
  • Không có network endpoint mới — không cần security review.
  • Kết quả: POC chạy trong 1 tuần, demo cho leadership approve full deployment.

Anti-patterns — Sai lầm thường gặp với stdio

In log text ra stdout trong server code

Hiện tượng
Bạn print("DEBUG: calling tool") trong server code. Client báo "Invalid JSON message".
Tại sao sai
Stdout là kênh truyền JSON message. Bất kỳ text lạ nào ghi vào đó sẽ phá parser của client.

Cách đúng:

  • Log ra stderr thay vì stdout: print("DEBUG: ...", file=sys.stderr).
  • Tốt hơn: dùng context.info(...) của MCP (notification message) — sẽ học ở bài 10.6.

Buffer stdout

Hiện tượng
Gửi request, server "im lặng", nhưng thực ra đã xử lý xong. Response chỉ hiện khi server exit.
Tại sao sai
Python default buffer stdout khi không phải terminal. Client đợi response mãi không đến.

Cách đúng:

  • Dùng sys.stdout.flush() sau mỗi message (SDK tự làm).
  • Hoặc chạy server với python -u server.py (unbuffered).

Assume server chạy forever

Hiện tượng
Server crash giữa chừng, client tưởng server chưa trả lời, đợi mãi.
Tại sao sai
Khi subprocess exit, stdio của nó đóng. Client cần detect EOF hoặc process exit signal.
Cách đúng
SDK tự handle. Nếu bạn build client raw, watch cả stdin EOF lẫn process signal. Khi detect, re-spawn server.

Hardcode path Unix trong server config

Hiện tượng: args: ["run", "/Users/alice/server.py"] — cho Windows user không chạy được.

Cách đúng:

  • Publish server lên package manager (pip, npm) để tránh path hardcode.
  • Hoặc dùng env var: ${HOME}/server.py.

Mẹo nâng cao

Mẹo 1: Lưu JSON messages mẫu thành snippet

Lưu file stdin-samples.txt chứa các JSON message handshake + tool call mẫu. Khi cần test server mới, copy-paste vào terminal là xong. Tiết kiệm gõ đi gõ lại.

Mẹo 2: Pipe từ file vào server

Không cần gõ bằng tay. Viết file test.jsonl chứa 4 message (1 dòng 1 JSON):

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}

Chạy:

cat test.jsonl | uv run server.py

Server xử lý từng dòng, in response ra stdout. Dùng làm smoke test cho CI.

Mẹo 3: Tách riêng stdin và stdout log

Khi debug, chạy:

uv run server.py 2> server-errors.log < test.jsonl > responses.jsonl

2> là stderr (log), < là stdin input, > là stdout output. Sau khi chạy, bạn có 2 file sạch để so sánh.

Mẹo 4: Test stdio speed — biết baseline throughput

Stdio là transport nhanh nhất (trong process tree cùng máy). Benchmark đơn giản:

time (cat 1000-messages.jsonl | uv run server.py > /dev/null)

Nếu bạn thấy HTTP deploy chậm hơn stdio rõ rệt, so sánh với baseline này để biết đâu là overhead transport vs đâu là logic tool.

Áp dụng ngay

Bài tập 1: Handshake bằng tay (15 phút)

Bước 1: Tạo calc.py:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP(name="calc")

@mcp.tool()
def multiply(a: int, b: int) -> int:
    return a * b

if __name__ == "__main__":
    mcp.run()
Bước 2
Chạy uv run calc.py.
Bước 3
Gửi 4 message theo thứ tự:
  1. initialize request (id=1)
  2. notifications/initialized
  3. tools/list (id=2)
  4. tools/call cho multiply(a=6, b=7) (id=3)

Bước 4: Ghi lại:

  • Protocol version server trả về: ___________
  • Số tool trong tools/list result: ___________
  • Giá trị text trong CallToolResult: ___________

Bài tập 2 (optional): Log error handling

Sửa multiply để raise exception khi b=0 (giả sử giả vờ đây là phép chia). Gửi tools/call với b=0 qua stdin. Observe: server trả về gì? error field trong JSON trông thế nào? Đây là training data cho bạn debug production sau này.

Tóm tắt bài học

Những điều cốt lõi
  • Transport tách biệt với message — cùng JSON message, có thể đi qua nhiều transport khác nhau.
  • Stdio = subprocess + stdin/stdout — client spawn server, ghi vào stdin, đọc từ stdout. Unix classic.
  • Stdio là full-duplex — cả 4 loại message (2 chiều request & response) đều đi được dễ dàng.
  • Handshake 3 bước bắt buộc — initialize request → result → initialized notification. Không có bước 3, server không phục vụ.
  • Stdio là baseline — các transport khác đều cố gắng đạt được năng lực của stdio. Nhớ "4 kịch bản giao tiếp" để so sánh khi đọc bài về HTTP.
  • Stdio cho local, không cho remote — client & server phải cùng máy. Muốn remote → cần HTTP transport.

Tài liệu tham khảo

Kiểm tra nhanh1 câu hỏi · chọn đáp án để xem giải thích
    Bạn đã hoàn thành bài này chưa?Tiến độ lưu trên trình duyệt của bạn.

    Khóa học cùng lộ trình