Structured Output and Annotations
text มีไว้ให้ model, structure มีไว้ให้โปรแกรม
หัวข้อที่มีชื่อว่า “text มีไว้ให้ model, structure มีไว้ให้โปรแกรม”array content ของ tool คือสิ่งที่ language model อ่าน แต่ยิ่งนานไป ผู้บริโภค ผลลัพธ์ของ tool ก็ยิ่งไม่ใช่ model — แต่เป็นโค้ด: host ที่ generate wrapper แบบมี type, sandbox แบบ “code mode” ที่ต่อ tool เป็นลูกโซ่, MCP App ที่ render chart ผู้บริโภคพวกนี้อยากได้ JSON ที่รู้ shape ไม่ใช่ prose ที่ต้องมา parse
MCP ให้ทั้งสองช่องทางในผลลัพธ์เดียว:
{ "jsonrpc": "2.0", "id": 5, "result": { "resultType": "complete", "content": [ { "type": "text", "text": "{\"name\":\"Travel mug\",\"price\":24}" } ], "structuredContent": { "name": "Travel mug", "price": 24 } }}structuredContent— JSON value อะไรก็ได้ (object, array, string, number, boolean หรือ null) ถ้า tool ประกาศoutputSchemaค่านี้ ต้อง ตรงตาม schema นั้นcontent— ยังคงต้องมี เพื่อ backward compatibility tool ที่คืน structured content ควรคืน JSON ที่ serialize แล้วเป็น blocktextด้วย เพื่อให้ client รุ่นเก่าและ model ยังได้อะไรไปบ้าง
flowchart LR S["Tool result"] --> C["content[] text"] S --> SC["structuredContent"] C --> LLM["Language model อ่าน prose"] SC --> Code["Host code validate กับ outputSchema"] SC --> App["MCP App render UI"]
อย่าสับสนกับฟีเจอร์ “structured outputs” ของ LLM structuredContent คือข้อมูลที่ server ผลิต ไม่เกี่ยวอะไรกับวิธีที่ model generate text
การประกาศ output schema
หัวข้อที่มีชื่อว่า “การประกาศ output schema”{ "name": "product_details", "description": "Look up one product by its exact name", "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, "outputSchema": { "type": "object", "properties": { "name": { "type": "string" }, "price": { "type": "number" } }, "required": ["name", "price"] }}สัญญาที่มาพร้อมกัน:
- server ต้อง คืน
structuredContentที่ตรงตามoutputSchema - client ควร validate
structuredContentกับ schema นั้น (และทำตามกฎ$ref— ห้าม fetch$refที่ชี้ไป network โดยอัตโนมัติ) outputSchemaเป็น JSON Schema 2020-12 อะไรก็ได้ รวมถึง array ที่ root:
{ "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }ทำไปทำไม? host ที่รู้ shape ของ output จะ generate function แบบมี type ได้ (productDetails(name): Promise<{name: string; price: number}>) สำหรับ programmatic tool calling, ต่อ tool ของคุณเข้ากับตัวอื่นโดยไม่ต้องให้ข้อมูลไหลผ่าน model และปฏิเสธผลลัพธ์ที่ผิดรูปก่อนมันจะไปปนเปื้อนบทสนทนา tool ที่ไม่มี outputSchema จะตกไปอยู่ในสภาพ “มันเป็น string นะ โชคดี”
server.registerTool( 'product_details', { description: 'Look up one product by its exact name', inputSchema: z.object({ name: z.string() }), outputSchema: z.object({ name: z.string(), price: z.number() }), }, async ({ name }) => { const product = catalog.find((p) => p.name === name); if (!product) { return { content: [{ type: 'text', text: `No product named ${name}` }], isError: true }; } const output = { name: product.name, price: product.price }; return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output, }; },);เมื่อตั้ง outputSchema แล้ว ผลลัพธ์ต้องมี structuredContent และ SDK จะ validate มันกับ schema ก่อนส่ง
from pydantic import BaseModel, Field
from mcp.server import MCPServerfrom mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("catalog")
class Product(BaseModel): name: str price: float = Field(description="Price in USD.")
@mcp.tool()def product_details(name: str) -> Product: """Look up one product by its exact name.""" for p in CATALOG: if p["name"] == name: return Product(**p) raise ToolError(f"No product named {name!r}")การ return pydantic BaseModel ให้ object แบบ unwrapped: JSON schema ของ model กลายเป็น outputSchema และ instance กลายเป็น structuredContent การ return ค่า primitive อย่าง str หรือ int จะถูก wrap — SDK สร้าง schema หน้าตาแบบ {"result": …} เพื่อให้ค่ายังมี shape ที่ชัดเจน
annotation: บอก host ว่า tool ทำตัวยังไง
หัวข้อที่มีชื่อว่า “annotation: บอก host ว่า tool ทำตัวยังไง”host คือคนตัดสินใจว่าจะถามคนก่อนรัน tool ไหม มันอ่านโค้ดของคุณไม่ได้ ดังนั้นบอกมันด้วย annotation:
{ "name": "clear_catalog", "title": "Clear the catalog", "description": "Remove every product from the catalog", "annotations": { "readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false }}| Hint | ค่าเริ่มต้น | ความหมาย |
|---|---|---|
readOnlyHint | false | tool ไม่แก้ไข environment ของมัน |
destructiveHint | true | ถ้าไม่ใช่ read-only tool อาจทำการอัปเดตแบบทำลาย (ลบ, เขียนทับ) ไม่ใช่แค่เพิ่มอย่างเดียว |
idempotentHint | false | เรียกซ้ำด้วย argument เดิมไม่มีผลเพิ่มเติม |
openWorldHint | true | tool ติดต่อกับสิ่งภายนอก (เว็บ, third-party API) ไม่ใช่ระบบปิด |
host ที่ทำตัวดีจะทำอะไรกับสิ่งเหล่านี้:
- อนุมัติ tool ที่
readOnlyHint: trueอัตโนมัติ; ถามยืนยันกับตัวที่ destructive - retry tool ที่
idempotentHint: trueได้อย่างปลอดภัยหลัง timeout; ไม่ retry ตัวที่ไม่ idempotent แบบสุ่มสี่สุ่มห้า - ติดธง tool ที่
openWorldHint: trueบน UI เพื่อให้ user รู้ว่าข้อมูลอาจออกนอกเครื่อง
ค่าเริ่มต้นถูกตั้งให้ มองโลกในแง่ร้าย โดยตั้งใจ: tool ที่ไม่มี annotation จะถูกมองว่าอาจ destructive, ไม่ idempotent และ open-world ดังนั้นตั้งมันซะ — tool ค้นหาแบบ read-only ที่ไม่ประกาศ readOnlyHint จะสร้างหน้ายืนยันที่ไม่จำเป็นขึ้นมา
annotation คือคำใบ้ ไม่ใช่การรับประกัน
หัวข้อที่มีชื่อว่า “annotation คือคำใบ้ ไม่ใช่การรับประกัน”spec พูดตรง ๆ: client ต้องถือว่า tool annotation เชื่อถือไม่ได้ เว้นแต่มาจาก server ที่เชื่อถือ server ที่ประสงค์ร้ายจะติดป้าย rm -rf ว่า read-only ก็ได้ annotation ช่วยให้ประสบการณ์กับ server ที่คุณเชื่อถือดีขึ้น แต่ไม่ใช่ security boundary host ยังควรแสดง input ของ tool ให้ user ดูก่อนส่ง และ user ยังต้องปฏิเสธการเรียกใดก็ได้
server.registerTool( 'clear_catalog', { title: 'Clear the catalog', description: 'Remove every product from the catalog', annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, }, async () => { catalog.length = 0; return { content: [{ type: 'text', text: 'Catalog cleared' }] }; },);from mcp.types import ToolAnnotations
@mcp.tool( title="Clear the catalog", annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True),)def clear_catalog() -> str: """Remove every product from the catalog.""" CATALOG.clear() return "Catalog cleared"title กับ name
หัวข้อที่มีชื่อว่า “title กับ name”name คือตัวระบุที่ model ใช้ใน tools/call; ต้องทำตามกฎการตั้งชื่อและไม่ควรเปลี่ยนพร่ำเพรื่อ title คือข้อความอิสระสำหรับคน: “Search the product catalog” แทนที่จะเป็น search_catalog host แสดง title ในหน้ายืนยันและตัวเลือก tool และถอยไปใช้ name เมื่อไม่มี resource และ prompt ก็แยกแบบเดียวกัน
tool, prompt, resource และตัว server เองแนบ icons ได้:
{ "icons": [ { "src": "https://example.com/search.png", "mimeType": "image/png", "sizes": ["48x48"] } ] }อนุญาตเฉพาะ URI แบบ https: และ data:; client ต้องปฏิเสธ javascript:, file: และ scheme อื่น, fetch icon โดยไม่แนบ credential และตรวจว่า origin ของ icon ตรงกับของ server client ที่ render icon ต้องรองรับ PNG และ JPEG และควรรองรับ SVG (พร้อม sanitize — SVG มี script ได้) และ WebP มอง icon เป็นของแถมสำหรับ host แบบกราฟิก อย่าให้ server ของคุณต้องพึ่งมัน
x-mcp-header
หัวข้อที่มีชื่อว่า “x-mcp-header”มีอีก property หนึ่งที่โผล่ใน inputSchema ได้: "x-mcp-header": "Region" บน parameter ชนิด primitive จะบอก client แบบ Streamable HTTP ให้สะท้อน argument นั้นไปเป็น header Mcp-Param-Region เพื่อให้ load balancer route ได้โดยไม่ต้อง parse body มันเป็นเรื่องของ transport ที่มีข้อจำกัดเข้มงวด — อยู่ในบท Streamable HTTP ห้ามใช้กับข้อมูลลับเด็ดขาด