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

Message & Transport LayerCơ bản25 phút

Ở 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.

Bạn sẽ học được
  • 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.
  • Mô tả được cách STDIO transport hoạt động: subprocess, stdin, stdout.
  • Tự chạy một MCP server bằng stdio không cần client — chỉ dùng terminal.
  • Liệt kê được 4 tình huống giao tiếp mà mọi transport phải hỗ trợ (client↔server, request/response).
  • 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.

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:

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

Ba điều cần nhớ:

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.

  • Client khởi động server như subprocess — ví dụ uv run server.py hoặc npx mcp-server-xyz.
  • Client ghi vào stdin của server — đó là cách gửi request.
  • Server ghi ra stdout — đó là cách trả response.
┌────────────────────────────────────────────────────────────┐
│                                                            │
│   CLIENT PROCESS (vd: Claude Desktop)                      │
│                                                            │
│    ┌────────────────────────────┐                          │
│    │                            │                          │
│    │     1. spawn subprocess    │                          │
│    │            │               │                          │
│    │            ▼               │                          │
│    │    ┌───────────────┐       │                          │
│    │    │ SERVER PROCESS│       │                          │
│    │    │   (your code) │       │                          │
│    │    └───────────────┘       │                          │
│    │            ▲     │         │                          │
│    │            │     │         │                          │
│    │    write   │     │  read   │                          │
│    │            │     ▼         │                          │
│    │         stdin   stdout     │                          │
│    │                            │                          │
│    └────────────────────────────┘                          │
│                                                            │
└────────────────────────────────────────────────────────────┘
cat file.txt | grep "error" | wc -l

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:

Chạy nó:

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

Bước 1: Chạy server

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):

uv run server.py

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

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

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

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

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

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

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

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

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

Bước 4: Gọi tool

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

Bước 4: Gọi tool

Response:

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

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

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.

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

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:

Quy tắc vàng:

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".

  • 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.
    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 ────│
      │          ...            │

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:

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ó?

┌───────────────────────────────────────────────────────────┐
│                                                           │
│   (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                             │
│                                                           │
└───────────────────────────────────────────────────────────┘

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

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.

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

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ư:

Chú ý: 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.

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

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:

📣 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:

🏢 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 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.
  • 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.
  • 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:

❌ 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:

❌ 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:

  • 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.
  • 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).
  • 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):

Chạy:

{"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}}}

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

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:

cat test.jsonl | uv run server.py

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

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:

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

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

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.

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

Áp dụng ngay

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

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

Bước 2: Chạy uv run calc.py.

Bước 3: Gửi 4 message theo thứ tự:

Bước 4: Ghi lại:

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.

  • initialize request (id=1)
  • notifications/initialized
  • tools/list (id=2)
  • tools/call cho multiply(a=6, b=7) (id=3)
  • Protocol version server trả về: ___________
  • Số tool trong tools/list result: ___________
  • Giá trị text trong CallToolResult: ___________
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()

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

🎯 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
  • MCP Transport Specification — chi tiết cả stdio & http
  • Python MCP SDK — stdio example — mã mẫu chính thức
  • Claude Desktop config guide — cách thêm stdio server
Nội dung này có hữu ích không?