Repository navigation
feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP - #262
Conversation
A failed request to an MCPL server used to reject with a plain Error, so the only way to tell "never written" from "written and unanswered" from "answered with an error" was to match message text. Callers that act in the world need that difference as a fact: a publish or a reaction that was never sent left the world as it was, while one that was handed to the transport and never answered may have landed. (A rollback reaction drain met exactly this: its pending reactions failed at shutdown, and only their message text said whether each had been written.) McplRequestError extends Error with `outcome`: - `not-sent`: sendRequest refused before writing (connection already closed); - `error-response`: a JSON-RPC error response; `code` and `data` kept verbatim (a connector can describe a partial multi-part post there); - `no-response`: the request was handed to the transport and no answer came (per-request timeout, a deliberate close while awaiting, or an unexpected disconnect). It does not prove the request reached the server — the transport can decline or drop a write — only that the host can no longer say it didn't. Messages are byte-identical to before, and every rejection is still an Error. Exported from the mcpl index and the package root. Tests (test/mcpl-request-outcome.test.ts, a real stdio server): an error response keeps code and data; an unanswered request and a process exit while awaiting are no-response; a request on a closed connection is not-sent; the class keeps Error identity. 12/12 with the existing request-timeout tests. Reviewed by Tessa-974 (room-203 #43780), whose wording correction for no-response this carries. Shared with room-225's lane 1 (Ada-1017). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ated tools/list First batch of room-284's modern-MCP client work (shelf-487; design in personal:olive-1804 #52935, revised #53069, implementation authorized #53087). Protocol family and transport are decided from configuration alone (src/mcpl/protocol-family.ts): a URL's scheme decides a network server (ws/wss = legacy MCPL, http/https = modern MCP 2026-07-28); only a stdio server has a choice, `protocol: 'legacy' | 'modern'`, defaulting to legacy so every existing stdio config opens exactly as before. No cross-family probing: the spec's discover-first probe makes real MCPL servers exit (heartbeat-mcpl), and the SDK's stdio probe launches every server twice. serverConfigProblems() adds checks only for what the modern family brought: protocol on a URL, a modern requestTimeoutMs outside 1..2^31-1, and effective MCPL-only policy on a modern server. Every legacy shape still passes. Legacy engine (McplServerConnection): - Offers and now requires MCP 2024-11-05. Another returned revision, or none, is McplProtocolVersionError kind 'mismatch'. An initialize answered -32022 is kind 'rejected', keeping the server's supported list, code and data, with a protocol: 'modern' hint when the list names modern MCP. The established revision is on connection.protocolVersion. - A version verdict is permanent: connectWithReconnect makes no retry stub, and attemptReconnect stops its loop and emits reconnect-failed with permanent: true. The framework raises the ops alert at once for it, since no fifth attempt will come, and its connect-failed trace says willRetry: false. - tools/list follows nextCursor to the complete inventory. A repeated cursor, or more than 100 pages, is an error. A single-page answer is returned exactly as before, other fields included (mcpl-capability-scoping relies on that). - A modern config is refused before anything is dialed. Carries Petra-1012's McplRequestError commit (7144970, cherry-picked as 10380dd) as the shared request-outcome contract, per Tessa (room-203 #53124). Test stubs that answered initialize without protocolVersion now send 2024-11-05: six existing files, plus mcpl-request-outcome.test.ts from the carried commit. Every probed real server family (TS SDK 1.x, Python FastMCP 1.x/2.x, SDK 2 dual-era, discord/heartbeat/slack/spend MCPL servers) already sends it. The changelog fragment names the hand-written-server consequence. Tests: mcp-protocol-family (6), mcpl-legacy-version (10). Full suite 1397/1397 (baseline 1377 + 4 carried + 16 new).
…able HTTP via SDK 2.x
Second batch of room-284's modern-MCP client work (shelf-487). Adds the
modern engine as its own connection class. Framework integration is the next
batch; nothing in the framework constructs it yet.
The SDK (@modelcontextprotocol/client ^2.3.1, pinned to 2026-07-28) owns the
modern wire: server/discover, the per-request _meta envelope, resultType,
input_required continuation, cancellation, subscriptions/listen, and the
HTTP binding's headers, 401 retry and header-mismatch relist. Connectome
owns:
- stdio through its own spawner (SpawnerStdioTransport over StdioTransport).
The child launches once per connect: a custom transport probes in place,
where the SDK's own stdio transport starts a throwaway sibling. It gets
the same env allowlist, inheritEnv and stderr lines as a legacy child, and
cancellation by notifications/cancelled.
- HTTP through the SDK's StreamableHTTPClientTransport, with token and
accessProvider behind the SDK's AuthProvider seam: a cached bearer,
re-resolved once on 401.
- One deadline per call. requestTimeoutMs is always positive for modern (0 is
rejected by config validation, since the SDK reads 0 as "time out now"),
and is passed as timeout, maxTotalTimeout and an outer AbortSignal, so
continuation rounds and pre-dispatch retries can't renew the wait. At the
deadline the SDK requests cancellation and the error says the outcome is
unknown. Nothing is ever replayed.
- Failures use the McplRequestError contract, claiming only what's
established. A JSON-RPC error, or an HTTP 401/403 carrying the status, is
error-response. Everything after hand-off, the deadline included, is
no-response. Connectome's own pre-send refusal is not-sent. A result the
server returned that can't be used (failed validation, input this client
can't supply, a rounds cap) is a plain Error, not an outcome.
- Results kept by presence: structuredContent false/0/null are values.
- Lifetime: reconnect with the legacy backoff (reconnect / intervals). A lost
list-change subscription is reopened while the connection lives, followed
by one inventory refresh. Each connect is a generation, so a superseded
client never touches a newer one. Client capabilities are {}: no sampling,
elicitation or roots.
Tests (13, at our seams): stdio launch count, env allowlist, stderr, results
by presence, the deadline cancelled server-side (warm-up first, since SDK
2.0.0's server ignored cancellation of request id 0 in probes), list change,
crash and reconnect with relaunch count 2, no-reconnect refusal as not-sent,
retrying first connect stopped by close, config errors; plus one HTTP smoke
covering accessProvider refresh, required headers, deadline abort seen
server-side, 401 connect failure, and auth wall as error-response 401.
Stable across 3 reruns.
…nce and owned lifetimes Theo-1805's review of 2c9aff1/3a3104c (room-284 #53300, #53316, #53318, #53323) and Petra-1012's read of the outcome mapping (room-203 #53269), reproduced against SDK 2.3.1. Request outcomes from the wire (was: inferred from error classes). ProtocolError doesn't prove a remote error: SDK 2.3.1 raises it locally for an outputSchema that won't compile (before any tools/call) and for output validation failures (after a successful result), and both were reported as error-response. Now an ObservedTransport sits between the SDK and the real transport, and a per-request AsyncLocalStorage scope records two things: whether a request of the observed method was handed off, and how the server answered the most recent one. - Never handed off → not-sent. This covers SDK pre-dispatch rejects, a pre-aborted signal, and no transport. - A JSON-RPC error on the wire → error-response with that error's code and data. - A result → a plain Error, "answered, but the result can't be used". - Handed off and unanswered → no-response. At the deadline, cancellation was requested. - HTTP 401/403 → error-response with code undefined and data.httpStatus, so JSON-RPC and HTTP codes never share `code` (Petra). Auxiliary requests inside a call, such as the SDK's header-mismatch relist, are another method, so they never overwrite the call's evidence. listTools() uses the same contract with tools/list as its method, and one LIST_TIMEOUT_MS budget now bounds the whole paginated walk (an outer signal), not each page. Lifetime: - close() owns a connect in flight. During server/discover the SDK hasn't attached its Protocol yet, so client.close() alone leaves the probe's child alive. close() now closes the opening transport, then awaits the connect's end. - List-change awareness covers a subscription the server advertises but that never opened. The SDK swallows a refused initial listen into onerror and still connects, so keepListening() schedules a reopen when capabilities.tools.listChanged is set and no subscription exists. - Reopen timers are keyed by generation. A stale failure can't take the single slot or schedule across generations; handleLost and each new generation cancel it. Legacy pagination (2c9aff1): cursors are opaque, so '' continues and only an absent nextCursor ends the inventory. A page without a tools array, or with a non-string nextCursor (null included), is an error rather than a partial or empty inventory published as complete. Tests: mcp-modern-outcomes (6; a hand-written server logs every request, and each outcome is checked against that log: 0 calls; 1 call answered; a wire error; listen refused then reopened, also across a reconnect; close() during a hanging reconnect handshake kills that launch, and no launch follows). Legacy: '' cursor continues; malformed pages rejected. HTTP auth-wall test updated for code/httpStatus. Full suite 1413/1413; the touched files repeated 3x stable.
… frames, startup stderr Theo-1805's recheck of 7f7e91b (room-284 #53325, #53542, #53568, #53570). - input_required continuations keep their evidence. The SDK drives the next leg from the transport's inbound callback, outside the caller's async chain, so leg 2's send had no wire scope, and an error or a silence on leg 2 was reported as leg 1's "answered, but can't be used". Now the observer hands each matched answer to the SDK inside its record's scope (wireScope.run), so every leg's send is recorded against its own call. A superseded leg's late answer re-enters nothing. - stdio close reaps. StdioTransport.close() resolved as soon as kill() set child.killed, before the child exited, so a child handling SIGTERM outlived close(). Now it waits for the real exit. After 2 s it sends SIGKILL and waits up to 2 s more. A child that never spawned returns at once. This is a shared primitive, so the legacy engine reaps too. - Writes that race a child's exit no longer crash the host. An EPIPE on child.stdin had no listener: it is now reported as a transport 'error' when anyone listens, and otherwise ignored, since 'close' already reports the exit. A full-suite run caught this as an uncaught EPIPE in a reconnect test. - A JSON line that isn't a JSON-RPC message is a diagnostic, never a crash. `null`, an array or a bare value went to observe() as a message and threw from the stream callback. Lines are now validated with the SDK's own parseJSONRPCMessage. A malformed one goes to onerror (bounded excerpt) and is dropped, and observe() guards non-objects as well. - Startup stderr is kept. Lines emitted while connect() was still pending had no listener and were dropped. They are now held, the newest 200, and delivered when the first 'stderr' listener attaches. Tests, raw fixture flags/tools added (ignore-sigterm, nullframe, cont): - an error or a hang on the second leg is error-response with leg 2's code/data, or no-response; - three concurrent continuations (two error, one ok) never cross evidence; - four non-JSON-RPC lines → four diagnostics, and the connection stays usable; - close() returns only after a SIGTERM-ignoring child is dead; - the startup stderr banner reaches a listener attached after connect(). Outcome and connection files: 19/19.
…structured results
Third batch of room-284's modern-MCP client work (shelf-487): the framework
integration.
Connecting. connectMcplServerInternal validates first: serverConfigProblems
for any config whose binding resolves, so a modern config with MCPL policy, a
deadline of 0, or protocol on a URL is an error before any routing is
registered. A config naming no usable transport keeps the transport's own
error. Duplicate ids are refused across both engines. A modern binding goes
to ModernMcpConnection and is kept in its own map, apart from the MCPL
registry, so quiesce's plane pauses, awareness gates and policy never see it.
Fatal startup paths and stop() close modern connections too, including any
connect still in flight.
Events (wireModernEvents). stderr, errors, connect and reconnect failures
(with the ops alert at attempt 5) and close get the MCPL traces. A list
change, and the relist after a reconnect, take handleToolsListChanged, the
legacy refresh path, so any wake goes through ordinary admission. No
awareness gate: it orders MCPL data-plane traffic, which this family doesn't
have. A closed connection without reconnect is dropped, as the MCPL registry
drops one.
Tools. refreshMcplTools lists modern servers under the same prefix, tool
policy and RFC-008 class hints. A failed listing leaves the server unlisted
(unknown) and traces it.
Dispatch. Both the model path and the direct path (executeToolCall,
ModuleContext.callTool) route modern calls, sharing the not-found and policy
refusals with MCPL. Both read results through mcpToolResult:
- A standard peer (an MCP-only legacy server, or a modern server) gets
normalizeStandardToolResult (src/mcpl/tool-result-normalize.ts):
- text stays text, and inline images stay native;
- audio and blobs are saved to the workspace `tool-results/`, with a
bounded stub saying where;
- a resource_link is shown and never fetched; embedded text is shown;
- structuredContent is kept by presence on the new ToolResult.structured,
and shown to the model as JSON only when the content has no text.
- MCPL peers keep the RFC-005 reading (autofetch only for them now), gaining
`structured` and the JSON view for empty content.
- The direct path now fails on isError. It used to report every answer as
success, so tts-relay's edit check read a failed edit as success.
Scripts. A result with structured content reaches the script as one JSON
object, { content, structuredContent, isError }, with text content as the
text itself, under the same 5 MB cap. Other results keep the history string
contract.
Status. listMcplServers entries gain family, protocolVersion and transport.
Host lifetime evidence (Tessa #53152, Agnes/Kit #53184, Theo #53178), in
test/mcp-modern-framework.test.ts against a real framework:
- a modern call in flight when quiesce begins completes, and the drain
succeeds: the server finished, nothing was aborted, and the result
reached the turn;
- a list change while quiesced refreshes the inventory, and its wake parks
(gatedRequests) with no inference until resume, then runs;
- a modern child lost while quiesced is relaunched and relisted with no turn
and no new wake.
Also covered: listing and status, the direct path (structured by presence
including 0, tool error as a failure, media stubs), the model path, the
script object, a config error at connect, and the standard reading for an
MCP-only legacy peer. Plus 10 unit tests for the normalizer.
Docs: README "Modern MCP servers" (family selection table, what differs),
requestTimeoutMs doc for modern, and changelog fragments
(modern-mcp-client.added, mcp-standard-tool-results.changed).
Receipts (Lethe, Node 24.19.0): every test file run standalone gives
1441/1441 (133 files 1375 plus mcpl-coalescing 66). The full-suite runner
twice reported fewer tests than exist (1420, 1425) with 0 failures, because
one file's later subtests went missing from the report; per-file runs are
complete. mcpl-coalescing hung once at R16 under 6-way parallel load and
once right after that run was killed. It then passed twice standalone (66/66)
and R16 alone 3x; baseline 02adf27 passed R16 once. Not attributed either way.
…ors, reap verdicts Theo-1805's source review of f9954a5 (room-284 #53865, #53879, #53922). Connect ownership. ModernMcpConnection is now create() (sync, validating) followed by start(). The framework registers and wires the connection before its first connect, so: - stop() and disconnect own and close a connect in flight; - a second connect for the same id is refused synchronously; - a connection closed mid-connect never finishes its setup; - a failed first connect is unregistered, and its cleanup failure is traced beside the connect error, not in its place. start() resolves unconnected when closed. open() launches nothing once closed. Result views stay separate, nothing lowered early: - Direct path (executeToolCall, ModuleContext.callTool). data is the raw content array again, as before. isError is now a failure (success: false, error text, raw content kept). structured is added by presence. - Model path. The structured JSON fallback keys on the server's own text blocks; generated stubs no longer suppress it. A payload that can be neither shown nor saved makes the result a failure to materialize ("result incomplete …; the tool may already have completed"), never a success with a lost payload. - Scripts. A script call returning structuredContent gets the direct reading (detected by the registered script waiter for its id). The script receives {content: <blocks as sent>, structuredContent, isError, error?} as valid JSON, so the image-plus-scores case keeps its image payload. Over 5 MB the whole object is saved to the workspace, and the script gets {isError, oversized: {chars, savedTo}}. If it can't be saved, an explicit materialization failure. Results without structuredContent keep the existing string contract, pinned by test (JSON-encoded "echo:x"). Selectors. A configuration naming no usable transport is an error for every config, in the framework and in McplServerConnection's handshake. That covers an http(s) url with transport 'websocket', transport 'http' with a ws url or no url (now "transport "http" requires "url""), and unknown schemes. There is no fall-through to another opener. Valid legacy shapes resolve as before. Reap verdicts. StdioTransport.close() is memoized (calls share one attempt) and rejects if the child is still alive 2 s after SIGKILL. ModernMcpConnection.close() closes transport first, then client: during discovery only the transport ends the probe. The transport's verdict is collected, not swallowed, and close() rethrows. stop() uses allSettled: each failure is logged and traced, and the rest of shutdown still runs. disconnect finishes its removal before reporting. Tests: - framework: - raw direct data (echo → [{type:'text'}], media → every block as sent); - an incomplete model result when no workspace is mounted; - an MCP-only legacy peer's model reading, with the structured fallback beside its stubs; - script objects for structured_and_text and scored_image (image payload intact), plus the pinned no-structured contract; - selector rejections; - stop() reaping a connect in flight while a second connect for the id is refused. - normalizer: the materialization failure (no save; failed save), and the structured fallback beside a stub. - outcomes: an unreapable child (signals suppressed) fails close() explicitly, and a second close() shares the verdict. Receipt (Lethe, Node v24.19.0, npx tsc build): every file standalone, 4-way (/tmp/olive-284/run-perfile2.sh, logs in /tmp/olive-284/logs/perfile2/): 134 files, 1445 tests, 1437 pass. The 8 failures were all in checkpoint-attribution, where a prototype-built fake framework has no scriptToolWaiters. Fixed with `?.`, and that file reran 10/10 (checkpoint-attribution.rerun.log). mcpl-coalescing completed 66/66 in this run.
…rphaning it Theo-1805's lifetime point on b32cd73 (room-284 #53928). A failed connect's cleanup could log a reap failure and let the reconnect loop start another generation, losing ownership of a child that might still be running. That is the same no-orphan contract close() keeps. - ModernMcpConnection keeps every generation whose cleanup couldn't confirm its child exited (`unreaped`), and emits an 'error' that says so. - While any is held, willReconnect is false. No replacement launch starts: the failure is reported as reconnect-failed / connect-failed with `permanent: true`, and the message names the unreaped launch. - close() retries each held generation and reports whatever still fails. - StdioTransport.close() re-checks a failed verdict on a later call: a child that has exited since counts as reaped. No new signals are sent. - The framework's modern failure handler raises the ops alert at once on `permanent`, since no later attempt will come. Test (mcp-modern-outcomes): the first launch serves. Later launches answer server/discover with an error and have their signals suppressed, so the cleanup after the failed reconnect can't reap. The result is a permanent reconnect-failed naming the halt, willReconnect false, and no third launch. Once the stuck child is gone, close() settles cleanly. Receipt (VPS via on-vps, node v24.21.0, npm 11.19.0; sources b32cd73 plus these changes, sha256 942e8b902834ecf2): the seven modern/legacy test files 64/64.
…down Theo-1805 (room-284 #54217): the owning layer wasn't memoized. The first close() cleared `session` before awaiting teardown, so a second concurrent close() could resolve while the teardown was still pending or about to fail. Calls to close() now queue behind the one in flight. Each step closes every generation the connection still owns: a connect in flight, the live one, and any a failed cleanup couldn't reap. A step that can't reap keeps those generations held, so a later close() re-checks them, and StdioTransport's re-check counts a child that has exited since as reaped. The held list is deduplicated: open()'s own cleanup and close() can report the same generation. Test: with a child whose signals are suppressed, two concurrent close() calls both stay unsettled at 1 s and then fail in order with the one verdict. Once the child is gone, a third close() settles cleanly. Receipt (VPS via on-vps, node v24.21.0, without --test-force-exit; sources 477d993 plus this change, sha256 a149e67aa046d80b): the seven modern/legacy files 65/65.
…tion Theo-1805 (room-284 #54276): after `await framework.stop()`, a call to connectMcplServer() still reached StdioTransport.spawn. Registering before start() makes a connect the framework has already admitted its own, but it did nothing to close admission to new connects arriving during or after stop(). stop() now sets mcpServerAdmissionClosed as its first act, and never resets it. connectMcplServer() and connectMcplServerInternal() refuse while it is set, before anything initializes, spawns or dials: "was not connected: the framework is stopping or stopped". That covers both engines, restartMcplServer's reconnect, and a call that arrives after teardown has collected the modern map. A legacy connect whose handshake was already running when stop() began is removed and refused once the handshake returns: the registry teardown had already collected it, so it would otherwise outlive stop(). This was the same lifetime gap on the legacy side, predating this work. Test (mcp-modern-framework, "the framework owns a modern connect in flight"): after stop(), a new connect is refused, with no launch in the fixture's log. Receipt (VPS via on-vps, node v24.21.0, without --test-force-exit; sources 8b74d24 plus this change, sha256 867b325e5920ead5): mcp-modern-framework, mcp-modern-outcomes and mcpl-runtime-lifecycle, 31/31.
…ng refusal Theo-1805 (room-284 #54366): the new legacy branch in 3aeea4c, for a connect whose handshake returned after stop() began, used `removeServer(...).catch(() => {})`. That dropped a reap failure and reported only that the framework stopped. The refusal now carries it: "…stopped while it was connecting; its cleanup also failed: <reason>". This follows the explicit cleanup-failure rule. Legacy connection cancellation is not redesigned. Receipt (VPS via on-vps, node v24.21.0, no force-exit; sources 3aeea4c plus this change, sha256 ab943f271bfe9118): build clean; mcp-modern-framework + mcpl-runtime-lifecycle 18/18.
[Medium impact] The PR reaches the framework’s MCP server connections and the tool paths shared by their callers. Fix the credential leak, late-disconnect success reports and stale tool classes before merging. Findings
Fix with agent prompt### Issue 1
src/mcpl/protocol-family.ts:217-223
`serverCredentialPatterns` misses query credentials after the WebSocket URL is rebuilt. Configuring `?password=secret%20key123` together with `token` makes `buildWebSocketUrl` send `?password=secret+key123&token=…`. The redactor keeps the original `%20` form and the decoded space form, but neither matches the `+` form.
A server that echoes the request URL in its close reason therefore exposes the password in tool failures and traces. Derive patterns from the URL’s serialized query too.
> **How this was verified:** The configured query was removed by `redactServerText`, but the query produced by `buildWebSocketUrl`’s serialization remained unchanged.
### Issue 2
src/framework.ts:17784-17787
A disconnect during the final tool listing does not end the connect or restart. `connectMcplServerInternal` releases its id hold before this await, and this code checks only `stopHasBegun` afterward.
While the listing waits on another server, `disconnectMcplServer` can remove the new connection completely; the original connect then resolves successfully. A restart does the same even after its claim is marked `endedBy`, instead of reporting that the server is down. Keep cancellation ownership through the listing and check it before reporting success.
### Issue 3
src/framework.ts:18768-18772
An in-flight refresh can restore a lost modern session’s tool classes. If it has listed server A and is waiting on server B, A can disconnect with `reconnect: true`. The close listener clears A’s class hints, but this final check still counts A’s registered id as live and publishes the old hints again.
Until another listing completes, observers can receive arguments using an old `files` class where the cleared, unclassed state would withhold them. Invalidate collected listings when their session ends, not only when their server id disappears.
> **How this was verified:** A held refresh restored `files` after the close listener cleared it, and `openingFor` then included arguments that an empty class withheld.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryAdds modern MCP over stdio and HTTP alongside legacy MCPL, with shared tool routing, structured script results, stricter configuration checks and awaited child teardown.
Reviews (2) · Last reviewed commit: "docs(mcp): the modern-server refusal lis..." · Reviewed by Greptile |
Greptile's review of anima-research#262 at ee4d424 (findings 1, 2, 3, 4, 6, 7), and two related edges from Iris-1827's independent assessment (room-293 #54849). Design agreed in room-293 #55051, #55064, #55135, #55137. The rule is the one the modern engine already kept: nothing launches while an earlier launch may still be running, and a teardown that can't confirm a child exited isn't finished. Framework (findings 1 and 2; Iris's edge on failed modern connects): - holdMcpServerId: a connect or disconnect in flight holds its server id. Together with the live connections (MCPL registry, modern map) that is what holds an id, from a connect's admission until its teardown is done. - A connect is refused while anything holds the id, in the same synchronous step as the check: a legacy handshake still running (finding 2), a connect of either family, or a disconnect still tearing down ("still being disconnected", finding 1). Fail-fast, as the existing test pins. - A disconnect takes the hold over. It starts closing a modern connection (which ends a connect in flight), then waits for the operation it displaced, so a legacy connect in flight lands whole before it is removed. Each release frees only its own claim. - A rejected close is not a finished teardown (Iris, #55064). Disconnect now removes nothing until the close verdict is clean; this reverses the earlier "fail only after the rest of the removal" order, which released the id. On failure the server stays registered, closed and listed, the error says so, a connect for the id is refused ("not connected and not reconnecting: disconnect it first"), and a retried disconnect completes once the exit is confirmed. A failed modern connect whose cleanup can't reap keeps its entry the same way, and its error carries both failures. - A modern map entry leaves only after its close settles, so stop() still sees a teardown in flight; the modern 'close' handler removes only a connection lost on its own (ModernMcpConnection.isClosed), never one the host is closing. A modern connect closed by stop() or a disconnect while connecting now rejects with "was not connected", as the legacy path does. - The legacy 'connect-failed' handler raises the ops alert on `permanent`. Legacy McplServerConnection (findings 3, 4, 6; Iris's reconnect edge): - connect() and connectWithReconnect() run checkServerConfig plus the modern-family refusal before anything launches, outside the retry path, so `protocol` on a WebSocket url is refused and a configuration error is never turned into a retry stub (finding 6). - handshake() takes an AbortSignal (checked once the transport is open, raced against the initialize answer), and no longer swallows a cleanup failure: it throws HandshakeCleanupError carrying the transport. - Launches whose close couldn't confirm the child exited are held (`unreaped`). While any is held, reconnecting is halted (`permanent`) and close() retries the reap (finding 4). connect() throws the new exported McplUnreapedLaunchError, whose `connection` owns the launch and can retry the reap; connectWithReconnect() returns that connection, halted. - Reconnect attempts are tracked and abortable. An attempt first waits for the previous transport's close (after reconnectAfterFailure the child may still be reaping), and an attempt aborted by close() closes its new transport instead of adopting it. - close() keeps its synchronous effects and also aborts a reconnect in flight. Its teardown is shared: calls queue behind the one in flight, each waits for the aborted attempt, closes the transport (memoized: the reaping verdict) and re-checks every held launch, and rejects naming what still can't be reaped (finding 3). 'close' is still emitted once, when a live transport is torn down. - McplServerRegistry keeps a failed launch's owner registered, and re-registers a connection whose removal failed, so the id stays held and a retry finds it. Modern (finding 7): start() runs once; later calls get the first promise. Tests (all on the VPS via on-vps, node v24.21.0): - mcpl-legacy-lifetime (new, 8): config refused before any dial (a real WebSocket server counts 0 dials), never retried; concurrent close() waits for the reap; failed reaps reject for every concurrent call and a later close re-checks; connect() hands over its unreaped owner; a halted stub; a halted reconnect loop; close() during a reconnect handshake ends that launch promptly; no reconnect launch while the previous child lives. - mcp-server-ownership (new, 9): connect during disconnect refused, then listed and routed; two simultaneous disconnects; a hold releases only its own claim; modern connect refused during a legacy handshake; disconnect during a legacy connect; Iris's regression for both families (cleanup fails, connect refused, child exits, retried disconnect completes, id free); failed connects whose cleanup can't reap, both families. - mcp-modern-outcomes: start() runs once. - Fixtures: legacy server modes error/hang/slow and ok-then-<mode>, IGNORE_SIGTERM; raw modern server flag reject-discover. - Mutation check: ten mutations, each reverting one of these fixes, each failed the tests aimed at it (hold ignored; close not shared; handshake swallowing; close not aborting; no wait for the previous child; registry or framework dropping a failed teardown; failed modern connect dropping its entry; start not memoized; no config check). - This tree alone: mcpl-legacy-lifetime 8/8, mcp-server-ownership 9/9, mcp-modern-outcomes 14/14, mcp-modern-framework 11/11, mcpl-legacy-version 11/11.
…pt cap Greptile's review of anima-research#262 at ee4d424, findings 5 and 8. Finding 5: a saved payload's name was the date plus the call id, sanitized and cut to 80 characters, so distinct ids could share it (`call:a` and `call/a`, or ids that differ only past the kept prefix), and a later writeBinary replaced the earlier result's file. The name now ends in a full randomUUID() after the readable part. The oversized-script save had the same property with 8 hex characters; it now uses a full UUID too. Finding 8: the script view of structured results had no coverage at its size cap or for either workspace outcome. A fixture tool `sized` pads structuredContent so the serialized object's length is exact (the cap measures that length, Iris-1827, room-293 #54849). Through a real modern server and a real WorkspaceModule (store wired as the host wires it): - exactly 5,000,000 characters arrive inline, whole; - one more is saved, and the script gets {isError, oversized} only; the saved object, read back through the workspace, is the whole {content, structuredContent, isError}, and `error` too on a tool error, whose isError stays visible without reading the file; - no workspace, or a write that fails, gives the explicit failure to deliver (savedTo null, "the tool may already have completed"), never a success. A payload test saves four results whose ids collide after sanitizing or truncation, and reads each file back with its own bytes. Mutation check: dropping the UUID from the label fails the payload test. Receipt, both commits (VPS via on-vps, node v24.21.0, npm 11.19.0; clean tree, sources sha256 b9e5424066c10bd5; `npm run clean && npm run build`, then every test file standalone, 4 at a time): 136 files, 1469 tests, 1468 pass. The one failure is conversation-routing.test.js at file level ("Queue is closed" after the fork channel_publish test ends), which fails the same way on the base ee4d424, 3 of 3, and which Petra-1779 recorded as pre-existing in room-272.
…t in flight Iris-1827's review of 7e0a97c (room-293 #55410) found a blocking hole in the legacy retry ownership, reproduced through public methods on the VPS: connectWithReconnect with an `ok-then-hang` server, `die`, wait for launch 2 (initialize pending), `reconnectAfterFailure()`, wait for launch 3, then `close()`. The recycle scheduled a second attempt while the first was in flight; attemptReconnect overwrote `reconnecting`, so close() aborted and reaped only the newer handshake and resolved with launch 2 still alive. - attemptReconnect is single-owner: while an attempt is in flight, a new request starts nothing. That attempt either adopts its launch or schedules the next retry itself, so nothing needed is dropped; a timer can't fire inside an attempt's tail, since the tail is microtasks. Found in my own re-read while the review ran: a disconnect waited out a legacy connect's post-handshake setup on a live connection, as long as that setup's requests took, where the old code's disconnect ended it by closing. (Iris checked one such request: featureSets/update has its own 15 s bound, so it isn't left unbounded by requestTimeoutMs 0.) A disconnect now ends a connect in flight for both families: - it closes what the id has at once: a modern connection (which ends its connect), and a registered legacy one through registry.removeServer, whose failure path keeps the connection registered; - a legacy connect still handshaking checks, when the handshake finishes, whether a disconnect has taken its hold (`displaced`, beside the existing stop() check), closes its connection and fails with "was not connected: it was disconnected while connecting"; - the disconnect then waits for it to settle, and removes what it registered, re-checking anything kept after a failed close. A connect already past its handshake when the disconnect closes it may resolve (its setup tolerates the closed connection) before the removal; one still handshaking rejects. Both end in the same teardown (Iris exercised the first with a server hanging on featureSets/update and requestTimeoutMs 0: the disconnect completed in 8 ms and reaped the child). Tests: - mcpl-legacy-lifetime: Iris's interleaving. No second attempt starts beside the one in flight; close() resolves promptly with every launch reaped. - mcp-server-ownership: a disconnect during a legacy handshake ends that connect when the handshake finishes (it rejects "disconnected while connecting"), removes it whole, and nothing else launches; a disconnect during a hanging modern connect ends it at once (under 5 s, not the 30 s connect timeout). - Mutations: without the single-owner guard, Iris's test fails (and the orphaned child outlives the file); without the displaced check, the legacy-handshake test fails; the earlier ownership mutations still fail their tests with the reworked disconnect. Receipt (VPS via on-vps, node v24.21.0): mcpl-legacy-lifetime 9/9, mcp-server-ownership 10/10, mcp-modern-framework 14/14, mcp-modern-outcomes 14/14, mcpl-legacy-version 11/11.
|
All eight findings in the review of
Iris-1827 reviewed the work independently:
Thirteen mutations, each reverting one fix, each failed the tests aimed at it. Full suite on c35d286's code, every test file run standalone on a Linux VPS (Node 24.21.0): 136 files; all 1,470 tests pass. The run predates the changelog-only amendments. — Oscar-1826 |
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at c35d286 (contains current main). Gates on the repo's own harness: a fresh npm install, a clean dist, tsc exit 0, then node --test --test-force-exit dist/test/*.test.js: 1466 pass, 0 fail, 0 cancelled.
Changes requested for one misclassification, with a small fix. Probes through the real engines (our own fixture servers, plus a plain HTTP listener) confirm most of the boundary claims. Notes follow.
1. A deadline during continuation pacing is reported as "answered, but unusable" (blocking, small). The PR's contract is that failures are typed by what actually crossed the transport, and that a deadline reads as no-response, "outcome unknown". With requestTimeoutMs: 600 and a server that answers leg 1 with a requestState-only input_required late in the budget, SDK 2.3.1 sleeps REQUEST_STATE_ONLY_LEG_PACING_MS = 250 before sending leg 2, and the deadline fires inside that sleep:
| leg 1 answers at | runs | result |
|---|---|---|
| 100 ms | 1 | McplRequestError no-response, legs 2, "Cancellation was requested; the outcome is unknown" ✓ |
| 380 ms | 1 | plain Error, no outcome, 1 leg: "MCP server "m" answered tools/call "contslow", but the result can't be used: TimeoutError: The operation was aborted due to timeout" |
| 450 ms | 3 of 3 | the same plain Error |
So on exactly the load-shedding path, a deadline reaches the model and modules as a definite-sounding "the server answered", without the "verify before retrying" guidance. The cause is in requestFailure (modern-connection.ts ~758), which checks record.answer === 'result' before the RequestTimeout branch. Skipping the answered branch when the error is an SDK RequestTimeout made all three cases correct, and mcp-modern-outcomes + mcp-modern-connection stayed 23/23. In this case nothing is in flight, so the message shouldn't claim cancellation was requested.
2. What held (our second reader's probes, mutation-checked where noted; we reran the deadline and version cases ourselves).
- Env allowlist: a modern stdio child sees exactly the same allowlisted keys as a legacy child under the same config, with none of
ANTHROPIC_API_KEY,DISCORD_TOKEN,AWS_SECRET_ACCESS_KEYor an arbitraryFOO.inheritEnv: truepasses the whole env, as documented. ForcingbuildChildEnvto inherit turned the probe red, so both engines share the one path. - Deadline: a never-answering call is bounded at the deadline,
no-response, sent once, onenotifications/cancelled, nothing replayed. The bound is total across legs (leg 1 at 450 ms, call ended at 604 ms against 600). - Auth: on a persistent 401 the provider is called twice and each token goes out once. A cross-origin 307 drops the
Authorizationheader. - Strict legacy version:
'2025-03-26', an omitted field, and'2024-11-05 'all fail asMcplProtocolVersionErrormismatch, permanently (reconnect-failed{ permanent: true }, no loop), with clear log text. The house's MCPL servers all answer exactly2024-11-05on main: discord-mcpl, portal-mcpl, heartbeat-mcpl, eidoverse-worlds, and our rig's chaos servers. - Prefix collisions: a same
toolPrefixis refused in every order.
3. Non-blocking notes.
- A bearer over cleartext.
serverConfigProblemshas no rule for a token on a non-loopbackhttp://url. A plain listener on a LAN address receivedAuthorization: Bearer SECRETwith nothing logged. The legacyws://path does the same (?token=), so this is parity, not a new hole. A refusal or a one-line warning for token + non-loopbackhttp:would be cheap. Separately, anaccessProviderthat throws has itserror.messagelogged verbatim (twice); worth knowing if providers put secrets in errors. - No soft landing for an omitted
protocolVersion.mainignored the field. Now a hand-written resident server that omits it vanishes at startup with oneconsole.errorline (connected:false, retrying:false, 0 tools). It's documented as breaking. A one-release warning mode, or naming the field inlistMcplServers' status, would help operators find it. - telegram-mcpL answers through FastMCP (it forwards
initialize, so the SDK echoes the requested version if it still supports it), and pinsmcp[cli]>=1.19.0with no upper bound. A future Python SDK that drops2024-11-05would make it fail this check permanently on a fresh install. That's a pin to cap in that repo, not in this PR. - Nested prefixes, pre-existing (from our second reader's probe).
foo+ toolbar--bazbesidefoo--bar+ toolbazputs twofoo--bar--bazentries in the model's tool list. The direct path (first config-order match) and the model path (last lister wins) can then dispatch to different servers. It reproduces with two legacy servers on main's code path, so it isn't this PR's, but modern servers always list after MCPL, so they now always win on the model path. Worth an issue.
…ponse slimepriestess's review of anima-research#262 at c35d286 (CHANGES_REQUESTED, review 5462380482) found a deadline misreported as an answer. With requestTimeoutMs 600 and a server that answers leg 1 with a requestState-only input_required late in the budget, SDK 2.3.1 sleeps REQUEST_STATE_ONLY_LEG_PACING_MS (250) before leg 2, and the deadline fires inside that sleep. The sleep rejects with SdkError(RequestTimeout, "TimeoutError: The operation was aborted due to timeout"), but requestFailure() checked `record.answer === 'result'` first, so the model and modules got a plain Error: "answered tools/call ..., but the result can't be used", with no outcome and no "verify before retrying". The cause is wider than the deadline. The wire record treated any result as the call's answer, and an input_required result is not one: it asks for another round. Nigel-1925's probe (room-341 #58500) showed the same branch misreading a connection lost between rounds. The SDK's _onclose clears _transport, so the retry leg's _requestWithSchemaViaCodec rejects with a plain Error("Not connected") before anything reaches ObservedTransport.send, and leg 1's answer still stood. A host close() during the pause goes the same way. What changed: - WireRecord.answer is now 'none' | 'error' | 'complete' | 'input-required' | 'unrecognized', read from the answer's resultType on the wire. Only an error or resultType 'complete' is a final answer. Keying "final" on 'complete', so that anything this revision doesn't define (a task-style result, a missing or non-string resultType) is 'unrecognized' and reads as unknown, was Nigel-1925's suggestion (#58520). - requestFailure(): a complete result that can't be used stays a plain error. A latest answer that doesn't finish the call is now no-response, outcome unknown, whatever ended the call: the deadline in the pause between rounds, the connection gone, or this client unable to go on (an input request it can't supply, the 10-round cap). That last part reverses what the comment documented ("input this client can't supply, a rounds cap" were plain errors). Each round re-invokes the server's handler with the echoed requestState, so a multi-step flow can have acted by the cap, and the outcome is unknown. Keeping the category on the wire record alone also keeps the module's own rule: classified by what crossed the boundary, never by the error's class. The error class now only picks the wording, as RequestTimeout already did. - Wording. Between rounds nothing is in flight, so the deadline message no longer claims cancellation was requested: "did not finish X within Nms: it asked for another round (input_required), and the deadline passed before that round was sent, so nothing was in flight to cancel. The outcome is unknown: the server may already have acted on the call. Verify state before retrying; ...". Other endings name the SDK's reason ("... the call ended before that round was sent: Not connected."). The in-flight deadline message is unchanged. - The module header, README and changelog.d/modern-mcp-client.added.md said "at the deadline cancellation is requested" unconditionally. They now say that only a leg in flight is cancelled, and that a call ending after an input_required answer is no-response. changelog.d/mcpl-request-outcome.added.md is Petra-1012's 10380dd, shared with anima-research#247-anima-research#257, and is left alone. Tests (mcp-modern-outcomes, raw fixture): `cont` gains arguments.leg1 'exit' (exits just after answering leg 1) and 'elicit' (asks for elicitation input), and leg2 'again' (sheds load on every round). New `task` and `bare` tools answer with resultType 'task' and with no resultType. Four new tests: - deadline between rounds: requestTimeoutMs 240, below the 250 ms pause, so the deadline always falls inside it and leg 1 has the whole budget to answer. Asserts no-response, no cancellation claim, one leg, and no notifications/cancelled on the wire; - connection lost between rounds: no-response "Not connected", one leg; - an elicitation request this client can't supply and the rounds cap (1 + 11 legs): both no-response; - a result type this revision doesn't define, and a result with none: both no-response. The existing "second leg decides" test (an error on leg 2 is error-response, a hung leg 2 is no-response with cancellation) passes unchanged. Evidence, VPS, node 24.21.0: mcp-modern-outcomes + mcp-modern-connection 27/27 (23 before plus 4). Six mutations, each failing exactly the tests aimed at it: c35d286's file (the 4 new tests), input_required read as complete (3), an unrecognized type read as complete (1), a missing resultType read as complete (1; Nigel-1925 found this one unpinned in review, hence `bare`), the between-rounds deadline wording (1), and no unfinished-answer branch (4). Nigel-1925's probe of the review's table now reads no-response in every row: 600 ms with leg 1 at 380 or 450 ms, and 1000 ms with leg 1 at 900 ms, each with 1 leg and 0 cancels. The 100 ms row keeps the in-flight cancellation (2 legs, 1 cancel).
…encrypted From slimepriestess's review of anima-research#262 (non-blocking note 3). A plain listener on a LAN address received "Authorization: Bearer SECRET" from a modern http:// server config with nothing logged. The legacy ws:// path does the same with ?token=, so this is parity, not a new hole. A refusal would break deliberate setups (container networks, tailnets, a TLS-terminating proxy), so this warns. It covers ws:// too, as Nigel-1925 suggested (room-341 #58500), so the two families behave alike. - protocol-family.ts: serverConfigWarnings(config) returns a message when a token or accessProvider is configured for an http:// or ws:// url whose host isn't loopback (localhost, *.localhost, 127.0.0.0/8, [::1], and the unspecified 0.0.0.0 and [::], which connect to this host). Stdio configurations, encrypted schemes and unusable configurations give no warning; an unusable one is serverConfigProblems' to report. checkServerConfig() now logs each warning with console.warn ("[mcp] ..."), after the problems check, so both engines warn once per connection at admission. That is ModernMcpConnection's constructor and McplServerConnection.connect()/connectWithReconnect(). It's exported beside serverConfigProblems for hosts' own validation. - server-connection.ts: connectWithReconnect() checked the configuration itself and then called connect(), which checked it again, so the warning would have been logged twice. The body of connect() is now connectChecked(), which both public entries call after their single check. There is no behaviour change otherwise. No test pins the single warning on this path: that needs a dial to a non-loopback host that fails at once. The cheap candidate, ws://0.0.0.0:1 (Nigel-1925's suggestion), is in fact local, so it is quiet here rather than a trigger. - The access provider's error message is still logged verbatim, as the WebSocket dial already does (transport.ts). Redacting it would drop the diagnosis, and only the provider knows what is secret in it. Tests (mcp-protocol-family): the warning cases for both schemes and the quiet ones (https, wss, loopback forms including 127.0.0.2 and 0.0.0.0, no credential, stdio with a url, an unusable scheme); checkServerConfig logs exactly one line, and ModernMcpConnection.create() logs one before anything is dialed. Mutations, each caught: no warnings at all (2 tests), checkServerConfig not logging (1), nothing counted as loopback (1), only 127.0.0.1 counted (1; from Nigel-1925's review), and the unspecified addresses not counted (1).
From slimepriestess's review of anima-research#262 (non-blocking note 3). main ignored an MCPL server's protocolVersion, and this PR's legacy engine now requires 2024-11-05 back. So a hand-written resident server that omits it "vanishes at startup with one console.error line (connected:false, retrying:false, 0 tools)". The strict check stays: room-284 chose it after surveying real servers, and the review found every house MCPL server answering exactly 2024-11-05. Part of the gap was ours, as Nigel-1925 found (room-341 #58500). The same verdict on reconnect raises the mcpl-down ops alert at once, because no later attempt comes. At startup it only logged and traced mcpl:server-connect-failed (willRetry false), since no stub exists to emit the permanent event. initializeMcpl's fail-open catch now raises the same alert for a McplProtocolVersionError: failures.log, an ops:alert trace, and the ops webhook when configured. A runtime connectMcplServer() needs no alert: its caller gets the error. Not taken: a one-release warning mode (the version check is the point, and a transitional mode is a deferral), and a failure reason in listMcplServers(). Every kind of connect failure lacks that today, so it belongs to its own change. Test (mcpl-legacy-version): a framework started with a 'missing'-mode server and reconnect: true. A module subscribes to traces in start(), which runs before the MCP servers connect, and sees mcpl:server-connect-failed with willRetry false, then ops:alert kind mcpl-down for that server, naming "protocolVersion (none)". Mutation (the alert removed): that test fails, nothing else.
|
@slimepriestess thank you for the review and the probes. The pacing case was a real misclassification, and it turned out to be one symptom of a wider one. 1. A deadline during continuation pacing. Fixed in So the record now keeps what the latest answer said. An error or a
A deadline with a leg in flight is unchanged: cancellation is requested and the outcome is unknown. A JSON-RPC error as the latest answer is still Your table, rerun through Nigel-1925's probe at
All four fail on 2. What held. Thank you for probing what held, too. Nothing there needed a change. 3. The non-blocking notes.
Full suite at Otis-1924 implemented these three commits. Nigel-1925 reviewed them independently (house corvids). — Otis-1924 |
slimepriestess
left a comment
There was a problem hiding this comment.
Re-reviewed at 660ed6f (three commits: the blocker plus both of the review's non-blocking notes). Approve. Both panels verified independently on the repo's own harness (npm test, pretest's fixture copy included): 1473 pass, 4 skipped, 0 fail — the skips are pre-existing Linux-only platform guards on the mount-watcher re-attach cases (August, tracked in #130), unrelated to this PR. With src/mcpl/modern-connection.ts reverted to c35d286, 4 tests go red (the pacing-deadline case and the no-response family); restored, green.
The blocker, fixed at the root rather than the symptom. The review found the requestFailure ordering; the fix goes under it: the wire record no longer treats any result as the call's answer — answer ∈ {none, error, complete, input-required, unrecognized}, and only an error or a complete result is final. The review's deadline-during-pacing case now reads no-response with the honest message ("it asked for another round… nothing was in flight to cancel. The outcome is unknown"), and the same root covers cases the review never probed: a connection lost between continuation rounds, a continuation the client can't go on with, and an unrecognized or missing resultType — each no-response, each tested, each red on revert. We also checked the property that matters most downstream: every no-response message carries "verify state before retrying; a blind retry of a side-effecting tool may duplicate it", and nothing in src branches on McplRequestError.outcome to retry — "unknown" cannot turn into an automatic duplicate.
Both non-blocking notes taken: a credential crossing non-loopback http:// or ws:// now warns (never refuses) with the https/wss remedy in the message — covering the legacy ws:// parity case too; and a protocol-version verdict at startup raises the ops alert exactly as on reconnect, so a vanished hand-written server is findable by an operator.
Two small carried notes, neither blocking: (1) when the deadline passes at input-required, "nothing was in flight to cancel" is true of the client's side — the server may hold the paused call's continuation state waiting for a round that never comes; if the modern protocol has a cancel for a paused call, sending it at the deadline would release that, and if not, one doc line saying the server keeps it until its own timeout. (2) An accessProvider that throws still has its error.message logged verbatim, twice — worth knowing if providers put secrets in error text.
|
Thank you for the re-review, and for checking that nothing in
Would you like these in this PR, or as a follow-up after merge, so your approval isn't reset? Since (2) touches secrets, we're happy to do it here if you prefer. — Ada-1017 |
…nima-research#262's new field (anima-research#277) anima-research#262 (modern MCP) adds ToolResult.structured right after isError, where 42ee2a8 put keepWhenWithheld, so the two conflicted textually (git merge-tree 42ee2a8 pull/262/head). After endTurn they merge cleanly. Only the field's place changes.
…nima-research#262's new field (anima-research#277) anima-research#262 (modern MCP) adds ToolResult.structured right after isError, where 42ee2a8 put keepWhenWithheld, so the two conflicted textually (git merge-tree 42ee2a8 pull/262/head). After endTurn they merge cleanly. Only the field's place changes.
main moved 71 merges since e670363. Beyond the conflicts, this is where they meet this branch. One rule for server ids, this branch's, across both engines: - A server id is held while a connect or a disconnect of it is in flight, or while a restart moves its server to it (mcpServerIdTaken, holdMcpServerId). anima-research#469's refuseMcplConnect and anima-research#506's mcplConnecting set went, so two rules don't guard the same ids. - A connect or a restart into an id whose connect is in flight, and whose server isn't registered yet, is refused in anima-research#506's words: 'MCPL server "<id>" has a connect in flight; try again once it settles'. - A disconnect made while a connect is in flight ends that connect and removes what it registered, where anima-research#506 refused the disconnect. - anima-research#469's prefixesMeet joins mcplPrefixCollision. It is checked at runtime connects and restarts in both engines, and a prefix that meets one a restart holds is refused too. create() already checks every configured server, a modern one included. - A tool call while its server connects reads anima-research#506's "connecting" text when a connect holds the id or a restart claims it (mcplServerAbsentText). Request errors (anima-research#247, in 0.22.3, and anima-research#506): - McplRequestError has no code, as 0.22.3 has it. The modern engine's two error-response failures pass their data alone, and the code stays in the message. The class this branch's first commit added is gone, so there's one. - The legacy engine's request errors take anima-research#506's words. The not-sent refusal after a stdio pipe failure says this host can no longer write to the server, and the method and the pipe's error go to the log. - protocolVersionRejection reads the server's message with describeCause, bounded, as the initialize error does. A message with no primitive form threw out of the handshake's line listener. Tool results: - An MCP tool's error result with no words, or only whitespace, reads as anima-research#506's text in both readings and on the direct path (mcplToolNoWordsText; normalizeStandardToolResult takes it as noWords). - The direct path's failure is mcplToolFailure's text without the server's other blocks, as 0.22.3 gives it, with structured kept by presence. A script's structured failure keeps its content. - anima-research#510's in-process registration: autoFetchReferences takes a mode, 'register', 'model' or 'script', in place of a positional boolean whose meaning differed on each side. executeMcplToolCall registers an MCPL peer's references only. The model's dispatch fetches eagerly, and a script's reads fetch everything. - A script's call of a tool that isn't an MCPL server's (a module's) materializes the references registered against their servers again, as main does. An MCPL server's were fetched at dispatch. - referenceBlockForScript gives a block the classifier rejects anima-research#506's notice (classifyBlock's shown), as the model's lanes do. Inbound handlers: - Main's containedInboundHandler replaces this branch's answerOnce and handlerFailureText. What a failure records, in its trace and its log line, goes through the server's redactor, which wireMcplEvents and a queued manifest run (rerunManifestRefresh) pass. It defaults to the text as it is. Transport (anima-research#494, anima-research#499, anima-research#485): - StdioTransport takes main's constructor, (child, rl, serverId), with both pipeFailure and stderrEnded. The handshake's wait for stderr's end comes through unchanged. - The boolean settings check is main's assertBooleanSetting, keeping this branch's dialsNetwork skip for inheritEnv. Elsewhere: - stop() is main's step()-by-step teardown, with endConnectsInFlight as its first step. - addModule checks the tool prefix, then resetCompletedStop('addModule()') (anima-research#532). - thrownText is main's boundedErrorMessage, with this branch's redactor applied before the cut. - Changelog: this branch's fragments say what changes from 0.22.3. The folded mcpl-request-outcome fragment is main's, and anima-research#506's connect-in-flight fragment describes the merged rule. - Tests: main's new stub servers answer protocolVersion. Words and log lines follow the merged code. The test stand-ins use the merged constructor, and wireMcplEvents gets its config. A close that now waits for the child is awaited after its request's rejection is observed. Two manifest re-run tests count the configuration read at each run's start. anima-research#506's test of refuseMcplConnect now tests the merged rule. Receipts, on Linux at this commit's tree: the typecheck and the build are clean, and every test file, each in a process of its own, passes: 232 files and 4,339 tests (4,322 passing, 17 skipped, none failing) 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>
…ze, two stubs drop mcplConnecting, and anima-research#506's fragment names the restart it covers - mcpl-stderr-tail: the startup test's server ends in EXIT_ON_INITIALIZE, as the file's other servers do, so its failure takes the same path on every run. - mcpl-reconnect-reregistration and reaction-baseline-platform no longer seed mcplConnecting, which the merge removed and nothing reads. - mcpl-connect-in-flight.fixed.md: the refusal in its words is for a runtime connect, or a restart of another server into the id. A restart of the connecting server itself is refused first by the restart's own check, which says it is being connected. The three test files pass at this commit on Node 24.22.0: 21, 4 and 8 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nima-research#469's rule At a runtime connect, a server whose prefix meets a modern server's is refused, and so is a modern server whose prefix meets a legacy one's, whichever family connected first. A restart that would move a modern server to a prefix meeting another server's is refused before its teardown, and the server stays up. With mcplPrefixCollision's prefixesMeet loop removed on its own, this test fails, and mcpl-prefixes-meet still passes, so nothing else pinned the rule at a runtime connect. At this commit, on Node 24.22.0, mcp-server-ownership passes 41 of 41. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Our APPROVE stands at What we ran, on macOS, Node 20.20.2:
Non-blocking:
|
…handshake only The in-flight refusal's doc and its changelog fragment said the words hold in either engine. They hold where mcpServerIdTaken finds a connect in flight and no server registered: a legacy connect's handshake, since the legacy registry takes a server once its handshake is done. A modern connect registers its connection first, so a second connect of its id reads as already registered, and a tool call during it reads that connection's own refusal. Both texts now say so, and mcplServerAbsentText's doc says when its "connecting" text applies. Comments and changelog only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Our APPROVE stands at |
|
Pushed
CI's six checks pass at Written by Crispin-2406. — Lottie-2661 |
|
Replying to this comment. At
Written by Crispin-2406. — Lottie-2661 |
main at 5227741 brings anima-research#526, anima-research#531, anima-research#532, anima-research#535, anima-research#533 and anima-research#262. anima-research#262 landed before this branch, so this branch carries how the two meet. The one conflicting file is src/framework.ts, in two blocks of a script's tool-result conversion: - anima-research#262 gives a script an MCP structured result whole (scriptStructuredResult), and names the 5,000,000-character cap on what a script receives AgentFramework.SCRIPT_RESULT_MAX_CHARS. Both blocks take anima-research#262's side. - This branch named the same cap SCRIPT_TOOL_RESULT_MAX_CHARS and set it as a script's call's resultMaxChars. That constant is gone, and resultMaxCharsFor gives a script's call anima-research#262's AgentFramework.SCRIPT_RESULT_MAX_CHARS, so one name carries the cap a script receives and the field that tells a module about it. anima-research#535 and anima-research#533 change the workspace module without a conflict. Since anima-research#535, parseFilePath also refuses a path that leads into a nested mount through a link, and edit and edit_many get that refusal through editablePath, as every file tool does. anima-research#535's test of a directory replaced by a link into the nested mount covers edit, write and delete, and now covers edit_many too, with the refusal's exact text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The edit planner's branch, which this one is stacked on, merged main at 5227741 into its head, as 8b03920. That brings anima-research#262 and the workspace changes of anima-research#533 and anima-research#535, among others. This branch's changes apply to it as they stand: the merge has no conflict, and its diff from 8b03920 is this branch's diff from 4da02df, 10 files, +891 -137.
… closes start, and again after the reap main brought anima-research#262, whose disconnect holds the server's id (holdMcpServerId), starts closing what the id has at once so a connect in flight ends, waits for what held the id before, then confirms the reap and removes channel entries after it. This branch removes a server's channel entries before its connection closes, so the lifecycle work queued for them is cancelled (ChannelRegistry.removeServer bumps the server's removal count) and can't run against the closing connection. The two met in src/framework.ts's disconnect: - refuseMcplConnect, which anima-research#262 replaced with holdMcpServerId, stays removed, as main has it. - The channel entries are removed first, before the legacy abort and the closes start, with this branch's comment. - main's removal after the reap stays, now as a second call: a connect that was still running when the disconnect began may register channels after the first removal, and they are removed with the rest. - The disconnect's doc takes this branch's first sentence, and main's paragraph says a connect in flight settles, and a close that can't confirm a child exited fails the disconnect, before anything but the channel entries the disconnect found when it began is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld writes for modules and the modern MCP client meet the registry What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose drafts and the `drafts` tool (#247); a module reads, edits and removes its held writes (#526); the deferred-writes follow-up (#461: a targetless write on a host with no primary, boot renumbering, sharded injection); the modern MCP client (#262) and the inbound source envelope; a stop that reaches a turn with no live stream (#529), every cleanup (#517), an MCP connect in flight, and a start during stop() (#532); allowedTools enforced before every dispatch (#220, breaking for hosts); repeated agent names refused at create() (#523, breaking); the fork-name collision fix (#513); the tune-out coordinator's timer-failure reporting and new refusal words (#506); request-only rows (silent ticks and [Continue] replies) rendered before their response; `callerTurnId` on tool calls. Conflicts (36 hunks, 7 files), and what was decided: - src/framework.ts (23): imports and the registry fields are unions (main's `releasesAfterResume` and `conversationSpawns` kept; the shared-slot set and the home-channel map stay answered by the registry, the one new use in the drafts resend reads `registry.homeChannel`). `isAddressedMessage` stays tag-only; main's call sites pass tags alone. Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as main types it) plus the branch's `addressedElsewhere`. The subconscious setup keeps the reader-forks branch; the coordinator hooks keep `announce`. `getAllTools` offers main's `drafts` beside `tune_out` and `focus`; an agent's tool block is its surface owner's, with the reader tools, filtered by main's `utils` rule. The deferred-writes restore is main's (refuses to start on a queue it can't read; `heldAt`; numbered 1..N) with the branch's re-wake for a restored tune-out release. The channel-incoming handler stamps main's inbound source envelope, then presents the author and admits. The ephemeral run keeps both watchdogs (the branch's deadline, main's stalled-batch abandon). Dispatch sets `callerTurnId` then applies the branch's deny-at-dispatch. `endEphemeralRunRefused` is gone: main's #532 refuses the start itself. `tune_out`/`focus` still dispatch through the queue (main's change there was isError). - src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and `thrownReason`; `enter` still allows no reader (silent hold) and takes main's words for a bound or already-tuned-out channel; the coalesced wake timer wraps the branch's `fireWake` in main's `timerFailed`; `cancel_tuneout` keeps the server-qualified lookup and the all-except path, with main's `notTunedOut` words; `speak_in_channel` has main's words and the branch's caller fallback. - src/agent.ts (2): main's `requestSettings()` plus `assistantParticipant`; `presentOwnTurns` before main's request-only rows, which render under `presentAs`. - src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its transport confirmation; the locus is admission's. `onRouteFailure` is main's type (with `outcome`). - src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions. Outside the hunks, where main's tests met the branch's rules: - main's #506 rule (a refusal is an error result) is applied to `tune_out` and `focus`, whose refusals were `isError: false`; an unknown channel is refused in main's words (`unknown channel: <id>`). - a tune-out with no reader is a silent hold only where the host offers `tune_out` without one: the coordinator gains `holdWithoutReader`, the framework passes `tuneOutOffered`, and main's tune-out-messages test holds as written; the branch's §3.3 row opts its host in. - main's tests that reached `conversationAgentHomes` use the registry (`bindAsConversationFork`, a registry stub); the ended-agents log is registered through `ensureStateRegistered`, as main's registration test requires; main's content-parity test counts the placeholder to the end of its text field, since the channel lane stores it under its author; main's route-failure rows that route by the registry's default locus run under `speech.locus: 'latest-inbound'`, the rule they assume; the branch's present-while-acting routing literal gains main's `turnId`, `drafts` and `keptPrivate`. - main's new `@modelcontextprotocol` dependencies installed (rig relinked after), and the merge fragment extended. Composed rig (the branch chronicle at 03b7bc1 and context-manager at bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests, 4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row, the Python eager-task-factory row, and two deferred-window-only rows that context-manager 0.15 makes moot. Typecheck clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main brings anima-research#533, whose disk reads check the mount's root and read a file beneath it through the root they checked (viewOf, openBeneathRoot), anima-research#262's MCP client, and anima-research#537. Resolved, in index.ts only: - The imports take both sides' names. - WorkspaceReadErrorCode's doc keeps the include's rewrite of the codes and adds anima-research#533's sentence on a root whose realpath or stat fails. - readFileFromDisk's doc keeps the include's list of codes, with anima-research#533's "missing or non-regular file". - The runtime view is anima-research#533's viewOf, which now carries the mount's include, as the view the include built in its place did. viewOf also gives the view a disk read checks the mount's root with, where the include changes nothing. Merged without a conflict: the rule for a place in a nested mount's directory, in openContainedFile, which anima-research#533 moved into openBeneathRoot, and the rest of anima-research#533's index.ts, observe.ts and disk-agreement.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main brings anima-research#533, whose disk reads check the mount's root and read a file beneath it through the root they checked (viewOf, openBeneathRoot), anima-research#262's MCP client, and anima-research#537. Resolved, in index.ts only: - The imports take both sides' names. - WorkspaceReadErrorCode's doc keeps the include's rewrite of the codes and adds anima-research#533's sentence on a root whose realpath or stat fails. - readFileFromDisk's doc keeps the include's list of codes, with anima-research#533's "missing or non-regular file". - The runtime view is anima-research#533's viewOf, which now carries the mount's include, as the view the include built in its place did. viewOf also gives the view a disk read checks the mount's root with, where the include changes nothing. Merged without a conflict: the rule for a place in a nested mount's directory, in openContainedFile, which anima-research#533 moved into openBeneathRoot, and the rest of anima-research#533's index.ts, observe.ts and disk-agreement.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t, in #506's words #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 #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: #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 #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>
…anima-research#506's words and F240 main moved 331 commits on from 9e3e407, among them anima-research#262 (the modern MCP client and the legacy engine's protocol boundary), anima-research#506 (the agent's words for failed requests), anima-research#532, anima-research#537, anima-research#210 and anima-research#542 (F240: a redaction that can't read its configuration withholds the text). What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, imports: main's new imports, with anima-research#250's McplRequestError beside McplProtocolVersionError and McplUnreapedLaunchError. - src/framework.ts, the barrier: main changed failMcplDataPlaneGate's recycle (a connection it couldn't close takes its tools off the list). anima-research#250 removes the gate and its recycle, so that change has nowhere to go; the close for good it rests on (closeMcplServerForGood) is main's and stays, and a runtime connect whose policy registration throws still goes through it. - src/framework.ts, the connect: main split connectMcplServerInternal into the id hold and connectHeldMcplServer, with a modern family, a stop check and an abort. That structure is main's. anima-research#250's parts go into it: the parameter is `staged`, the ledger is read first only by a legacy connect that derives the protective default (anima-research#542's sentence about a ledger that breaks after that read stays), listeners are wired before any plane opens, a runtime connect whose policy registration throws closes for good, and a staged startup connect returns its connection while a runtime one opens its planes and starts delivery in the background before returning it. - src/framework.ts, the restart: main's restart (its in-flight, claim, id and prefix checks, and its claims) with anima-research#250's rule for the ledger read: only a legacy server that derives the default reads it before disconnecting. - src/mcpl/server-connection.ts: main's file, which moved the reconnect into reconnectOnce, with anima-research#250's five rewrites of comments that named the awareness barrier. - test/failed-create-releases: main's two-engine test, with anima-research#250's legacy server (`chat`, the reregistering fixture) and no ledger options. - test/mcpl-reconnect-reregistration, test/stop-with-host-operations: wireMcplEvents takes the config now. - test/stop-ends-awareness-drains: anima-research#250's assertions, with anima-research#506's words for a request the close ended. - test/placed-reaction-baseline: anima-research#542's two tests go in ahead of anima-research#250's restart tests. The reconnect one breaks the ledger with breakLedger, since version 3 reads the file only when it has no document in memory. - test/mcpl-awareness-barrier.test.ts and its fixture stay deleted. Of main's changes to them, the deadline wording and the generation tests' signatures go with the file; the relist after a failed close is anima-research#459's test, which anima-research#250 keeps in mcpl-runtime-connect-failure.test.ts, so that assertion moves there. Outside the conflicts: - test/fixtures/reregistering-mcpl-server.mjs, anima-research#250's own fixture, answers initialize with protocolVersion 2024-11-05: main's legacy engine refuses an initialize answer without it (anima-research#262), and main gave its own fixtures the field. - test/discord-awareness-delivery's inbound test passes a config to wireMcplEvents, which records through that server's redactor now. - main's modern-MCP connect and restart read no ledger and install no gate, so they meet nothing anima-research#250 removed. Two of main's comments, and the modern client's pending fragment, named an awareness gate as MCPL surface a modern server lacks; with no gate left, they no longer name it. - main's modern test in placed-reaction-baseline breaks the ledger with breakLedger too, for the same reason. - anima-research#506's words reach anima-research#250's own assertions: a hung reaction's recorded error says the server didn't answer within its deadline, and a request the connection's loss ended says the connection closed before it answered. anima-research#250's test of a request the host's close ends goes: main's "closing the connection while a request awaits its answer" pins the same outcome, and stop-ends-awareness-drains pins its words. - test/mcp-server-ownership: main's two tests of the gate's recycle (an accounting failure recycling a server whose child can't be reaped, with and without reconnect) call installMcplDataPlaneGate and failMcplDataPlaneGate, which anima-research#250 removes, so they go with the gate. The close for good they lead to is still pinned for a runtime connect's failure, in mcpl-runtime-connect-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arch#506's words and anima-research#542 main moved 331 commits on from 9e3e407, among them anima-research#262 (the modern MCP client and the legacy engine's protocol boundary), anima-research#506 (the agent's words for failed requests), anima-research#532, anima-research#537, anima-research#210 and anima-research#542 (a redaction that can't read its configuration withholds the text). What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, imports: main's new imports, with anima-research#250's McplRequestError beside McplProtocolVersionError and McplUnreapedLaunchError. - src/framework.ts, the barrier: main changed failMcplDataPlaneGate's recycle (a connection it couldn't close takes its tools off the list). anima-research#250 removes the gate and its recycle, so that change has nowhere to go; the close for good it rests on (closeMcplServerForGood) is main's and stays, and a runtime connect whose policy registration throws still goes through it. - src/framework.ts, the connect: main split connectMcplServerInternal into the id hold and connectHeldMcplServer, with a modern family, a stop check and an abort. That structure is main's. anima-research#250's parts go into it: the parameter is `staged`, the ledger is read first only by a legacy connect that derives the protective default (anima-research#542's sentence about a ledger that breaks after that read stays), listeners are wired before any plane opens, a runtime connect whose policy registration throws closes for good, and a staged startup connect returns its connection while a runtime one opens its planes and starts delivery in the background before returning it. - src/framework.ts, the restart: main's restart (its in-flight, claim, id and prefix checks, and its claims) with anima-research#250's rule for the ledger read: only a legacy server that derives the default reads it before disconnecting. - src/mcpl/server-connection.ts: main's file, which moved the reconnect into reconnectOnce, with anima-research#250's five rewrites of comments that named the awareness barrier. - test/failed-create-releases: main's two-engine test, with anima-research#250's legacy server (`chat`, the reregistering fixture) and no ledger options. - test/mcpl-reconnect-reregistration, test/stop-with-host-operations: wireMcplEvents takes the config now. - test/stop-ends-awareness-drains: anima-research#250's assertions, with anima-research#506's words for a request the close ended. - test/placed-reaction-baseline: anima-research#542's two tests go in ahead of anima-research#250's restart tests. The reconnect one breaks the ledger with breakLedger, since version 3 reads the file only when it has no document in memory. - test/mcpl-awareness-barrier.test.ts and its fixture stay deleted. Of main's changes to them, the deadline wording and the generation tests' signatures go with the file; the relist after a failed close is anima-research#459's test, which anima-research#250 keeps in mcpl-runtime-connect-failure.test.ts, so that assertion moves there. Outside the conflicts: - test/fixtures/reregistering-mcpl-server.mjs, anima-research#250's own fixture, answers initialize with protocolVersion 2024-11-05: main's legacy engine refuses an initialize answer without it (anima-research#262), and main gave its own fixtures the field. - test/discord-awareness-delivery's inbound test passes a config to wireMcplEvents, which records through that server's redactor now. - main's modern-MCP connect and restart read no ledger and install no gate, so they meet nothing anima-research#250 removed. Two of main's comments, and the modern client's pending fragment, named an awareness gate as MCPL surface a modern server lacks; with no gate left, they no longer name it. - main's modern test in placed-reaction-baseline breaks the ledger with breakLedger too, for the same reason. - anima-research#506's words reach anima-research#250's own assertions: a hung reaction's recorded error says the server didn't answer within its deadline, and a request the connection's loss ended says the connection closed before it answered. anima-research#250's test of a request the host's close ends goes: main's "closing the connection while a request awaits its answer" pins the same outcome, and stop-ends-awareness-drains pins its words. - test/mcp-server-ownership: main's two tests of the gate's recycle (an accounting failure recycling a server whose child can't be reaped, with and without reconnect) call installMcplDataPlaneGate and failMcplDataPlaneGate, which anima-research#250 removes, so they go with the gate. The close for good they lead to is still pinned for a runtime connect's failure, in mcpl-runtime-connect-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arch#506's words and anima-research#542 main moved 331 commits on from 9e3e407, among them anima-research#262 (the modern MCP client and the legacy engine's protocol boundary), anima-research#506 (the agent's words for failed requests), anima-research#532, anima-research#537, anima-research#210 and anima-research#542 (a redaction that can't read its configuration withholds the text). What anima-research#250 removes stays removed, and what main added keeps working beside it. Conflicts: - src/framework.ts, imports: main's new imports, with anima-research#250's McplRequestError beside McplProtocolVersionError and McplUnreapedLaunchError. - src/framework.ts, the barrier: main changed failMcplDataPlaneGate's recycle (a connection it couldn't close takes its tools off the list). anima-research#250 removes the gate and its recycle, so that change has nowhere to go; the close for good it rests on (closeMcplServerForGood) is main's and stays, and a runtime connect whose policy registration throws still goes through it. - src/framework.ts, the connect: main split connectMcplServerInternal into the id hold and connectHeldMcplServer, with a modern family, a stop check and an abort. That structure is main's. anima-research#250's parts go into it: the parameter is `staged`, the ledger is read first only by a legacy connect that derives the protective default (anima-research#542's sentence about a ledger that breaks after that read stays), listeners are wired before any plane opens, a runtime connect whose policy registration throws closes for good, and a staged startup connect returns its connection while a runtime one opens its planes and starts delivery in the background before returning it. - src/framework.ts, the restart: main's restart (its in-flight, claim, id and prefix checks, and its claims) with anima-research#250's rule for the ledger read: only a legacy server that derives the default reads it before disconnecting. - src/mcpl/server-connection.ts: main's file, which moved the reconnect into reconnectOnce, with anima-research#250's five rewrites of comments that named the awareness barrier. - test/failed-create-releases: main's two-engine test, with anima-research#250's legacy server (`chat`, the reregistering fixture) and no ledger options. - test/mcpl-reconnect-reregistration, test/stop-with-host-operations: wireMcplEvents takes the config now. - test/stop-ends-awareness-drains: anima-research#250's assertions, with anima-research#506's words for a request the close ended. - test/placed-reaction-baseline: anima-research#542's two tests go in ahead of anima-research#250's restart tests. The reconnect one breaks the ledger with breakLedger, since version 3 reads the file only when it has no document in memory. - test/mcpl-awareness-barrier.test.ts and its fixture stay deleted. Of main's changes to them, the deadline wording and the generation tests' signatures go with the file; the relist after a failed close is anima-research#459's test, which anima-research#250 keeps in mcpl-runtime-connect-failure.test.ts, so that assertion moves there. Outside the conflicts: - test/fixtures/reregistering-mcpl-server.mjs, anima-research#250's own fixture, answers initialize with protocolVersion 2024-11-05: main's legacy engine refuses an initialize answer without it (anima-research#262), and main gave its own fixtures the field. - test/discord-awareness-delivery's inbound test passes a config to wireMcplEvents, which records through that server's redactor now. - main's modern-MCP connect and restart read no ledger and install no gate, so they meet nothing anima-research#250 removed. Two of main's comments, and the modern client's pending fragment, named an awareness gate as MCPL surface a modern server lacks; with no gate left, they no longer name it. - main's modern test in placed-reaction-baseline breaks the ledger with breakLedger too, for the same reason. - anima-research#506's words reach anima-research#250's own assertions: a hung reaction's recorded error says the server didn't answer within its deadline, and a request the connection's loss ended says the connection closed before it answered. anima-research#250's test of a request the host's close ends goes: main's "closing the connection while a request awaits its answer" pins the same outcome, and stop-ends-awareness-drains pins its words. - test/mcp-server-ownership: main's two tests of the gate's recycle (an accounting failure recycling a server whose child can't be reaped, with and without reconnect) call installMcplDataPlaneGate and failMcplDataPlaneGate, which anima-research#250 removes, so they go with the gate. The close for good they lead to is still pinned for a runtime connect's failure, in mcpl-runtime-connect-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s branch's content arrays and links main brought anima-research#262, which serves modern MCP servers beside MCPL ones and splits each tool result's reading by peer: an MCPL peer's keeps the RFC-005 reading, and a standard peer's (a modern server's or an MCP-only legacy one's) goes through the standard normalizer. It also moved the model path's conversion into mcpToolResult, an in-process caller's into directToolResult, and the script-demand fetch into autoFetchReferences' modes. Nine hunks of framework.ts conflicted. - An MCPL peer's passed-through blocks are marked as content (dataIsContent), on the model's path in mcpToolResult and for an in-process caller in executeMcplToolCall, as this branch marked them. A standard peer's results stay as anima-research#262 has them. - autoFetchReferences takes anima-research#262's modes and doc; its `live` list leaves out a uri only links name, so neither the model's eager fetch nor a script's demand fetch fetches a link. - The script path takes anima-research#262's structure and its cap constant. A module's array still skips links and registers each reference before its fetch, and its text is read with the result's dataIsContent. - stop() and a server's removal take back the server's address in the reference registry beside anima-research#262's new steps; setMcplServerConfig stays the one place a config is recorded. - A link in a script's structured result (referenceBlockForScript) now classifies as a reference. It carries `uri` exactly where the model's line for it shows the uri: shownLinkUri, which the tool result's link line asks too, decides both (Ada-1017's call, with Crispin-2406's reason: what a script prints comes back to the model). Where the stub gives a reason the fetcher would refuse, the object carries the same words as `unfetchable` (unfetchableReason), beside anima-research#262's `expired`. A test runs five links through both lanes, and anima-research#262's unreleased mcp-standard-tool-results fragment says both. anima-research#262's script-structured-references test is turned round for them: its harness records the `files` peer's address, as configuring it does, so its link is a reference the host can fetch, with no uri, and its expired reference carries the stub's words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s; one clock reading per object and stub Ada-1017's call (room-203 #148838), on Desmond-2579's read (#148827): a script's reference object always carries a refId, which a model's line for a link doesn't, so its words say what a fetch_reference with that refId meets rather than mirroring the line. An unsaved reference that counts as expired carries `expired` as anima-research#262 has it, and one the fetcher would refuse carries `unfetchable`, the words a stub gives, a link whose line shows its uri included: the host can't fetch such a link, so its refId can only be refused. `uri` stays the line's one decision (shownLinkUri). This reverts ec8809b's gating of `expired` on the line being a stub (af-f53-sol I30), which went past Ada's "`expired` stays as anima-research#262 has it" (#148223). referenceBlockForScript and buildReferenceStub each read the clock once and pass that reading to shownLinkUri, referenceExpiry and unfetchableReason, so an object or a stub can't disagree with itself across an expiry (Desmond's second point, Ada's yes). anima-research#262's unreleased mcp-standard-tool-results fragment says both words again, and that a link whose line shows its uri carries them. Tests: the five links' objects carry each refusal's exact words, a link whose line shows its uri included; an expired link whose line shows its uri carries `expired` and `unfetchable`; a clock that moves past an expiry after its first reading leaves a reference's and a link's object and stub as of that reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laces it main brought, among others, #533 (the mount root's identity and bounds), #535 (mount roots placed where disk has them: readNesting replaces spellNestedDirs, and a nested mount's directory has a place per way it lies there, each with its spellings), #262, #537, #543 and the 0.22.3 release. F194's changelog fragment and its edit of #525's are both still unreleased. The five conflicts were all in src/modules/workspace/index.ts: - the ./observe.js import: main's names, with F194's foldsCase and WorkspaceEntries; - the nested mounts' field: main's NestedMountEntry[], with F194's rootFoldsCase beside it; - three in start, around what it reads before it sets anything: main's readNesting, and F194's root folding read after it, both assigned after the check for a stop begun meanwhile. As Ada-1017 set for this merge, start reads each root's folding from the root #535 places, where disk has it: readNesting now returns its real roots (reals) beside the nesting, and start calls foldsCase on each. Before, F194 read it at the root as configured, so a root configured through a link was judged by the directory holding the link rather than by the one holding the directory the files are in. F194's step asks holderOf, which now reads every place and spelling #535 gives a nested mount's directory, so the step needs no change for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What this is
The framework can now connect to modern MCP servers (revision
2026-07-28) beside its MCPL servers, over stdio or Streamable HTTP, through the official SDK 2.x client (@modelcontextprotocol/client).Existing MCPL integrations keep their engine,
McplServerConnection. What changes for them is listed under "Breaking and visible changes". A modern server joins the same tool paths any MCPL server's tools use. It has no MCPL surface.!Fixes #222: closing a stdio server now waits for its child to exit, and sends SIGKILL if it outlives SIGTERM ("The modern engine" and "The legacy engine", below).
!Fixes #393:
addModulerefuses a name a server holds as its tool prefix, ascreate()does at startup ("In the framework", below).How a server's family is chosen
The configuration decides; nothing probes the server.
McplServerConfigcommand2024-11-05+ MCPL)command+protocol: 'modern'urlwithws:///wss://urlwithhttp:///https://transportgains'http'. A configuration with bothcommandandurlneeds atransportto say which is used. With one, it resolves as before, so a host can still layer a url andtransport: 'websocket'over a file's command. The legacy engine's own transport selector decides through the same rule.Configurations are refused before anything is spawned or dialed when:
transportand the url disagree,transportis'http'with no url, orcommandandurlcome with notransport;http://orhttps://url carries userinfo:fetchrefuses such a url, and its error quotes the password, so the refusal points attokenoraccessProviderinstead;protocolis set on a URL server;tokenoraccessProvider);requestTimeoutMsis outside the integers 1..2³¹−1 (the SDK reads 0 as "time out now"), or a legacy one is above 2³¹−1, which Node ran as a 1 ms timer;allowHostCommands: true, anenabledCapabilitiespattern that matches a capability denied by default (contextHooks.beforeInference.inject.system,toolLifecycle.observeortoolLifecycle.inputs),channelSubscription: 'auto'or a non-empty list, a scope's non-emptywhitelist, atoolLifecycleobserveorinputsblock, or atoolLifecycle.maxInputBytesor anautofetchbound above its default. The refusal names the part that asks, such asscopes.<set>.whitelist.The check of the boolean settings that
maingained (#451) is separate from these refusals, since it exists to refuse some configurations that worked before. It skipsinheritEnvfor every network server, anhttp(s)://one included, since only the stdio spawn reads it.A configuration refused at startup is traced as
mcpl:server-connect-failedwithwillRetry: false. At startup, that trace'swillRetryis true only when the failed connect left a connection registered that will reconnect. A startup connect that fails and leaves a child it couldn't reap raises themcpl-downops alert, as a protocol-version verdict does, since nothing retries its reap on its own.Policy that only takes something away, or repeats a default, is accepted on a modern server as already satisfied, since it has no MCPL surface:
enabledFeatureSetsand the rest ofenabledCapabilities, which are allow-lists over what the server declares,disabledFeatureSets,disabledCapabilities, a scope'sblacklist,channelSubscription: 'manual', atoolLifecycle.maxInputBytesor anautofetchbound at or below its default, andshouldTriggerInference, which can only narrow which channel messages trigger inference. No warning is logged for them, so a host that puts its deny-list on every server it deploys can deploy a modern one, and doesn't hear about it at every start.A
tokenoraccessProviderconfigured for a non-loopbackhttp://orws://url gets a warning at admission, in both engines. On such a url, so do aws://url's userinfo, a query parameter named like a credential or carrying a credential-shaped value, and a credential-like path segment. The warning names the field, never the value. IPv4-mapped loopback and a trailing-dotlocalhostcount as loopback.resolveServerBinding,serverConfigProblemsandserverConfigWarningsare exported, so a host can apply the same rules in its own configuration validation. So ischeckServerConfig, the check both engines run: it throws one error naming every problem, and logs each warning withconsole.warn. The two revisions are exported asLEGACY_MCP_PROTOCOL_VERSIONandMODERN_MCP_PROTOCOL_VERSION, and a modern tool call's result type asModernToolCallResult.A server's configuration is retained by reference: the wire reads it again at a 401 refresh or a legacy redial. So it's the caller's to keep unchanged while the server uses it, nested values included. Changed settings go through
restartMcplServer(id, newConfig), and a network server's rotating credentials throughaccessProvider.McplServerConfigandrestartMcplServersay so.The legacy engine
initializemust return exactly2024-11-05. Any other answer, or none, fails asMcplProtocolVersionError(kind: 'mismatch'), stating what each side said.-32022rejections. Aninitializerejected with-32022fails askind: 'rejected', keeping the server'ssupportedlist, code and data. When that list names modern MCP, the message suggestsprotocol: 'modern'.reconnect-failedcarriespermanent: true. At startup, as on a reconnect, it raises themcpl-downops alert.tools/listfollowsnextCursor. Before, every page after the first was dropped silently. A repeated cursor, or more than 100 pages, is an error, and a nullnextCursorends the inventory.connection.protocolVersion.close()shares, so one that ignores SIGTERM gets SIGKILL 2 s later, and its exit closes the connection, naming the pipe's error. Nothing more is written to it after the failure, and a request made then is refused asnot-sent. A child that outlives SIGKILL too is held as a launch that can't be reaped; by our reading, during a connect, the connect's own cleanup holds it once the connect fails, at the latest when it times out. Under Bun, a child that closes its stdin isn't reported, since Bun emits no'error'event for the writes to it, so request timeouts bound it, as they do a child that stops reading.close()calls share one attempt. A successful attempt is kept, so later calls change nothing, and a failed one is cleared, so the next call retries what it couldn't reap.'close'is emitted once, when the teardown of a connection the host closed while it was live completes, with its close info (mcpl: forward transport close info on connection 'close' #322). A single failure is the transport's own error, and several are anAggregateErrornaming the server.connect()andconnectWithReconnect()throw the exportedMcplUnreapedLaunchError, whoseconnectionowns it and whosecleanupis the failure of the cleanup that couldn't reap it. A connection holding one is closed for good, so a gate's release readies nothing on it, but still delivers its own bufferedconnect-failedandreconnect-failed, such as the permanent failure of a launch held before the gate first released it. A recycle after an awareness-accounting failure (reconnectAfterFailure()) ends a connection without reconnect withclose(), so a child it can't reap stays owned there too, and the server's tools leave the agents' list.close(), which waits for their cleanup. An aborted reconnect ends its own launch.close()during a WebSocket reconnect dial ends the dial at once, instead of waiting out the 15 s open timeout. A dial whose connect is aborted stops waiting for its access provider.addServer,connect()andconnectWithReconnect()take anAbortSignaland pass it on, so a connect still in its handshake can be ended, its launch reaped.connect()andconnectWithReconnect()apply the full configuration check, which is never retried.stdio child N of MCP server "id" did not exit within 2000ms of SIGKILL; it could not be reaped. So does the log line for a child that ignores SIGTERM.The modern engine (
ModernMcpConnection)Protocol. It's pinned to
2026-07-28, with empty client capabilities. Continuation (input_required), unsupported-input errors and pagination are left to the SDK.stdio. A custom SDK
Transportruns over the framework's own spawner, with the same env allowlist,inheritEnvand stderr surfacing (startup lines included). There is one launch per connect, and it waits for the child's'spawn'or'error'event (fix(mcpl): no hang after a signal kill, no uncaught transport or spawn errors #344), so a command that can't be spawned fails the connect at once with itsENOENT. Malformed lines become diagnostics. Closing waits for the child's real exit and sends SIGKILL after 2 s. A child still alive after that failsclose()explicitly. Aclose()made while the child is still spawning closes the child the spawn yields. A child whose stdin or stdout fails is ended as the legacy engine's is, and a send after the failure is refused.HTTP.
StreamableHTTPClientTransport, authorized fromtokenoraccessProvider, as a cached bearer refreshed once on 401.One deadline per call.
requestTimeoutMsbounds the whole call across every leg and the pauses between them (timeout=maxTotalTimeout, plus an abort signal). At the deadline, cancellation is requested for a leg in flight. Between the rounds of aninput_requiredcontinuation nothing is in flight, and the message says so. The protocol has no cancel for a call paused atinput_required, so whatever the server holds for it, beyond therequestStateit gave the client, stays until the server lets it go. Either way the outcome is reported as unknown, and nothing is replayed.Failures are
McplRequestError, with the outcome from what crossed the transport. It's observed per request scope, continuation legs included. Only an error or aresultType: 'complete'result is a final answer.not-sent: nothing reached the transport, including a send the stdio line refused before writing anything, as it does once the child's stdin or stdout has failed.error-response: the server answered with an error, or refused the call at the HTTP layer with 401 or 403, whatever form the HTTP client raised it in (a plain 403, orInsufficientScopeErrorfor aninsufficient_scope403 without an OAuth provider). The status is indata.httpStatus, and the response's body, which can echo the url, is left out of the data.no-response: everything else, outcome unknown. That includes a call that ends after aninput_requiredanswer, whether at the deadline, with the connection gone, or because this client couldn't go on (an input request it can't supply, the rounds cap), a 401 or 403 after an earlier round asked for another, since the server may already have acted, and an answer whose result type the revision doesn't define, or that has none.A complete result that can't be used is a plain error. A
tools/listfailure reads as a read that's safe to retry.Tool-list changes. The engine keeps a list-changed subscription open and reopens it when it's refused or lost, each connect's from the base interval. Its changes go through ordinary admission, so they park under quiesce like any wake.
A server that refuses the pinned revision is a
McplProtocolVersionError(rejected, with the server'ssupportedlist), as for the legacy engine, and it's never retried: the first connect throws even withreconnectset, and a reconnect emitsreconnect-failedwithpermanent: true. When the server speaks only legacy MCP, the message says how to reach it, by transport.Reconnects. They use the legacy backoff settings, kept under the timer limit. A lost session is reaped before the connection reconnects, so a second child never runs beside the first, and
close()waits for that reap. A launch that can't be reaped halts reconnecting, and is a permanent failure:reconnect-failedwithpermanentset, which the framework raises as anmcpl-downalert, and the server's tools leave the agents' list. A first connect whose launch can't be reaped fails, withreconnector without, since no retry can come. A transport that closes while the client connects makes that connect a failed one, as when a child exits at the connect's list-change subscribe: withoutreconnect,start()rejects with "closed its connection while connecting", and with it the connection backs off and tries again. A stdio child that stays alive but silently stops reading its stdin is bounded only by each call's deadline, and so, under Bun, is one that closes its stdin.start()runs once.Closing. A connection the host closes while it has a live session emits
'close'once, when the close confirms the teardown: a failed close emits none, and the retry that reaps the child does. One lost on its own emits'close'at the loss, while its child's reap runs. A single failure is the transport's own error, and several are anAggregateErrornaming the server. Concurrent calls queue behind the close in flight.close()waits for a connect's own cleanup, and reports what that cleanup couldn't reap.ModernMcpConnection.connect(), the exported create-and-start helper, throwsMcplUnreapedLaunchErrorcarrying the connection when its cleanup can't reap the child, so a direct caller can retry the reap withclose(). The framework itself usescreate().In the framework
create()'s check reads every configured server, whatever its family. MCPL-specific paths stay legacy-only.listMcplServers()entries gainfamily,protocolVersion,transportandrefused.familyandtransportarenullonly when no binding resolves, andpolicyEstablishedisnullfor a modern server.stop(), a disconnect and a duplicate connect for the same id all see a connect in flight.MCPL server "<id>" has a connect in flight; try again once it settles. A modern connect registers its connection first, so its id reads as already registered.main, 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 refuses a disconnect while a connect is in flight, since there nothing can stop the connect. Here the disconnect ends it.reconnect, leaves once its child's reap is confirmed, and one whose reap fails stays registered, holding the id, for a disconnect to retry. Either way its tools leave the agents' list then.stop().connectMcplServer,disconnectMcplServerandrestartMcplServerare operator operations (fix(framework): stop() waits for an operator's operation already running, and refuses new ones #325).stop()refuses them once it has begun, and waits for those running. It ends each connect in flight first, whatever its phase: a modern connect's connection is closed, a legacy connect still in its handshake is aborted and its launch reaped, a legacy connect past its handshake has its connection closed, and a connect listing every server's tools stops waiting. Each rejects with "was not connected: the framework stopped while it was connecting".stop()then finishes its whole teardown within its budget, both engines' connections among it, a modern one labelledmcp:<id>in a timeout's list of what's pending, and rejects with every failure, naming each child it couldn't reap and its server. A laterstop()retries the reap. Acreate()that fails releases what it started throughstop()(fix(create): a failed create() releases what it had acquired #426), which closes both engines' connections.restartMcplServer.stop()has begun, while a connect or disconnect of the server it restarts is in flight, and for an id or prefix another restart holds, or a prefix that meets that one.addModulerefuses a name a server's tool prefix holds, ascreate()does at startup.stop(). Whatever ends a restart after its teardown, its error says so first:MCP server "a" is down: it was disconnected for its restart, and the restart's connect failed:, then the connect's own reason. A restart ended this way leaves the server unconfigured, as any disconnect does. One whose connect fails on its own leaves the server configured under the new configuration and its id, holding its tool prefix, as any failed connect does.reconnect-failedwithpermanent: true). So does one the framework closes for good whose close can't confirm its child exited, such as a failed connect's, or a policy refusal's'close'fallback, whose failed close is reported as itself, not as an unanswered policy request.tool:failed. Neither engine's server is traced closed while its child may still be running.Server text the framework records
A connect error, a transport error or a server's stderr can quote a credential, and the framework writes such text into traces, console lines, the ops alert (
logs/failures.logand the webhook) and the model's tool results. Every such record is redacted with its server's own configuration and what that server's dials sent, in both engines. A connection's event records use a redactor built once for that connection, and built again once a dial has sent something new. An operation's records use what was present when it began. A model dispatch, a manifest refresh and a policy request use their server's configuration. The startup cleanup and the tool refresh, whose text may come from any server, use the configurations present when they began and those present when each record is made. Each reads what its configurations' dials have sent by the time the record is made. Text that may come from any server is matched against every configuration's strings at once, and every place one matches is replaced, overlapping places included.The redactor replaces:
tokenparameter, which makesURLSearchParamswrite the whole query again, form-encoded (a space as+). Each url a dial sends with an access provider's token counts too, the newest sixteen;name=value, where no letter or digit adjoins the pair, sov=2leavesdev=2alone;@, with no length floor, and the Basic credential a WebSocket client sends for it;GITHUB_TOKEN,API_KEYS,PGPASSWORD,DB_PASS,AUTHORIZATION,SESSION_COOKIE,TOKEN2), raw, as JSON encoders write them, and percent-encoded. A stdio server's command-line arguments aren't derived.A string replaced on its own has at least 8 characters, since a shorter one can't be told from ordinary text, and a short query value goes only with its name. A credential neither the configuration holds nor a dial sent, such as another origin's, is outside it, and so are other encodings. A host's own connect (
connectMcplServer,ModernMcpConnection.connect) still gets its error as raised, and JSON-RPC answers, a server's ownisErrorresults included, pass as they are.The records it covers: connect and reconnect failures, with their traces and alerts, and the last of the server's stderr that a failed handshake keeps, in the trace and the console lines; transport errors and stderr lines; the malformed-line diagnostic, redacted before it's cut at 200 characters;
tools/listfailures and a connect's failed cleanup; a policy request's refusal, degraded features and unanswered error; the console lines for a manifest fetch or refresh, atools/observerefusal, a re-registration after a reconnect, and the tool refresh; a server request whose handler threw (#263), redacted before its 500-character cut; and a tool call's failure, as the caller gets it and as thetool:failedtrace records it.Errors and
listMcplServersshow a url through one redaction: it drops userinfo and the fragment, replaces every query value and a query name that may itself be a credential, and replaces a path segment that looks like a credential (16 or more characters with a digit, or 32 or more of anything). An invalid url isn't shown at all. An access provider's own error is logged by its class and its message's length, never its text.What the model, modules and scripts receive
MCPL peers keep their RFC-005 reading. For other servers, normalization happens at dispatch, so the existing renderers are unchanged.
tool-results/, under unique names, with a bounded stub naming the file. An image or audio block with data but nomimeTypeis saved asapplication/octet-stream.resource_linkis shown as a link, with its name and URI bounded at 2048 code points, and never fetched.structuredContentis shown as JSON when the server sent no text of its own.executeToolCall,ModuleContext.callTool). A success'sdatais still the raw content array. A modern server'sisErrorresult is a failure with its text, as an MCPL server's is.ToolResult.structuredcarriesstructuredContentby presence, on a success or a failure.structuredContentgets{content, structuredContent, isError, error?}as one JSON object. From a server that isn't an MCPL peer,contentis the blocks as sent,resource_linkand embedded resources included, andstructuredContentis the tool's own JSON. Over the 5 MB cap, the framework saves the object to the workspace and the script getsoversized: {chars, savedTo}, or an explicit failure if it can't be saved.contentget the treatment every lane the model reads gives them, since what a script prints comes back to the model. An RFC-005 reference arrives without its URI, which can be a bearer credential, as{type, refId, name?, mimeType?, sizeBytes?, digest?, expiresAt?, savedTo}, from that block's own testimony, its text fields stripped of control and bidi characters as in the model's stub.savedTois the workspace path the framework fetched its payload to for the script, ornullwhen the fetch didn't save it. An unfetched reference that has expired also carriesexpired, the stub's reason, as the stub judges it: a saved copy is still served.fetch_referencetakes therefIdas itsref_idargument. For an MCPL peer, the dispatch fetches every reference a script will read, after the eager pass the model's calls get. A block the classifier rejects arrives as the notice the model reads in its place, and inline data that also claims a bulk disposition as the withheld notice.BigIntfrom an in-process module, reaches the script as an error that names the tool, gives the reason and says the call itself completed: the object above withcontent: [],structuredContent: nullandisError: true, orError: …for a result without structured content. An error result that can't be converted keeps the tool's own error. A module result that can't be stored is an error naming the tool (fix(tools): a module's tool result that can't be stored becomes an error naming the tool (#309) #310), which a script reads asError: ….code_executiontool's description says what each tool function returns, in these forms.Two older gaps have their own issues: in #298, one block the tool-result lanes don't recognize sends the whole array to the JSON fallback, references' URIs included, and in #299 nothing enforces
disposition: "never". This PR leaves those lanes as they are.Breaking and visible changes
These are in
changelog.d:protocolVersionmust add it. Every server family we probed answers2024-11-05.structured. A result throughexecuteToolCallorModuleContext.callToolcarries the server'sstructuredContentasstructuredwhen it sent one, a failure's included.not-sent, where it waited out its timeout and was reported as possibly executed. Under Bun, a child that closes its stdin isn't reported, so request timeouts still bound it.stop()can reject. It finishes its teardown, then rejects naming each child it couldn't reap and its server. An owned store stays open after such a failure, and a laterstop()retries the reap. Hosts that chain work on it should catch that, since the framework is stopped either way. A connect or restart in flight whenstop()begins is ended, not waited for.McplServerRegistry.closeAll()reports every failure.commandwithurland notransportand a legacyrequestTimeoutMsabove 2³¹−1 among them. On a modern server, that includes MCPL policy that asks for MCPL behaviour, but not policy that only takes something away, such as the deny-list connectome-host's overlay route puts on every server it deploys.McplServerConnection.connect(),McplServerConnection.connectWithReconnect()andModernMcpConnection.connect()throwMcplUnreapedLaunchErrorcarrying the connection when its cleanup can't reap the child. Withreconnectset,connectWithReconnect()resolved with a retrying stub for every failed launch before. It still does for a failed launch whose child was reaped, unless the failure is a protocol-version verdict.refusedis the verdict to read, andfamilyandtransportare null only when no binding resolves.expired, the stub's reason.What each part is for
This change lets the framework use servers that speak modern MCP beside its MCPL servers, whose engine stays as it was. For each part: what it buys, what it costs to keep, and the simpler option it was weighed against.
initialize, and one the SDK's probe would launch twice, keep working. It costs the table and the refusals for combinations that name no usable transport. Probing would let a host omit the setting, at those servers' expense.2024-11-05gets a verdict naming both sides, rather than a session that half works, and a server's tools past its first page aren't dropped. It costs the check, its two error kinds, and a cursor loop with two bounds.srcretries on it. It costs a record per call and its legs. Reading the outcome from the SDK's error classes would be shorter, but the SDK raises the same classes before sending and after an answer.'close'.stop()ending connects in flight. A stop isn't held up by a connect's handshake, its feature-set request, a listing or an access provider that never settles. It costs an abort or close for each phase of a connect. Waiting with a budget would still stall stops with no budget, the default.Tests
mcp-modern-connection,mcp-modern-outcomesandmcp-modern-httpcover both transports, the HTTP auth adapter, deadlines and cancellation, continuation legs, outcomes from wire evidence, reaping and unreapable children, malformed frames, restoration after losing the connection or the subscription, a connect's lost transport, and concurrent close.mcp-modern-frameworkcovers both consumer routes, tool policy, the script and module views, quiesce, ownership of a connect in flight and admission afterstop().mcp-server-ownershipcovers one owner per id across both engines, unreaped children,stop(), the restart's claims, the tool refresh's rules and a connect's final check.mcpl-legacy-lifetimecovers the legacy engine's close, reconnects and unreaped launches.mcp-tool-result-normalizecovers result reading, andscript-structured-referencesandcode-execution-structured-resultsa script's structured results, the second through a real MCPL peer and a real Python script.mcp-protocol-familycovers family selection, validation, the cleartext warning and the redactor, andmcp-recorded-textthe records it covers.mcpl-legacy-versioncovers the version check,-32022and pagination, and captures the unchanged legacy opening on the wire.protocolVersion: '2024-11-05',mcpl-list-statusexpects the new status fields, and the tests that meet this PR's signatures pass a configuration towireMcplEventsor expect fix(shutdown): preserve final state and propagate cleanup failures #221's error forms. Of the suitesmaingained,failed-create-releasesalso connects a modern server,mcpl-stderr-tailchecks the stderr tail's redaction where it's recorded, andmcpl-config-booleansaccepts anhttps://server's quotedinheritEnv.mcpl-agent-wordstests the connect-in-flight refusal as this head words it. Two manifest re-run tests inmcpl-request-handler-errorslet each run read the server's configuration first, as this head's runs do.mcpl-request-outcome's close test observes its request's rejection before it awaits a close that now waits for the child.c7eda6ce, each fix of a defect has a test that fails on the code before it, and each piece, removed on its own, fails a test. Two exceptions. The redaction of one console line has no test, since reaching it needs a reconnect handshake that finishes after its close began. And the startup trace'swillRetrytest passes on the code before6bad04c8, which traced that failure through the connection's own event. One piece from an earlier round fails no test when removed either: a disconnect's second removal of the id once a connect it ended has settled, the retry for a child that connect's cleanup couldn't reap (c35d286). The commit messages name the tests.Checks
CI ran the typecheck, the build and the test suite at
b1ce54c2, this head: its run. This head holdsmainat124edb46.At
a79a56da, every test file also passes on Linux, each run in a process of its own: 232 files and 4,339 tests on Node 24.22.0, 4,322 passing and 17 skipped, and the same on Node 20.20.2. The three commits after it change four test files, comments and a changelog fragment. On Node 24.22.0, the three test files3f343d6fchanges pass at it, andmcp-server-ownership, whichfffa8573adds a test to, passes 41 of 41 at that commit.b1ce54c2changes only comments and a changelog fragment.Reviews
Each review, and what answered it:
ee4d424. Petra-1012 reviewed the mapping of modern failures ontoMcplRequestError, and Kit-1008 and Agnes-1009 settled the integration with feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) #255's lease and quiesce contracts.ee4d424: eight findings, answered in91d2411,7e0a97candc35d286by Oscar-1826, which Iris-1827 reviewed.c35d286: answered indf209f2,0adf92eand660ed6fby Otis-1924, which Nigel-1925 reviewed. slimepriestess approved660ed6f.660ed6f: 84 issues, 77 fixed inf56d854f,6d0ab418,f1a6e8f8and559ecaaf, built by Agnes-1009, Nell-1783, Olive-1011 and Ada-1017 and each read by another of them.5abaf9f, built by Ada-1017 and reviewed by Helen-2283.58ea8e4, which Basil-1007 reviewed.mainwith fix(shutdown): preserve final state and propagate cleanup failures #221 and fix(workspace): materialize refuses files whose disk copy changed since last write #169, merged in501817f, and both engines' teardown made one contract in0baafd0b, which Basil-1007 reviewed.0baafd0: answered in2f0382a5to69ce7ec7by Ada-1017, Basil-1007 and Rosalind-2359, which Beth-2389 and Daniel-2377 read.mainwith fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263, merged inc4eb1e82and7c76a2e1, which Daniel-2377 and Nell-1783 read.7c76a2e1: answered in4f025627to406164ebby Ada-1017, which Beth-2389 read, withmainmerged in9fad7b27, which Phoebe-2419 read.mainatcd036e0d, merged in05350338to3f8304edby Ada-1017, which Phoebe-2419 read.636ddd70to4fa550d2by Ada-1017, which Milo-2429 read.4fa550d2: R1-1 to R1-7, answered in05768682toc7eda6ceby Crispin-2406, which Ezra-2423 and Myrtle-2415 read, withmainmerged inc59efc48. R1-5 is Nested tool prefixes can give two servers one tool name, and the model and direct paths can disagree on which server gets it #265's collision, which fix(mcpl)!: refuse toolPrefixes that meet, so one tool name has one server #469 fixed onmainand this head extends to modern servers, and R1-6 is An MCPL server that connects and immediately dies reconnects forever without ever raising the reconnect-failed alert #375. R1-1's pipe side is A stdio MCPL child that stops reading stdin stays "connected": every call times out and nothing reconnects #374. slimepriestess approvedc7eda6ce.c7eda6ceand Greptile on #337 atf28dde4f: answered in87d1ae1ctoefa07806by Crispin-2406, with the doc line promised on Oct 9 in6c476173. An internal review of those five commits found 24 issues.f98a6571fixes 15 of them, and this description one more. Nigel-2654 read the round.322c67a3, the last head an internal review had read: 46 issues.6bad04c8fixes 23 of them, and this description 5 more. An internal review of6bad04c8: 23 issues.b965af04fixes 9 of them, and this description 2 more. An internal review ofb965af04: 12 issues.179f66fafixes 4 of them, and this description one more. Nigel-2654 read the three rounds.mainat86a36834, merged in6662cb16by Crispin-2406 with Effie-2756's notes on how it meets fix(mcpl): an MCPL server's boolean settings are true or false, and anything else is refused (#379) #451.3c043d5dcorrects two comments,cddfc19etests a failedcreate()with a modern server, and5237b6d3tests a disconnect during a restart's tool listing. Nigel-2654 read all four.5237b6d3: approved, with one minor finding, R2-1, answered in323a32afby Crispin-2406. slimepriestess re-read5237b6d3and kept their approval, with a note thatcloseSessioncan't reject, whichb75b6fa3writes into its doc.mainate6703634, merged in5bcf3fd7by Crispin-2406, with fix(mcpl): a programmatic tool call reaches the server the model's call does #412's tool resolution and fix(mcpl): a server closed for good leaves the registry, and a failed runtime connect settles its gate #459's gate settling.b965af04, the last head an internal review had read, withmainate6703634: 21 issues.bd24be7dfixes 11 of them and the texts of a twelfth. An internal review ofbd24be7dand991eb1a2: 15 issues.6dfee0ebfixes 7 of them. Nigel-2654 read the round. slimepriestess re-read the round and kept their approval at0faeab1a.mainat124edb46, merged ina79a56daby Crispin-2406, with fix(mcpl)!: refuse toolPrefixes that meet, so one tool name has one server #469's prefix rule, 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 agent words and fix(mcpl): references are bound to every server that delivered them #510's reference binding. Nigel-2654 and Myrtle-2415 read the merge, and Kestrel-2409 its transport and stderr files.3f343d6ftakes their points, andfffa8573tests a modern server against fix(mcpl)!: refuse toolPrefixes that meet, so one tool name has one server #469's prefix rule. slimepriestess read the merge and kept their approval atfffa857, andb1ce54c2narrows the connect-in-flight wording they found too wide. Their approval stands atb1ce54c.Olive-1804 implemented this pull request, and many hands have built on it since, each named above. This description is written for its head by Crispin-2406 (Claude Opus 5.5).