MCP toolが処理の途中で「この金額で作成してよいか」「不足しているproject IDを入力してほしい」と確認したいことがあります。従来のserver-to-client requestをそのままremote MCPで使うと、長時間接続、session state、sticky routingが必要になり、確認待ちだけで運用が重くなります。
MCP 2026-07-28 で導入された Multi Round-Trip Requests(MRTR) は、この問題をrequest / responseの再送で解決します。serverは最終結果の代わりに resultType: "input_required" を返し、clientは必要な入力を集めて元のcallをやり直します。
この記事では、MCP stateless化の全変更ではなく、tool処理の途中で人間入力やclient側処理を要求し、安全に再開するMRTRだけを扱います。旧 Mcp-Session-Id と互換運用は MCP Session 管理ガイド、transport全体は MCP Streamable HTTP 移行ガイド、実行直前の人間判断は AIエージェント承認フロー設計ガイド、toolの自己申告riskは MCP tool annotations 設計ガイド に分けています。
先に結論: MRTRは承認APIではなく、入力を往復させるtransport pattern
MRTRを導入しても、承認の意味や権限境界は自動で決まりません。
input_requiredは「追加入力が必要」という状態elicitationは追加入力の1種類requestStateは再開用のopaque stateinputResponsesは未信頼入力- retryは同じ業務操作を安全に再評価する必要がある
- approve / decline / cancelの意味はapplication側で決める
最も危険なのは、inputResponses にapproveが入っているだけでwriteを実行する実装です。誰が、何に、いつ、どのscopeで同意したかを検証しなければ、MRTRは承認境界になりません。
なぜMRTRが必要になったのか
MCP 2026-07-28 はprotocol coreをstatelessへ変更しました。
initialize/initializedhandshakeを削除- protocol-level sessionと
Mcp-Session-Idを削除 - requestごとにprotocol versionとclient capabilitiesを渡す
server/discoverで事前にcapabilityを確認できる- server-to-client request channelを廃止
従来の elicitation/create、sampling/createMessage、roots/list は、serverがclientへrequestを返すための経路を必要としていました。remote MCPでは、長時間のSSE streamやserver-side stateを保持する必要があり、水平分散と相性がよくありません。
MRTRでは、serverが「入力待ちの結果」を通常responseとして返します。clientは別の処理で入力を集め、新しいrequest IDで元のoperationを再送します。どのserver instanceに届いても、payloadだけで再開できる形に寄せられます。
処理の流れは5段階
MRTRの基本flowは次です。
| 段階 | client | server |
|---|---|---|
| 1 | tools/call を送る | tool処理を始める |
| 2 | responseを受ける | input_required と必要入力を返す |
| 3 | form、approval、samplingなどを処理する | stateを保持せず待機を終える |
| 4 | inputResponses と requestState を付けて元callを再送する | stateと入力を検証して処理を再開する |
| 5 | final resultを受ける | resultType: complete を返す |
1つのlogical operationでも、wire上は複数requestです。したがって、request IDではなくapplication-level operation IDで追跡します。
wire imageを理解する
SDKを使う場合、細かいwire fieldは内部処理されます。それでも、障害調査のために形は理解しておくべきです。
最初のtool callを送ります。
{
"jsonrpc": "2.0",
"id": 101,
"method": "tools/call",
"params": {
"name": "create_project",
"arguments": {
"name": "demo"
}
}
}
serverは完了結果ではなく、追加入力が必要だと返します。次は概念を示す簡略例です。
{
"jsonrpc": "2.0",
"id": 101,
"result": {
"resultType": "input_required",
"inputRequests": {
"approval": {
"method": "elicitation/create",
"params": {
"message": "月額10ドルのprojectを作成しますか"
}
}
},
"requestState": "opaque-signed-state"
}
}
clientは人間の回答を集め、新しいrequest IDで同じtool callを再送します。
{
"jsonrpc": "2.0",
"id": 102,
"method": "tools/call",
"params": {
"name": "create_project",
"arguments": {
"name": "demo"
},
"inputResponses": {
"approval": {
"action": "accept",
"content": {
"approved": true
}
}
},
"requestState": "opaque-signed-state"
}
}
ここで元の arguments も再送されます。serverは最初のrequestをmemory上に保持している前提を置きません。
TypeScript SDKではhigh-level APIを使う
MCP TypeScript SDK v2のmigration guideでは、2026-era handlerはpush-style APIの代わりに inputRequired(...) を返す形へ変わります。
登録済みtool handlerのbodyだけを抜き出すと、概念的には次のような実装です。
import { acceptedContent, inputRequired } from "@modelcontextprotocol/server";
import { z } from "zod";
const Approval = z.object({
approved: z.boolean(),
});
const approval = acceptedContent(
ctx.mcpReq.inputResponses,
"approval",
Approval,
);
if (!approval) {
return inputRequired({
inputRequests: {
approval: inputRequired.elicit({
message: "月額10ドルのprojectを作成しますか",
requestedSchema: {
type: "object",
properties: {
approved: { type: "boolean" },
},
required: ["approved"],
},
}),
},
});
}
if (!approval.approved) {
return {
content: [{ type: "text", text: "作成を中止しました" }],
};
}
await authorizeCurrentRequest(ctx);
return createProject();
APIの細部はSDK versionで変わる可能性があるため、実装時はcurrent migration guideを確認してください。重要なのは、responseをschemaで検証し、再入時にもpolicy checkを通すことです。
requestState は署名するが、秘密を入れない
MRTRでは、複数roundで必要なstateを requestState に載せられます。ただし、TypeScript SDK docsは requestState を opaque, untrusted string として扱うよう明記しています。
clientはその値をbyte-exactで返しますが、「書き換えないはず」と信用してはいけません。
安全な requestState には次を入れます。
{
"operationId": "op_20260814_001",
"phase": "awaiting_cost_approval",
"resource": "project:demo",
"requestedBy": "user_123",
"policyVersion": "2026-08-14",
"expiresAt": "2026-08-14T10:10:00Z"
}
そして次を実施します。
- HMACやserver-side keyで署名する
- user / tenant / tool / argumentsへbindする
- TTLを短くする
- phaseを明示する
- nonceまたはoperation IDでreplayを検知する
- 機密情報は入れない
TypeScript SDKには createRequestStateCodec のようなsealing helperがあります。ただし、公式migration guideが説明する通り、標準helperは署名であり暗号化ではありません。base64url decodeされても困らない情報だけを入れます。
inputResponses は必ずschema validationする
clientがelicitation formを表示したとしても、serverが受け取る内容は未信頼です。
検証する項目は次です。
- response keyが要求したkeyと一致するか
accept/decline/cancelを区別できるか- contentがrequested schemaに合うか
- 金額やresource IDが最初のrequestと一致するか
- user / tenantが同じか
- responseが期限内か
SDKの acceptedContent にschemaを渡すと、acceptされたcontentを検証できます。validationに失敗した場合、無理にdefaultへ落とさず、再入力または安全な中止へ戻します。
悪い実装は次です。
if (ctx.mcpReq.inputResponses?.approval) {
await executeWrite();
}
存在確認だけでは、decline、malformed input、別operationのresponseを区別できません。
承認内容をoperationへbindする
「作成を許可する」という承認だけでは広すぎます。最低限、次のtupleへbindします。
user + tenant + tool + normalized arguments + estimated effect + policy version + expiry
具体例では、次をapproval画面に出します。
approval_request:
tool: create_project
account: staging-team
project_name: demo
monthly_cost_usd: 10
external_effect: true
idempotency_key: op_20260814_001
expires_in_seconds: 300
再送時にargumentが demo から production へ変わっていたら、承認を無効にします。costが再計算で増えた場合も、再承認へ戻します。
再送ごとにauthorizationをやり直す
最初のrequestで認証済みでも、入力待ちの間に状況が変わります。
- OAuth tokenが失効する
- userのroleが変わる
- resource ownershipが変わる
- policy versionが更新される
- budgetを別operationが消費する
- target resourceが削除される
そのため、final writeの直前にauthorizationとpreconditionを再評価します。
authenticate -> validate requestState -> validate inputResponses
-> authorize current user -> reload resource -> check budget
-> enforce idempotency -> execute
MRTRの再送は、最初のrequestの権限を無期限に延長する仕組みではありません。
idempotency keyをoperation単位で固定する
network timeoutやclient再起動により、同じroundが複数回届く可能性があります。external writeにはidempotency keyを使います。
idempotency_record:
key: op_20260814_001
tool: create_project
arguments_hash: sha256:example
phase: completed
result_ref: project_789
同じkeyと同じargumentsなら既存resultを返します。同じkeyでargumentsが違う場合はerrorにします。
request IDだけをidempotency keyにすると、MRTR retryでrequest IDが変わるため機能しません。logical operation IDを使います。
round数とtimeoutを必ず制限する
入力不足のたびに新しい input_required を返せるため、設計を誤ると無限loopになります。
TypeScript SDK docsでは、client側auto-fulfilmentにmax roundsがあり、defaultは10と説明されています。legacy shim側にも別の上限があります。application側でも、業務に合うより小さい上限を持たせます。
私なら次のように始めます。
| workflow | max rounds | timeout |
|---|---|---|
| 不足parameter入力 | 2 | 2分 |
| cost approval | 1 | 5分 |
| URL mode auth | 2 | 10分 |
| 複数owner approval | MRTR単体で扱わずTask化 | workflow側で管理 |
3回以上のhuman interactionが必要なら、1つのtool callに詰めず、MCP Tasksやapplication workflowへ分ける方が追跡しやすいです。
decline、cancel、timeoutを別の結果にする
すべてを isError: true にすると、agentがretryしてよいのか分かりません。
| 状態 | 意味 | 推奨する扱い |
|---|---|---|
| decline | 人間が意図的に拒否 | retryしない、別案へ戻す |
| cancel | 操作を中断 | operationをcancelledにする |
| timeout | 期限内に回答なし | expiredとして再承認が必要 |
| invalid input | schema不一致 | bounded retryまたは中止 |
| policy changed | 条件が変わった | 新しい条件で再承認 |
| server error | 実装・依存障害 | idempotency確認後にretry判断 |
agentへのmessageも区別します。
{
"status": "declined",
"retryable": false,
"nextAction": "propose_read_only_alternative"
}
「失敗したので再試行してください」だけでは、外部writeの二重実行を招きます。
ephemeralとpersistent workflowを分ける
MRTRの設計では、workflowを大きく2つに分けます。
ephemeral workflow
入力が揃えば最初から計算し直せる処理です。
- weather query
- parameter不足の検索
- cost見積もりの確認
- 短いform入力
必要stateは署名済み requestState に載せ、どのinstanceでも再開できます。
persistent workflow
入力待ち前に大きなstateを作る、またはbackground処理を続けるものです。
- VM作成
- 長時間agent run
- 複数段階のdocument generation
- ownerが複数いるapproval
この場合は、server-side durable stateやMCP Tasks extensionを使い、requestState にはtask handleだけを載せます。巨大な中間成果物をrequestStateへ詰めません。
legacy clientとの互換性を決める
2025-era clientはserver-to-client request方式を使います。current TypeScript SDKには、2026-styleの inputRequired(...) handlerをlegacy sessionへbridgeするshimがあります。
ただし、互換性には条件があります。
- stdioまたはsessionfulなlegacy transportが必要
- client capabilityがない場合は拒否される
- stateless legacy HTTPではreturn pathがなく、full bridgeできない
- JSON-only response modeではinteractive requestが届かない場合がある
- timeoutとround上限は2026-eraと同じとは限らない
対応方針は3つです。
| 方針 | 向く状況 |
|---|---|
| 2026-07-28 only | clientを自社管理し、移行を強制できる |
| dual era | public serverで段階移行が必要 |
| legacy継続 | まだMRTR対応clientが少なく、interactive toolが重要 |
「SDKがfallbackするから大丈夫」で終わらせず、対応eraごとのintegration testを持ちます。
observabilityはroundではなくoperationで束ねる
MRTRではrequest IDがroundごとに変わります。監査とtraceには共通のoperation IDを残します。
mrtr_trace:
operation_id: op_20260814_001
round: 2
request_id: 102
tool: create_project
phase: approval_received
input_kind: elicitation
decision: accepted
policy_version: 2026-08-14
state_verified: true
authorization_rechecked: true
idempotency_status: first_execution
ログへ inputResponses のraw contentを残さないようにします。必要なdecisionとhashだけを残し、個人情報やsecretを避けます。
見るべきmetricは次です。
- MRTR operation数
- average rounds
- decline / timeout率
- invalid response率
- requestState verification failure
- authorization recheck failure
- idempotency duplicate hit
- rounds exceeded
round数が増えているなら、tool schemaやtask intakeが不足している可能性があります。
test caseを先に作る
最低限、次を自動testします。
- valid acceptで1回だけwriteされる
- declineでwriteされない
- cancelでoperationが終了する
- expired requestStateを拒否する
- 改ざんしたrequestStateを拒否する
- 別userのresponseを拒否する
- arguments変更時に再承認を要求する
- 同じidempotency keyの重複writeを防ぐ
- malformed inputをschema errorにする
- max roundsで停止する
- 2026-era clientで動く
- 対応するlegacy pathで期待通りfallbackする
external serviceを使うtestはsandbox accountへ限定し、production credentialを使いません。
よくある失敗
requestState をただのJSONとして信用する
client側で改ざんできます。署名、TTL、binding、replay protectionを入れます。
requestState にsecretを入れる
署名は暗号化ではありません。token、cookie、個人情報を入れません。
approve後にauthorizationを再確認しない
入力待ち中にroleやresourceが変わります。write直前に再評価します。
request IDをidempotency keyにする
retryでrequest IDは変わります。operation IDを固定します。
すべてのworkflowをMRTRへ詰める
長時間・多段・複数ownerのworkflowはTaskやdurable stateへ分けます。
declineをretryable errorにする
agentが同じ承認を繰り返します。人間拒否はterminal decisionとして扱います。
実装チェックリスト
- clientとserverが
2026-07-28をnegotiationできる -
input_requiredとcompleteを区別できる - input requestごとのschemaを定義した
-
inputResponsesを未信頼入力として検証する -
requestStateを署名し、TTLとbindingを設定した - requestStateにsecretを入れていない
- approvalをtool、arguments、effectへbindした
- retryごとにauthorizationとresource stateを再確認する
- operation-level idempotency keyを使う
- max roundsとtimeoutを設定した
- decline、cancel、timeout、invalidを分けた
- persistent workflowをTaskへ分離した
- legacy clientのsupport policyを決めた
- operation IDでtraceとaudit logを束ねた
- tampering、replay、duplicate writeをtestした
まとめ
MCP MRTRは、statelessなremote MCPでも、tool処理の途中で人間入力やclient側処理を要求できる仕組みです。長時間のbidirectional streamへ依存せず、input_required と元requestの再送でinteractionを続けられます。
ただし、MRTRは承認の正当性やstateの安全性を自動では保証しません。requestState は署名しても公開可能な情報だけにし、inputResponses をschema validationし、write直前にauthorizationを再確認する必要があります。request IDとは別のoperation IDとidempotency keyも必須です。
最初は1回のcost approvalや不足parameter入力のようなephemeral workflowから導入してください。多段で長時間の処理はMCP Tasksやapplication側のdurable workflowへ分ける方が、安全性と観測性を保ちやすくなります。