拒む道具 — スキーマは約束であって保証ではない

5 分
目標MCP サーバー講座 3 編目。入力スキーマを付け、そのスキーマを信じないハンドラを書く。0 人を入れよという要求と 99 人を入れよという要求が、それぞれ違う理由で拒まれるところまで。

2 編目の道具は引数を取らなかった。今回は取る。そして 受け取ったものを信じない。

スキーマ

inputSchema: const {
  'type': 'object',
  'properties': {
    'count': {'type': 'integer', 'description': 'How many to admit, 1 or more'},
  },
  'required': ['count'],
},

これを付ければ呼ぶ側が何を送ればよいか分かる。モデルならここに合わせて引数を作る。

ところがスキーマは保証ではない

'type': 'integer' と書いたから整数が来るのではない。スキーマは 呼ぶ側への案内 であり、その側が守らないかもしれないし守れないかもしれない。

だからハンドラがもう一度見る。

handler: (args) async {
  final n = args['count'];
  // The schema says integer; the caller may still send anything.
  if (n is! int || n < 1) {
    return CallToolResult(
      content: [TextContent(text: 'desk.admit: count must be 1 or more')],
      isError: true,
    );
  }
  // And the desk cannot admit people who are not there.
  if (n > waiting) {
    return CallToolResult(
      content: [TextContent(text: 'only $waiting waiting')],
      isError: true,
    );
  }
  waiting -= n;
  return CallToolResult(
    content: [TextContent(text: jsonEncode({'waiting': waiting}))],
  );
},

二つの拒否は性質が違う

検査が二つあり、理由が違う。

n < 1 — 値そのものが成り立たない。0 人を入れよは常に誤りだ。入力の問題。

n > waiting — 値は正常だが今の状態では不可能だ。3 人待ちで 99 人は入れられない。だが 100 人待ちなら 99 は正常だ。状態の問題。

これを分けねばならない理由はメッセージにある。

count must be 1 or more     ← 送り直しても同じく誤り
only 3 waiting              ← 3 以下で送ればよい

isError: true でひとまとめにすれば、呼ぶ側は再試行すべきか諦めるべきか分からない。

黙って受けてはいけない理由

拒む代わりにこう書くこともできる。

if (n > waiting) n = waiting;   // 黙って合わせてやる

動きはする。ところが 呼んだ側は 99 人入れたと信じる。 画面に 99 が出て、帳簿に 3 が残り、締めが合わない。そしてどこでずれたかを探す術が無い。

計測の編の 12V クランプと同じ場所だ。切ったなら 切ったと言わねばならない。

検証

三つの要求を続けて送る。

ask step3 \
  '...{"name":"desk.admit","arguments":{"count":0}}}' \
  '...{"name":"desk.admit","arguments":{"count":99}}}' \
  '...{"name":"desk.admit","arguments":{"count":1}}}'

grep -q 'count must be 1 or more' captures/s3.txt || die "zero was not refused"
grep -q 'only 3 waiting'          captures/s3.txt || die "over-count was not refused"
grep -q 'waiting.*2'              captures/s3.txt || die "a valid admit did not go through"
step3  refused 0 and 99, admitted 1 -> waiting 2

三つ目の検査が重要だ。 前の二つだけなら「全部拒む道具」も通ってしまう。拒めるかと通せるかを一緒に見る必要がある。

自分で動かす

dart run bin/step3.dart
bash verify.sh

持ち帰るもの

  1. スキーマ+ハンドラの検査 — スキーマは案内、検査は防御
  2. 拒否を二種類に — 入力の問題と状態の問題はメッセージが違うべきだ
  3. 拒否の検査+通過の検査 — 両方あって検証が成立する

次の編

画面を付ける。コードではなく、サーバーが差し出す文書として。

サンプルを実行する

makemind-academy/course_server/
git clone https://github.com/makemind-academy/course_server
cd course_server
dart pub get
dart run bin/step3.dart
GitHub で開く →