Repository navigation
fix(mcp): the modern engine's failed requests, and a connect in flight, in #506's words - #540
Conversation
…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>
[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. FindingsFix 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.SummaryAligns modern MCP failure replies with the legacy engine and gives clearer replies while a server connects.
Reviews (1) · Last reviewed commit: "fix(mcp): the modern engine's failed req..." · Reviewed by Greptile |
| * 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); |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
| 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 ?? {})), |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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>
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>
Anarchid
left a comment
There was a problem hiding this comment.
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-897vsserver-connection.ts:1693-1700); - the pipe refusal (
retryTextvs: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).
- the closed-connection refusal (
- 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 readsreconnectEnabled.
- The
lostbranch is reachable as claimed. In SDK 2.3.1,Protocol._onclosecallsonclose, which setssession.lost, before it rejects the pending handlers (client/dist/src-*.mjs:5861-5877). A host close also setssession.lost, butrequestFailurechecksclosedByHostfirst, so the host-close text wins. - In-flight connects:
mcpServerIdTakengives the in-flight text whenever a connect holds the id (framework.ts:23249).- The hold is released in
finallybeforerefreshMcplTools(:22222-22227,:22973). - The early refusal in
runConnectMcplServerreaches the hold through synchronous code only:loadHostStatesreturnsvoid, so "nothing awaited between" holds. modernStillConnectingcovers both dispatch paths. A restart's gap between its disconnect and its connect is covered byrestartClaims, and its connect takes aconnecthold 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.pipeis set only when a later send findspipeFailurealready 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.tsdocuments 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 makeswillReconnectfalse (:458-460), only aftercloseSessionresolves with a failure.- Meanwhile the reap's
markClosedhas already emitted'close', which rejects the SDK's pending handlers. The lost branch andretryText()then readwillReconnectwhile it is still true. - The legacy engine holds the launch first (
server-connection.ts:2221-2226, whereholdUnreapedclearsreconnectEnabledat: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 rejectsno-responsewith "… 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-failedwithpermanent: true.willReconnectreads 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 theError(the'unreaped'listener at:316currently drops it), and callholdUnreaped(session, failure)inhandleLostbeforecloseSession. - 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 withmarkClosed(). 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:
connectCheckednow keepsconfigfor every connection (server-connection.ts:850).- For a stdio server, the framework passes a copy whose
envis a getter. The getter callsgetPlacedReactionBaseline()(framework.ts:22337-22345), and that callsdiscordAwarenessOutbox.batches(), which doesreadFileSync+JSON.parse+structuredClone(recovery/discord-awareness-outbox.ts:282-284and itsread()). - The error answer's log line (
server-connection.ts:2112-2115) callsredactServerText(text, this.config), which rebuilds the patterns, and so readsenv, 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:
tscis 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,
failDispatchredacts 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:
logValueRedactedredacts beforeprinted()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
nin 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 toRequestTimeout(src-*.mjs:6165-6206), soAbortSignal.timeoutand the SDK timer read alike. A local output-schema compile failure throws beforerequest(), which givesnot-sent(index.mjs:4224-4256). - Legacy config retention: reconnecting still turns on
reconnect: truealone. The only gate isreconnectEnabled(server-connection.ts:1570), and the other readers ofthis.configare redaction andallowHostCommandson 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'sObservedTransport.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).
…, 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>
…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>
…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>
…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>
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.tsat5227741b):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.tsat5227741b):MCPL server "m" has a connect in flight; try again once it settlesonly while nothing is registered under the id (:22939). A modern connect registers its connection before it starts, and a legacy one registers its server once its handshake is done, so both readMCPL server "m" is already registeredfor part of their connect.connection … is not established(:18032,:24150), where one during a legacy handshake 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 "connecting" text.:22671), wheremainbefore feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 refused first.Changes
The modern engine's request failures (
requestFailure, andrequest()'s refusal with no session), outcome by outcome. Each outcome and itsdataare as before,data.httpStatusincluded.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.not-sent:The request wasn't sent: this host can no longer write to the "m" server, so nothing ran., then whether it's reconnecting;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'soutputSchemathe SDK can't compile does, so the text claims neither.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.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.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.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.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.no-response: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;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;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/listfailure reaches only the refresh'smcpl:server-errortrace. 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, withJSON.stringify's escapes and with its slashes escaped. Tokens and env values had these forms. A url's credentials, itsname=valuepairs 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:
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 readsis already registered, which is so.modernStillConnecting, on the model's dispatch and the in-process path): until the connection's first session is up, the call readsThis 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.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.mdsays 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.mdsays either engine again.mcpl-agent-words.changed.mdsays 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.mdsays 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
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.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 aslogValuereads it, and otherwise prints exactly aslogValuedoes.reconnect: truealone (reconnectEnabled).logValueRedactedredacts 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.\n, whosenwould 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.Left out:
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: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: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 throughlogValueRedactedkeeps 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:c35d286), which feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262's description named as untested.Checks
These ran at the merge of
main,e64a6090. The head after it changes only a comment.main's code: withsrcandchangelog.dfrommainfe821876, and these tests,mcp-protocol-familycan't load, since it importslogValueRedacted, whichmainlacks, and 26 tests fail across six other files.logValueRedactedand each engine's use of it;mainfe821876, 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.npm test, and its packed release installs on its own.Reviews
mainwith fix(mcpl): a redaction that can't read its config withholds the text #542, and approved their code and texts.9ad13d4fand found two issues: the legacy call left waiting, and a url credential in a JSON value's log line. This head fixes both.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
stop()reads as feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 has it. Those texts reach whoever connected the server, an operator or a host's tool.Other open pull requests
requestFailure, already conflicts withmaininsrc/mcpl/modern-connection.ts. This change adds conflicts with it there and intest/mcp-modern-outcomes.test.ts.changelog.d/modern-mcp-client.added.md,changelog.d/mcpl-connect-in-flight.fixed.md,changelog.d/mcpl-agent-words.changed.mdandchangelog.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 theno-changeloglabel.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-seruaccount.