0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP AppsのAgent UIは、チャット欄より先にtool stateを型にする

0
Posted at

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の返答を追うには便利です。ただし、実行制御の正本には向きません。

自分なら実装順をこうします。

  1. app側のtool stateとeventを定義する
  2. SDK eventをadapterで正規化する
  3. approval recordを保存し、reload後に復元する
  4. statusとoutput kindごとにrendererを分ける
  5. reject、timeout、duplicate、未知eventをtestする
  6. 最後にchat上の見せ方を整える

modelやSDKを差し替えても、この境界は残ります。chat componentを交換できることより、承認待ちを実行中と取り違えず、失敗を完了と表示しないことのほうが大事です。

Agent UIを作り始めるときは、最初に5つの状態と遷移表を書く。message bubbleは、そのあとで十分です。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?