ElicitationとMulti Round-Trip Requests
サーバーが処理の途中でユーザー入力を必要とする場合の仕組み (Elicitation と Multi Round-Trip Requests、以下 MRTR) と、非推奨になったクライアント機能 (Roots / Sampling / Logging) の扱いを解説します。2026-07-28 版は、プロトコルがステートレスになったため、サーバーからクライアントへのリクエストの方式が変わりました。変更の全体像は MCP最新仕様 2026-07-28 の変更点と移行、Tools のリクエストの流れは Tools - ツールの定義と実行 を参照してください。
1. このドキュメントで扱うこと
- Elicitation の二つのモード
- Multi Round-Trip Requests (MRTR)
inputRequestsに入れられるものrequestStateの扱い- 旧方式との違い
- セキュリティ
- Roots の非推奨
- Sampling の非推奨
- Logging の非推奨
Elicitation の二つのモード
Elicitation は、サーバーがクライアントを通じてユーザーに追加の情報を求める仕組みです。クライアントは、ユーザーとのやり取りと、共有するデータの管理を手放しません。モードは 2 つあります。
| モード | 用途 | データの流れ |
|---|---|---|
フォーム (form) | 構造化された入力 (名前、メール、選択肢など) | クライアントを経由する。クライアントがフォームを描画する |
URL (url) | 認証、決済、API キーの入力など、機密性の高い帯域外のやり取り | URL 以外のデータは、クライアントに見えない |
mode は省略でき、その場合はフォームとして扱います。リクエストには、なぜ入力が必要かを説明する message が必須です。
クライアントは、対応するモードを capability で宣言します。空の elicitation: {} は、フォームだけに対応するという意味です。サーバーは、クライアントが宣言していないモードのリクエストを送ってはいけません。
{
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": { "form": {}, "url": {} }
}
}
}
フォームモードは、requestedSchema に、JSON Schema のサブセットを指定します。クライアントの実装を単純にするため、プロパティがプリミティブ型だけの平らなオブジェクトに限られます。使えるのは、文字列 (minLength、maxLength、format は email / uri / date / date-time)、数値と整数 (minimum、maximum)、真偽値、列挙 (単一選択、oneOf と const による表示名付きの選択、複数選択) です。入れ子の構造やオブジェクトの配列は、意図的に除外されています。default を指定すると、クライアントはフォームの初期値にできます。
応答は 3 つのアクションで表します。
action | 意味 | content |
|---|---|---|
accept | ユーザーが明示的に承認し、送信した | フォームなら、スキーマに合う値。URL モードでは省略 |
decline | ユーザーが明示的に拒否した | 通常は省略 |
cancel | 明示的な選択なしに閉じた (ダイアログを閉じる、Escape など) | 通常は省略 |
サーバーは、3 つそれぞれを適切に扱います。decline は代替案を示し、cancel は後で再度尋ねる、といった具合です。ユーザーが拒否または取り消すこと、クライアントが処理に失敗することを前提にして書きます。
URL モードは、url に、ユーザーが開く URL を指定します。認証、決済、サーバーが保管する API キーの入力のように、クライアントやモデルのコンテキストを通してはいけないやり取りのためのものです。
{
"method": "elicitation/create",
"params": {
"mode": "url",
"url": "https://mcp.example.com/ui/set_api_key",
"message": "Please provide your API key to continue."
}
}
URL モードの accept は、ユーザーが URL を開くことに同意した、という意味にすぎません。やり取りは帯域外で行われ、完了したかどうかはクライアントに通知されません。サーバーは、再試行を受けたときに、完了したかを自分で判断します (後述)。クライアントは、ユーザーが元のリクエストを再試行またはキャンセルできる操作を用意することが推奨されます。
URL モードは、MCP サーバーそのものへのクライアントの認可 (MCPの認可) のためのものではありません。サーバーが、外部の (サードパーティの) サービスの認可を、ユーザーに代わって得るときに使います。クライアントのベアラートークンは変わりません。
Multi Round-Trip Requests (MRTR)
2026-07-28 版では、サーバーはクライアントへ、独立したリクエストを送れません。代わりに、クライアントのリクエストへの応答として「追加の入力が必要」と返し、クライアントが入力を付けて同じリクエストを再試行します。これが MRTR です。共有ストレージや、スティッキーなロードバランシングを必要とせずに、サーバー起点のやり取りができます。
流れは、クライアントの通常のリクエスト、サーバーの input_required、入力を付けた再試行、サーバーの最終結果の 4 手順です (表は後述の「MRTR の流れ」を参照)。
ポイントは、各リクエストが完全に独立していることです。再試行を処理するサーバーのインスタンスは、再試行のリクエストに含まれる情報以外を必要としません。
MRTR を使えるのは、次のクライアントのリクエストだけです。ほかのリクエストでは、サーバーは input_required を返してはいけません。
tools/callresources/readprompts/get
クライアントの規則は次のとおりです。
inputRequestsがあれば、再試行の前に、その入力を用意しなければならない。なければ、すぐに再試行してよいrequestStateがあれば、一字一句そのまま再試行に付ける。中身を調べたり、解析したり、変更したりしてはならない。サーバーがrequestStateを返していなければ、付けてはならない- 再試行の JSON-RPC
idは、最初のリクエストと異なる値にする。別々のリクエストであるため inputRequestsとrequestStateは、再試行だけに影響する。並行して送っているほかのリクエストには使えない
サーバーの規則は次のとおりです。
InputRequiredResultには、inputRequestsとrequestStateの少なくとも一方を含める- クライアントが capability を宣言していない種類のリクエストを、
inputRequestsに入れてはいけない - クライアントが入力を満たす、あるいは再試行するとは限らない。必要な情報が欠けた再試行には、エラーにせず、もう一度
input_requiredを返して求める inputResponsesに、期待しない項目があれば、無視してよい
inputRequests に入れられるもの
inputRequests は、サーバーが付けた識別子をキーにしたマップで、値は次のいずれかのリクエストです。キーは、そのリクエストの中で一意にします。
| 値のリクエスト | 内容 | 状態 |
|---|---|---|
elicitation/create | ユーザーへの入力要求 (フォーム、URL) | 推奨 |
sampling/createMessage | クライアントの LLM への生成の要求 | 非推奨 |
roots/list | クライアントのルートの一覧 | 非推奨 |
対応する inputResponses は、同じキーに、クライアントの結果 (Elicitation なら action と content) を入れたマップです。
{
"resultType": "input_required",
"inputRequests": {
"github_login": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please provide your GitHub username",
"requestedSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}
}
}
},
"requestState": "AEAD-protected blob"
}
再試行は、同じ tools/call に inputResponses と requestState を付けます。
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "New York" },
"inputResponses": {
"github_login": { "action": "accept", "content": { "name": "octocat" } }
},
"requestState": "AEAD-protected blob"
}
}
requestState の扱い
requestState は、サーバーが再試行の間に状態を持ち回るための、サーバーだけが意味を知る不透明な文字列です。形式 (base64 の JSON、暗号化した JWT、バイナリーなど) は自由です。サーバーは、状態をサーバー側に保存せずに、ここへ詰めて、クライアントに預けられます。
ただし、クライアントを通るため、攻撃者が制御する入力として扱わなければなりません。
requestStateが認可、リソースへのアクセス、ビジネスロジックに影響するなら、HMAC や AEAD で完全性を保護し、検証に失敗した状態は拒否しなければならない。改ざんしても、リクエストが失敗するだけの場合に限り、保護を省略できる- リプレイを防ぐため、完全性保護の対象に、次のものを含めて、受け取った際に検証することが推奨される
- 認証された主体 (別の主体が提示した状態は拒否する)
- 短い有効期限 (期限後の状態は拒否する)
- 元のリクエストの識別子 (メソッド名と、主なパラメーターのダイジェストなど。一致しないリクエストの状態は拒否する)
- これらは、再利用できる範囲を狭め、ユーザーやリクエストをまたいだ流用を防ぐが、1 回しか使えないことまでは保証しない。1 回限りの引き換えのように、最大 1 回の消費が必須なら、サーバー側で強制する
サーバーが識別子 (ユーザー ID、注文番号、外部の認可の状態) を requestState に埋め込む場合は、署名の対象に入れ、復号した値を鵜呑みにせず、現在の認証情報と突き合わせます。機密そのもの (外部サービスのトークンなど) は、暗号化していても requestState に入れず、サーバー側に保管します。
旧方式との違い
旧方式では、サーバーが、クライアントの要求の処理中に、SSE のストリームなどを通じて elicitation/create、sampling/createMessage、roots/list を独立したリクエストとして送り、クライアントの応答を待ちました。2026-07-28 版は、これをサポートしません。破壊的変更です。サーバーからクライアントへのリクエストは、すべて MRTR で行います。
| 観点 | 旧方式 | MRTR |
|---|---|---|
| 起点 | サーバーがリクエストを送る | クライアントのリクエストへの応答として、サーバーが入力を求める |
| 待機 | 元のリクエストを開いたまま、サーバーが応答を待つ | 元のリクエストはいったん完結。再試行は独立したリクエスト |
| 状態 | 接続やサーバー内に保持 | requestState に詰めて、クライアントに預ける |
| インスタンス | 同じインスタンスに戻る必要がある | どのインスタンスでも処理できる |
| 長時間の接続 | SSE などが必要 | 不要 |
URL モードの完了を知らせる notifications/elicitation/complete も、サーバーからの通知として廃止されました。サーバーは、クライアントが再試行したときに、requestState (または自分の保管する状態) から、帯域外のやり取りが完了したかを知り、結果を返すか、まだなら再度 input_required を返します。完了するまで再試行の中で待つ、という実装もあり得ます。結果は、再試行で知るのが基本です。
また、状態を保管しなくてよい、というのは MRTR の利点ですが、必須ではありません。サーバーが状態を保管する場合は、ユーザーに安全に紐付け、不正なアクセスから保護しなければなりません。リモートサーバーでは、ユーザーの識別は、MCP の認可で得た資格情報 (たとえば sub クレーム) から導きます (MCPの認可)。
セキュリティ
Elicitation で最も重要なのは、機密情報をフォームモードで求めないことです。パスワード、API キー、アクセストークン、決済情報などは、フォームを使ってはならず (MUST NOT)、URL モードを使わなければなりません。氏名、メールアドレス、ユーザー名のような一般的な連絡先や属性の情報は、一律には禁止されていません。サーバーの判断で、ユーザーが確認して拒否できることを前提に、フォームで求められます。
クライアントは、次のことをしなければなりません。
- どのサーバーが情報を求めているかを、UI で分かるようにする
- 拒否とキャンセルの選択肢を、はっきり示す
- フォームでは、送信前に、ユーザーが内容を確認して修正できるようにする
- URL モードでは、移動先のドメインを表示し、ユーザーの同意を得てから開く
URL モードの悪用 (フィッシング) に備えて、URL に関する規則が詳細に定められています。
サーバーは、次のようにします。
- URL に、ユーザーの機微な情報 (資格情報、個人情報) を入れない
- 保護されたリソースへ事前に認証済みの URL を渡さない。悪意あるクライアントがなりすませるため
- HTTPS を使う (開発環境を除く)
- フォームモードの項目の中に、クリックさせる URL を入れない
クライアントは、次のようにします。
- URL や、そのメタデータを自動で先読みしない
- ユーザーの明示的な同意なしに、URL を開かない
- 完全な URL を、同意の前にユーザーが確認できるよう表示する
- クライアントやモデルが、ページの内容や入力を見られない安全な方法 (OS 標準のブラウザー画面など) で開く。たとえば iOS の
SFSafariViewControllerは適しているが、WKWebViewは適さない - サブドメインのなりすましを防ぐため、ドメインを強調する。Punycode のような曖昧、不審な URI には警告を出す
もう 1 つの重要な攻撃が、URL の転送によるアカウントの乗っ取りです。URL モードは URL を返すため、攻撃者がその URL を被害者に送ることができます。
- 悪意あるユーザー (Alice) が、サーバーに接続して Elicitation を起こす
- サーバーが、外部のサービスへの OAuth の認可 URL を生成し、Alice のクライアントが表示する
- Alice は、自分では開かず、同じサーバーのユーザー Bob にその URL を開かせる
- Bob は、自分の接続の認可だと思って完了させる
- サーバーは、コールバックを Alice のリクエストのものと扱い、外部サービスのトークンを Alice の ID に紐付ける。結果として、Bob のアカウントのデータを Alice が使える
防ぐには、Elicitation を始めたユーザーと、URL を開いて認可を完了するユーザーが同一人物であることを、サーバーが確認しなければなりません。一般的な方法は、外部の認可エンドポイントではなく、サーバー自身の「接続用 URL」(たとえば https://mcp.example.com/connect?...) を返すことです。そのページで、セッション Cookie などでユーザーを識別し、Elicitation を生成したユーザー (MCP の認可サーバーの sub) と一致することを確認してから、外部の認可サーバーへリダイレクトします。URL を改ざんされても破られない方法で、識別しなければなりません。
関連して、サーバーが外部サービスから得たトークンを、MCP クライアントに渡してはいけません。また、クライアントのトークンを外部サービスへ使い回すトークンパススルーは禁止されています。詳細は MCPセキュリティベストプラクティス - 攻撃と対策 を参照してください。さらに、サーバーが、クライアントから渡された自己申告のユーザー識別を信用してはいけません (I am [email protected] のような入力を、本人確認として扱わない)。
Roots の非推奨
Roots は、クライアントが、関連するディレクトリーやファイルをサーバーに伝える機能でした。2026-07-28 版で非推奨になりました (SEP-2577)。もともと「情報としての指針」にすぎず、サーバーが従う義務もなく、採用も限られていました。
代替は、ツールの引数、リソースの URI、サーバーの設定で、作業対象のディレクトリーやファイルを明示的に渡すことです。たとえば、analyze_project のようなツールに path を引数で渡し、サーバーがそれを許可された範囲の中だけで解決する、という形にします。
Sampling の非推奨
Sampling は、サーバーが、クライアントの LLM に生成を要求する機能でした。これも非推奨です。実装が複雑 (人間の承認、モデルの選択、ツールのループ) で、プロンプトインジェクションやデータ流出の攻撃面にもなっていました。
新しい実装は採用せず、LLM が必要なサーバーは、LLM プロバイダーの API を直接利用します。モデルの選択、パラメーター、ストリーミングをサーバーが制御できます。既存の実装も、移行が推奨されます。
includeContext の thisServer と allServers も、個別に非推奨です。サーバーは、これらを避け (既定値の none なら省略できる)、クライアントが sampling.context を宣言していない限り使ってはいけません。
Logging の非推奨
Logging (logging/setLevel、notifications/message) も非推奨です。代替は、stdio のサーバーなら stderr、構造化された可観測性なら OpenTelemetry です。詳細は MCPの可観測性と監査 を参照してください。
三つの非推奨は、いずれも、2026-07-28 版のリリースから少なくとも 12 か月は、仕様に残ります。それまでの間、ワイヤー上の挙動は変わらず、互換性が保たれます。実装は、非推奨の capability を交渉した場合に警告を出すことが推奨されます。MRTR では、roots/list や sampling/createMessage も inputRequests に入れられますが、新規の実装では使いません。
2. MRTR の流れ
| 手順 | 送信者 | 内容 |
|---|---|---|
| 1 | クライアント | 通常どおり tools/call を送る |
| 2 | サーバー | 追加情報が必要なら input_required と inputRequests を返す |
| 3 | クライアント | ユーザーに確認し、inputResponses を付けて同じリクエストを再送する |
| 4 | サーバー | 必要な情報が揃えば complete の結果を返す |
3. 開発者がつまずきやすいポイント
3.1 再試行の JSON-RPC id を再利用する
再試行は、独立した新しいリクエストです。最初のリクエストと同じ id を使ってはいけません。
3.2 requestState を加工する
クライアントが requestState を解析して書き換える、あるいは、付けない、といった実装は、サーバーが状態を復元できずに失敗します。受け取った文字列を、そのまま返します。
3.3 requestState を署名なしで信用する
requestState に「承認済み」や「ユーザー ID」を平文で入れ、そのまま信用すると、クライアントが書き換えて認可を迂回できます。完全性の保護、主体と有効期限と元のリクエストの束縛を行い、1 回限りが必要なら、サーバー側で強制します。
3.4 サーバー内に待機する状態を持とうとする
旧方式のように、サーバーのメモリーで応答を待つ実装は、複数インスタンスでは動きません。必要な状態は、requestState に詰める (保護して) か、共有ストレージに保管します。
3.5 サポートしていない種類の inputRequests を送る
クライアントが elicitation を宣言していないのに elicitation/create を入れてはいけません。各リクエストの _meta にある capability を確認します。
3.6 input_required を返せないメソッドで返す
MRTR は、tools/call、resources/read、prompts/get だけです。
3.7 URL モードの accept を完了と取り違える
accept は、同意にすぎません。ユーザーが実際に完了したかを、再試行を受けたサーバーが判断します。まだなら、再度 input_required を返します。
3.8 機密情報をフォームで求める
API キーやパスワードのフォームは、仕様違反です。URL モードで、サーバー自身の信頼できるページへ送ります。
3.9 notifications/elicitation/complete を待つ
廃止されています。完了は、再試行で知ります。
4. コード例
ツールの途中でユーザーの確認を求める (サーバー)
TypeScript で、requestState を HMAC で保護しながら、MRTR を実装する例です。SDK の API は変わるため、ここでは、プロトコル上のやり取りに注目してください。
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.STATE_SECRET!; // 鍵は、シークレットマネージャーなどで管理する
type State = { sub: string; tool: string; argsDigest: string; exp: number };
function seal(state: State): string {
const body = Buffer.from(JSON.stringify(state)).toString("base64url");
const mac = createHmac("sha256", SECRET).update(body).digest("base64url");
return `${body}.${mac}`;
}
function open(token: string, expectedSub: string, expectedDigest: string): State {
const [body, mac] = token.split(".");
const expected = createHmac("sha256", SECRET).update(body).digest("base64url");
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) {
throw new Error("invalid requestState");
}
const state: State = JSON.parse(Buffer.from(body, "base64url").toString());
// 主体、有効期限、元のリクエストが一致するか検証する
if (state.sub !== expectedSub || state.exp < Date.now() || state.argsDigest !== expectedDigest) {
throw new Error("requestState does not match this request");
}
return state;
}
async function handleDeleteProject(req: ToolCallRequest, principal: string) {
const digest = digestOf(req.params.name, req.params.arguments);
const responses = req.params.inputResponses;
if (!responses?.confirm) {
return {
resultType: "input_required",
inputRequests: {
confirm: {
method: "elicitation/create",
params: {
mode: "form",
message: `Delete project ${req.params.arguments.id}? This cannot be undone.`,
requestedSchema: {
type: "object",
properties: { ok: { type: "boolean", title: "Delete" } },
required: ["ok"],
},
},
},
},
requestState: seal({
sub: principal,
tool: req.params.name,
argsDigest: digest,
exp: Date.now() + 5 * 60_000,
}),
};
}
open(req.params.requestState!, principal, digest); // 検証に失敗したら例外
const r = responses.confirm;
if (r.action !== "accept" || !r.content?.ok) {
return { resultType: "complete", isError: true, content: [{ type: "text", text: "Deletion was not confirmed." }] };
}
await deleteProject(req.params.arguments.id);
return { resultType: "complete", content: [{ type: "text", text: "Deleted." }] };
}
再試行のループ (クライアント)
async function callToolWithInput(client: McpClient, name: string, args: object) {
let inputResponses: Record<string, unknown> | undefined;
let requestState: string | undefined;
for (let attempt = 0; attempt < 5; attempt++) {
// JSON-RPC の id は、呼び出しのたびに新しくなる (SDK が採番する)
const result = await client.request("tools/call", {
name,
arguments: args,
...(inputResponses && { inputResponses }),
...(requestState !== undefined && { requestState }), // 受け取った値を、そのまま返す
});
if (result.resultType !== "input_required") return result;
requestState = result.requestState; // 解析しない
inputResponses = {};
for (const [key, request] of Object.entries(result.inputRequests ?? {})) {
inputResponses[key] = await askUser(request); // ユーザーに表示して回答を得る
}
}
throw new Error("too many input_required rounds");
}
URL モードの要求
{
"resultType": "input_required",
"inputRequests": {
"connect_github": {
"method": "elicitation/create",
"params": {
"mode": "url",
"url": "https://mcp.example.com/connect?ticket=opaque-one-time-ticket",
"message": "Connect your GitHub account to continue."
}
}
},
"requestState": "AEAD-protected blob"
}
URL には、ユーザーの機微な情報を入れず、事前に認証済みの URL にもしません。ticket は、サーバー側で、このユーザーに紐付く、使い捨ての不透明な値にします。
5. まとめ
- Elicitation には、構造化入力のフォームモードと、機密性の高いやり取りの URL モードがある。機密情報はフォームで求めない
- サーバーからクライアントへのリクエストは、MRTR (
input_required、inputRequests、inputResponses、requestState) で行う。旧方式のサーバー起点のリクエストは、サポートされない - MRTR を使えるのは、
tools/call、resources/read、prompts/get - 再試行は別の
idの独立したリクエスト。クライアントはrequestStateをそのまま返す requestStateは、攻撃者が制御する入力。完全性を保護し、主体、有効期限、元のリクエストに束縛する- URL モードでは、URL を開くユーザーと Elicitation を始めたユーザーの同一性を確認する。クライアントは完全な URL を表示し、同意を得る
- Roots、Sampling、Logging は非推奨。ツールの引数や設定、LLM プロバイダーの直接の利用、stderr や OpenTelemetry へ移行する