呼んで受け取る — 拒否は例外ではない

5 分
目標MCP クライアント講座 3 編目。道具を呼んで答えを読む。結果が値ではなくコンテンツの一覧であること、そしてサーバーが拒んだときそれが throw で来ないこと — だから捕まえるのではなく読まねばならない。

2 編目で何があるかを知った。呼ぶ。

結果は値ではない

final ok = await client.callTool('desk.admit', {'count': 1});

返るのは数ではなく コンテンツの一覧 だ。テキストかもしれないし画像かもしれないし複数かもしれない。だから取り出す場所を一箇所に置く。

Map<String, dynamic> decode(CallToolResult r) {
  final first = r.content.first;
  if (first is! TextContent) {
    throw StateError('expected text content, got ${first.runtimeType}');
  }
  return jsonDecode(first.text) as Map<String, dynamic>;
}

is! TextContent の検査を抜いて直にキャストすれば、サーバーがいつか画像を混ぜて送る日に まったく別の場所で 壊れる。ここで濾すからスタックが読める。

admit 1 -> {waiting: 2}

拒否は throw で来ない

サーバー 3 編目で作った拒否を呼んでみる。

final refused = await client.callTool('desk.admit', {'count': 99});
final text = (refused.content.first as TextContent).text;
stdout.writeln('admit 99 -> isError=${refused.isError} "$text"');
admit 99 -> isError=true "only 2 waiting"

例外は出なかった。 await が正常に完了し、結果に isError: true が付き、理由がテキストに入っている。

これを知らないとこう書く。

try {
  await client.callTool('desk.admit', {'count': 99});
  // ここまで来たので成功
} catch (e) {
  // 拒否はここに来る ... と思うが来ない
}

拒否が catch に行かないので 成功として処理される。 画面に「99人入場」と出て、サーバーは誰も入れていない。

なぜ例外ではないのか

例外は「この呼び出しが成立しなかった」という意味だ。接続が切れた、そんな道具が無い。

拒否は 呼び出しが成立し、サーバーが判断した結果 だ。道具は正常に走り、答えが「だめだ」なのである。そしてその答えには理由が付いている — その理由を使うには結果として受け取らねばならない。

only 2 waiting を読めば 2 以下で送り直せる。例外として捕まえれば、その文はスタックトレースの中に埋まる。

検証

run step3 > captures/s3.txt || die "step3: failed (a refusal should not throw)"
grep -q 'admit 1 -> {waiting: 2}'   captures/s3.txt || die "the call did not go through"
grep -q 'admit 99 -> isError=true'  captures/s3.txt || die "the refusal was not surfaced"
grep -q 'only 2 waiting'            captures/s3.txt || die "the reason did not survive"

一行目が重要だ。拒否が例外で来ればプログラムが死に、終了コードが 0 でなくなる — || die がそこで捕まえる。

三つ目の検査(only 2 waiting)が実際に欠陥をひとつ捕まえた。サーバーの 4〜6 段階が、3 段階で分けた二つの拒否をひとつに潰していたのだ。サーバーだけを回していれば 3 段階の検査は通り、以降の段階はそのメッセージを見ないので永遠に分からなかった。 二つのサンプルを噛み合わせて回したから露見した。

自分で動かす

dart run bin/step3.dart
bash verify.sh

持ち帰るもの

  1. decode 一箇所 — コンテンツ種別の検査をここで
  2. isError を読む — 拒否は結果だ
  3. 拒否が例外で来ないかの検査 — 終了コードで確認される

次の編

画面を受け取る。クライアントに画面が一行も無い状態で。

サンプルを実行する

makemind-academy/course_client/
git clone https://github.com/makemind-academy/course_client
cd course_client
dart pub get
(cd course-server && dart pub get)
dart run bin/step3.dart
GitHub で開く →