ออกแบบ MCP tool ให้ AI ใช้ถูก: description คือ API, schema คือ guardrail
เมื่อผู้เรียก tool เป็น LLM ไม่ใช่มนุษย์ วิธีออกแบบ tool ต้องเปลี่ยนไปทั้งหมด บทเรียนจากการสร้าง MCP server ทดสอบระบบ ว่าทำไม description คือ prompt, schema คือ guardrail และทำไมควรคืน raw data
อ่าน ~8 นาที
ผู้เรียก tool ของผมเป็น LLM ไม่ใช่มนุษย์
ผมสร้าง MCP server ตัวหนึ่งขึ้นมาเพื่อให้ AI agent ใช้รันเทสต์และจัดการ state ของโปรเจกต์ให้อัตโนมัติ — seed database, snapshot/restore, spin mock server, ดู log แบบ live. ตอนออกแบบผมนึกว่ามันคือการเขียน API ธรรมดา แต่พอใช้จริงถึงรู้ว่าผิดตั้งแต่แรก เพราะผู้เรียก tool ไม่ใช่มนุษย์ที่อ่าน docs ได้ แต่เป็น LLM ที่ตัดสินใจจากข้อความสั้นๆ ที่เราเขียนบรรยาย tool ไว้
ในโลก MCP (Model Context Protocol) แต่ละ tool จะถูกส่งเข้า context ของ model เป็นชื่อ + description + JSON schema ของ argument. Model เห็นแค่นั้น แล้วต้องตัดสินเองว่า "งานนี้ควรเรียก tool ตัวไหน ใส่ค่าอะไร". นั่นแปลว่า description ไม่ใช่ documentation ที่ไว้เปิดอ่านตอนสงสัย — มันคือส่วนหนึ่งของ promptที่ model ใช้ reasoning ทุกครั้ง. เขียนคลุมเครือ = agent เดา = เรียกผิด tool หรือใส่ arg ผิด บทความนี้คือสิ่งที่ผมได้เรียนรู้ว่าการออกแบบ tool ให้ AI ต่างจากการออกแบบ API ให้คนตรงไหน
Tool description matters: บรรทัดเดียวที่ทำให้ agent เรียกผิด vs ถูก
สมมติผมมี tool สองตัวคือ db_snapshot กับ db_seed. ถ้าผมเขียน description แบบขี้เกียจ:
// description แย่ — agent แยกไม่ออกว่าเมื่อไหร่ควรใช้ตัวไหน
db_snapshot: "backup database"
db_seed: "setup database data"
คำว่า "backup" กับ "setup" มันกว้างเกินไป. ตอน agent เจองาน "เตรียมข้อมูลก่อนรันเทสต์" มันอาจเลือก db_snapshot เพราะคิดว่า "เตรียม = backup ก่อน" ทั้งที่จริงต้อง seed. ผมแก้เป็น description ที่ระบุ เมื่อไหร่ใช้ + ผลลัพธ์ + ข้อควรระวัง:
// description ดี — บอก when/what/side-effect ครบ
db_seed: "Load fixture data into the test database before a test run.
Strategies: drop_first | upsert | insert. Mutates data —
call db_snapshot first if you need to restore afterwards."
db_snapshot: "Dump all collections to a JSONL snapshot on disk so you can
db_restore to this exact state later. Read-only, safe to call
anytime. Use before any test that mutates data."
ความต่างคือ description ที่ดีเขียนเหมือนบอก junior dev ว่าเมื่อไหร่ควรหยิบเครื่องมือชิ้นนี้ ไม่ใช่แค่บอกว่ามันทำอะไร. คำว่า "before a test run", "Mutates data", "Read-only, safe to call anytime" คือสัญญาณที่ model ใช้จับคู่กับสถานการณ์ตรงหน้า หลักที่ผมยึด: ถ้าอ่าน description แล้วยังเดาได้หลายทาง แปลว่ามันคลุมเครือเกินไปสำหรับ LLM
Strict schema = guardrail: บังคับ required/enum กัน hallucinated arg
ปัญหาถัดมาคือ LLM ชอบ "แต่งค่า" ที่ดูสมเหตุผลแต่ไม่มีอยู่จริง (hallucination). ถ้า tool ผมรับ argument แบบหลวมๆ เป็น free-form string agent จะใส่อะไรก็ได้. ทางแก้คือทำ JSON schema ให้เข้ม: กำหนด required, ใช้ enum จำกัดค่าที่เป็นไปได้, และปิด additionalProperties
// schema ที่ทำหน้าที่เป็น guardrail
{
"type": "object",
"properties": {
"strategy": {
"type": "string",
"enum": ["drop_first", "upsert", "insert"], // agent เดาค่าอื่นไม่ได้
"description": "How to apply fixtures to existing data"
},
"collection": { "type": "string" }
},
"required": ["strategy", "collection"],
"additionalProperties": false // ไม่ให้แอบใส่ field แปลกปลอม
}
enum คือพระเอกตัวจริง. ถ้า agent พยายามใส่ strategy: "merge" (ซึ่งฟังดูมีเหตุผลแต่ระบบผมไม่มี) schema จะ reject ทันทีก่อน handler ทำงาน. นี่คือการย้าย validation ไปอยู่ที่ชั้น schema แทนที่จะปล่อยให้ logic ข้างในพัง. Guardrail ที่ดีทำให้ input ผิดกลายเป็น error ที่ชัดเจน แทนที่จะเป็น behavior ที่เพี้ยนแบบเงียบๆ
Self-heal: error ที่ actionable ให้ agent แก้เองรอบถัดไป
จุดที่ทำให้ agent-facing tool ต่างจาก API มนุษย์มากที่สุดคือสิ่งที่เกิดหลัง error. คนเจอ error แล้วอ่าน docs แก้เอง แต่ agent จะอ่าน error message แล้วลองใหม่ในเทิร์นถัดไปทันที. ดังนั้น error ต้อง actionable — บอกว่าผิดตรงไหนและควรทำอะไร
// error แย่ — agent ไม่รู้จะแก้ยังไง วนลูปเดิม
{ "error": "Invalid input" }
// error ดี — บอก field, ค่าที่ผิด, และค่าที่ยอมรับ → agent แก้เองได้
{
"error": "Invalid 'strategy': got \"merge\".
Allowed values: drop_first, upsert, insert.",
"field": "strategy"
}
ผมเรียกคุณสมบัตินี้ว่า self-heal: เมื่อ schema reject แล้วส่ง error ที่บอกค่าที่ถูกต้องกลับไป agent มักแก้ได้เองในรอบถัดไปโดยไม่ต้องให้คนเข้ามาช่วย. เท่ากับ strict schema + descriptive error รวมกันกลายเป็น feedback loop ที่ทำให้ agent ฉลาดขึ้นเรื่อยๆ ระหว่างทำงาน. นี่คือเหตุผลที่ผมไม่เคยคืน error สั้นๆ แบบ "Bad request" ให้ agent — มันเหมือนบอก junior dev ว่า "ผิด" แต่ไม่บอกว่าผิดอะไร
คืน raw data ไม่ใช่ prose: ให้ agent reason เอง
ตอนแรกผมชอบทำ tool ที่คืนคำตอบสวยๆ เป็นภาษามนุษย์ เช่น "เทสต์ผ่าน 47 จาก 50 มี 3 อันที่ fail ส่วนใหญ่เป็นเรื่อง timeout". ฟังดูดีใช่ไหม แต่มันคือกับดัก เพราะผมตีความข้อมูลไปแล้วแทน agent. คำว่า "ส่วนใหญ่" กับ "timeout" คือข้อสรุปของผม ถ้า agent อยากรู้ว่า test ไหน fail ด้วย assertion อะไรบรรทัดไหน มันหมดสิทธิ์ เพราะข้อมูลดิบถูกบีบทิ้งไปแล้ว
// อย่าคืน prose ที่ตีความไว้แล้ว
"Tests done: 47/50 passed, mostly timeouts"
// คืน structured data ให้ agent reason เอง
{
"total": 50, "passed": 47, "failed": 3,
"failures": [
{ "name": "checkout flow", "reason": "assertion",
"expected": 200, "actual": 500, "file": "checkout.test.ts:42" },
{ "name": "cart sync", "reason": "timeout", "ms": 5000 }
]
}
หลักคือ tool ควรเป็นเซนเซอร์ที่ให้ข้อมูลดิบ ไม่ใช่ analyst ที่สรุปมาแล้ว. LLM เก่งเรื่อง reasoning บนข้อมูล structured อยู่แล้ว งานของ tool คือส่งข้อเท็จจริงที่ครบและตรวจสอบย้อนได้ ส่วนการตีความปล่อยให้ layer ที่ฉลาดกว่า (ตัว agent เอง) จัดการ ยิ่งเราสรุปให้เยอะ ยิ่งตัด option ของ agent ทิ้ง
Safety by design: destructive tool ต้องมี guard ในตัว
tool ที่ agent เรียกได้เองอันตรายกว่า API ปกติ เพราะไม่มีมนุษย์กดยืนยันก่อนทุกครั้ง. tool ตัวที่ผมกลัวที่สุดคือ db_drop_test_db — ลบทั้ง database. ถ้า agent เกิด reasoning ผิดแล้วเรียกมันใส่ prod database ล่ะ? ผมเลยฝัง guard ไว้ในตัว tool เอง ไม่พึ่ง discipline ของ caller:
// guard: ยอม drop เฉพาะ DB ที่ชื่อขึ้นต้น test_ หรือ test-
function dropTestDb(dbName, override = false) {
const isTestDb = /^test[_-]/i.test(dbName);
if (!isTestDb && !override) {
throw new Error(
`Refusing to drop "${dbName}": name must match /^test[_-]/i. ` +
`Pass override:true only if you are certain.`
);
}
// ... ทำงานจริงต่อ
}
หลักคือ ทำให้การทำสิ่งอันตรายโดยบังเอิญเป็นไปไม่ได้ ไม่ใช่แค่ไม่แนะนำ. Default ต้องปลอดภัย ถ้าจะทำอะไรที่ irreversible ต้องมี explicit override ที่ agent ต้องตั้งใจใส่ ไม่ใช่เผลอ. Guard แบบนี้ราคาถูกมาก (regex บรรทัดเดียว) แต่กันหายนะได้ทั้งก้อน. ผมมองว่า destructive tool ทุกตัวควรมีคำถามในหัวว่า "ถ้า agent hallucinate argument ที่แย่ที่สุด จะเกิดอะไรขึ้น" แล้วปิดทางนั้นด้วยโค้ด
Local-first + bind 127.0.0.1: ไม่เปิด tool อันตรายออก network
ข้อสุดท้ายเป็นเรื่อง blast radius. tool อย่าง mock server ที่ผมสร้าง ผม bind มันไว้ที่ 127.0.0.1 เท่านั้น ไม่ผูกกับ 0.0.0.0. เหตุผลคือ mock server, database tool, event log พวกนี้เป็น dev tool ที่ไม่ควรเข้าถึงได้จากภายนอกเครื่องเลย
// bind localhost เท่านั้น — เครื่องอื่นใน LAN ยิงเข้าไม่ได้
server.listen(port, "127.0.0.1"); // ✅ local-only
// ห้าม: server.listen(port, "0.0.0.0") ❌ เปิดออก network
พอผูกกับ loopback interface อย่างเดียว ต่อให้ agent หรือ mock route ถูกตั้งค่าพลาด ก็ไม่มีทางที่เครื่องอื่นในเน็ตเวิร์ก (เช่น 192.168.x.x) จะเรียกเข้ามาได้. ผมยังใส่ ring buffer จำกัด history ไว้ (เก็บแค่ N request ล่าสุดต่อ server) เพื่อไม่ให้ memory บวมเมื่อรันยาวๆ. Local-first คือ default ที่ปลอดภัยสำหรับ tool ที่ agent ควบคุม — เปิดออก network เมื่อจำเป็นจริงๆ และเมื่อ agent ควบคุม tool ได้เอง ความปลอดภัยไม่ควรพึ่งว่า agent จะ "เรียกถูกเสมอ" แต่ต้องพึ่งว่า worst case มันทำอะไรไม่ได้
สรุป
การออกแบบ tool ให้ AI ใช้ ไม่ใช่การเขียน API ให้คน มันคือการเขียน prompt + guardrail + safety net ในก้อนเดียวกัน สิ่งที่ผมยึดหลังจากสร้าง MCP server จริง:
- Description คือ prompt — เขียนให้ model แยกออกว่าเมื่อไหร่ใช้ tool ไหน บอก when/what/side-effect ไม่ใช่แค่ว่ามันทำอะไร
- Strict schema คือ guardrail — ใช้ required + enum + additionalProperties:false กัน hallucinated argument ก่อนถึง handler
- Error ต้อง actionable — บอก field ที่ผิดและค่าที่ยอมรับ เพื่อให้ agent self-heal เองในรอบถัดไป
- คืน raw structured data — ให้ agent reason บนข้อเท็จจริงดิบ อย่าตีความเป็น prose ให้ก่อน
- Safety by design — destructive tool ต้องมี guard ในตัว (เช่น รับเฉพาะ DB ชื่อ
test_*) default ปลอดภัย ต้อง explicit override - Local-first — bind
127.0.0.1จำกัด blast radius อย่าเปิด dev tool อันตรายออก network
หัวใจของทุกข้อคือความจริงข้อเดียว: ผู้เรียกเป็น LLM ที่ตัดสินจากสิ่งที่เราเขียนบอก และลองใหม่จากสิ่งที่เราตอบกลับ ทุกคำใน description และทุก field ใน error คือ interface ที่ agent ใช้คิด ออกแบบมันเหมือนออกแบบ prompt แล้ว agent จะใช้ถูก




