本文へ移動
🔁

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 state
  • inputResponses は未信頼入力
  • retryは同じ業務操作を安全に再評価する必要がある
  • approve / decline / cancelの意味はapplication側で決める

最も危険なのは、inputResponses にapproveが入っているだけでwriteを実行する実装です。誰が、何に、いつ、どのscopeで同意したかを検証しなければ、MRTRは承認境界になりません。

なぜMRTRが必要になったのか

MCP 2026-07-28 はprotocol coreをstatelessへ変更しました。

  • initialize / initialized handshakeを削除
  • 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は次です。

段階clientserver
1tools/call を送るtool処理を始める
2responseを受けるinput_required と必要入力を返す
3form、approval、samplingなどを処理するstateを保持せず待機を終える
4inputResponses と requestState を付けて元callを再送するstateと入力を検証して処理を再開する
5final 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側でも、業務に合うより小さい上限を持たせます。

私なら次のように始めます。

workflowmax roundstimeout
不足parameter入力22分
cost approval15分
URL mode auth210分
複数owner approvalMRTR単体で扱わず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 inputschema不一致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 onlyclientを自社管理し、移行を強制できる
dual erapublic 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します。

  1. valid acceptで1回だけwriteされる
  2. declineでwriteされない
  3. cancelでoperationが終了する
  4. expired requestStateを拒否する
  5. 改ざんしたrequestStateを拒否する
  6. 別userのresponseを拒否する
  7. arguments変更時に再承認を要求する
  8. 同じidempotency keyの重複writeを防ぐ
  9. malformed inputをschema errorにする
  10. max roundsで停止する
  11. 2026-era clientで動く
  12. 対応する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へ分ける方が、安全性と観測性を保ちやすくなります。

参考資料

WRITTEN BY nidoneko

Full-stack engineer with 8+ years of experience in TypeScript, React, Node.js, and cloud-native development across healthcare, finance, HR, and IoT domains.

View Profile →