Multi Round-Trip Requests (MRTR)
ปัญหาที่ MRTR แก้
หัวข้อที่มีชื่อว่า “ปัญหาที่ MRTR แก้”MCP ยุคเก่า (2025-11-25 และก่อนหน้า) ยอมให้ server ส่ง elicitation/create, sampling/createMessage หรือ roots/list เป็น JSON-RPC request ไปหา client ผ่าน session วิธีนี้ใช้ได้กับ stdio แต่บังคับให้ remote server ต้องเก็บ state ต่อ session และมี back-channel เปิดค้างไว้ — ซึ่งพังทันทีที่เอา load balancer ไปวางหน้า replica สองตัว เพราะ replica ที่ได้รับ retry ต้องรู้ว่าอีกตัวถามอะไรไป
Multi Round-Trip Requests (MRTR) ตัด back-channel ทิ้ง server ไม่เคยเป็นฝ่ายถาม แต่ ตอบกลับด้วยคำถาม แล้ว client ส่ง request เดิมซ้ำโดยแนบคำตอบมาด้วย replica ตัวไหนก็จบงานได้ เพราะทุกอย่างที่ต้องใช้อยู่ใน retry นั้นแล้ว
flow สี่ขั้น
หัวข้อที่มีชื่อว่า “flow สี่ขั้น”- client ส่ง request ปกติ — สมมติว่า
tools/call - server รู้ตัวว่าต้องการข้อมูลเพิ่ม เลยตอบกลับด้วย
InputRequiredResult - client รวบรวม input ที่ถูกขอ (จากผู้ใช้ หรือจากที่อื่น) แล้ว retry request เดิม พร้อมแนบ input
- คราวนี้ server มีข้อมูลพอ ทำงาน แล้ว return ผลลัพธ์สุดท้าย
sequenceDiagram participant C as Client participant S as Server C->>S: tools/call (id 1, params) Note over S: ต้องการ input S-->>C: result resultType input_required (inputRequests, requestState) Note over C: request 1 จบแล้ว Note over C: รวบรวม input จากผู้ใช้ C->>S: tools/call (id 2, params เดิม + inputResponses + requestState) Note over S: ประกอบ state คืนจาก requestState แล้วรัน S-->>C: result resultType complete
สังเกตสิ่งที่ไดอะแกรม ไม่ได้ แสดง: state ใด ๆ ที่ server ต้องถือไว้ระหว่างขั้น 2 กับขั้น 3 request 1 จบทันทีที่ server ตอบ ส่วน request 2 เป็น request ใหม่เอี่ยมที่ครบในตัวเอง
type สามตัว
หัวข้อที่มีชื่อว่า “type สามตัว”InputRequiredResult
หัวข้อที่มีชื่อว่า “InputRequiredResult”result ที่ resultType เป็น "input_required" มี field ได้ไม่เกินสองตัว:
inputRequests— map จาก key ที่ server ตั้งเอง (string) ไปยัง request object ที่ client ต้องไปหาคำตอบมาrequestState— string แบบ opaque ที่มีความหมายเฉพาะกับ server
server ต้อง ใส่มาอย่างน้อยหนึ่งในสอง จะใส่แค่ requestState ก็ได้ (แปลว่า “ไว้ค่อย retry ทีหลัง ฉันเก็บของที่ต้องใช้ไว้ในนี้แล้ว”) หรือแค่ inputRequests ก็ได้
{ "jsonrpc": "2.0", "id": 1, "result": { "resultType": "input_required", "inputRequests": { "pick_region": { "method": "elicitation/create", "params": { "mode": "form", "message": "Which region should the database live in?", "requestedSchema": { "type": "object", "properties": { "region": { "type": "string", "enum": ["eu-west-1", "us-east-1", "ap-southeast-1"] } }, "required": ["region"] } } } }, "requestState": "v1.eyJuYW1lIjoib3JkZXJzIn0.h2Kq..." }}InputRequests
หัวข้อที่มีชื่อว่า “InputRequests”value ใน inputRequests คือ request shape ปกติของ MCP — หนึ่งใน ElicitRequest, CreateMessageRequest (sampling) หรือ ListRootsRequest key ให้ server เป็นคนตั้ง และต้องไม่ซ้ำกันภายใน response เดียว InputRequiredResult ตัวเดียวรวมหลายคำถามไว้ด้วยกันได้
InputResponses
หัวข้อที่มีชื่อว่า “InputResponses”ตอน retry client ส่ง inputResponses มา — key ชุดเดิม แต่ละตัว map ไปยัง result type ที่ตรงกัน (ElicitResult, CreateMessageResult, ListRootsResult):
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "provision_database", "arguments": { "name": "orders" }, "inputResponses": { "pick_region": { "action": "accept", "content": { "region": "eu-west-1" } } }, "requestState": "v1.eyJuYW1lIjoib3JkZXJzIn0.h2Kq...", "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } } } }}กฎสามข้อของการ retry:
- JSON-RPC
idต้องต่างจาก ครั้งแรก — มันเป็น request ที่เป็นอิสระต่อกัน nameและargumentsเดิมถูกส่งซ้ำแบบไม่แก้ไขrequestStateถูกส่งคืน แบบ byte ต่อ byte ถ้า server ส่งมา และไม่ใส่ถ้า server ไม่ได้ส่ง client ห้าม parse, แก้ไข หรือตีความมันเด็ดขาด
MRTR ใช้ได้ที่ไหนบ้าง
หัวข้อที่มีชื่อว่า “MRTR ใช้ได้ที่ไหนบ้าง”มีแค่ client request สามแบบที่รับ InputRequiredResult ได้:
| client request | return input_required ได้ไหม |
|---|---|
tools/call | ได้ |
resources/read | ได้ |
prompts/get | ได้ |
อื่น ๆ ทั้งหมด (tools/list, server/discover, …) | ไม่ได้ |
ถ้าเมื่อไรอยาก elicit ระหว่าง tools/list แปลว่าออกแบบผิด: รายการต้องไม่เปลี่ยนตาม connection หรือเป็นผลข้างเคียงของ request อื่น
ข้อกำหนดฝั่ง server
หัวข้อที่มีชื่อว่า “ข้อกำหนดฝั่ง server”- ห้ามขอสิ่งที่ client ทำไม่ได้ ถ้า
io.modelcontextprotocol/clientCapabilitiesของ client ไม่ได้ประกาศelicitationserver ห้ามใส่elicitation/createลงในinputRequestsให้เช็ค capability จาก request นี้ — ไม่มี handshake ก่อนหน้าให้จำ - ห้ามคิดเอาเองว่า retry จะมา ผู้ใช้อาจปฏิเสธ client อาจยอมแพ้ อย่าจอง resource ในขาแรกที่ต้องรอขาสองมาเก็บกวาด
- input ที่หายไปไม่ใช่ error ถ้า retry มาถึงโดยไม่มีสิ่งที่ขอไป และยังต้องใช้อยู่ ให้ return
InputRequiredResultใหม่ถามอีกรอบ — ไม่ใช่ JSON-RPC error - มองข้ามสิ่งที่ไม่ได้ขอ key เกินใน
inputResponsesให้เมิน - วนได้ server จะ return
input_requiredซ้ำหลายรอบ ถามทีละอย่าง จนกว่าจะได้ครบก็ได้
requestState ถือว่าถูกควบคุมโดยผู้โจมตี
หัวข้อที่มีชื่อว่า “requestState ถือว่าถูกควบคุมโดยผู้โจมตี”เพราะ requestState ออกจาก server แล้ววนกลับมาผ่าน client client ที่ประสงค์ร้ายหรือถูกยึดจึงแก้ไขมันได้ spec พูดตรง ๆ ว่า: ถ้า requestState มีผลต่อ authorization, การเข้าถึง resource หรือ business logic server ต้อง ปกป้อง integrity ของมัน — ทำ HMAC หรือ seal ด้วย AEAD — และปฏิเสธทุกอย่างที่ verify ไม่ผ่าน
เพื่อจำกัดการ replay ให้ใส่สิ่งเหล่านี้ไว้ใน payload ที่ถูกป้องกัน แล้วตรวจตอนได้รับ:
- principal ที่ authenticate แล้ว — ปฏิเสธ state ที่ผู้ใช้คนอื่นเอามายื่น
- TTL สั้น ๆ — ปฏิเสธ state ที่เก่าเกิน
- identifier ของ request ต้นทาง — ชื่อ method บวก digest ของ param สำคัญ เพื่อให้ state ที่ออกให้
provision_database("orders")เอาไป replay กับdelete_database("orders")ไม่ได้
มาตรการเหล่านี้จำกัดช่องโหว่ แต่ไม่รับประกันว่าใช้ได้ครั้งเดียว ถ้า state หนึ่ง ๆ ต้องถูกใช้ไม่เกินหนึ่งครั้ง (เช่นการแลกรับสิทธิ์ครั้งเดียว) ต้องบังคับฝั่ง server ด้วย store การป้องกัน integrity จะข้ามได้ ก็ต่อเมื่อ การแก้ไขข้อมูลทำได้อย่างมากแค่ทำให้ request ล้มเหลว
SDK ทำอะไรให้บ้าง
หัวข้อที่มีชื่อว่า “SDK ทำอะไรให้บ้าง”คุณแทบไม่ต้องประกอบ InputRequiredResult เองเลย SDK ทางการทั้งสองตัวให้เขียน handler เหมือนคำถามเป็น synchronous แล้วแยกเป็นสองขาให้เบื้องหลัง
import { McpServer } from '@modelcontextprotocol/server';import * as z from 'zod/v4';
const server = new McpServer({ name: 'infra', version: '1.0.0' });
server.registerTool( 'provision_database', { description: 'Create a database', inputSchema: z.object({ name: z.string() }) }, async ({ name }, ctx) => { // Looks synchronous. On the wire this returns input_required, // and the handler is re-run on the retry with the answer available. const answer = await ctx.mcpReq.elicitInput({ mode: 'form', message: `Which region should ${name} live in?`, requestedSchema: { type: 'object', properties: { region: { type: 'string', enum: ['eu-west-1', 'us-east-1'] } }, required: ['region'], }, }); if (answer.action !== 'accept') { return { content: [{ type: 'text', text: 'Provisioning cancelled.' }] }; } return { content: [{ type: 'text', text: `Created ${name} in ${answer.content?.region}` }] }; });from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServerfrom mcp.server.mcpserver import Elicit, Resolve
mcp = MCPServer("infra")
class Region(BaseModel): region: str
async def ask_region(name: str) -> Elicit[Region]: return Elicit(f"Which region should {name} live in?", Region)
# The Resolve parameter is filled from inputResponses on the retry.# The tool body only runs once the answer is present.@mcp.tool()async def provision_database( name: str, where: Annotated[Region, Resolve(ask_region)]) -> str: return f"Created {name} in {where.region}"รูปแบบ Resolve ของ Python เป็นแบบที่ใช้ได้ข้ามยุค: กับ client ยุคใหม่จะได้ MRTR สองขาที่สะอาด และยังทำงานกับ client ยุคเก่าที่คาดหวัง request แบบ push ได้ ไม่ว่าจะทางไหน mental model สำคัญคือ handler ของคุณอาจถูกรันมากกว่าหนึ่งครั้ง เก็บ side effect ไว้หลังจุดที่ input ครบทุกตัวแล้ว
ข้อคิดด้านการออกแบบ
หัวข้อที่มีชื่อว่า “ข้อคิดด้านการออกแบบ”- ถามทีละข้อเมื่อต้องเรียงลำดับ ถามหลายข้อพร้อมกันเมื่อไม่ต้อง
inputRequestsเป็น map คำถามที่เป็นอิสระต่อกันเดินทางไปด้วยกันได้ ประหยัดไปหนึ่งรอบ - ทำ
requestStateให้เล็ก มันวิ่งผ่าน client สองรอบและไปโผล่ใน log เข้ารหัสแค่ id กับ digest ไม่ใช่เอกสารทั้งก้อน - retry ต้อง idempotent retry ส่ง argument เดิมซ้ำ ถ้าขาแรกทำอะไรที่ย้อนกลับไม่ได้ไปแล้ว ขาสองต้องไม่ทำซ้ำ — อีกเหตุผลที่ควรเลื่อน side effect ออกไป