Call and Read — a Refusal Is Not an Exception
5 minPart 2 learned what is there. Now it calls.
The result is not a value
final ok = await client.callTool('desk.admit', {'count': 1});What comes back is not a number but a content list. It may be text, it may be an image, there may be several. So the unwrapping lives in one place.
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>;
}Drop the is! TextContent check and cast directly, and the day the server mixes in an image it blows up somewhere else entirely. Catching it here is what makes the stack readable.
admit 1 -> {waiting: 2}A refusal does not throw
Call the refusal built in server part 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"No exception. The await completed normally, the result carries isError: true, and the reason is in the text.
Not knowing this produces code like:
try {
await client.callTool('desk.admit', {'count': 99});
// got here, so it succeeded
} catch (e) {
// the refusal lands here ... except it does not
}The refusal never reaches catch, so it is treated as success. The screen says "99 admitted" and the server let nobody in.
Why it is not an exception
An exception means the call did not take place — the connection dropped, or there is no such tool.
A refusal means the call took place and the server judged. The tool ran fine and the answer is no. And that answer carries a reason — to use it, you have to receive it as a result.
Read only 2 waiting and you can send 2 or fewer. Catch it as an exception and that sentence is buried in a stack trace.
Verification
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"The first line matters. If a refusal arrives as an exception the program dies and the exit code is not zero — || die catches it there.
The third check caught a real defect. Server steps 4 through 6 had collapsed the two refusals part 3 separated into one. Running the server alone, step 3's check passes and the later steps never look at that message, so it would never have surfaced. Driving the two samples against each other is what exposed it.
Run it yourself
dart run bin/step3.dart
bash verify.shWhat to take
- One
decode— put the content-type check there - Read
isError— a refusal is a result - Check that a refusal does not throw — the exit code shows it
Next
Receive the screen, with not one line of it in the client.
Run the sample
cd course-client dart pub get dart run bin/step3.dart