Skip to content

fix(mcp): the modern engine's failed requests, and a connect in flight, in #506's words - #540

Merged
nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/modern-agent-words
Oct 19, 2026
Merged

nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/modern-agent-words

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

TL;DR: the modern MCP engine, which #262 added and no release has shipped yet, says what happened when a request fails in the words #506 gave the MCPL engine: whether anything took effect, and what works from there. The method, the code and the SDK's words go to the host's log. A connect in flight now reads as one in either engine: to a second connect until the connect lets go of the id, just before it lists its tools, and to a tool call until its server can take one.

Problem

#506 changed the texts an agent reads for a failed MCPL request, a refused tool call and a block the host can't show. Each now says what happened, whether anything took effect, and what works from there, or that nothing does, and method names, request ids, JSON-RPC codes and close details went to the log. #262 brought the modern engine in beside it, and its request failures still read the operator's way (src/mcpl/modern-connection.ts at 5227741b):

  • Cannot send tools/call "x": connection to "m" is not established (:880);
  • MCP server "m" tools/call "x" was not sent: … (:1024);
  • MCP server "m" returned error for tools/call "x": [-32603] … (:1028);
  • MCP server "m" did not answer tools/call "x" within 60000ms. Cancellation was requested; … (:1073), and the same shape for the rest (:1031–:1077).

A connect in flight read the same way only in part (src/framework.ts at 5227741b):

Changes

The modern engine's request failures (requestFailure, and request()'s refusal with no session), outcome by outcome. Each outcome and its data are as before, data.httpStatus included.

  • No session (closed, or lost and not yet back), not-sent: The request wasn't sent: the connection to the "m" server is closed, so nothing ran., then whether this host is reconnecting, as the legacy engine says it. After this host's own close: The request wasn't sent: this host closed its connection to the "m" server, so nothing ran. If calling it again fails, ask whoever runs this host.
  • Refused before the wire, not-sent:
    • after a stdio pipe failure: The request wasn't sent: this host can no longer write to the "m" server, so nothing ran., then whether it's reconnecting;
    • otherwise: The request wasn't sent: this host couldn't send it to the "m" server, so nothing ran. If calling it again fails, ask whoever runs this host. The cause can pass, as the stdio line closing when the connection is lost does, or stay, as a tool's outputSchema the SDK can't compile does, so the text claims neither.
  • An error answer, error-response: The "m" server answered with an error: <its message>, trimmed and bounded. With no message: that it answered with an error without saying what went wrong, so whether the request took effect is unknown, and to check its effect before trying again.
  • A complete answer the SDK rejects, still a plain error: The "m" server answered, but not in a form this host can read, so whether the request took effect is unknown. Check its effect before trying again.
  • Another round asked for and not sent, or an answer that isn't a complete result, no-response: that the server didn't finish the request (it asked for more from this host, and the time ran out or the call ended before this host sent it), or that its answer wasn't a complete result, so whether the request took effect is unknown.
  • HTTP 401 or 403, error-response: The "m" server refused this host access, so the request didn't run. If you need it, ask whoever runs this host. A 403 can refuse a valid credential that lacks permission, so it says access. Refused on a later round, no-response: the effect is unknown.
  • The deadline, no-response: The "m" server didn't answer within 60 seconds, so whether the request took effect is unknown: this host asked it to cancel, and it may still complete on the server. Check its effect before trying again, since repeating something that changes state may do it twice.
  • Anything else after the request went out, no-response:
    • cut off by this host's close: The "m" server didn't answer before this host closed its connection, so whether the request took effect is unknown. Check its effect before trying again., the legacy engine's words;
    • its connection closed under it: The connection to the "m" server closed before it answered, so whether the request took effect is unknown., then whether this host is reconnecting, the legacy engine's words;
    • otherwise: The request to the "m" server failed before an answer came back, so whether it took effect is unknown. Check its effect before trying again. The connection may still stand, as after an HTTP 500, so it says nothing of reconnecting.

A tools/list failure reaches only the refresh's mcpl:server-error trace. Where its effect would be in doubt, it says instead that listing changes nothing on the server, so it's safe to try again. Each failure the SDK reports writes one log line with the method, the tool as a log field, and the code, HTTP status, deadline or transport's words, redacted. A refusal with no session writes none, as the legacy engine's refusal on a closed connection writes none. A server's error message reaches the caller unredacted, trimmed and bounded as before. The log has it redacted, whatever JSON value it is, since the log is a record of the server's text.

The legacy engine's error answer: its log line, which #506 added, now prints the server's message with any credential the server's configuration holds replaced, whatever JSON value it is, as the modern engine's line and the legacy engine's other lines do. The caller's text keeps it unredacted. A legacy connection kept its configuration only when it reconnects, so every connection now keeps it to redact with. The call is settled before the line is written, so nothing the line does can leave it waiting. When the configuration can't be read, as a stdio server's can't while the framework's reaction-baseline ledger can't, the line withholds the message, as #542 has every redaction do.

The redactor (serverCredentialPatterns): every string it removes, from every source, is also removed as JSON encoders write it, with JSON.stringify's escapes and with its slashes escaped. Tokens and env values had these forms. A url's credentials, its name=value pairs and its userinfo units now have them too. So a url credential holding a quote or a backslash goes from a log line that prints a server's JSON value as its JSON text, and from a server's own JSON that echoes it. A pair or a userinfo unit, which goes only where no letter or digit adjoins it, goes after a JSON escape that ends in one too, such as \n, as it does after the control character the escape stands for.

The framework:

  • A connect in flight, either engine (mcpServerIdTaken): while any connect holds the id, a second connect, or a restart of another server into it, reads fix(mcpl): a failed MCPL request, the framework's refusals of an MCPL tool call, and the blocks the host can't show say what happened in the agent's words #506's in-flight words, whether or not the connect has registered anything yet. The hold lasts through a legacy handshake or a modern start, and the setup after a legacy server registers. The connect lets go of the id before it lists its tools, and from then a second connect reads is already registered, which is so.
  • A tool call during a modern connect (modernStillConnecting, on the model's dispatch and the in-process path): until the connection's first session is up, the call reads This host is connecting to the "m" server that provides …, so the call didn't run. Try again in a moment., as one during a legacy handshake does. Once the session is up, the call goes to it, even while the connect still lists its tools.
  • The id is refused before the trees are read (runConnectMcplServer), so a refused connect reads none and reports only its refusal. The connect asks again as it takes the id, with nothing awaited between.

Changelog: all four fragments are unreleased. modern-mcp-client.added.md says a modern server's failures read as an MCPL server's, and a call during its connect as one during an MCPL connect. mcpl-connect-in-flight.fixed.md says either engine again. mcpl-agent-words.changed.md says the error answer's log line has the server's message redacted, where the caller's text keeps it unredacted. redact-recorded-mcp-credentials.fixed.md says every string the redactor removes goes in its JSON forms too, and that a JSON escape before a pair doesn't adjoin it.

What each part is for

  • The texts, one per outcome. An agent reads them, and each tells it whether the call ran and what to do next, as the MCPL engine's texts do. Each is chosen by what crossed the wire, as the outcome already was, so the words follow the outcome and add no state.
  • retryText. The one clause the no-session and pipe texts share, as the legacy engine words it: whether this host is reconnecting.
  • NotWrittenError.pipe. The transport already refuses a write after a pipe failure. The flag carries that cause to the text, which can then say this host can no longer write to the server, rather than that it couldn't send this request.
  • The listing's own clause. A listing changes nothing on the server, so "check its effect" would send its reader looking for nothing. It's one variant of the shared phrase, kept in the one function rather than as a second set of texts.
  • One log line per failure the SDK reports. The text left out what the operator needs: the method, the tool, the code, the status, the deadline. The log keeps them, redacted, as the legacy engine's lines do. An HTTP status other than 401/403 is only in the SDK's error, not its message, so the line names it.
  • The host-close and lost branches. The legacy engine has words for a request its host's close cut off, and for one whose connection closed under it. These give the modern engine the same, and the lost one says whether this host is reconnecting. request() passes whether its session was lost, which the session already records.
  • logValueRedacted. A server can echo a credential in its error message, as a server that repeats its url does, and the message can be any JSON value. One helper serves both engines' lines: it redacts the value's text as logValue reads it, and otherwise prints exactly as logValue does.
  • The legacy connection's configuration, kept for every connection. It was kept only for reconnecting, so the line had nothing to redact with on a connection that doesn't reconnect. Whether it reconnects still turns on reconnect: true alone (reconnectEnabled).
  • Settling the call before its log line. By the time the line is written, the call's entry and timer are gone, so nothing else would settle it, and a host's logger can throw.
  • JSON forms for every string the redactor removes, in one pass. logValueRedacted redacts a value's JSON text, where a quote or a backslash is escaped. One rule for every source replaces the forms tokens and env values had on their own, and also reaches a server's own JSON.
  • A JSON escape as a pair's boundary. In a value's JSON text, a newline before a pair is written \n, whose n would otherwise count as adjoining it, so a pair whose value goes only as a pair, such as a short one, stayed in the log after a newline in an object-valued message, where the same text as a string didn't.
  • mcpServerIdTaken's one condition. It gives the in-flight words whenever a connect holds the id, in place of a condition that asked whether something was registered yet.
  • modernStillConnecting. A modern connection registers before it starts, so a call during its connect finds a connection that can't take it yet. One check on the two paths that send a tool call gives the "connecting" text, and asks no more than the hold the connect already keeps and whether a session is up.
  • The id check before the read. It costs one call of the check the connect makes anyway, and a refused connect then reads no store.
  • The tests, below.

Left out:

  • A second set of texts for the modern engine. One family of words serves both engines.
  • Words keyed on the cause before the wire, such as the stdio line closing against a schema that won't compile. Both stop the request before anything is handed off, and one text that claims neither holds for both.
  • The connect registering a modern connection only once it's connected. It would change what stop() and a disconnect find while a connect runs, which both close through the registered connection.

Tests

  • mcp-modern-outcomes: each outcome's text, and its log line. That covers:
    • the no-session texts after a loss, with and without reconnect, and after this host's close;
    • a refusal before the wire, after a pipe failure and otherwise;
    • an error answer with words and without, and the log redacting a token the words echo, as a string and inside an object;
    • the deadline, and another round not sent, at the deadline and when the call ended;
    • an answer that isn't a complete result;
    • a request this host's close cuts off, and one whose child dies, with and without reconnect;
    • HTTP 401 on the first round and a later one;
    • a listing's variants, a connection lost under it and this host's close included.
  • mcp-modern-http: the deadline over HTTP, a 401 on a later round with its log line, and an HTTP 500. The 500's status and the transport's words, with a path credential, go to the log, redacted, and stay out of the caller's text. The log has the server's echoed error message redacted too.
  • mcp-modern-framework: during a modern connect, a second connect reads the in-flight words, and a tool call reads the "connecting" text and reaches nothing.
  • mcpl-agent-words:
    • a call during a modern connect reads the "connecting" text on both paths, and goes to the connection once its session is up;
    • after a legacy server registers, a second connect still reads the in-flight words;
    • a legacy server's error answer that echoes its configured token reaches the caller unredacted, and the log line has it redacted, as a string and inside an object;
    • an error answer whose configuration can't be read settles its call with the server's words, and its log line withholds them;
    • one whose log line throws settles its call too, and the dispatch reports the failure.
  • mcp-protocol-family: a url's path segment, query value, short pair, password and short userinfo unit are removed in their JSON forms, and an object logged through logValueRedacted keeps no url password. A pair and a userinfo unit go after each JSON escape that ends in a letter or digit, and stay where a letter that's text adjoins them. The floor applies form by form: a seven-character token's raw form stays and its eight-character JSON form goes.
  • mcp-server-ownership:

Checks

These ran at the merge of main, e64a6090. The head after it changes only a comment.

  • With this head's src and tests, on Linux with Node 24.22.0, the sixteen changed or most affected test files pass: 400 tests. fix(mcpl): a redaction that can't read its config withholds the text #542's three are among them.
  • Against main's code: with src and changelog.d from main fe821876, and these tests, mcp-protocol-family can't load, since it imports logValueRedacted, which main lacks, and 26 tests fail across six other files.
  • Each piece removed on its own: each of 45 pieces fails at least one test. They include:
    • the conditions that choose a text: the pipe, this host's close, a loss, reconnecting, a listing, a message with no words, the deadline's round;
    • each log line, the tool as its field, and the HTTP status in it;
    • logValueRedacted and each engine's use of it;
    • keeping the legacy configuration;
    • the in-flight condition, both paths' connecting check, and the refusal before the read;
    • the disconnect's second removal;
    • settling before the legacy log line;
    • the redactor's JSON forms for strings, pairs and userinfo units, with their slashes escaped, its floor form by form, and a pair's start after a JSON escape;
    • the listing variants of the lost and host-close branches.
  • The whole suite: this head, which merges main fe821876, each test file in a process of its own, on Linux. The typecheck and the build are clean, and 240 files and 4,640 tests pass on Node 24.22.0 (4,623 passing, 17 skipped, none failing), and the same on Node 20.20.2.
  • CI: GitHub's CI run at this head passes all four Build & Test legs, Ubuntu and macOS on Node 20 and 24, each running npm test, and its packed release installs on its own.

Reviews

  • An internal review of the change before the round that answered it: 21 issues. This head fixes 10; 2 are as the repository owner meant them, 7 weren't worth changing, and 2 weren't real.
  • Nigel-2654 read each round and the merge of main with fix(mcpl): a redaction that can't read its config withholds the text #542, and approved their code and texts.
  • Greptile reviewed it at 9ad13d4f and found two issues: the legacy call left waiting, and a url credential in a JSON value's log line. This head fixes both.
  • An internal review of the rounds since the first internal review, through 2820a431: 23 issues. One was judged in two parts, so 24 verdicts. This head fixes 8 of them. 1 was the trade-off of settling first, that the error answer's log line went unwritten when the configuration couldn't be read; with fix(mcpl): a redaction that can't read its config withholds the text #542 merged in, the line withholds the message instead. 12 weren't worth changing, and 3 weren't real.

Out of scope

Other open pull requests


  • Changelog fragments updated: changelog.d/modern-mcp-client.added.md, changelog.d/mcpl-connect-in-flight.fixed.md, changelog.d/mcpl-agent-words.changed.md and changelog.d/redact-recorded-mcp-credentials.fixed.md, all unreleased. This PR's entry is those edits, so the release notes describe each change once, as it ships. The changelog check counts only added fragments, so this PR carries the no-changelog label.

Written by Crispin-2406 (Claude Opus 5.5). Ada-1017 made the design calls.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

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

…t, in anima-research#506's words

anima-research#506 gave the MCPL texts an agent reads the words an agent can act on:
what happened, whether anything took effect, and what works from there,
with the method, the code and the close's details in the host's log.
The modern engine, which anima-research#262 added and no release has shipped yet,
still said "MCP server "m" tools/call "x" was not sent: …", "returned
error for tools/call "x": [-32603] …" and "did not answer tools/call "x"
within 60000ms. Cancellation was requested; …". This gives it the same
words where the engines share an outcome, and their shape where they
don't, and the connect-in-flight refusal the same reach.

The modern engine's request failures (requestFailure), each with its
outcome as before:
- No session (closed, or lost and not yet back): legacy's text for a
  request on a closed connection, saying whether this host is
  reconnecting, or, after this host's own close, only that.
- Refused before the wire: after a stdio pipe failure, that this host
  can no longer write to the server, and whether it's reconnecting;
  otherwise that it couldn't send it, and to ask whoever runs this host
  if calling again fails, since the cause can pass (the stdio line
  closing as the connection is lost) or stay (an outputSchema the SDK
  can't compile).
- An error answer: the server's own words, trimmed and bounded, or, with
  none, that the effect is unknown. The code goes to the log.
- A complete answer the SDK rejects: that it answered in a form this host
  can't read, so the effect is unknown. Still a plain error.
- Another round asked for and not sent, or an answer that isn't a complete
  result: the effect is unknown.
- HTTP 401/403: the server refused this host access, so the request didn't
  run; on a later round, the effect is unknown. The status stays in
  data.httpStatus.
- The deadline: this host asked the server to cancel, and the effect is
  unknown.
- Anything else after the request went out: cut off by this host's
  close, or its connection closed under it (request() passes
  session.lost), the legacy engine's words for each, the second saying
  whether this host is reconnecting; otherwise it failed before an answer
  came back, so the effect is unknown, and as the connection may still
  stand, as after an HTTP 500, it says nothing of reconnecting.
A tools/list failure, read only by the refresh's trace, says listing
changes nothing on the server, so trying again is safe, where a tool
call's says to check its effect. Each failure the SDK reports writes one
log line with the method, the tool as a log field, and the code, HTTP
status, deadline or transport's words, redacted; a refusal with no
session writes none, as legacy's on a closed connection. The server's
error words reach the caller unredacted, trimmed and bounded as before,
and the log redacted, whatever JSON value they are (logValueRedacted in
log-value.ts, for both engines).

The legacy engine: anima-research#506's log line for a server's error answer now
prints the message redacted, through logValueRedacted. A connection that
doesn't reconnect kept no configuration, so the line had nothing to
redact with; connectChecked now keeps it for every connection, and
reconnecting still turns on reconnect: true alone (reconnectEnabled).

The framework:
- mcpServerIdTaken: while any connect holds the id, in either engine, a
  second connect, or a restart of another server into it, reads anima-research#506's
  in-flight words. A modern connect registers its connection before it
  starts, and a legacy one registers its server once its handshake is
  done, so both read "already registered" for part of their connect
  before.
- A tool call, the model's or an in-process one, routed to a modern
  connection whose connect hasn't brought its first session up reads the
  "connecting" text (modernStillConnecting), as one during a legacy
  handshake does, where it read the connection's "not established".
- runConnectMcplServer refuses the id before it reads the server's
  restored trees, as main's order did, so a refused connect reads none.

Changelog: the unreleased modern-mcp-client fragment says its failures
read as an MCPL server's where the two share an outcome, and in their
shape where they don't, and a call during its connect as one during an
MCPL connect; mcpl-connect-in-flight says either engine again;
mcpl-agent-words says the error answer's log line has the message
redacted.

Tests: the modern outcome tests pin each text and its log line, the
no-session texts after a loss with and without reconnect and after this
host's close, a request a host close cuts off, one whose child dies, and
the log redacting an echoed token as a string and inside an object;
mcp-modern-http the HTTP status in the log line; mcpl-agent-words the
legacy line redacting an echoed token likewise; mcp-modern-framework a second connect and a tool call
during a modern connect; mcpl-agent-words the connecting text on both
paths and a call once the session is up, and the in-flight words after a
legacy server registers; mcp-server-ownership that a refused connect
reads no trees, and that a disconnect during a handshake whose child
can't be reaped rejects and keeps the server registered (the second
removal in runDisconnectMcplServer, which no test pinned).

Receipts, on Linux. With this commit's src and tests, on Node 24.22.0:
the twelve changed or most affected test files pass, 271
tests; with src and changelog.d taken from main 5227741 and these tests,
24 tests fail across six files; and each of 35 pieces, removed on its
own, fails at least one test. This commit merged with main ce1a073,
each test file in a process of its own: the typecheck and the build are
clean, and 234 files and 4,421 tests (4,404 passing, 17 skipped, none
failing) pass on Node 24.22.0, and the same on Node 20.20.2, with CI's
stop pair passing on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru nissa-seru added the no-changelog Exempts this PR from the changelog check (internal-only change) label Oct 18, 2026
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 2/5 Tier: apex

[Medium impact] The changes affect MCP request messages, host logs, and runtime server connections.

Fix the error logging before merging; it can expose URL passwords and leave legacy calls waiting forever.

Findings

  1. P1 Security URL passwords remain in logs ▶
  2. P1 Error answers leave calls waiting ▶
Fix with agent prompt
### Issue 1
src/mcpl/log-value.ts:55
`logValueRedacted` can miss URL passwords inside object-valued messages. With `url: 'wss://h/mcpl?password=secret%22key123'` and `message: { said: 'secret"key123' }`, `textOf` adds a backslash before the quote. The URL patterns cover the raw and percent-encoded password, but not that JSON-escaped form. The full password remains readable in the log.

Cover the helper's JSON encoding when building the patterns, and add this case to the tests.

**How this was verified:** The legacy error-answer path passes object-valued messages through `JSON.stringify` before matching URL patterns that contain neither the resulting escaped password nor an equivalent match.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
src/mcpl/server-connection.ts:2101
If the Discord awareness outbox becomes corrupt or unreadable after a legacy stdio server connects, this new log call can leave a tool call waiting forever. With the default reaction settings, `config.env` is a getter that reads that file. `redactServerText` reads `env`, so it can throw here.

`handleResponse` has already removed the request and cleared its timer, but has not called `pending.reject`. The outer catch only reports the error. Neither a timeout nor a later close can then settle the call.

Keep a logging failure from skipping `pending.reject`, without falling back to printing secrets.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Aligns modern MCP failure replies with the legacy engine and gives clearer replies while a server connects.

  • Modern MCP failures tell agents what happened and what to do next.
  • MCP error logs hide credentials echoed by servers.
  • MCP connects hold their server ID until setup is ready.

Reviews (1) · Last reviewed commit: "fix(mcp): the modern engine's failed req..." · Reviewed by Greptile

Comment thread src/mcpl/log-value.ts
* text holds nothing `redact` replaces prints exactly as logValue prints it.
*/
export function logValueRedacted(value: unknown, redact: (text: string) => string): string {
return printed(redact(textOf(value)), FIELD);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security URL passwords remain in logs

logValueRedacted can miss URL passwords inside object-valued messages. With url: 'wss://h/mcpl?password=secret%22key123' and message: { said: 'secret"key123' }, textOf adds a backslash before the quote. The URL patterns cover the raw and percent-encoded password, but not that JSON-escaped form. The full password remains readable in the log.

Cover the helper's JSON encoding when building the patterns, and add this case to the tests.

How this was verified: The legacy error-answer path passes object-valued messages through JSON.stringify before matching URL patterns that contain neither the resulting escaped password nor an equivalent match.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/log-value.ts
Line: 55

Comment:
**URL passwords remain in logs**

`logValueRedacted` can miss URL passwords inside object-valued messages. With `url: 'wss://h/mcpl?password=secret%22key123'` and `message: { said: 'secret"key123' }`, `textOf` adds a backslash before the quote. The URL patterns cover the raw and percent-encoded password, but not that JSON-escaped form. The full password remains readable in the log.

Cover the helper's JSON encoding when building the patterns, and add this case to the tests.

**How this was verified:** The legacy error-answer path passes object-valued messages through `JSON.stringify` before matching URL patterns that contain neither the resulting escaped password nor an equivalent match.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2820a431. The redactor gave a token and an env value their JSON forms, but not a credential taken from the url, so logValueRedacted's JSON text kept a url password holding a quote. Every string the redactor removes, from every source, the url's credentials, pairs and userinfo units included, is now also removed as JSON encoders write it, in one pass in serverCredentialPatterns. A server's own JSON that echoes a decoded url credential goes too. mcp-protocol-family has each url source in its JSON forms, and your example through logValueRedacted.

Written by Crispin-2406.

— Lottie-2661

Comment thread src/mcpl/server-connection.ts Outdated
console.error(
`MCPL server "${this.id}" answered ${pending.method} (id=${response.id}) with error ${logValue(response.error.code)}: ` +
logValue(response.error.message),
logValueRedacted(response.error.message, (text) => redactServerText(text, this.config ?? {})),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Error answers leave calls waiting

If the Discord awareness outbox becomes corrupt or unreadable after a legacy stdio server connects, this new log call can leave a tool call waiting forever. With the default reaction settings, config.env is a getter that reads that file. redactServerText reads env, so it can throw here.

handleResponse has already removed the request and cleared its timer, but has not called pending.reject. The outer catch only reports the error. Neither a timeout nor a later close can then settle the call.

Keep a logging failure from skipping pending.reject, without falling back to printing secrets.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/server-connection.ts
Line: 2101

Comment:
**Error answers leave calls waiting**

If the Discord awareness outbox becomes corrupt or unreadable after a legacy stdio server connects, this new log call can leave a tool call waiting forever. With the default reaction settings, `config.env` is a getter that reads that file. `redactServerText` reads `env`, so it can throw here.

`handleResponse` has already removed the request and cleared its timer, but has not called `pending.reject`. The outer catch only reports the error. Neither a timeout nor a later close can then settle the call.

Keep a logging failure from skipping `pending.reject`, without falling back to printing secrets.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2820a431. The call is now rejected before the log line, so nothing the line does can leave it waiting. #542, merged in since, has a redaction that can't read its configuration withhold the text rather than throw, so the line now prints the message withheld. mcpl-agent-words covers both: a configuration whose env getter throws, where the call rejects with the server's words and the line withholds them, and a logger that throws, where the call still settles.

Written by Crispin-2406.

— Lottie-2661

…credentials as JSON writes them

Two findings from Greptile's review of anima-research#540 at 9ad13d4.

A legacy call could wait forever. handleResponse deletes the pending
request and clears its timer, then wrote the error answer's log line,
which redacts with the connection's configuration. For a stdio server
the framework connects, that configuration's env is a getter that can
read the reaction baseline's ledger, and it throws when the ledger can't
be read. The throw skipped pending.reject, and the dispatch's containment
only reported it. Since 9ad13d4 every connection keeps its
configuration, so this reached every such server. The call is now
settled first. A throw in the log line then prints none of the server's
text, and the dispatch reports it.

A url's credential could stay in a log line that prints a JSON value.
logValueRedacted redacts a value's JSON text, where JSON.stringify
escapes a quote, a backslash and a control character. The redactor
removed a token or an env value in that form, but not a credential taken
from the url, such as a path segment, a query value, a WebSocket
password, a name=value pair or a userinfo unit. Every string the
redactor removes, from every source, is now also removed as JSON encoders
write it, with JSON.stringify's escapes and with its slashes escaped, in
one pass in serverCredentialPatterns. So a server's own JSON that echoes
a decoded url credential goes too. The unreleased
redact-recorded-mcp-credentials fragment says so.

Tests: mcpl-agent-words has an error answer whose log line can't be
redacted: the call rejects with the server's words, no log line prints
them, and the dispatch failure is reported. mcp-protocol-family has a
url's path segment, query value, short pair, password and short userinfo
unit removed in their JSON forms, and an object logged through
logValueRedacted keeping no url password.

Receipts, on Linux. With this commit's src and tests, on Node 24.22.0:
the thirteen changed or most affected test files pass, 307 tests. With
src and changelog.d taken from 9ad13d4 and these tests, the three tests
this commit adds fail, across two files. With them taken from main
5227741, mcp-protocol-family can't load, since it imports
logValueRedacted, which main lacks, and 25 tests fail across six other
files. Each of 41 pieces, this commit's 6 and the 35 before it, removed
on its own, fails at least one test. This commit merged with main
aae86b7, each test file in a process of its own: the typecheck and the
build are clean, and 239 files and 4,612 tests (4,595 passing, 17
skipped, none failing) pass on Node 24.22.0, and the same on Node
20.20.2, with CI's stop pair passing on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crispin-2406 and others added 3 commits October 18, 2026 21:38
A name=value pair or a userinfo unit taken from a server's url is
replaced only where no letter or digit adjoins it. In a JSON value's
text, which logValueRedacted redacts, a control character before it is
written as an escape that ends in one (\n, \t, \r, \b, \f, \uXXXX). So
a pair whose value goes only as a pair, such as a short one, stayed in
the log after a newline in an object-valued message, where the same text
as a string was redacted. Such an escape now counts as a boundary too.
The unreleased redact-recorded-mcp-credentials fragment and
redactServerText's doc say so.

Texts:
- The in-flight fragment said the refusal lasts "until that connect
  lists its tools", which reads as until listing settles. It now says
  until that connect lets go of the id, just before it lists its tools.
- mcplServerConnectingText's and mcplServerGoneText's docs described
  only a legacy server. They now say a modern connection is registered
  before it starts and reads the connecting text until its first
  session is up.
- The modern outcome test's header says an error answer's code goes to
  the log line.
- withJsonForms' comment says a url credential gets JSON's forms as a
  token does, where it said every form a token gets.

Tests: mcp-protocol-family has a pair and a userinfo unit after each such
escape, with a letter that's text still adjoining them, and the per-form
floor: a seven-character token's raw form kept and its eight-character
JSON form replaced. mcp-modern-outcomes has the listing variants of a
connection lost under the request and of this host's close.

Receipts, on Linux. With this commit's src and tests, on Node 24.22.0:
the thirteen changed or most affected test files pass, 309 tests. With
src and changelog.d taken from 2820a43 and these tests, one test fails:
the pair and the userinfo unit after a JSON escape. The tests of the
floor and of the listing variants pin what 2820a43 already did, and
pass there. Each of 45 pieces, removed on its own at this commit, fails
at least one test. This commit merged with main 00eb87a, each test file
in a process of its own: the typecheck and the build are clean, and 240
files and 4,631 tests (4,614 passing, 17 skipped, none failing) pass on
Node 24.22.0, and the same on Node 20.20.2, with CI's stop pair passing
on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main now carries anima-research#542, whose redactions withhold the text, rather than
throw, when the configuration they redact with can't be read. anima-research#542 lands
ahead of this pull request, which takes its text where the two meet:
- protocol-family.ts merges without conflict: anima-research#542's withheld text and
  its read of the url sit beside this pull request's JSON forms and its
  escape boundary.
- The legacy error answer's log line redacts with the connection's
  configuration, so with anima-research#542 it withholds the message, where it threw,
  while a stdio server's reaction-baseline ledger can't be read. The
  call is still settled before the line is written.
- mcpl-agent-words' test of an unreadable configuration now asserts the
  withheld line beside the call settled with the server's words. A new
  test has the logger throw on that line, which leaves the call settled
  only because it was settled first, so that order still has a test that
  fails without it.

Receipts, on Linux. With this merge's src and tests, on Node 24.22.0:
the sixteen changed or most affected test files, anima-research#542's three among
them, pass, 400 tests. With src and changelog.d taken from main fe82187
and these tests, mcp-protocol-family can't load, since it imports
logValueRedacted, which main lacks, and 26 tests fail across six other
files. Each of 45 pieces, removed on its own at this merge, fails at
least one test. The whole suite at this merge, each test file in a
process of its own: the typecheck and the build are clean, and 240 files
and 4,640 tests (4,623 passing, 17 skipped, none failing) pass on Node
24.22.0, and the same on Node 20.20.2, with CI's stop pair passing on
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hrow

The comment on handleResponse's error branch gave settling the call
before its log line the reason 2820a43 had: that line redacted with a
configuration whose env getter could throw. With anima-research#542, merged in at the
commit before this, that redaction withholds the message instead. The
comment now gives the reason that stands, and that mcpl-agent-words'
test of a logger that throws pins: the call's entry and timer are gone
by then, and a host's logger can throw.

This changes only that comment, so the receipts of the merge before it
hold for this tree's code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit b2f93a1 into anima-research:main Oct 19, 2026
6 checks passed

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at 068cd23 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: COMMENT (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at 068cd23 against main b82ba63 (merge base fe82187). The PR had already merged, as b2f93a1, so everything below is follow-up work.

Verdict: COMMENT. The change is sound. Two minor follow-ups, nothing that should have held the merge.

The modern engine's failure texts are now chosen by the same wire evidence as before, and they read word for word like the legacy engine's wherever the two share an outcome. The in-flight id hold now covers both engines through one condition, and the tests pin each text and each ordering claim. The two findings are edge paths: an unreapable child's loss, and a hot-path cost. Neither is in the main texts.

There is no earlier review of ours on this PR, so there are no dispositions to give.

The two questions this round was asked to settle

Are failed requests and in-flight connects reported correctly, and consistently with the legacy engine? Yes, with one exception: R1-1.

  • Texts that match legacy word for word:
    • the closed-connection refusal (modern-connection.ts:891-897 vs server-connection.ts:1693-1700);
    • the pipe refusal (retryText vs :1711-1715);
    • a request cut off by this host's close (vs :1438-1439);
    • a connection lost under a request (vs :2182-2185);
    • an error answer without words (vs :2107-2108).
  • Divergences, all deliberate and true for each engine:
    • the write-failure text, as the description explains;
    • the deadline: the modern engine does send a cancel, where legacy abandons the request;
    • the modern engine reads willReconnect, which also counts a host close and an unreaped launch, where legacy's lost branch reads reconnectEnabled.
  • The lost branch is reachable as claimed. In SDK 2.3.1, Protocol._onclose calls onclose, which sets session.lost, before it rejects the pending handlers (client/dist/src-*.mjs:5861-5877). A host close also sets session.lost, but requestFailure checks closedByHost first, so the host-close text wins.
  • In-flight connects:
    • mcpServerIdTaken gives the in-flight text whenever a connect holds the id (framework.ts:23249).
    • The hold is released in finally before refreshMcplTools (:22222-22227, :22973).
    • The early refusal in runConnectMcplServer reaches the hold through synchronous code only: loadHostStates returns void, so "nothing awaited between" holds.
    • modernStillConnecting covers both dispatch paths. A restart's gap between its disconnect and its connect is covered by restartClaims, and its connect takes a connect hold of its own. A disconnect takes the hold over, so a call during it reaches the closing connection and reads the host-close text.

How does it interact with the pipe-error handling of #374 and #472? It neither fixes nor worsens either one.

  • NotWrittenError.pipe is set only when a later send finds pipeFailure already recorded (:323-324). That is the same field the legacy engine reads (server-connection.ts:1707-1717), so both engines say the same thing whenever the pipe error is observed.
  • The write that itself hits EPIPE has already been handed off. It ends as "closed before it answered … unknown" once the child is reaped, which is the conservative reading and the right one.
  • Under Bun, no 'error' fires (transport.ts documents this for 1.3.10, 1.4.3 and 1.4.4), so the pipe branch is unreachable in both engines. A call there waits out its deadline and reads the deadline text. That is #374's symptom, unchanged.
  • The one new interaction is R1-1, on the path where the child also outlives SIGKILL.

Blockers

None.

Major

None.

Minor

R1-1. When a pipe failure's child can't be reaped, the modern engine tells calls it is reconnecting, one tick before it halts reconnecting for good.

Where:

  • The 'unreaped' route, onUnreaped → handleLost (src/mcpl/modern-connection.ts:583-592, :316).
  • handleLost (:766-789) clears the session and starts the reap. The launch is held, which makes willReconnect false (:458-460), only after closeSession resolves with a failure.
  • Meanwhile the reap's markClosed has already emitted 'close', which rejects the SDK's pending handlers. The lost branch and retryText() then read willReconnect while it is still true.
  • The legacy engine holds the launch first (server-connection.ts:2221-2226, where holdUnreaped clears reconnectEnabled at :1144-1151) and only then rejects pending calls.

Probe: on Node 22.21, with reconnect: true, a child that answers and then closes its stdin (the fixture's deafen tool), and child.kill stubbed so the child outlives SIGKILL:

  • At about 0.8 s, a call reads the pipe text. Legacy says the same during the escalation.
  • At 4.33 s, 'unreaped' fires. The call that was pending rejects no-response with "… so whether the request took effect is unknown. Check its effect before trying again; this host is reconnecting to it."
  • A call in the same tick reads "… This host is reconnecting to it; try again later."
  • In that same tick the connection logs "Not starting another launch while it may still be running" and emits reconnect-failed with permanent: true. willReconnect reads false from then on.
  • Legacy says "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."

Failure scenario: a stdio server's stdin breaks and its child is stuck where SIGKILL can't end it. Every call that was in flight tells the agent the server is coming back and to try later. It never comes back. The agent learns that only on its next call.

Fix:

  • On the 'unreaped' route the failure is already known, so hold the launch before ending the session, as legacy does. Forward the Error (the 'unreaped' listener at :316 currently drops it), and call holdUnreaped(session, failure) in handleLost before closeSession.
  • Add a test that drives the real route: a real pipe failure, a stubbed kill, and a call pending across it.
  • The existing R1-1 tests (test/mcp-modern-outcomes.test.ts:216-240) simulate the loss with markClosed(). That takes the close route, not 'unreaped', which is why this path has no test.

Sol found this. Its further claim, that pending calls would wait out their deadline, did not hold up in the probe: they are rejected at the reap.

R1-2. On a framework-launched legacy stdio server, every JSON-RPC error answer now reads the reaction-baseline ledger from disk, synchronously, to redact its log line.

Where:

  • connectChecked now keeps config for every connection (server-connection.ts:850).
  • For a stdio server, the framework passes a copy whose env is a getter. The getter calls getPlacedReactionBaseline() (framework.ts:22337-22345), and that calls discordAwarenessOutbox.batches(), which does readFileSync + JSON.parse + structuredClone (recovery/discord-awareness-outbox.ts:282-284 and its read()).
  • The error answer's log line (server-connection.ts:2112-2115) calls redactServerText(text, this.config), which rebuilds the patterns, and so reads env, on every call. A pure-function check confirms one getter read per line.
  • The getter-derived key, DISCORD_SUPPRESSED_REACTIONS_BASELINE, isn't credential-named (CREDENTIAL_NAME_PART, protocol-family.ts:414), so the read buys no redaction.
  • Before this PR, a non-reconnecting connection kept no config, and a reconnecting one read it only on reconnect-failure lines.

Failure scenario: with an awareness outbox configured, a server that refuses calls routinely (a JSON-RPC error is the ordinary "refused" answer) does a blocking file read, a parse and a regex build on the event loop for each refusal. The cost grows with the ledger. It is small per call, but it was never on this path before.

Fix:

  • Build the connection's redactor once, with serverTextRedactor(config), as the modern engine's transports do (modern-connection.ts:284). It rebuilds only when a dial notes something new, and a stdio launch never does.
  • The credential-named env values are static, and only the non-credential baseline changes, so nothing goes unredacted.
  • Building it at connect, where the ledger was just read successfully, also takes the unreadable-ledger case off this line. The test "an error answer whose configuration can't be read … withholds" would then change with it. That trade-off is the author's call.

Verified fine

  • Tests and CI: tsc is clean. The seven changed test files pass on Node 22.21.0 (176 tests). CI at this head is green on all four Build & Test legs, and the packed install passes.
  • Text table: each modern outcome's text matches the description's table, and each branch logs exactly one line through requestFailure. The no-session refusal logs none, as legacy's closed-connection refusal logs none.
  • Error answers:
    • A message that isn't a string reads as "no words" in both engines.
    • The caller's text is the server's own words, bounded, as legacy gives them. On the model's dispatch path, failDispatch redacts the text anyway (framework.ts:24506-24512).
    • Legacy settles the call before writing the log line (server-connection.ts:2104-2115), so a logger that throws can't strand it.
  • Redaction:
    • logValueRedacted redacts before printed() truncates at 256 units, so no credential is cut across the bound.
    • Patterns grow at most 4× per string, deduplicated by a Set. Regex metacharacters are escaped.
    • The JSON-escape start lookbehind only widens matches. A literal backslash followed by n in raw text also counts, which over-redacts, the safe direction.
    • The per-form floor behaves as the changelog fragment says.
  • SDK classification: the HTTP status is kept on SdkHttpError. An aborted signal maps to RequestTimeout (src-*.mjs:6165-6206), so AbortSignal.timeout and the SDK timer read alike. A local output-schema compile failure throws before request(), which gives not-sent (index.mjs:4224-4256).
  • Legacy config retention: reconnecting still turns on reconnect: true alone. The only gate is reconnectEnabled (server-connection.ts:1570), and the other readers of this.config are redaction and allowHostCommands on reconnect.
  • Changelog fragments: all four describe what the code does.

Method. Two passes, each claim re-verified before adoption.
Sol (Codex gpt-5.6-sol, reasoning xhigh, read-only sandbox): about 16 minutes, 70 commands, 8.0M input tokens (7.7M cached), 30k output tokens. Its sandbox could not open loopback sockets or spawn processes, so it could not run the integration tests. It had no network access.
Claude: a forest-level pass against current main, the installed SDK 2.3.1, and local test runs. It also ran three bounded probes on Node 22.21: a BigInt argument, a real unreapable pipe failure, and env-getter reads per error line.
Who caught what:

  • R1-1: Sol found the reconnect wording. The probe confirmed it and refuted Sol's companion claim about deadlines.
  • R1-2: Claude only.
  • The settled questions: both passes independently, with the same conclusions.
  • Sol's other two findings are not raised here. A pre-wire serialization failure (a BigInt or cyclic argument) reads no-response. That is real and reproduced, but it predates this PR (#262's ObservedTransport.send), and only in-process module code can reach it, so it is left for a separate issue. A failed HTTP request also emits a connection 'error'. That predates this PR too, and under the framework it is a trace, not a second log line, so it doesn't contradict the description's one-line claim.
    Not reached: Bun runs (the Bun behaviour comes from the transport's own documentation and #472), macOS, and the full suite locally (CI covers it).

nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…, not only an added one

A change whose entry belongs in a fragment already pending, such as a
follow-up to the change that added it, amends that fragment in place, and
the check counted added fragments alone (--diff-filter=A), so such a change
needed the 'no-changelog' label though it carries an entry (#540's case).
The check now counts an added or modified fragment (--diff-filter=AM), in a
variable named for what it holds. CONTRIBUTING.md, the check's error line
and the pull request template's checkbox say an edited pending fragment
counts.

A deleted fragment still doesn't count, as 7fc6a14 meant, and neither does
one git reports as a rename. Nor does an edit of changelog.d/README.md,
which isn't a fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 19, 2026
…llback, the all-except hold's channel included

What main brings since aae86b7 (0.22.3): #557, an ephemeral run's turn
locus, channel context and channel_publish default are its own channel or
nothing (`takesGlobalFallback`, `globalFallbackFor`;
`usedEphemeralAgentNames` becomes a Map of run | conversation); #559's
sized waits in the modern MCP outcome tests; #544's reconnect registration
and late-response rules; #547 and the F194 workspace spelling work; #402's
BOM-tolerant gate files; #540's agent words; #541/#542 publish and
redaction fixes.

Conflicts, all in src/mcpl/channel-registry.ts, resolved by composition
with the rule nissa-seru's review of #546 named: the all-except hold's
channel goes BEHIND the same check as the legacy fallback, so a subagent
whose run has no channel of its own never gets the focused channel either.

- buildChannelContext: main's `!== undefined` name keying and its
  `fallback` variable, where the fallback is `globalFallbackFor(agentName)`
  for a named conversation and the shared fallback for none.
- resolveLocus: `home ?? active ?? globalFallbackFor(conversationId)`.
  `globalFallbackFor` answers null for a conversation that takes no global
  fallback, else `sharedFallback()` = the focus channel, else
  `fallbackLocus()` (the latest delivered inbound under the legacy rule,
  otherwise null — Dendrite's `speech.locus`).
- channel_publish's default: main's caller-keyed form over the same
  `globalFallbackFor`, so a resident's publish with no channelId goes to the
  focus channel while a hold is open, else the legacy fallback; a subagent's
  is refused and names one. Before this merge the publish default on this
  branch skipped the focus channel; the three lookups now agree.

Two seams the build and tests found:

- framework.ts resumeAgent still called `.add` on the Set main made a Map;
  a resumed job is an ephemeral run, so it is `set(name, 'run')` and takes
  no fallback.
- A message delivered to a conversation fork (the `deliverTo` path and
  routeConversationIncoming) returned before `noteDelivered`, so on this
  branch it never became the latest delivered inbound — main's
  `markAccepted` recorded every accepted message, and #557's
  "a conversation fork keeps the fallback" test caught the gap. Both paths
  now record a delivered message as a resident's path does; held and
  dropped ones still never are.

Test adaptation: test/ephemeral-no-global-fallback.test.ts is about the
global fallback itself, so its fixture opts into `speech.locus:
'latest-inbound'`, as route-failure-targeting did at the third catch-up;
under Dendrite's `delivery` default a resident with no delivery has no
locus, which admission-e2e covers.

Composed rig: npm test 4862 tests, 4831 pass, 1 fail, 30 skipped; the one failure (host-quiesce's 2,101-message resume batch, a 60 s wait) was under three concurrent suites and passes alone, 48/48.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…nge marker, with no awareness gate

main moved on from fe82187 by anima-research#543, anima-research#540, anima-research#402, anima-research#545, anima-research#547, anima-research#544, anima-research#557,
anima-research#559, anima-research#560 and anima-research#562. Of these, anima-research#544 meets anima-research#250: an MCPL reconnect now holds
control traffic as well as data until its new grant, with a per-connection
marker for a policy exchange in flight, which the awareness gate's release
and completion consulted. What anima-research#250 removes stays removed, and what main
added keeps working beside it.

Conflicts:
- src/framework.ts, the gate: anima-research#544 added mcplPolicyExchanges,
  mcplPlanesMayOpen and settlePolicyExchange, made the gate's release and
  completion pass a connection by while its exchange is in flight, and added
  openMcplPlanesAfterExchange for a connection a newer gate had passed by.
  anima-research#250 removes the gate, so readyMcplPlanes, its one opener, takes the
  marker's check: a resume or startup's staged open passes such a
  connection by, and the exchange's own caller opens it once it settles.
  openMcplPlanesAfterExchange has no gate to follow, and goes; its callers
  call readyMcplPlanes for their connection.
- src/framework.ts, the connect: anima-research#544's marker at registration and its
  transport-bound exchange stay. A staged connect runs its exchange on the
  current transport, as anima-research#544's does, and a runtime connect on the
  transport it registered, closed for good if the exchange throws
  (settlePolicyExchange's closeOnFailure, in place of anima-research#250's own close).
  module:added keeps anima-research#544's check that the registered transport still
  serves, made after a runtime connect's open.
- src/framework.ts, the reconnect handler: anima-research#544's marker and transport
  checks in anima-research#250's shape: the exchange, then readyMcplPlanes for the
  connection, which is relisted and announced only while its transport
  still serves.
- src/mcpl/server-connection.ts: main's file, with anima-research#250's rewrites of the
  comments that named the awareness barrier or gate.
- README.md: anima-research#250's text, with anima-research#544's sentence about an exchange in flight
  moved into the delivery bullet.
- test/fixtures/reregistering-mcpl-server.mjs: main's, its header naming
  the three test files that use it.
- test/mcpl-awareness-barrier.test.ts stays deleted. anima-research#544's assertion there,
  that a failing runtime connect is closed while its exchange's marker
  still holds it, moves to mcpl-runtime-connect-failure.test.ts, where anima-research#250
  keeps those tests.

Outside the conflicts:
- test/mcpl-reconnect-registration.test.ts, anima-research#544's: its harness stood on
  the gate. Its tests now stand on readyMcplPlanes, as startup's staged
  open and a resume reach it, and the cases only a gate had (a list
  change's or an awareness sync's completion, a runtime connect's awareness
  accounting, a newer gate replacing a connect's) go with the gate. Its
  first staged-startup test records startup's open of every connection
  through readyMcplPlanes instead of the gate's completion.
- changelog.d: 0.23.0 folded modern-mcp-client.added.md,
  stop-reaches-every-cleanup.fixed.md and mcpl-reconnect-registration.fixed.md,
  so anima-research#250 no longer edits the first two. Its one-shot fragment says what
  changes from 0.23.0 for all three: only startup's staged open and
  resume() open every connection's planes, an exchange in flight still
  holds its connection through both, and the marks a drain hadn't sent at
  stop() stay queued in the ledger for the next start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…is reported once, and an unreaped launch is held first

Anarchid's review of #540 found four gaps in the two MCP connection
classes
(#540 (review)).
#556 holds the two it set aside; the review itself gave R1-1 and R1-2.

#556's first: a call whose arguments JSON can't write (a BigInt, a
cyclic object) put nothing on the wire, but the modern engine read it as
no-response, "whether it took effect is unknown". ObservedTransport.send
marked the request handed off before the inner send, and undid the mark
only for NotWrittenError, while the serializer's TypeError came from
inside the inner send. It now writes an observed request's JSON before
the mark, so a call whose first leg JSON can't write reads not-sent over
stdio and over HTTP, and the transport is still handed the caller's
message. The legacy engine already refused such a request as not sent,
but said "Try again later"; sendRequest now serializes it before it
keeps anything, and refuses it in the modern engine's words: "If calling
it again fails, ask whoever runs this host."

#556's second: the SDK's HTTP send reports a failure through the
transport's onerror and then throws the same object. So a failed
request reached whoever sent it, and also raised the connection's
'error', which the framework traces as mcpl:server-error, or with no
'error' listener logged a second line. While a request's or a
response's send is in flight, ObservedTransport now holds what that
send's transport reports, and once the send settles it forwards all of
it but the error the send threw, which its sender reports: a call
through its outcome, the connect through its failure, the SDK through
its own report. A notification holds nothing, since its sender may
swallow its failure, as the SDK's listen teardown does. The scope is per
send and names its transport, and a settled send's scope lets go of it.

R1-1: when a stdio child's pipe failed and the child outlived SIGKILL,
the modern engine held the launch only once the reap's close had failed,
after that close had ended the pending calls. So they, and a call made
as the connection closed, read "this host is reconnecting to it" one
tick before reconnecting halted for good. The transport's 'unreaped' now
passes the reap's failure, and handleLost records the hold once it has
started the reap and before it emits 'close', as the legacy engine holds
it first. It reports the hold after 'close', where the other route
reports one too, in a finally. The permanent reconnect-failed comes from
the reap's continuation, as on the route where the reap finds the child
alive, and not when the host has closed the connection meanwhile, whose
close() then reports the failure itself. A listener that throws can't
skip the reap, the hold, 'close', the hold's report or the permanent
failure.

R1-2: a legacy connection redacted each error answer's log line with a
redactor built for that line, and for a stdio server the framework
launched, the configuration's env getter reads the Discord awareness
ledger from disk. The connection now keeps one redactor, made at its
first error answer; serverTextRedactor builds again only after a build
that failed, which withholds that line, or once a dial notes something
new.

Also: each legacy refusal as not sent, for these arguments, for a failed
pipe and for a write the transport refuses at once, rejects before it
logs, so a host logger that throws can't replace the refusal.
holdsUnreapedChild's doc says it may not read true yet while a reap
runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-changelog Exempts this PR from the changelog check (internal-only change)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants