A Tool That Refuses — a Schema Is a Promise, Not a Guarantee
5 minThe tool in part 2 took no arguments. Now it does. And it does not trust what it gets.
The schema
inputSchema: const {
'type': 'object',
'properties': {
'count': {'type': 'integer', 'description': 'How many to admit, 1 or more'},
},
'required': ['count'],
},With this the caller knows what to send. A model shapes its arguments to it.
But a schema is not a guarantee
Writing 'type': 'integer' does not make an integer arrive. A schema is guidance to the caller, and the caller may not follow it, or may not be able to.
So the handler looks again.
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}))],
);
},The two refusals differ in kind
There are two checks, for different reasons.
n < 1 — the value itself makes no sense. Admitting zero people is always wrong. A problem with the input.
n > waiting — the value is fine but impossible right now. You cannot admit 99 from a queue of 3. With 100 waiting, 99 is normal. A problem with the state.
The distinction matters because of the message.
count must be 1 or more ← sending it again is wrong the same way
only 3 waiting ← send 3 or fewer and it worksCollapse both into isError: true and the caller cannot tell whether to retry or give up.
Why not accept it quietly
You could write this instead of refusing.
if (n > waiting) n = waiting; // quietly make it fitIt runs. And the caller believes 99 were admitted. The screen shows 99, the ledger holds 3, and closing does not tie out. With no way to find where it diverged.
Same place as the 12 V clamp in the instrument piece. If you clamped it, say you clamped it.
Verification
Three requests, back to back.
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 2The third check is the important one. With only the first two, a tool that refuses everything also passes. Refusing and letting through have to be checked together.
Run it yourself
dart run bin/step3.dart
bash verify.shWhat to take
- Schema plus a handler check — the schema guides, the check defends
- Two kinds of refusal — input problems and state problems need different messages
- A refusal check and a pass check — verification needs both
Next
Attach a screen. Not as code, but as a document the server hands out.
Run the sample
cd course-server dart pub get dart run bin/step3.dart