stdio
client เป็นเจ้าของ process
หัวข้อที่มีชื่อว่า “client เป็นเจ้าของ process”ใน transport แบบ stdio MCP client เปิด server เป็น subprocess ทั้งสองฝั่งคุยกันผ่าน standard stream ของ child process:
- server อ่าน JSON-RPC จาก
stdinและเขียน JSON-RPC ลงstdout - แต่ละ message คือ JSON-RPC request, notification หรือ response หนึ่งตัว
- message คั่นด้วย newline และ ห้าม มี newline ฝังอยู่ข้างใน
ไม่มีการตั้ง connection ไม่มี port ไม่มี TLS pipe มีอยู่เพราะ process มีอยู่ และหายไปเมื่อ process หายไป
sequenceDiagram participant C as Client (host) participant S as Server (child process) C->>S: spawn (command, args, env) C->>S: stdin: tools/call (id 1) S-->>C: stdout: notifications/progress S-->>C: stdout: response (id 1) S-->>C: stderr: "cache warm" (log, ถูกเพิกเฉยหรือเก็บไว้) C->>S: ปิด stdin S-->>C: exit 0
กฎของ stdout
หัวข้อที่มีชื่อว่า “กฎของ stdout”นี่คือกฎที่คนทำผิดบ่อยที่สุด และมันเด็ดขาด:
- server ห้าม เขียนอะไรลง
stdoutที่ไม่ใช่ MCP message ที่ถูกต้อง - client ห้าม เขียนอะไรลง
stdinของ server ที่ไม่ใช่ MCP message ที่ถูกต้อง
console.log("starting…") หรือ print("debug") ที่หลุดมาจะไปตกอยู่กลาง JSON stream และ client จะ parse message ถัดไปไม่ได้ ให้ส่ง log ไป stderr แทน server อาจ เขียน UTF-8 ลง stderr สำหรับ output แบบ informational, debug และ error; client อาจ เก็บ, ส่งต่อ หรือเพิกเฉยมัน และ ไม่ควร ถือว่า output บน stderr แปลว่าเกิด error
// TypeScript — never console.log in a stdio serverconsole.error('cache warm'); // fine: stderrprocess.stdout.write('hello'); // corrupts the protocol stream# Python — same ruleimport sysprint("cache warm", file=sys.stderr) # fineprint("hello") # corrupts the protocol streamใครเขียนอะไร
หัวข้อที่มีชื่อว่า “ใครเขียนอะไร”แต่ละฝั่งเขียนได้เฉพาะ message บางประเภท:
| ทิศทาง | Request | Notification | Response |
|---|---|---|---|
| Client → server (stdin) | ได้ | ได้ (เช่น notifications/cancelled) | ไม่ได้เด็ดขาด |
| Server → client (stdout) | ไม่ได้เด็ดขาด | ได้ (progress, log, subscription event) | ได้ |
server ไม่เคยเขียน JSON-RPC request เมื่อมันต้องการอะไรจาก client — input จากผู้ใช้, completion, รายการ roots — มันจะ return InputRequiredResult แล้ว client retry พร้อมคำตอบ นั่นคือแพทเทิร์น Multi Round-Trip Requests และมันคือสิ่งที่ทำให้ stdio ยังคงเป็นช่องทาง request/response ธรรมดา
เพราะ message ทั้งหมดของ server ใช้ stdout เดียวกัน client จึงจับคู่มันด้วย id (response) หรือด้วย _meta["io.modelcontextprotocol/subscriptionId"] (notification จาก request subscriptions/listen) ไม่มี stream แยกต่อ request
request metadata อยู่ inline
หัวข้อที่มีชื่อว่า “request metadata อยู่ inline”stdio ไม่มีชั้น header protocol version, capability ต่อ request และตัวตนของ client (ถ้ามี) อยู่ใน _meta.io.modelcontextprotocol/* ภายในแต่ละ request; method และ argument อยู่ตรงที่ JSON-RPC กำหนด ไม่มีอะไรถูก mirror ไปที่อื่น
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Bangkok"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"ExampleClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}บรรทัดเดียว ไม่ต้อง pretty-print
Cancellation
หัวข้อที่มีชื่อว่า “Cancellation”ไม่มี stream ต่อ request ให้ปิด client จึง cancel ด้วยการส่ง notification ที่อ้างถึง ID ของ request:
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"user clicked stop"}}server ควร หยุดงานทันทีที่ทำได้ และ ห้าม ส่ง message ใด ๆ สำหรับ request นั้นอีก
Shutdown
หัวข้อที่มีชื่อว่า “Shutdown”client ควร ปิด server ตามลำดับนี้:
- ปิด
stdinของ child process - รอให้ process ออก
- ถ้าไม่ออกภายในเวลาที่เหมาะสม ให้ terminate มัน — บน POSIX ไล่จาก
SIGTERMไปSIGKILL; บน Windows ใช้TerminateProcessหรือ Job Object
server ควร ออกทันทีเมื่อ stdin ถึง end-of-file นั่นคือสัญญาณ graceful-shutdown เดียวที่ portable ดังนั้นการเคารพมันคือสิ่งที่ทำให้ server ของคุณไม่ถูก kill server จะเริ่ม shutdown เองด้วยการปิด stdout แล้วออกก็ได้
การจบแบบไม่คาดคิด
หัวข้อที่มีชื่อว่า “การจบแบบไม่คาดคิด”ถ้า process ของ server ตาย client ควร restart มัน เพราะ protocol เป็น stateless จึงไม่มีอะไรที่หายไปแล้วสร้างใหม่ไม่ได้: request ที่ค้างอยู่ก็แค่ retry กับ process ใหม่ และ stream subscriptions/listen ต้องส่งใหม่เพราะ server ไม่เก็บ subscription state ข้าม restart นี่คือประโยชน์โดยตรงของการออกแบบแบบ stateless — restart คือการ retry ไม่ใช่กระบวนการกู้คืน
รัน stdio server
หัวข้อที่มีชื่อว่า “รัน stdio server”import { McpServer } from '@modelcontextprotocol/server';import { serveStdio } from '@modelcontextprotocol/server/stdio';import * as z from 'zod/v4';
serveStdio(() => { const server = new McpServer({ name: 'weather', version: '1.0.0' }); server.registerTool( 'get-forecast', { description: 'Forecast for a city', inputSchema: z.object({ city: z.string() }) }, async ({ city }) => ({ content: [{ type: 'text', text: `Sunny in ${city}.` }] }), ); return server;});from mcp.server import MCPServer
mcp = MCPServer("weather")
@mcp.tool()def get_forecast(city: str) -> str: """Forecast for a city.""" return f"Sunny in {city}."
if __name__ == "__main__": mcp.run() # transport defaults to "stdio"host เปิดมันอย่างไร
หัวข้อที่มีชื่อว่า “host เปิดมันอย่างไร”host เก็บ command line พร้อม environment แล้ว spawn มัน สองสำนวนที่คุณจะเห็นทุกที่คือ npx -y <package> สำหรับ server บน npm และ uvx <package> สำหรับ server บน PyPI — ทั้งคู่ดาวน์โหลดตอนรันครั้งแรกและไม่ต้องติดตั้งแบบ global
Claude Desktop อ่าน claude_desktop_config.json:
{ "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/Projects"] }, "git": { "command": "uvx", "args": ["mcp-server-git", "--repository", "/Users/me/Projects/app"] } }}.mcp.json ระดับ project ของ Claude Code ใช้รูปร่างเดียวกัน โดยมี type ระบุชัด, env และการขยายตัวแปร:
{ "mcpServers": { "local-db": { "type": "stdio", "command": "/path/to/server", "args": ["--config", "${CLAUDE_PROJECT_DIR}/config.json"], "env": { "DB_URL": "${DB_URL:-postgresql://localhost}" } } }}credential ของ stdio server มาจาก env และไฟล์ในเครื่อง ไม่ใช่ จาก OAuth — authorization framework มีไว้สำหรับ HTTP transport เท่านั้น เพราะ host รันคำสั่งอะไรก็ตามที่ถูก config ไว้ host จึงแสดงคำสั่งเต็ม ๆ และขอความยินยอมก่อนรันคำสั่งใหม่; ดู threats and mitigations
probe สำหรับ legacy
หัวข้อที่มีชื่อว่า “probe สำหรับ legacy”server ที่เขียนตาม 2025-11-25 และก่อนหน้าคาดหวัง handshake initialize ก่อนอย่างอื่น client ที่รองรับทั้งสองยุค ควร ส่ง server/discover ก่อน โดยใส่ version ยุคใหม่ที่ต้องการใน _meta มีสามผลลัพธ์ที่เป็นไปได้:
- ได้
DiscoverResult— server เป็น modern เลือก version จากsupportedVersionsแล้วทำต่อ - ได้ modern error ที่รู้จัก เช่น
UnsupportedProtocolVersionError(-32022) — server เป็น modern แต่ไม่รองรับ version นั้น retry ด้วยตัวหนึ่งจากรายการsupportedห้าม fall back - ได้ error อื่นใด หรือไม่มีคำตอบภายใน timeout ที่เหมาะสม — server เป็น legacy fall back ไปใช้ handshake
initialize
การ fallback ต้องไม่ผูกกับ error code ตัวใดตัวหนึ่ง: legacy server ตอบ request ที่ไม่รู้จักก่อน initialize ด้วย error แบบ implementation-defined (มักเป็น -32601 หรือ -32602) หรือเงียบไปเลย แม้แต่ client ที่เป็น modern อย่างเดียวก็ควร probe เพราะ legacy server บางตัวจะยินดีประมวลผล tools/call ที่คลุมเครือระหว่างยุคด้วย semantics แบบ legacy แทนที่จะล้มเหลว โมเดลของยุคทั้งหมดอยู่ใน Backward Compatibility