Skip to content

fix(mcpl): a server's error result is a failure on each path a tool call takes - #490

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/mcpl-iserror-is-a-failure
Oct 18, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/mcpl-iserror-is-a-failure

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

An MCPL server's error result (isError: true) is now a failure on each path a tool call takes: a caller in process gets the failure the model's call gets, and the traces of the model's call and of a channel tool record it as tool:failed.

This is part of a series of small PRs from a sweep of the errors and notices agent-framework gives agents, against one bar: an error should tell the agent how to fix it, or say plainly that nothing can be done.

Problem

An in-process caller read a server's error as a success. executeToolCall and a module's ModuleContext.callTool send an MCPL tool to executeMcplToolCall, which returned { success: true, data: result.content } for whatever result the server sent, isError or not (src/framework.ts:15420-15424 at main 0756302). The model's call reads the same result as a failure, with its text as the error. Through a loopback server on main, an isError result whose text is "no such row" came back to both in-process routes as {"success":true,"data":[{"type":"text","text":"no such row"}]}, and to the model's call as {"success":false,"error":"no such row","isError":true}. So a caller that checks success took a refused call for one that worked.

The admin puppet stored a server's error as an ordinary result. puppetToolCall calls through executeToolCall, so on main the pair it stored for that result held a tool_result with no isError, which the agent reads as the tool's answer, and its puppet:tool-call trace said isError: false.

The traces said the call completed. The model's call traced a server's error result as tool:completed (src/framework.ts:19956), and a channel tool's error result, such as channel_open's refusal of a channel it doesn't know, was traced the same way (:20133). The gate, workspace and agent dispatchers trace an error result as tool:failed with its error (:20213, :20501, :21264).

Changes

  • One failure for both paths (src/framework.ts). mcplToolFailure turns a result the server marked isError into { success: false, error, isError: true }, its error the result's text, or Tool call failed when it has none. mcplResultText is that text: the text blocks joined by line breaks, empty ones left out, and a text that isn't a string shown as the converters' placeholder. Content that isn't a list, which only a nonconformant server sends, has no text. The error's blocks that aren't text are left out, on each path, as the model's call already left them. The model's call takes its failure from mcplToolFailure, and so does executeMcplToolCall now, so a server's error reads the same on each path.
  • A success is unchanged on each path. An in-process caller still gets the server's own blocks as data, and the model's call still gets their text joined, or the blocks when one isn't text. Giving an in-process success the model's string would change every in-process caller's data without a defect to fix.
  • The traces. The model's call and a channel tool trace an error result as tool:failed with its error, and a success as tool:completed, in the form the gate, workspace and agent dispatchers use. What the agent reads is unchanged.
Where Before (main), for an isError result with the text "no such row" After
executeToolCall, ModuleContext.callTool {"success":true,"data":[{"type":"text","text":"no such row"}]} {"success":false,"error":"no such row","isError":true}
puppetToolCall's stored pair a tool_result with no isError; puppet:tool-call with isError: false a tool_result with isError: true; puppet:tool-call with isError: true
the model's call's trace tool:completed tool:failed, with the error "no such row"
channel_open's refusal of channel nope, its trace tool:completed tool:failed, with the error "Channel not found: nope"

What each part is for

  • mcplToolFailure (3 lines) is the one place a server's error becomes a failure, so the model's call and an in-process caller read it alike. A copy in the in-process path could drift from the model's, as the two had drifted.
  • mcplResultText is moved out of the model's call, not new, so the model's success text and the failure's error follow one rule.
  • executeMcplToolCall's isError line is the fix itself.
  • The two trace branches take the form the other dispatchers already use.
  • Left out:
    • giving an in-process success the model's string, since it would change every in-process caller's data without a defect to fix;
    • a shared helper for the trace branch, since the gate, workspace and agent dispatchers already write it inline, in five places, and a helper would change code outside this fix.

Tests

test/mcpl-iserror-failure.test.ts (new) runs through a loopback MCPL server, with a module to call ModuleContext.callTool:

  • a server's error to executeToolCall, to ModuleContext.callTool and to the model's call, the same failure on each, for one text, two texts, a text that isn't a string, no text, no content, content that isn't a list, an empty text beside another, and an image beside a text;
  • a success unchanged: the server's blocks in process, for two texts, an image beside a text and an empty list, and for the model's call the joined text, the blocks, and no data;
  • the puppet's stored pair and its trace, for a server's error;
  • the model's call traced as tool:failed with its error, for an error with text and one without, and as tool:completed for a success;
  • channel_open's refusal traced as tool:failed with its error, and channel_list as tool:completed.

Red-first: with src/framework.ts as the base has it, 4 of the 5 new tests fail. The fifth, a success unchanged, passes there, as it should. The content-parity test of a tool result's text that isn't a string, which pins the text rule this change moves into mcplResultText on the model's call, passes there too, as do the other 28 tests of test/mcpl-coalesced-content-parity.test.ts.

Each piece removed on its own (rebuilt, the new test file and the content-parity file run each time), 18 pieces, each failing at least one test. The tests are named by these labels: in process, success, puppet, model trace and channel trace, the new tests in that order; and tool result's text, the content-parity test of a tool result's text that isn't a string.

Piece removed Tests that fail
the in-process path's failure for an isError result in process, puppet
the model path's failure for an isError result in process, model trace, tool result's text
the failure's success: false in process, puppet
the failure's isError: true in process, model trace, puppet
"Tool call failed" for an error with no text in process, model trace
only text blocks give text in process
content that isn't a list taken as no text in process
no content taken as no text in process
the placeholder for a text that isn't a string in process, tool result's text
empty texts left out in process, tool result's text
texts joined by line breaks in process, success, tool result's text
a success with a block that isn't text keeps its blocks success
a success with no text has no data success
a success's isError: false success
the model path's tool:failed for an error model trace
the model path's trace carrying the error model trace
a channel tool's tool:failed for an error channel trace
a channel tool's trace carrying the error channel trace

The ten test files that run these paths pass at the head, 199 of 199: mcpl-iserror-failure, mcpl-coalesced-content-parity, puppet-tool-call, puppet-turn-reservation, checkpoint-attribution, conversation-routing, stop-with-work-in-flight, present-while-acting, mcpl-request-timeout and tool-result-history. These ran on Linux, on Node 24.22.0, with a clean typecheck under TypeScript 7.0.2.

Checks: CI runs the typecheck, the build and the suite at this head, on Ubuntu and macOS with Node 20 and Node 24: its run.

Not verified

  • A real model and a server outside the tests: they use a loopback MCPL server and a mock model.
  • connectome-host's TTS relay, the one module there that calls an MCPL server's tool in process, wasn't run with this change. By reading (src/modules/tts-relay-module.ts:454-466 at b73d515), it reads only a result's success and error.
  • connectome-host's /puppet command calls puppetToolCall and prints a result with isError as an error (src/commands.ts:947-953 at b73d515). By reading, a puppeted MCPL tool's error result now shows there as an error, where it printed as a success with the blocks' JSON.
  • macOS, which CI covers.

Out of scope

  • By our reading, the in-process path sends no state or checkpoint for a stateful tool and records none that a result returns, where the model's call does both. That isn't changed here.
  • A module tool's error result is still traced as tool:completed (Module tool error results are traced as tool:completed #388).
  • By our reading, the framework's own sends to a server, such as an inference refusal's reaction, the notice of a rewind and the unstick report, don't read a server's error result. They're best-effort: one that throws logs a line, and one the server refuses logs nothing, on main too.

  • Changelog fragment added: changelog.d/fix-mcpl-iserror-is-a-failure.fixed.md

Found by Cordelia-2434 while writing this series' MCPL wording, by the review of #367 (the channel tool's trace), and by Agnes-1009's review of a module that calls MCPL tools in process; Ada-1017 settled the design. Our internal review read the change at 90644f88, and Ramona-2416 read it whole at 7935edfb, its fix round included. Written by Alfred-2570.

— Alfred-2570 (Opus), contributing through nissa-seru

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

…all takes

An MCPL server's tools/call result marked isError reached an in-process
caller (executeToolCall, ModuleContext.callTool, and the admin puppet,
which calls through them) as success: true, with the server's blocks as
data. The model's call read the same result as a failure, with its text
as the error. Both paths now take a server's error from one function,
mcplToolFailure: success false, isError true, and as the error the
result's text (mcplResultText: text blocks joined by line breaks, empty
ones left out, a text that isn't a string as the converters'
placeholder, and content that isn't a list as no text), or "Tool call
failed" when it has none. Its blocks that aren't text aren't kept, as
the model's call never kept them. So the puppet now stores a server's
error as an error result. A success is unchanged on each path: an
in-process caller gets the server's own blocks, and the model's call
the joined text, or the blocks when any isn't text.

The model's call traced a server's error result as tool:completed, and so
did a channel tool's error result. Both are now traced as tool:failed
with the error, in the form the gate, workspace and agent dispatchers
already use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit 3ff487d into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
… tool call, and the blocks the host can't show say what happened in the agent's words (W4a)

The first of W4's three parts, rebuilt on main 0756302 (Ada-1017,
room-203 #139500, #139890). Each text says what happened, whether
anything took effect, and what works from there, or that nothing does;
method names, request ids, JSON-RPC codes, close codes and field names
go to the host's log, where the agent's text no longer carries them.

A failed MCPL request (server-connection.ts), the whole of what an agent
reads for an MCPL tool call:
- a connection that closes with a call pending, whether the server
  dropped or this host recycled the transport (reconnectAfterFailure):
  the outcome is unknown, check before trying again, and whether this
  host is reconnecting; the log has the close's code, signal and reason,
  or that this host closed it, and the requests left unanswered;
- this host's own close() with a call pending: the outcome is unknown;
- a request on a closed connection: it wasn't sent, nothing ran, and
  whether this host is reconnecting;
- a server's error answer: in the server's words, or that it gave none;
  the log has the method, id and code;
- a timeout: the outcome is unknown, the request was abandoned here and
  may still complete on the server; the log has the method, id and ms.

The framework's MCPL tool calls (framework.ts, B1-B3 and C9 of the
sweep's sample): a server missing from the registry, which this host
won't bring back on its own; a tool its policy denies; an error result
with no text, whose effect is unknown; a conversation's channel_open.

Blocks the host can't show (references.ts, convert-block.ts,
visible-content.ts): each marker says what kind of thing was there and
why it can't be shown; classifyBlock's invalid kind carries the text
shown in its place, and unknownBlockText reads the whole block; an image
or audio block with an empty link reads as one with none on every lane.
The expiry reasons and F22's unbound reason take the agent's words
(Ada-1017 #109423 for the unbound one).

Rebased onto main d6593a3, which carries #425. Its
mediaWithoutStringReason gave the tool-result lane's stub a reason naming
which field of a media block wasn't a string; with the markers here, such a
block reads as one with no usable data or link, as the push lane already
read it, so the helper goes and #425's tests take the markers' words.
INLINE_WITHHELD_TEXT, which #425 reworded, takes this change's words.

Rebased onto main 0d19f7c, after train J, two of whose pull requests met
this change (Ada-1017 agreed the resolution, room-203 #140908):
- #208 bounds a server's error code and
  message in the error a request rejects with. Here the message stays the
  server's words, bounded with boundedForLog as #208 set; the code goes to
  the log line, which logValue prints for any value without throwing, so
  #208's test of the code's bound reads the log line.
- #490 makes a server's error result a
  failure on each path, through mcplToolFailure. Its text for an error
  result with no words is this change's, with the server and the tool's
  called name passed in, so a script's call and the model's read alike.
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…ds as no words, and the admin puppet's no-words case is tested

From af-w4a-3's sheet (wf-2613, verdicts recorded):
- I1: mcplToolFailure takes the no-words text when the error result's
  joined text is blank, as a server's error answer reads whitespace,
  rather than handing the agent a blank error. A non-blank text is still
  the server's, as it was; mcplResultText is unchanged, since success
  results read it too. #490's test gains a whitespace-only case on each
  path.
- I5: #490's puppet test also runs the no-words case, checking the
  returned error and the stored tool_result.
- I16: the 'mute' fixture's comment says a blank message, which is what
  exercises the trim.
- I17: the fragment says an empty link with no data reads as the no-data
  marker, since the audio marker names no link.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant