MCP toolをReactへ載せると、chat bubbleの見た目より先に困ることがあります。承認待ちなのか、実行を始めてよいのか、処理中なのか止まったのか。その区別がUIから消えます。
transcriptにはtool callと結果が残っていても、ユーザーが知りたいのは実行の現在地と次に押せる操作です。
最近のagent SDKはtyped tool output、approval、resume、telemetry、状態を持つ実行環境を扱うようになりました。MCP Appsではinteractive UIをsandboxed iframeへ描画する経路も入ってきます。SDKのeventをそのままmessage componentへ流すだけでは、状態と権限の境界が足りません。
自分ならchat componentを作り込む前に、app側のtool stateを型にします。この記事では、特定SDKの型名には寄せず、React UIが使う小さなcontractを作ります。
SDKのevent名をUIのcontractにしない
SDKやprotocolが変わるたびにcomponentの分岐を書き換える設計はつらいです。source eventはadapterで受け、UIにはapp側の状態だけを渡します。
type ToolCallContext<TInput> = {
callId: string;
toolName: string;
input: TInput;
};
type FailureReason =
| "rejected"
| "timeout"
| "tool-error";
type ToolViewState<TInput, TOutput> =
| {
status: "input-ready";
context: ToolCallContext<TInput>;
}
| {
status: "approval-required";
context: ToolCallContext<TInput>;
requestedAt: number;
}
| {
status: "running";
context: ToolCallContext<TInput>;
startedAt: number;
}
| {
status: "output-ready";
context: ToolCallContext<TInput>;
output: TOutput;
}
| {
status: "failed";
context: ToolCallContext<TInput>;
reason: FailureReason;
message: string;
retryable: boolean;
};
これはMCPやVercel AI SDKの公式型ではなく、記事用のapp-level contractです。source側でevent名がどう定義されていても、component側には持ち込みません。
adapterの責務は狭くします。
type ToolEvent<TInput, TOutput> =
| {
type: "input-received";
context: ToolCallContext<TInput>;
needsApproval: boolean;
receivedAt: number;
}
| { type: "approved"; callId: string; decidedAt: number }
| { type: "rejected"; callId: string; message: string }
| { type: "started"; callId: string; startedAt: number }
| { type: "succeeded"; callId: string; output: TOutput }
| { type: "timed-out"; callId: string; message: string }
| { type: "errored"; callId: string; message: string; retryable: boolean };
declare function decodeKnownSourceEvent(
raw: unknown,
): ToolEvent<unknown, unknown> | null;
function toToolEvent(raw: unknown): ToolEvent<unknown, unknown> | null {
// SDK / MCP固有のschema検証と変換はここで行う。
// 未知のeventをrunningへ丸めず、nullを返して観測ログへ送る。
return decodeKnownSourceEvent(raw);
}
decodeKnownSourceEvent()は利用するSDKごとに実装します。未知のeventを見なかったことにしてはいけません。黙ってrunningへ落とすと、UIは止まったtoolを「処理中」と表示し続けます。
streaming中のpartial resultが必要なら、runningへ何でも詰め込まず、streamingを新しいstateとして追加したほうが追いやすいです。状態が増えることより、意味の違う状態を同じ表示へ丸めるほうが後で効きます。
componentはstatusだけを見て描画する
状態を正規化できれば、React側はかなり素直になります。
function assertNever(value: never): never {
throw new Error(`Unhandled tool state: ${JSON.stringify(value)}`);
}
function ToolPanel({
state,
}: {
state: ToolViewState<ImageToolInput, ImageToolOutput>;
}) {
switch (state.status) {
case "input-ready":
return <InputPreview context={state.context} />;
case "approval-required":
return (
<ApprovalPanel
context={state.context}
requestedAt={state.requestedAt}
/>
);
case "running":
return (
<RunningPanel
callId={state.context.callId}
startedAt={state.startedAt}
/>
);
case "output-ready":
return <OutputRenderer output={state.output} />;
case "failed":
return (
<FailurePanel
reason={state.reason}
message={state.message}
retryable={state.retryable}
/>
);
default:
return assertNever(state);
}
}
大きなMessageBubbleの中へ条件分岐を足し続けるより、入力確認、承認、実行中、出力、失敗を別componentにしたほうが直しやすいです。assertNeverがあるので、stateを増やしたのにrendererを忘れるとTypeScriptが知らせてくれます。
failedも一種類の赤いerror boxにはしません。ユーザーがrejectしたcallは「失敗しました」ではなく「実行しませんでした」と表示できます。timeoutなら再実行を出し、入力不正なら入力画面へ戻す。状態名が同じでも、reasonで次の操作は変わります。
approvalはmodalではなく、復元するデータにする
approvalを一時的なmodalとして実装すると、reloadした瞬間に判断が消えます。さらに危ないのは、承認したinputと実際に実行したinputがずれるケースです。
保存したいのは少なくとも次の情報です。
type ApprovalDecision = "pending" | "approved" | "rejected";
type ApprovalRecord = {
callId: string;
toolName: string;
inputDigest: string;
decision: ApprovalDecision;
requestedAt: number;
decidedAt?: number;
actorId?: string;
};
inputDigestは、承認対象の引数を固定するために使います。秘密値をそのまま保存するという意味ではありません。UIにはredact済みのpreviewを出し、実行側では「承認された引数と同じか」を照合します。
状態遷移はこのくらい明示しておくと、実装とreviewが噛み合います。
| 現在 | event | 次 | UIの扱い |
|---|---|---|---|
input-ready |
実行開始 | running |
cancelと開始時刻を表示 |
input-ready |
承認が必要 | approval-required |
引数previewとapprove / reject |
approval-required |
approve | running |
承認記録を保存してから開始 |
approval-required |
reject | failed |
reason: rejectedとして終了 |
running |
typed output | output-ready |
output専用rendererへ渡す |
running |
timeout / tool error | failed |
原因とretry可否を表示 |
| 任意 | 別のcallIdのevent |
変更しない | 観測ログへ送る |
approveボタンのclickだけでrunningへ進める場合も、先にrecordを永続化します。保存に失敗したのにUIだけrunningへ変わると、reload後の正本がなくなります。
外部送信、課金、公開、削除のような操作では、生成処理のapprovalを使い回しません。「画像を生成してよい」と「その画像を公開してよい」は別の判断です。
reducerは成功例より、壊れ方から書く
遷移をpure functionへ寄せると、UIを起動しなくてもfailure pathを確認できます。
function reduceToolState<TInput, TOutput>(
state: ToolViewState<TInput, TOutput> | null,
event: ToolEvent<TInput, TOutput>,
): ToolViewState<TInput, TOutput> {
if (event.type === "input-received") {
return event.needsApproval
? {
status: "approval-required",
context: event.context,
requestedAt: event.receivedAt,
}
: {
status: "input-ready",
context: event.context,
};
}
if (state === null || event.callId !== state.context.callId) {
throw new Error("Event does not match the active tool call");
}
if (state.status === "approval-required" && event.type === "approved") {
return {
status: "running",
context: state.context,
startedAt: event.decidedAt,
};
}
if (state.status === "approval-required" && event.type === "rejected") {
return {
status: "failed",
context: state.context,
reason: "rejected",
message: event.message,
retryable: false,
};
}
if (state.status === "input-ready" && event.type === "started") {
return {
status: "running",
context: state.context,
startedAt: event.startedAt,
};
}
if (state.status === "running" && event.type === "succeeded") {
return {
status: "output-ready",
context: state.context,
output: event.output,
};
}
if (state.status === "running" && event.type === "timed-out") {
return {
status: "failed",
context: state.context,
reason: "timeout",
message: event.message,
retryable: true,
};
}
if (state.status === "running" && event.type === "errored") {
return {
status: "failed",
context: state.context,
reason: "tool-error",
message: event.message,
retryable: event.retryable,
};
}
throw new Error(
`Invalid transition: ${state.status} -> ${event.type}`,
);
}
productionでは、同じeventの再送に備えてevent IDやsequenceも持たせます。ここで無条件に例外を握りつぶすと二重実行を見つけにくいので、duplicateとinvalid transitionは分けて記録します。
最初に書くtestはhappy pathだけでなく、次のケースです。
- approval前の
startedを拒否する - reject後に
outputが来てもoutput-readyへ進めない - timeoutを
failedにし、retry可能として表示する - 異なる
callIdのeventでstateを書き換えない - duplicate eventでtoolを二重実行しない
- reload後にpending / approved / rejectedを復元する
- 未知のsource eventを観測ログへ残す
表にして既存のtest runnerへ流せば、追加するeventごとの差分もreviewしやすくなります。
const transitionCases = [
{
name: "approvalなしでは実行へ進まない",
before: approvalRequiredState,
event: startedWithoutApproval,
expected: "invalid-transition",
},
{
name: "timeoutはretry可能なfailedになる",
before: runningState,
event: timedOutEvent,
expected: "failed",
},
{
name: "別callのoutputは現在のstateを変えない",
before: runningState,
event: outputFromAnotherCall,
expected: "call-id-mismatch",
},
] as const;
この配列のfixtureは各projectの型へ合わせます。画面がきれいに動いても、危険な状態を「実行可能」や「完了」と誤表示したら失敗です。
text、structured output、resourceを同じrendererへ入れない
tool outputを全部Markdownへ変換すると、構造化データの型を捨てることになります。resourceを生のHTMLとして差し込むのはさらに危険です。
type ImageToolOutput =
| { kind: "text"; text: string }
| {
kind: "image";
src: string;
width: number;
height: number;
format: "png" | "jpeg" | "webp";
}
| {
kind: "resource";
uri: string;
mimeType: string;
};
rendererはkindごとに分けます。
function OutputRenderer({ output }: { output: ImageToolOutput }) {
switch (output.kind) {
case "text":
return <TextOutput text={output.text} />;
case "image":
return (
<ImagePreview
src={output.src}
width={output.width}
height={output.height}
format={output.format}
/>
);
case "resource":
return <ResourceBoundary resource={output} />;
default:
return assertNever(output);
}
}
ResourceBoundaryではURI、scheme、MIME type、許可originを確認してからrendererを選びます。interactive UIをiframeへ渡すなら、sandbox属性、hostとのmessage bridge、許可するoriginをapp側のpolicyとして管理します。iframeを使っただけで安全になるわけではありません。
通常DOMへremote HTMLを直接注入したり、dangerouslySetInnerHTMLで表示したりする経路は作らないほうがよいです。未対応のresourceは無理に描画せず、種類とURIを確認できるfallbackへ落とします。
画像toolでは「生成完了」と「公開可能」を分ける
例として、AIが画像を生成するtool UIを考えます。
output-readyになったら、画像、dimensions、formatをpreviewします。この時点でtool callは完了しています。ただし、SNSへ公開してよいとは限りません。公開操作には別のstateとapprovalを持たせます。
type PublishState =
| "not-requested"
| "approval-required"
| "publishing"
| "published"
| "failed";
画像の縦横比が投稿先に合わない場合も、agentが勝手にcropして公開するより、人間の確認工程へ戻したほうが事故が少ないです。Instagram向けの最終調整なら、生成物を保存してからResize Image for Instagramのようなブラウザ内で処理するresizerへ渡し、1080 x 1080、1080 x 1350、1080 x 1920などのpreviewを人が確認する手順にできます。これはMCP連携の例ではなく、公開前の手作業を残す例です。
ここを一つの「完了」へ丸めると、toolの成功がそのまま外部公開の許可になります。Agent UIで分けたいのは見た目より、この権限です。
最初に書くのはmessage listではなく遷移表
chat transcriptは必要です。判断の経緯やtoolの返答を追うには便利です。ただし、実行制御の正本には向きません。
自分なら実装順をこうします。
- app側のtool stateとeventを定義する
- SDK eventをadapterで正規化する
- approval recordを保存し、reload後に復元する
- statusとoutput kindごとにrendererを分ける
- reject、timeout、duplicate、未知eventをtestする
- 最後にchat上の見せ方を整える
modelやSDKを差し替えても、この境界は残ります。chat componentを交換できることより、承認待ちを実行中と取り違えず、失敗を完了と表示しないことのほうが大事です。
Agent UIを作り始めるときは、最初に5つの状態と遷移表を書く。message bubbleは、そのあとで十分です。