Build

Call and Read — a Refusal Is Not an Exception

By makemind · Apr 3, 2026

Part 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.

This content requires Developer or above

Sign in and upgrade your plan to continue reading.

View Plans
Twitter