A Tool That Refuses — a Schema Is a Promise, Not a Guarantee

5 min
GoalThird in the mcp_server course. An input schema goes on, and a handler that does not trust it. Including why "admit 0" and "admit 99" are refused for two different reasons.

The 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 works

Collapse 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 fit

It 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 2

The 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.sh

What to take

  1. Schema plus a handler check — the schema guides, the check defends
  2. Two kinds of refusal — input problems and state problems need different messages
  3. 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