Ship MCP Server: วัดกันจริงด้วย TypeScript และ Python
เซิร์ฟเวอร์เดียวกันเขียนสองครั้ง—สาม tools, หนึ่ง resource, หนึ่ง prompt—แล้วชั่งน้ำหนัก: 94 packages เทียบ 28 และ cold start 145 ms เทียบ 709
ในหน้านี้
นี่คือข้อถกเถียงเรื่องภาษาแบบครบถ้วน วัดให้เห็นก่อนจะเริ่มพูดสักคำ
node ./incidents.js 144.5 ms
python incidents.py 709.4 ms
npx incidents-mcp 712.6 msสองบรรทัดแรกคือการเปรียบเทียบที่ทุกคนอยากเห็น บรรทัดที่สามคือเซิร์ฟเวอร์ TypeScript ตัวเดียวกับบรรทัดแรก แต่เปิดใช้ในแบบที่จะถูกแจกจ่ายจริง — และมันจบที่ห่างจาก Python เพียงสามมิลลิวินาที
บทที่ 26 อ่าน Model Context Protocol เทียบกับสเปกของมันเองด้วย JSON-RPC ดิบ เพราะ JSON-RPC ดิบไม่มีภาษา บทนี้มีสองภาษา และน้ำหนักของข้อถกเถียงอยู่ตรงนี้: เซิร์ฟเวอร์เดียวกัน เขียนสองครั้ง สาม tools, หนึ่ง resource, หนึ่ง prompt, ทั้งสอง SDKs, ไม่มีทางลัดฝั่งไหน จากนั้นจึงไปที่ transports, inspector, 401 และตัวเลขที่ยังไม่มีใครเผยแพร่
เซิร์ฟเวอร์ และเหตุผลที่มีห้าสิ่งนี้อยู่ในนั้น
ลิงก์ไปยังส่วน: เซิร์ฟเวอร์ และเหตุผลที่มีห้าสิ่งนี้อยู่ในนั้นบันทึกเหตุการณ์ หนึ่งชุดมีสาม tools เพราะการแยกระหว่างอ่านกับเขียนของ บทที่ 18 ต้องเห็นได้ชัด: search_incidents อ่าน, open_incident เขียนและส่ง handle กลับมา, resolve_incident รับ handle นั้นแล้วปิด หนึ่ง resource คือ incidents://open เพราะการอ่านรายการปัจจุบันคือสิ่งที่ แอปพลิเคชัน แนบไว้ หนึ่ง prompt คือ postmortem เพราะ “เขียนสรุปเรื่องนี้” คือ slash command ของคน นั่นคือลำดับชั้นการควบคุมของบทที่ 26 — model, application, person — แปลงเป็นการลงทะเบียนห้ารายการ
handle สำคัญกว่าที่เห็น บทที่ 26 ทำปฏิทินของเล่นพังด้วยการเก็บสถานะไว้ในอาร์เรย์ระดับโมดูล: โปรโตคอลไม่มี session ดังนั้น tool สำหรับสร้างจึงส่ง opaque identifier กลับมา และทุก call หลังจากนั้นรับมันเป็น argument ปกติ ไม่มีอะไรในไฟล์ใดสมมติว่า caller คือ process ที่เปิดมันขึ้นมา
นี่คือ tool เดียวกันในทั้งสองภาษา ลงทะเบียนวางข้างกัน:
server.registerTool(
"resolve_incident",
{
description:
"Close an incident by handle and record its cause.",
inputSchema: {
id: z.string().describe(
"The handle returned by open_incident, e.g. INC-3."),
cause: z.string().describe(
"One sentence. What actually broke."),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
},
},
async ({ id, cause }) => {
const at = OPEN.findIndex((i) => i.id === id);
if (at < 0) {
return { isError: true, content: [{ type: "text",
text: `No open incident ${id}. ` +
`Call search_incidents first.` }] };
}
const [done] = OPEN.splice(at, 1);
return { content: [{ type: "text",
text: JSON.stringify({ ...done, cause }) }] };
},
);@server.tool(
description=
"Close an incident by handle and record its cause.",
annotations=ToolAnnotations(
readOnlyHint=False,
destructiveHint=True,
idempotentHint=True,
),
)
def resolve_incident(
id: Annotated[str, Field(description=
"The handle returned by open_incident, e.g. INC-3.")],
cause: Annotated[str, Field(description=
"One sentence. What actually broke.")],
) -> Incident:
for at, i in enumerate(OPEN):
if i["id"] == id:
done = OPEN.pop(at)
return {**done, "cause": cause}
raise ValueError(
f"No open incident {id}. Call search_incidents first.")อ่านสิ่งที่ เหมือนกัน ก่อน เพราะนั่นคือข้อค้นพบ ทั้งคู่ประกาศชื่อ คำอธิบาย string arguments สองตัวที่มีคำอธิบาย และ annotations สามรายการ ทั้งคู่เป็น function เดียว ทั้งคู่ไม่กล่าวถึง JSON-RPC, framing, stdout หรือ protocol version เลย SDKs ทั้งสองมาบรรจบที่รูปทรงเดียวกัน ซึ่งคือสิ่งที่ “Tier 1” ควรหมายถึง1
มีความต่างจริงสองอย่าง และทั้งสองจะย้อนกลับมาในภายหลัง TypeScript อธิบาย arguments ด้วย schema library — ในที่นี้คือ Zod — และ schema เป็นค่าที่คุณเขียน Python อธิบายด้วย type hints ของ function เองและอ่านตอน import ซึ่งเป็นเหตุผลที่มันรู้เรื่องบางอย่างเกี่ยวกับ function ที่ไฟล์ TypeScript ไม่เคยบอกมันไว้ และเส้นทาง error: TypeScript ส่ง tool result กลับพร้อม isError, Python raise จดจำจุดนี้ไว้
การลงทะเบียนอีกสี่รายการไม่ได้ต่างกันในเชิงโครงสร้าง resource คือ server.registerResource("open-incidents", "incidents://open", …) เทียบกับ @server.resource("incidents://open", …); prompt คือ registerPrompt เทียบกับ @server.prompt บรรทัดสุดท้ายของแต่ละไฟล์คือ transport: await server.connect(new StdioServerTransport()) เทียบกับ server.run()
ไฟล์เต็ม: TypeScript 81 บรรทัดที่ไม่ว่างและ 3,060 bytes เทียบกับ Python 63 บรรทัดและ 2,555 bytes รับตัวเลขนี้พร้อมเกลือที่มันควรได้รับ — จำนวนบรรทัดวัด formatter พอ ๆ กับภาษา ซึ่งเป็นเหตุผลที่ไม่มีตัวเลขไหนอยู่ในตารางพาดหัวด้านล่าง
หนึ่ง client, สอง servers
ลิงก์ไปยังส่วน: หนึ่ง client, สอง serversหลักฐานว่าภาษามองไม่เห็นคือ client หนึ่งตัวรันสองครั้งในสิบเอ็ดบรรทัด:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const client = new Client({ name: "incident-cli", version: "1.0.0" });
await client.connect(new StdioClientTransport({
command: process.argv[2], args: process.argv.slice(3) }));
const { tools } = await client.listTools();
console.log("tools:", tools.map((t) => t.name).join(", "));
const opened = await client.callTool({ name: "open_incident",
arguments: { title: "Queue backed up", severity: "sev2" } });
console.log("open_incident ->", JSON.stringify(opened.content));ชี้มันไปที่แต่ละ server ตามลำดับ ผลลัพธ์จริง ตัดให้สั้น:
$ node client.ts node incidents.ts
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\"id\":\"INC-3\"}"}]
$ node client.ts ./py/.venv/bin/python ./py/incidents.py
tools: search_incidents, open_incident, resolve_incident
open_incident -> [{"type":"text","text":"{\n \"id\": \"INC-3\"\n}"}]tools เดียวกัน ลำดับเดียวกัน handle เดียวกัน client TypeScript บอกไม่ได้ว่า server เขียนด้วยอะไร และมันไม่เคยถาม นั่นคือคำสัญญาทั้งหมดของโปรโตคอลที่ยังคงยืนอยู่
ตอนนี้ดู whitespace ในผลลัพธ์ที่สอง เพราะมันไม่ใช่แค่ความสวยงาม: Python SDK serialize payloads ด้วย pydantic_core.to_json(result, fallback=str, indent=2) ในการอ่าน resource ที่มีสอง incidents อยู่ในรายการ body ของ TypeScript ยาว 136 อักขระและ 37 o200k_base tokens; body ของ Python ยาว 185 และ 62 เพิ่มขึ้นหกสิบแปดเปอร์เซ็นต์ของ tokens สำหรับแถวที่เหมือนกันทุกประการ จ่ายโดยใครก็ตามที่อ่าน resource เข้า prompt ทุกครั้ง
catalogue เล่าเรื่องเดียวกันด้วยสาเหตุที่ใหญ่กว่า ทั้งสอง servers, tools สามตัวเหมือนกัน, tools/list ชั่งน้ำหนักทีละ key:
| key | TypeScript | Python |
|---|---|---|
name | 21 | 21 |
description | 46 | 46 |
annotations | 46 | 46 |
inputSchema | 211 | 192 |
outputSchema | — | 187 |
execution | 27 | — |
| total | 342 | 480 |
schema ของ input ฝั่ง Python ถูกกว่า — bridge ของ Zod ใน TypeScript ประทับ $schema และ additionalProperties ลงบนแต่ละตัว ช่องว่าง 138-token ทั้งหมดคือ output schema ที่ไม่มีใครเขียน resolve_incident ถูก annotate เป็น -> Incident ดังนั้น SDK จึง derive JSON Schema สำหรับ return type แล้วส่งไป มันมีประโยชน์จริง — นี่คือสิ่งที่ทำให้ client validate structuredContent ได้ — และมันคือ 187 tokens ใน context window ของคุณที่มาถึงเพราะ type hint กฎของ บทที่ 24 เรื่อง definitions ที่เบียดวัสดุสำคัญออกไป ใช้กับ schemas ที่คุณไม่รู้ว่าคุณมีด้วย
ทำให้พังโดยตั้งใจ: error message ที่รั่ว
ลิงก์ไปยังส่วน: ทำให้พังโดยตั้งใจ: error message ที่รั่วเส้นทาง error สองแบบข้างต้นไม่ใช่การเลือกสไตล์ ใส่ tool ที่ล้มเหลวแบบ integration จริง ๆ ให้แต่ละ server แล้วอ่านว่าสิ่งใดไปถึง model
TypeScript {"content":[{"type":"text","text":
"connect ECONNREFUSED 10.0.3.7:5432 (db-prod-eu, user=reporting)"}],
"isError":true}
Python {"content":[{"text":"Error executing tool boom","type":"text"}],
"isError":true}TypeScript SDK ใส่ internal address, port, database name และ service account เข้าไปใน context ของ model ส่วน Python SDK ไม่ใส่อะไรเหล่านั้นเลย; traceback ไปที่ stderr และอยู่บน server
ไม่มีอันไหนเป็นบั๊ก ทั้งคู่คือการตัดสินใจ และฝั่ง Python เขียนไว้ใน docstring ของตัวเอง: ToolError คือ “ความล้มเหลวที่คุณคาดไว้” และข้อความของมันจะถูกส่งกลับ “ใน content เพื่อให้ model อ่าน”; อย่างอื่น “ถือเป็น crash: model เห็นแค่ Error executing tool <name> และ server log traceback ที่ ERROR” class สำหรับกรณี crash พูดส่วนที่เหลืออย่างชัดเจน — “ไม่มีอะไรจากต้นฉบับไปถึง client”
พฤติกรรมทั้งสองผิดครึ่งหนึ่งของเวลา บทที่ 18 แย้งว่า validation error ควรกลับมาเป็น tool result ที่ model อ่านและแก้ได้ เพราะนั่นคือบรรทัดที่ให้ leverage สูงสุดใน integrations ส่วนใหญ่; ฝั่ง Python ต้อง raise ToolError โดยตรง และ ValueError เปล่า ๆ จะโยนประโยคที่มีประโยชน์ทิ้งไป ข้อโต้แย้งของ บทที่ 30 วิ่งไปอีกทาง: ทุกอย่างที่ tool ส่งกลับจะลงไปอยู่ใน context ที่ prompt injection ภายหลังอาจพยายามอ่านกลับออกมา และ exception string ที่ยังไม่ผ่านการ review คือข้อความที่ถูก audit น้อยที่สุดในระบบของคุณ
กฎที่รอดทั้งสองด้าน: ตัดสินใจต่อ tool ว่า failure ได้รับอนุญาตให้พูดอะไร และเขียน string นั้นเอง อย่าให้ข้อความ default ของ exception ตัดสินใจ ไม่ว่าในภาษาไหน
ทำให้พังโดยตั้งใจ: หนึ่งบรรทัดบน standard output
ลิงก์ไปยังส่วน: ทำให้พังโดยตั้งใจ: หนึ่งบรรทัดบน standard outputtutorial อย่างเป็นทางการระบุกฎโดยไม่กั๊ก: “For STDIO-based servers: Never write to stdout. Writing to stdout will corrupt the JSON-RPC messages and break your server. The print() function writes to stdout by default, so keep it out of a STDIO server entirely.”1 บทที่ 26 quote เวอร์ชัน normative ไว้ — server “MUST NOT write anything to its stdout that is not a valid MCP message”2
เพิ่มหนึ่งบรรทัดให้แต่ละ server แล้วอ่าน raw stream:
TypeScript incidents server starting
{"result":{"protocolVersion":"2025-11-25", … },"jsonrpc":"2.0","id":1}
Python {"jsonrpc":"2.0","id":1,"result":{ … }}
incidents server startingตัว Python แย่กว่า และเหตุผลไม่ใช่ MCP process ที่มี stdout เป็น pipe แทน terminal จะได้ stream แบบ block-buffered ดังนั้นบรรทัดหลุดจึงถูก flush เมื่อ buffer ตัดสินใจ — ที่นี่คือตอน exit, หลัง response ที่มันถูกเขียนก่อนหน้า corruption ไม่ปรากฏตรงที่บั๊กอยู่ เพิ่ม flush=True หรือ library ที่ flush แล้วมันย้ายที่
จากนั้นคือส่วนที่อธิบายว่าทำไมสิ่งนี้ถึงถูก ship ป้อน server ที่พังให้ clients สามตัว:
naive parser, dirty server SyntaxError: Unexpected token 'i',
"incidents "... is not valid JSON
SDK client, dirty server tools: search_incidents, open_incident, resolve_incident
MCP Inspector, dirty server full catalogue, no warningparser เจ็ดบรรทัดตายทันที client อย่างเป็นทางการและ Inspector ทั้งคู่ยักไหล่ — ข้ามบรรทัดนั้นแล้วไปต่อ กฎที่ทำให้พังเฉพาะ clients ที่ไม่มีใครใช้คือกฎที่จะไปถึง production แบบครบถ้วน นั่นคือเหตุผลที่คุ้มค่าจะทำให้พังโดยตั้งใจที่นี่ แทนที่จะไปเจอใน log ของลูกค้า
โหมด CLI ของ Inspector คือครึ่งที่มักถูกลืม: npx @modelcontextprotocol/inspector --cli <command> --method tools/list พิมพ์ catalogue แล้วออก ทำให้ script ได้ในแบบที่ browser UI ทำไม่ได้3
SDKs ทั้งคู่ install ได้เรียบร้อยใน directories ของตัวเอง ไม่มีอะไรแชร์กัน:
| TypeScript | Python | |
|---|---|---|
| package | @modelcontextprotocol/sdk 1.30.0 + zod 3.25.76 | mcp 2.1.1 |
| latest protocol revision implemented | 2025-11-25 | 2026-07-28 |
| transitive packages installed | 94 | 28 |
| installed size | 13.9 MiB | 44.3 MiB |
| files on disk | 3,386 | 2,018 |
| third-party packages loaded to serve stdio | 8 of 94 | 18 of 28 |
| bare interpreter start, median | 19.4 ms | 11.1 ms |
spawn → tools/list answered, median of 25 | 144.5 ms | 709.4 ms |
tools/list catalogue, o200k_base tokens | 342 | 480 |
ทุกแถวทำให้ประหลาดใจคนละทิศทาง นั่นคือเหตุผลที่การเปรียบเทียบนี้คุ้มจะรันจริงแทนที่จะเดา
TypeScript install packages มากกว่าสามเท่าแต่ใช้ bytes น้อยกว่าหนึ่งในสาม dependencies 94 ตัวคือ ecosystem ของ npm เป็นตัวมันเอง — fast-deep-equal, es-errors, dunder-proto ส่วน 28 ตัวของ Python น้อยกว่าแต่ใหญ่โต: cryptography, pydantic-core และ uvicorn เป็น compiled artefacts ถ้าสัญชาตญาณของคุณบอกว่า dependency count คือสิ่งที่ควรกังวล แถวนี้คือ counter-example
interpreter ของ Python เริ่มเร็วกว่า Node และห่างกันชัดเจน — 11.1 ms เทียบกับ 19.4 ms บนโปรแกรมว่าง ดังนั้น 565 ms ในแถว cold-start ไม่ใช่ภาษา มันคือ SDK และแถว loaded-packages บอกว่าทำไม:
TypeScript 8 of 94 sdk, zod, zod-to-json-schema, ajv, ajv-formats,
fast-deep-equal, fast-uri, json-schema-traverse
Python 18 of 28 mcp, mcp_types, pydantic, pydantic_core, anyio,
starlette, uvicorn, sse_starlette, httpx2,
cryptography, _cffi_backend, opentelemetry, click, …server ที่มี I/O เพียง pipe import ASGI web server, HTTP client และ TLS library ก่อนจะอ่านบรรทัดแรก TypeScript SDK ก็ ship Express, Hono, jose และ eventsource ด้วย — พวกมันนอนอยู่บนดิสก์โดยไม่ถูกอ่าน เพราะขอบเขต package กันมันออกจาก import server/stdio.js package ของ Python เป็น import graph เดียว ดังนั้น import mcp คือทั้งหมด: python -X importtime ระบุว่า 727 ms อยู่ที่ import mcp.server.mcpserver — ตัวเลขที่วัดภายใต้ import profiler จึงออกมาสูงกว่า 709 ms ที่ run แบบไม่ profile ใช้ตั้งแต่ spawn จน answer — และ 269 ms อยู่ที่ subtree mcp.types เพียงอย่างเดียว — wire types เป็น Pydantic models, หนึ่ง class ต่อ protocol message ต่อ revision และการสร้างมันคืองานที่ทำตอน import นี่คือ design trade ไม่ใช่ความชุ่ย — eager imports คือเหตุผลที่ Python SDK ส่ง run(transport="streamable-http") ให้คุณในบรรทัดถัดไปได้โดยไม่ต้อง install เพิ่ม
แล้วแถวสุดท้ายของบล็อกเปิดเรื่องก็ล้มข้อโต้แย้งนั้นเอง package server TypeScript ให้ถูกต้อง — entry bin, shebang, npm link, ไม่มีอะไรต้อง download — แล้ว launch ผ่าน npx ด้วย --no-install ซึ่งเป็นวิธีที่ published stdio server ถูกเริ่มจริง:
node ./incidents.js 144.5 ms
npx incidents-mcp 712.6 ms (+568.1 ms of launcher)
python incidents.py 709.4 mslauncher มีค่าใช้จ่าย 568 ms ต่อ start — สี่เท่าครึ่งของ import ทั้ง TypeScript SDK — และต้องจ่ายทุก launch เพราะ MCP host เริ่ม stdio server ด้วยการรัน command นั้น ดังนั้นรูปแบบที่ซื่อสัตย์ของ “TypeScript starts five times faster” คือ: ใช่ จนกว่าคุณจะแจกจ่ายมันด้วยวิธีปกติ caveat เดียวกันน่าจะใช้กับ uvx; เครื่องนี้ไม่มี uv ติดตั้ง ดังนั้นแถวนั้นจึงไม่มีอยู่ สิ่งที่ไม่ได้วัดไม่เข้าไปในตาราง
สอง transports และมีแค่สอง
ลิงก์ไปยังส่วน: สอง transports และมีแค่สองบทที่ 26 ครอบคลุม framing ของ stdio แล้ว เหลือสองเรื่องไว้สำหรับที่นี่
ข้อแรก: การรัน server ด้วย npx หรือ uvx คือ stdio transport ไม่มี “package mode” แยกต่างหาก configuration ของ host ระบุ command และ arguments; host spawn มันแล้วคุยผ่าน pipes นั่นคือเหตุผลที่ “ฉันจะแจกจ่ายสิ่งนี้อย่างไร” และ “มันพูด transport ไหน” เป็นคำถามเดียวกันในเครื่อง และเหตุผลที่ต้นทุนของ launcher อยู่ในบทว่าด้วยการ ship
ข้อที่สอง: stdio ไม่มีส่วน authorization เลย และ specification พูดไว้ในบรรทัดเดียว — implementations ที่ใช้ stdio “SHOULD NOT follow this specification, and instead retrieve credentials from the environment”4 security model ของมันคือของ operating system และขีดจำกัดของมันก็เช่นกัน: local subprocess ให้บริการได้พอดีหนึ่งเครื่องและหนึ่ง user
transport ที่ยังมีชีวิตอีกตัวคือ Streamable HTTP: endpoint เดียวที่รับ POST, หนึ่ง HTTP request ต่อ JSON-RPC message และ header Accept ที่ต้อง list ทั้ง application/json และ text/event-stream เพราะ server เลือกต่อ request ว่าจะตอบด้วยอันไหน5 บทที่ 14 parse event stream นั้นด้วยมือแล้ว ดังนั้น wire format ไม่มีอะไรใหม่ — มีแค่สิ่งที่ห่อมันไว้ obligation สามข้อของ revision ปัจจุบันพลาดง่าย และทั้งสาม test ได้:
version header ต้องตรงกับ body
ลิงก์ไปยังส่วน: version header ต้องตรงกับ bodyทุก POST มี MCP-Protocol-Version และค่าของมันต้อง match กับ protocolVersion ภายใน _meta ของ request เอง mismatch คือ 400 พร้อม header-mismatch error ไม่ใช่ยักไหล่5
ต้องมี headers อีกสองตัวเพื่อ compliance
ลิงก์ไปยังส่วน: ต้องมี headers อีกสองตัวเพื่อ complianceMcp-Method mirror method บนทุก request; Mcp-Name mirror params.name หรือ params.uri บน tools/call, resources/read และ prompts/get ทั้งสองมีไว้เพื่อให้ proxy route ได้โดยไม่ต้อง parse bodies5
รูปทรงเก่าหายไปแล้ว และต้องตอบด้วยการปฏิเสธ
ลิงก์ไปยังส่วน: รูปทรงเก่าหายไปแล้ว และต้องตอบด้วยการปฏิเสธGET stream, Mcp-Session-Id และ resumption Last-Event-ID ถูกถอดออกทั้งหมด server ที่พูดเฉพาะ revision นี้ควรตอบ 405 Method Not Allowed ต่อ GET หรือ DELETE, ignore session header โดยไม่ mint ใหม่ และ ignore Last-Event-ID5
ตอนนี้คือการวัดที่ปรับกรอบทั้งบทใหม่ ส่ง current-revision request ไปยังแต่ละ server ผ่าน HTTP
Python 200 {"result":{"resultType":"complete","cacheScope":"private","ttlMs":0,
"tools":[…],"_meta":{"io.modelcontextprotocol/serverInfo":{…}}}}
TypeScript {"error":{"code":-32000,"message":"Bad Request: Unsupported protocol
version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18,
2025-03-26, 2024-11-05, 2024-10-07)"}}constants สอดคล้องกับพฤติกรรม: LATEST_PROTOCOL_VERSION ของ Python SDK อ่าน 2026-07-28, ของ TypeScript SDK อ่าน 2025-11-25 ส่ง header-mismatch request จากขั้นด้านบน แล้ว Python server ตอบ 400 พร้อม error -32020 และข้อความ “mcp-protocol-version header does not match the request envelope's protocol version”; TypeScript SDK ไม่มี code แบบนั้น เพราะมันยังไม่ implement revision ที่ define สิ่งนี้
หน้าที่ list ทั้งสองไว้ที่ Tier 1 ยังบอกด้วยว่า “Each SDK provides the same functionality”1 ณ วันที่ด้านล่าง สำหรับ revision ปัจจุบัน ประโยคนั้นเป็นความใฝ่ฝัน ตรวจ LATEST_PROTOCOL_VERSION ใน SDK ที่คุณกำลังจะ install; มันคือหนึ่งบรรทัด และเป็น claim เดียวในบทนี้ที่จะยังสำคัญในอีกหนึ่งปี
401 และประโยคที่ควร quote
ลิงก์ไปยังส่วน: 401 และประโยคที่ควร quoteย้าย server ออกจากแล็ปท็อปของคุณ แล้ว client ของคนแปลกหน้ามาพร้อม token นี่คือครึ่งที่บทที่ 26 เว้นไว้ และครึ่งที่ product แบบ multi-user ข้ามไม่ได้
specification วาง MCP server ไว้ในบทบาท OAuth 2.1 และตั้งชื่อให้มัน: protected MCP server คือ resource server, client คือ OAuth client และ authorization server เป็นปัญหาของคนอื่น4 จากบทบาทนั้น มี mandatory clauses สี่ข้อ quote เต็มเพราะการ paraphrase คือทางที่ทำให้พลาด:
MCP servers, acting in their role as an OAuth 2.1 resource server, MUST validate access tokens as described in OAuth 2.1 Section 5.2. MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2. […] MCP clients MUST NOT send tokens to the MCP server other than ones issued by the MCP server's authorization server. MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.4
“Must not accept or transit” คือกฎ anti-passthrough และเป็นเหตุผลที่ apparatus เรื่อง audience มีอยู่ทั้งหมด server ที่ replay bearer token ที่มันได้รับไปยัง third-party API คือ confused deputy: มันยืมความน่าเชื่อถือของตัวเองให้ใครก็ตามที่เรียกมัน กฎห้ามการ reuse ไม่ใช่แค่การ storage
การทำให้บังคับใช้ได้ต้องใช้ RFCs สี่ฉบับ ฉบับละหนึ่งงาน6 RFC 9728 คือวิธีที่ client หา authorization server ตั้งแต่แรก: MCP server ให้บริการ protected-resource-metadata document และ 401 ชี้ไปหามัน RFC 8707 คือ parameter resource — client ต้องส่ง canonical URI ของ server ใน ทั้ง authorization request และ token request “regardless of whether authorization servers support it” เพื่อให้ token ที่ออกมาระบุ audience ของมัน RFC 9207 ปิด loop จากอีกฝั่ง: client บันทึก issuer ก่อน redirect และเทียบ iss ที่ส่งกลับด้วย string ตรงตัว ไม่มี normalisation — ไม่มี case folding, ไม่มี default-port elision, ไม่มี trailing slash และ RFC 7591, Dynamic Client Registration, ตอนนี้ deprecated แล้วเพื่อหลีกทางให้ Client ID Metadata Documents, “retained for backwards compatibility with authorization servers that do not support” เอกสารเหล่านั้น4
ต่อสิ่งนั้นเข้ากับทั้งสอง servers ด้วย token verifier ที่ไม่ทำอะไรนอกจากเช็ค audience ลำดับขั้นของ TypeScript:
no token 401 WWW-Authenticate: Bearer error="invalid_token",
error_description="Missing Authorization header",
scope="incidents:read",
resource_metadata="…/.well-known/oauth-protected-resource/mcp"
aud=other server 401 error_description="token audience is not this server"
no exp claim 401 error_description="Token has no expiration time"
right aud, no scope 403 error="insufficient_scope", scope="incidents:read"
right aud + scope 200 {"result":{"tools":[…]}}{"resource":"http://127.0.0.1:8931/mcp",
"authorization_servers":["https://auth.example.com/"],
"scopes_supported":["incidents:read","incidents:write"],
"resource_name":"Incidents"}SDKs ทั้งคู่ serve document นั้นและทั้งคู่ชี้ 401 ไปหามัน ซึ่งคือเรื่อง discovery ทั้งหมด: client ที่ไม่เคยเห็น server ของคุณเรียนรู้ว่าจะ authenticate ที่ไหนจากการถูกปฏิเสธ 403 เป็นคนละเรื่อง — token ใช้ได้ แต่ scope ไม่ใช่ — และ challenge ระบุสิ่งที่ขาดเพื่อให้ client step up แทนที่จะเริ่มใหม่
บันไดสองขั้นต่างกัน และไม่มีความต่างใดอยู่ใน specification TypeScript SDK ปฏิเสธ token ที่ ไม่มี expiry claim; ฝั่ง Python ส่ง 200 กลับ เพราะ expires_at เป็น optional บน AccessToken ของมัน และ None หมายถึง “no opinion” ส่วน 403 ของ Python ถือ error_description="Required scope: incidents:read" โดยไม่มี parameter scope ที่ specification บอกว่า servers ควร include verifier ไม่ใช่ที่สำหรับยอมรับ library default: audience check เป็นสิ่งที่คุณต้องเขียนเองในทั้งสองภาษา และ expiry ก็เช่นกัน
ข้อจุกจิกที่ซื่อสัตย์จาก run เดียวกัน GET บน endpoint ตอบ 404 ใน wiring ของ Express และ 400 Bad Request: Missing session ID ในฝั่ง Python ในขณะที่ specification ขอ 405 Method Not Allowed และ “session ID” เป็น vocabulary ที่ revision นี้ถอดออกแล้ว ไม่มีอันไหนอันตราย ทั้งคู่คือรูปทรงของ ecosystem ที่กำลัง migrate
servers อยู่ที่ไหนจริง ๆ
ลิงก์ไปยังส่วน: servers อยู่ที่ไหนจริง ๆชิ้นสุดท้ายของการ ship คือคุณ publish ที่ไหน และมันมีคำตอบพร้อมตัวเลข crawl วันนี้ server ทุกตัวใน official registry ที่ latest version:7
| servers | |
|---|---|
| total (latest version, not deleted) | 28,170 |
| active / deprecated | 27,853 / 317 |
| ship at least one installable package | 13,065 |
| remote only — a URL, nothing to install | 14,696 |
| npm | 8,275 |
| PyPI | 3,603 |
| OCI images | 867 |
mcpb bundles | 706 |
| NuGet / Cargo | 107 / 43 |
การอ่านสองแบบชี้ไปคนละทาง ตามจำนวน servers ที่ publish, npm นำ 2.3 ต่อ 1 — ตัวเลขที่คน quote เมื่อต้องการบอกว่า ecosystem เป็น TypeScript ตาม downloads, Python นำ: ในสามสิบวันที่ผ่านมา mcp ได้ 286.7 ล้าน เทียบกับ @modelcontextprotocol/sdk ที่ 194.7 ล้าน ก่อนจะบวก fastmcp ที่ 72.1 ล้าน7 ทั้งสองเป็น Tier 1, normative schema คือ schema.ts และ tutorial อย่างเป็นทางการ “Build an MCP server” เปิดที่ tab Python1 ไม่ว่าครึ่งไหนอยู่ในหัวคุณ อีกครึ่งก็จริงเช่นกัน
และแถวที่สำคัญกว่าทั้งคู่: มากกว่าครึ่งของ registry — 14,696 จาก 28,170 — ไม่มีอะไรให้ install พวกนั้นคือ web services ยอดรวม transport เห็นตรงกันจากอีกฝั่ง: จาก package entries 14,290 รายการ มี 13,787 รายการประกาศ stdio; จาก remote entries 16,640 รายการ มี 15,570 รายการประกาศ Streamable HTTP และ 1,070 รายการยังประกาศ HTTP+SSE ที่ deprecated แล้ว ดังนั้น “MCP server คือ subprocess บนแล็ปท็อปของคุณ” อธิบาย minority ที่กำลังหดตัว และทุกหนึ่งใน 14,696 นั้นต้องใช้ section ข้างบนแทน environment variable
แสดงรายละเอียด
ตั้งใจให้ bilingual และ precedent ของมัน
นี่คือบทเดียวใน course ที่ bilingual เพราะคำตอบที่ซื่อสัตย์แยกเป็นสองฝั่ง: registry เป็น npm-first และ downloads เป็น Python-first พร้อมกัน ณ วันนี้ การเขียนแค่หนึ่งในสองจะยกครึ่งคำถามทิ้งและอธิบาย ecosystem ผิดไปพร้อมกัน มี precedent แบบเปิดเผย — Hugging Face MCP Course ระบุ prerequisites ว่า “Experience with at least one programming language (Python or TypeScript examples will be shown)” และสอนทั้งคู่8 โปรโตคอลที่มูลค่าทั้งหมดอยู่ที่จำนวน implementations ไม่ใช่ที่ที่ควรพูดภาษาเดียว
Section ลงวันที่: ทุกอย่างข้างบนที่มีอายุการใช้งาน
ลิงก์ไปยังส่วน: Section ลงวันที่: ทุกอย่างข้างบนที่มีอายุการใช้งานอ่านและวัดเมื่อ 7 กันยายน 2026 เทียบกับ protocol revision 2026-07-28
| value | |
|---|---|
@modelcontextprotocol/sdk | 1.30.0, published 27 July 2026; 4,322,438 bytes unpacked, 693 files, 17 direct dependencies |
| latest revision it implements | 2025-11-25 |
mcp (PyPI) | 2.1.1, published 25 August 2026; 357,912-byte wheel, plus mcp-types 2.1.1 at 69,656 bytes |
| latest revision it implements | 2026-07-28 |
| SDK tiers | TypeScript, Python, C#, Go, Rust at Tier 1; Java, Ruby at Tier 2; Swift, PHP, Kotlin at Tier 3 |
| registry servers | 28,170 |
| downloads, last 30 days | mcp 286,653,871 · fastmcp 72,097,269 · @modelcontextprotocol/sdk 194,679,333 |
หมายเหตุ migration หนึ่งข้อที่ไม่ใช่ตัวเลข ใน mcp 2.x, FastMCP ถูก rename เป็น MCPServer และ tutorial ออนไลน์แทบทุกอันยังเปิดด้วย import เก่า SDK ship module ที่มีจุดประสงค์เดียวคืออธิบายเรื่องนั้น ซึ่งเป็น deprecation ที่ใส่ใจที่สุดในบทนี้:
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import
MCPServer) and other APIs changed; see the migration guide … or pin 'mcp<2'
to keep running v1 code.แล้วควรเลือกอันไหน
ลิงก์ไปยังส่วน: แล้วควรเลือกอันไหนเมื่อมีตารางอยู่ตรงหน้า recommendation น่าเบื่อ ซึ่งเป็นสัญญาณที่ดี
ถ้า server อยู่ใน web application ที่คุณรันอยู่แล้ว ให้เขียนด้วย TypeScript process เดียวกัน, deploy เดียวกัน, request handler เดียวกัน; Streamable HTTP คือ endpoint ที่คุณเพิ่มข้าง ๆ อันอื่น; และ 13.9 MiB กับ 145 ms เป็นของฟรีเพราะ runtime เปิดอยู่แล้ว นั่นคือส่วนใหญ่ของ remote servers 14,696 ตัว
ถ้า server ห่อ data tooling ให้เขียนด้วย Python สิ่งที่คุณ expose คือ pandas, warehouse client, transforms เท่ากับ notebook หนึ่งเล่ม และ server ในภาษาอื่นจะเป็น subprocess call ที่สวม schema ไว้ การ import เจ็ดร้อยมิลลิวินาทีใน service ที่ start ครั้งเดียวไม่ใช่ cost; ใน subprocess ที่ host relaunch ทั้งวัน มันใช่
และตอนนี้ แถว revision override ทั้งคู่ ถ้าคุณต้องใช้ 2026-07-28 — multi-round-trip requests, resultType, cache hints, server/discover — SDK หนึ่งในสองมีวันนี้ และอีกตัวยังไม่มี
ต่อจากนี้ไปไหน
ลิงก์ไปยังส่วน: ต่อจากนี้ไปไหนตอนนี้คุณ ship server เดียวกันได้ในภาษาใดภาษาหนึ่ง ป้องกันการเลือกด้วยตารางแทนความชอบ รันมันบน transports ที่ยังมีชีวิตทั้งสองแบบ และส่ง token ให้มันปฏิเสธได้แล้ว
สิ่งที่คุณสร้างยังคงเป็น function: schema, endpoint, สิ่ง deterministic ที่ model invoke ความรู้ทั้ง class ไม่เข้ากับรูปทรงนั้น — วิธีที่ เรา เขียน postmortem, fields ที่ incident reports ของเราต้องมี, ลำดับที่เราทำสิ่งต่าง ๆ และเหตุผล มันคือ procedure, มันคือ prose และการบังคับมันเข้าไปใน tool description คือวิธีที่ system prompts โตเป็นสองพัน tokens ที่ต้องจ่ายทุก turn ไม่ว่าบทสนทนาจะเกี่ยวกับ incidents หรือไม่
บทที่ 28 คือคำตอบอีกแบบ: folder ที่มี SKILL.md อยู่ในนั้นซึ่ง model อ่าน แทนที่จะ call, โหลดเป็นสามระดับเพื่อให้ reference material แทบไม่เสียค่าใช้จ่ายจนกว่าจะถึง turn ที่ต้องใช้ มันไม่มีภาษาหลัก และนั่นคือสิ่งแรกที่มันสอน
แหล่งที่มาและวิธีการ
ลิงก์ไปยังส่วน: แหล่งที่มาและวิธีการทุกอย่างที่นี่วัดเมื่อ 7 กันยายน 2026 บน Node 22.22.3 และ Python 3.14.4 เทียบกับ @modelcontextprotocol/sdk 1.30.0 พร้อม zod 3.25.76 และ mcp 2.1.1 โดยแต่ละตัว install ใน throwaway directory ของตัวเอง Timings เป็น medians ของ 25 launches, wall clock จาก spawn ถึงบรรทัดที่ถือ response tools/list; token counts คือ o200k_base ผ่าน tiktoken บน JSON ของแต่ละ definition ไม่มีการเรียก paid API: ไม่มีอะไรที่นี่ต้องใช้ model
servers สองตัวมี 81 และ 63 บรรทัดที่ไม่ว่าง; หนึ่งในสาม tools ของมันถูก reproduce ข้างบนในทั้งสองภาษา และการลงทะเบียนอีกสี่รายการต่างกันเฉพาะตามที่อธิบายไว้ นโยบาย error-disclosure ของ Python SDK quote จาก docstrings ของ ToolError และ UnexpectedToolError ใน mcp/server/mcpserver/exceptions.py; default pretty-printing คือ pydantic_core.to_json(result, fallback=str, indent=2) ใน mcp/server/mcpserver/resources/types.py และ utilities/func_metadata.py constants ของ protocol-version คือ LATEST_PROTOCOL_VERSION ใน mcp_types/version.py และใน types.js ของ TypeScript SDK ทั้งคู่อ่านจาก packages ที่ install แล้ว ไม่ใช่จาก changelog
รายการอ้างอิง
ลิงก์ไปยังส่วน: รายการอ้างอิง-
SDKs,
modelcontextprotocol.io/docs/sdk, และ Build an MCP server,modelcontextprotocol.io/docs/develop/build-server, ทั้งคู่อ่าน 7 กันยายน 2026 แหล่งที่มาของ tier table, ประโยค “Each SDK provides the same functionality but follows the idioms and best practices of its language”, ลำดับ language-tab ของ tutorial (Python, TypeScript, Java, Kotlin, C#, Ruby, Rust, Go), และกฎ logging ที่ quote เรื่องprint()และstdout↩ ↩2 ↩3 ↩4 -
stdio transport,
.../basic/transports/stdioแหล่งที่มาของ newline framing และกฎ puritystdoutบทที่ 26 อ่านหน้านี้เต็ม; อ้างที่นี่เพื่อบรรทัดที่ server ที่ทำให้พังละเมิด ↩ -
MCP Inspector,
modelcontextprotocol.io/docs/2026-07-28/tools/inspector, อ่าน 7 กันยายน 2026 หนึ่ง package, สาม clients หลัง binary เดียว — web,--cliและ--tui— ใช้ core เดียวกัน, transports ชุดเดียวกัน และ OAuth state ชุดเดียวกันบนดิสก์ CLI สร้าง catalogue traces ที่นี่ ↩ -
Authorization,
modelcontextprotocol.io/specification/2026-07-28/basic/authorization, อ่าน 7 กันยายน 2026 แหล่งที่มาของบทบาท resource-server; clauses การจัดการ token สี่ข้อที่ quote เต็ม; requirement ให้ servers implement RFC 9728 และ clients ใช้มันสำหรับ discovery; กฎ parameterresourceและนิยาม canonical-URI; ตาราง issuer-validation; การ deprecation ของ Dynamic Client Registration; ตาราง401/403/400และ challengeinsufficient_scope; และข้อยกเว้น stdio, “Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.” ↩ ↩2 ↩3 ↩4 -
Streamable HTTP,
.../basic/transports/streamable-http, และ Transports overview,.../basic/transportsแหล่งที่มาของกฎ single-endpoint POST, requirement dualAccept, headerMCP-Protocol-Versionและกฎ must-match-the-body ของมัน, headersMcp-MethodและMcp-Nameที่อธิบายว่า “REQUIRED for compliance”, การถอด GET stream, sessions และLast-Event-ID, guidance405, validationOriginที่ mandatory และการจัดประเภท transport HTTP+SSE ปี 2024-11-05 เป็น Deprecated ภายใต้ SEP-2596 ↩ ↩2 ↩3 ↩4 -
สี่ฉบับที่ specification พึ่งพา พร้อม draft ที่มัน profile: The OAuth 2.1 Authorization Framework,
draft-ietf-oauth-v2-1-13Campbell, B., Bradley, J. and Tschofenig, H., Resource Indicators for OAuth 2.0, RFC 8707, February 2020 — parameterresourceและ audience ที่มัน bind Jones, M.B., Hunt, P. and Parecki, A., OAuth 2.0 Protected Resource Metadata, RFC 9728, April 2025 — document ที่401ชี้ไป Meyer zu Selhausen, K. and Fett, D., OAuth 2.0 Authorization Server Issuer Identification, RFC 9207, March 2022 — parameterissและการเทียบ exact-string Richer, J. (ed.) et al., OAuth 2.0 Dynamic Client Registration Protocol, RFC 7591, July 2015, deprecated สำหรับ use นี้ และ Jones, M. and Hardt, D., The OAuth 2.0 Authorization Framework: Bearer Token Usage, RFC 6750, October 2012, section 3, สำหรับรูปทรง challengeWWW-Authenticateด้านบน ↩ -
Official MCP registry,
registry.modelcontextprotocol.io/v0/servers, crawl 7 กันยายน 2026 ด้วยversion=latest: 282 pages, 28,170 servers, tally โดยregistryTypeบน distinct server names ตัวเลข download:api.npmjs.org/downloads/point/last-monthสำหรับ@modelcontextprotocol/sdk(194,679,333 สำหรับ 8 สิงหาคม – 6 กันยายน 2026) และpypistats.org/api/packages/<name>/recentสำหรับmcpและfastmcpทั้งหมดอ่านวันเดียวกัน Package sizes มาจาก npm registry document และ PyPI JSON API ↩ ↩2 -
MCP Course, Hugging Face,
huggingface.co/learn/mcp-course, unit 0, อ่าน 7 กันยายน 2026: ใน prerequisites มี “Experience with at least one programming language (Python or TypeScript examples will be shown)” ↩