Skip to content

feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP - #262

Merged
nissa-seru merged 102 commits into
anima-research:mainfrom
nissa-seru:feat/modern-mcp-client
Oct 18, 2026
Merged

nissa-seru merged 102 commits into
anima-research:mainfrom
nissa-seru:feat/modern-mcp-client

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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: addModule refuses a name a server holds as its tool prefix, as create() does at startup ("In the framework", below).

Relations to open PRs.

How a server's family is chosen

The configuration decides; nothing probes the server.

McplServerConfig Family
command legacy (MCP 2024-11-05 + MCPL)
command + protocol: 'modern' modern, over stdio
url with ws:// / wss:// legacy, MCPL over WebSocket
url with http:// / https:// modern, over Streamable HTTP

transport gains 'http'. A configuration with both command and url needs a transport to say which is used. With one, it resolves as before, so a host can still layer a url and transport: '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:

  • transport and the url disagree, transport is 'http' with no url, or command and url come with no transport;
  • the scheme is unknown;
  • an http:// or https:// url carries userinfo: fetch refuses such a url, and its error quotes the password, so the refusal points at token or accessProvider instead;
  • protocol is set on a URL server;
  • a modern stdio server carries a credential (token or accessProvider);
  • a modern requestTimeoutMs is 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;
  • a modern server carries MCPL policy that asks for MCPL behaviour: allowHostCommands: true, an enabledCapabilities pattern that matches a capability denied by default (contextHooks.beforeInference.inject.system, toolLifecycle.observe or toolLifecycle.inputs), channelSubscription: 'auto' or a non-empty list, a scope's non-empty whitelist, a toolLifecycle observe or inputs block, or a toolLifecycle.maxInputBytes or an autofetch bound above its default. The refusal names the part that asks, such as scopes.<set>.whitelist.

The check of the boolean settings that main gained (#451) is separate from these refusals, since it exists to refuse some configurations that worked before. It skips inheritEnv for every network server, an http(s):// one included, since only the stdio spawn reads it.

A configuration refused at startup is traced as mcpl:server-connect-failed with willRetry: false. At startup, that trace's willRetry is 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 the mcpl-down ops 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: enabledFeatureSets and the rest of enabledCapabilities, which are allow-lists over what the server declares, disabledFeatureSets, disabledCapabilities, a scope's blacklist, channelSubscription: 'manual', a toolLifecycle.maxInputBytes or an autofetch bound at or below its default, and shouldTriggerInference, 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 token or accessProvider configured for a non-loopback http:// or ws:// url gets a warning at admission, in both engines. On such a url, so do a ws:// 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-dot localhost count as loopback.

resolveServerBinding, serverConfigProblems and serverConfigWarnings are exported, so a host can apply the same rules in its own configuration validation. So is checkServerConfig, the check both engines run: it throws one error naming every problem, and logs each warning with console.warn. The two revisions are exported as LEGACY_MCP_PROTOCOL_VERSION and MODERN_MCP_PROTOCOL_VERSION, and a modern tool call's result type as ModernToolCallResult.

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 through accessProvider. McplServerConfig and restartMcplServer say so.

The legacy engine

  • Version check. initialize must return exactly 2024-11-05. Any other answer, or none, fails as McplProtocolVersionError (kind: 'mismatch'), stating what each side said.
  • -32022 rejections. An initialize rejected with -32022 fails as kind: 'rejected', keeping the server's supported list, code and data. When that list names modern MCP, the message suggests protocol: 'modern'.
  • No retries on a version verdict. It produces no reconnect stub and stops a reconnect loop: reconnect-failed carries permanent: true. At startup, as on a reconnect, it raises the mcpl-down ops alert.
  • Pagination. tools/list follows nextCursor. Before, every page after the first was dropped silently. A repeated cursor, or more than 100 pages, is an error, and a null nextCursor ends the inventory.
  • Status. The established revision is on connection.protocolVersion.
  • Its lifetime matches the modern engine's.
    • Its stdio transport is the spawner the modern engine runs over: closing waits for the child's real exit, and sends SIGKILL after 2 s.
    • A child whose stdin or stdout fails is ended the same way, in the one attempt 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 as not-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 an AggregateError naming the server.
    • A launch that can't be reaped is held, and halts reconnecting. connect() and connectWithReconnect() throw the exported McplUnreapedLaunchError, whose connection owns it and whose cleanup is 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 buffered connect-failed and reconnect-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 with close(), so a child it can't reap stays owned there too, and the server's tools leave the agents' list.
    • Reconnect attempts run one at a time, never start before the previous child has exited, and are ended by 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() and connectWithReconnect() take an AbortSignal and pass it on, so a connect still in its handshake can be ended, its launch reaped. connect() and connectWithReconnect() apply the full configuration check, which is never retried.
    • The stdio reap error names its server: 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 Transport runs over the framework's own spawner, with the same env allowlist, inheritEnv and 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 its ENOENT. Malformed lines become diagnostics. Closing waits for the child's real exit and sends SIGKILL after 2 s. A child still alive after that fails close() explicitly. A close() 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 from token or accessProvider, as a cached bearer refreshed once on 401.

  • One deadline per call. requestTimeoutMs bounds 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 an input_required continuation nothing is in flight, and the message says so. The protocol has no cancel for a call paused at input_required, so whatever the server holds for it, beyond the requestState it 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 a resultType: '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, or InsufficientScopeError for an insufficient_scope 403 without an OAuth provider). The status is in data.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 an input_required answer, 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/list failure 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's supported list), as for the legacy engine, and it's never retried: the first connect throws even with reconnect set, and a reconnect emits reconnect-failed with permanent: 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-failed with permanent set, which the framework raises as an mcpl-down alert, and the server's tools leave the agents' list. A first connect whose launch can't be reaped fails, with reconnect or 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: without reconnect, 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 an AggregateError naming 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, throws McplUnreapedLaunchError carrying the connection when its cleanup can't reap the child, so a direct caller can retry the reap with close(). The framework itself uses create().

In the framework

  • Tool paths. Modern servers use the existing paths: inventory refresh, model dispatch, direct execution, pattern checks, availability, connect, disconnect, restart and shutdown. The prefix, tool allow and deny lists, and RFC-008 class hints work as for any server, and a modern server's prefix meets fix(mcpl)!: refuse toolPrefixes that meet, so one tool name has one server #469's rule as any server's does: it can't meet another server's at a runtime connect or a restart, and create()'s check reads every configured server, whatever its family. MCPL-specific paths stay legacy-only.
  • Status. listMcplServers() entries gain family, protocolVersion, transport and refused. family and transport are null only when no binding resolves, and policyEstablished is null for a modern server.
  • One owner per id. A server id has one owner from a connect's admission until its teardown is done, across both engines.
    • A connection is created and registered before its first connect, so stop(), a disconnect and a duplicate connect for the same id all see a connect in flight.
    • A connect is refused while another connect for that id is in flight (a legacy handshake included) or a disconnect is still tearing it down. During a legacy connect's handshake, before its server is registered, the refusal is 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.
    • A disconnect closes what the id has at once, and ends a connect in flight: a modern connect's connection is closed, and a legacy connect is aborted, or finds itself displaced once its handshake finishes. On 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.
    • A tool call routed to a server that isn't registered reads as fix(mcpl): a failed MCPL request, the framework's refusals of an MCPL tool call, and the blocks the host can't show say what happened in the agent's words #506's "connecting" text while a connect of its id is in flight, such as a legacy connect's handshake, or a restart holds the id, and as its "isn't connected" text otherwise. By our reading, a call during a modern connect reads that connection's own refusal.
    • The id is held until its child is confirmed exited, on every path. A disconnect or failed connect whose cleanup can't reap the child keeps the server registered, closed and listed as disconnected, with its tools out of the agents' list, and a connect for that id is refused until a retried disconnect confirms the exit. A modern server lost on its own, without 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.
    • A disconnect releases each tool prefix its server claimed at connect, whatever its configuration says since, and only while its own server holds it.
    • A connect gives up its hold once its setup is done, then lists every server's tools. After that listing it stands only while the connection it set up is still connected or reconnecting, not closed by the host, and no disconnect of the id is under way. Otherwise it fails with "was not connected: it was disconnected or lost while its tools were being listed", as when a disconnect has removed the server, or closed it and failed to reap its child.
  • stop(). connectMcplServer, disconnectMcplServer and restartMcplServer are 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 labelled mcp:<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 later stop() retries the reap. A create() that fails releases what it started through stop() (fix(create): a failed create() releases what it had acquired #426), which closes both engines' connections.
  • restartMcplServer.
    • It checks the new configuration, the new id and the tool prefix before it tears the running server down, and, for a legacy configuration, that the awareness ledger can be read. It's refused for an id or prefix another server or a module holds, a prefix that meets another server's, after 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.
    • From those checks through its connect, it holds the id and the tool prefix it moves the server to. Another connect or restart is refused them, naming the restart, and a module added meanwhile is refused the prefix. addModule refuses a name a server's tool prefix holds, as create() does at startup.
    • A disconnect of the id it moves the server to that begins during the restart ends it, and so does a 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.
  • The tool refresh. A refresh lists a snapshot of each engine's servers, taken as it reaches that engine's list.
    • A listing counts only while the session it came from is still its server's live one. A server removed while the refresh awaited the lists (a disconnect, or a server lost for good) contributes nothing, so whichever refresh finishes last can't bring back its tools. Nor does one whose connection no longer serves, because the host is closing it or failed to reap it. Nor does one whose connection closed after its listing began, even if it has reconnected since: the close drops its RFC-008 class hints as stale, which leaves its tools unclassed, the restrictive answer, and its reconnect lists the new session.
    • A refresh overtaken by one started after it, which has published already, publishes nothing, so an older listing can't replace a newer one. If it holds a listing of a server the published one has none for, such as one that refused the later refresh's request, it starts one more refresh.
    • A server lost for good, legacy or modern, leaves the agents' tool list, and so does one whose reconnecting halts for good (reconnect-failed with permanent: 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.
  • Traces. An MCP tool result marked as an error is traced as 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.log and 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:

  • from the url as each dial sends it, and decoded: credential-like path segments and query names, credential-shaped or credential-named query values, and a WebSocket url's username and password, wherever they occur. The url as sent is the configured one, except that a WebSocket dial sets the token as its token parameter, which makes URLSearchParams write 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;
  • every non-empty query parameter as its whole name=value, where no letter or digit adjoins the pair, so v=2 leaves dev=2 alone;
  • a WebSocket url's userinfo as one unit before its @, with no length floor, and the Basic credential a WebSocket client sends for it;
  • the static token, and each token an access provider hands a dial, in a url or a header, the newest sixteen: raw, as JSON encoders write it, and percent- or form-encoded in either case of hex;
  • a stdio server's environment values whose names have a part ending in a credential word, singular or plural, with any digits after 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 own isError results 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/list failures 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, a tools/observe refusal, 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 the tool:failed trace records it.

Errors and listMcplServers show 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.

  • The model.
    • Text stays native, and so does an image whose type a provider takes (png, jpeg, gif or webp) and that decodes within 5 MB, passed without a wrapped encoding's whitespace.
    • Audio, blobs, binary embedded resources and any other image are saved to the workspace under tool-results/, under unique names, with a bounded stub naming the file. An image or audio block with data but no mimeType is saved as application/octet-stream.
    • In a failed result (a tool error, or a payload that couldn't be kept), an image becomes a payload stub too, since the model reads only the error text.
    • A resource_link is shown as a link, with its name and URI bounded at 2048 code points, and never fetched.
    • structuredContent is shown as JSON when the server sent no text of its own.
    • An unknown block is shown as bounded JSON, and a malformed block gets a stub saying so. Content that's present but isn't an array makes the result a failure.
    • If a payload can't be saved, the failed save is logged with its reason and the result is reported as incomplete, noting that the tool may already have completed. It's never reported as a success with part of it missing, nor as a failed dispatch of a call that completed.
  • Modules (executeToolCall, ModuleContext.callTool). A success's data is still the raw content array. A modern server's isError result is a failure with its text, as an MCPL server's is. ToolResult.structured carries structuredContent by presence, on a success or a failure.
  • Scripts.
    • A script whose call returns structuredContent gets {content, structuredContent, isError, error?} as one JSON object. From a server that isn't an MCPL peer, content is the blocks as sent, resource_link and embedded resources included, and structuredContent is the tool's own JSON. Over the 5 MB cap, the framework saves the object to the workspace and the script gets oversized: {chars, savedTo}, or an explicit failure if it can't be saved.
    • An MCPL peer's blocks in that content get 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. savedTo is the workspace path the framework fetched its payload to for the script, or null when the fetch didn't save it. An unfetched reference that has expired also carries expired, the stub's reason, as the stub judges it: a saved copy is still served. fetch_reference takes the refId as its ref_id argument. 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.
    • A standard peer's content that isn't a list of blocks is the malformed result for a script too, with its structured content kept.
    • Results without structured content keep today's contract.
    • A result that can't be converted to JSON, such as a cyclic object or a BigInt from 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 with content: [], structuredContent: null and isError: true, or Error: … 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 as Error: ….
    • The code_execution tool'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:

  • The legacy version check. A hand-written server that omits protocolVersion must add it. Every server family we probed answers 2024-11-05.
  • The direct path's structured. A result through executeToolCall or ModuleContext.callTool carries the server's structuredContent as structured when it sent one, a failure's included.
  • Reaping. Closing a stdio server waits for the child to exit, and can fail if it can't be reaped.
  • A stdio pipe failure ends the child in both engines, with SIGKILL 2 s after its SIGTERM if it's still running, so a child that ignores SIGTERM no longer keeps its connection up. A request made after the failure rejects at once as 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 later stop() 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 when stop() begins is ended, not waited for. McplServerRegistry.closeAll() reports every failure.
  • Unusable configurations are errors before anything is spawned or dialed, command with url and no transport and a legacy requestTimeoutMs above 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.
  • A first launch that can't be reaped throws. McplServerConnection.connect(), McplServerConnection.connectWithReconnect() and ModernMcpConnection.connect() throw McplUnreapedLaunchError carrying the connection when its cleanup can't reap the child. With reconnect set, 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.
  • The listing. refused is the verdict to read, and family and transport are null only when no binding resolves.
  • A script's expired reference carries 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.

  • A second engine on the official SDK. A modern server is reached by the client that tracks the protocol, rather than by MCPL's engine taught a second protocol. It costs a second connection class and its tests, alongside the legacy one, with the same lifetime rules written twice. One engine for both protocols would mix MCPL's surface into the modern path, which has none.
  • The family by configuration. No server is probed, so an MCPL stdio server that exits on a first message other than 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.
  • The legacy version check and pagination. A server that doesn't speak 2024-11-05 gets 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.
  • Outcomes from what crossed the transport. A failed call's outcome says whether the server may have acted, which is what decides whether retrying can duplicate a side effect, and nothing in src retries 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.
  • One deadline per call. A call with continuation rounds can't outlive its configured time by starting each round afresh. It costs an abort per call that also covers the pauses between rounds.
  • One owner per id, and the id held until the child is confirmed exited. No second child launches beside one that may still be running, and no teardown removes what a later connection registered. It costs the per-id holds, a disconnect that ends a connect in flight, and the retained connections of unreaped launches. Allowing a connect beside a pending teardown would leak children. A child whose pipe failed is ended by the same escalation as a close, sharing its one attempt, so a child that ignores SIGTERM can't keep a connection up that can no longer write to it. Settling the write that hit the failure from its own callback instead would mean guessing which lines of a batched write landed.
  • A server given up on leaves the tool list. A server the host has closed for good, lost for good or stopped reconnecting to isn't left listed with tools whose every call would be refused, even when its child can't be reaped. It costs a refresh after each close for good that fails, through one helper for the framework's closes through the registry, and a held legacy connection still delivering its own buffered failure reports when a gate releases it. Leaving it to the close listener would keep such a server listed, since a close that can't reap emits no 'close'.
  • The restart's claims. A restart can't be refused after it has torn its server down, because nothing can take the id or prefix it moves to meanwhile. It costs a claim map and the error that says the server is down. Refusing a disconnect during a restart instead would leave the host to retry a cancel.
  • 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.
  • The tool refresh's rules. The published tool list holds the newest listing of each live session: no removed server's tools come back, no closed session's class hints are restored, and a dropped refresh doesn't lose a listing the newer one lacks. It costs a count of refreshes started, a close count per server id kept for the framework's life, and one more refresh when a dropped one holds news. Keeping each server's newest listing across refreshes would need state that outlives a refresh.
  • A connect's final check. A connect or restart doesn't report success for a server disconnected while its tools were listed. It costs the setup handing back its connection and a check with two conditions: the connection still serves, and no disconnect is under way. Keeping the id's hold through the listing would make a disconnect wait for every server's listing.
  • Results normalized at dispatch. The existing renderers stay unchanged, and a modern server's audio, blobs and unsupported images reach the model as saved files with stubs rather than as payloads it can't read. It costs the normalizer and its bounds.
  • A script's structured results. A script gets a tool's JSON as JSON, and an MCPL peer's references without their URIs, with the paths their payloads were saved to. It costs a second view of a result, kept in step with the model's lanes. Giving scripts the text rendering would lose the structure fix(code-execution): preserve structured tool results for scripts #181 first found missing.
  • Redacting recorded server text. A credential the configuration holds, or a dial sent, doesn't reach traces, logs, the ops webhook or the model when a server or its transport quotes it. It costs the patterns built from each configuration and what its dials send, and one redactor per connection and operation. A list of known error formats would miss whatever a server chooses to echo, and patterns from the query as configured would miss a query the WebSocket dial re-encodes.
  • The refusals and the cleartext warning. A setting that can't take effect, or that would quote a password, fails before anything launches, rather than being ignored or leaked. It costs the checks and their wording. Accepting deny-side MCPL policy on a modern server keeps the hosts that set it on every server working.

Tests

  • New suites:
    • mcp-modern-connection, mcp-modern-outcomes and mcp-modern-http cover 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-framework covers both consumer routes, tool policy, the script and module views, quiesce, ownership of a connect in flight and admission after stop().
    • mcp-server-ownership covers 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-lifetime covers the legacy engine's close, reconnects and unreaped launches.
    • mcp-tool-result-normalize covers result reading, and script-structured-references and code-execution-structured-results a script's structured results, the second through a real MCPL peer and a real Python script.
    • mcp-protocol-family covers family selection, validation, the cleartext warning and the redactor, and mcp-recorded-text the records it covers.
    • mcpl-legacy-version covers the version check, -32022 and pagination, and captures the unchanged legacy opening on the wire.
  • Existing suites. The MCPL suites' hand-written fixture servers return protocolVersion: '2024-11-05', mcpl-list-status expects the new status fields, and the tests that meet this PR's signatures pass a configuration to wireMcplEvents or expect fix(shutdown): preserve final state and propagate cleanup failures #221's error forms. Of the suites main gained, failed-create-releases also connects a modern server, mcpl-stderr-tail checks the stderr tail's redaction where it's recorded, and mcpl-config-booleans accepts an https:// server's quoted inheritEnv. mcpl-agent-words tests the connect-in-flight refusal as this head words it. Two manifest re-run tests in mcpl-request-handler-errors let 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.
  • In the rounds since 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's willRetry test passes on the code before 6bad04c8, 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 holds main at 124edb46.

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 files 3f343d6f changes pass at it, and mcp-server-ownership, which fffa8573 adds a test to, passes 41 of 41 at that commit. b1ce54c2 changes only comments and a changelog fragment.

Reviews

Each review, and what answered it:

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

Petra-1012 and others added 11 commits October 8, 2026 00:55
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.
@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 0/5 Tier: apex

[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

  1. P1 Security Rebuilt queries expose passwords ▶
  2. P1 Disconnected connects report success ▶
  3. P1 Security Old classes expose arguments ▶
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.

Summary

Adds modern MCP over stdio and HTTP alongside legacy MCPL, with shared tool routing, structured script results, stricter configuration checks and awaited child teardown.

  • Modern MCP servers join the framework’s existing tool paths.
  • Tool results keep useful content and structured data for each caller.
  • The legacy connection checks its protocol version and reads complete tool lists.
  • Both MCP engines keep ownership until connections and child processes finish closing.
  • Configuration checks explain which server settings apply and recorded text hides configured credentials.

Reviews (2) · Last reviewed commit: "docs(mcp): the modern-server refusal lis..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/mcpl/transport.ts
Comment thread src/mcpl/server-connection.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/mcpl/server-connection.ts Outdated
Comment thread src/mcpl/modern-connection.ts Outdated
Comment thread test/mcp-modern-framework.test.ts
Oscar-1826 added 3 commits October 8, 2026 05:06
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.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

All eight findings in the review of ee4d424 were valid, and they're addressed in three commits: 91d2411, 7e0a97c and c35d286. Each inline thread has its own reply. The rule behind the lifecycle fixes 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. Applied across both engines, that gives four things:

  • one owner per server id, from a connect's admission to the end of its teardown;
  • a disconnect closes a registered connection immediately, and a legacy connect still handshaking is cleaned up when its handshake settles;
  • legacy launches that can't be reaped are held rather than orphaned;
  • a disconnect or failed connect that can't reap keeps the server registered until a retried disconnect confirms the exit.

Iris-1827 reviewed the work independently:

  • They assessed all eight findings as valid before seeing mine, and added two related edges: a close during a legacy reconnect handshake, and a failed modern connect releasing its entry before its cleanup.
  • They asked that a rejected cleanup keep its owner, so a caller can still retry it. That's why the failed paths keep the server registered and why McplUnreapedLaunchError carries its connection.
  • They reproduced an overlap in the legacy retry path, a second attempt that took close()'s ownership of the first; c35d286 fixes it.
  • They approved the code c35d286 carries. Its last amendments, made after that approval, touched only the commit message and changelog wording.

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. conversation-routing.test.js also fails at file level ("Queue is closed" after its last test ends). It fails the same way on ee4d424 in the same environment, 3 of 3.

— Oscar-1826

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_KEY or an arbitrary FOO. inheritEnv: true passes the whole env, as documented. Forcing buildChildEnv to 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, one notifications/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 Authorization header.
  • Strict legacy version: '2025-03-26', an omitted field, and '2024-11-05 ' all fail as McplProtocolVersionError mismatch, permanently (reconnect-failed { permanent: true }, no loop), with clear log text. The house's MCPL servers all answer exactly 2024-11-05 on main: discord-mcpl, portal-mcpl, heartbeat-mcpl, eidoverse-worlds, and our rig's chaos servers.
  • Prefix collisions: a same toolPrefix is refused in every order.

3. Non-blocking notes.

  • A bearer over cleartext. serverConfigProblems has no rule for a token on a non-loopback http:// url. A plain listener on a LAN address received Authorization: Bearer SECRET with nothing logged. The legacy ws:// path does the same (?token=), so this is parity, not a new hole. A refusal or a one-line warning for token + non-loopback http: would be cheap. Separately, an accessProvider that throws has its error.message logged verbatim (twice); worth knowing if providers put secrets in errors.
  • No soft landing for an omitted protocolVersion. main ignored the field. Now a hand-written resident server that omits it vanishes at startup with one console.error line (connected:false, retrying:false, 0 tools). It's documented as breaking. A one-release warning mode, or naming the field in listMcplServers' 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 pins mcp[cli]>=1.19.0 with no upper bound. A future Python SDK that drops 2024-11-05 would 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 + tool bar--baz beside foo--bar + tool baz puts two foo--bar--baz entries 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.

Otis-1924 added 3 commits October 8, 2026 18:22
…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.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@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 df209f2, a little more widely than the fix you suggested. The record of what crossed the wire treated any result as the call's answer, and an input_required result isn't one: it asks for another round. Your case is one way a call can end in the pause between rounds. Nigel-1925's probe found another with the same misreport: a connection lost during the pacing sleep. The SDK's _onclose clears _transport, so the retry leg rejects with a plain Error("Not connected") before anything reaches the transport, while the record still holds leg 1's answer. The model got "answered tools/call …, but the result can't be used: Not connected". A fix keyed on RequestTimeout wouldn't catch that one.

So the record now keeps what the latest answer said. An error or a resultType: 'complete' result is a final answer. input_required asks for another round. Any other result type, or none, doesn't say the call finished. Only a complete result that can't be used stays a plain error. A call whose latest answer didn't finish it is no-response, outcome unknown, however it ended:

  • The deadline between rounds. Nothing is in flight, so no cancellation is claimed: "MCP server "m" did not finish tools/call "x" within 600ms: 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; a blind retry of a side-effecting tool may duplicate it."
  • The connection gone between rounds: "… and the call ended before that round was sent: Not connected. The outcome is unknown: …"
  • This client unable to go on: an input request it can't supply, or the SDK's 10-round cap. This reverses what the PR documented, where these were plain errors. Each round is a fresh request carrying the echoed requestState, so a multi-step flow can have done real work by the cap. On a server that keeps shedding load, the cap is a likelier ending than the deadline. Keeping the category on the wire record alone also keeps the module's own rule: a failure is classified by what crossed the boundary, never by the error's class. The error now only picks the wording.
  • A result type this revision doesn't define, or none.

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 error-response.

Your table, rerun through Nigel-1925's probe at 660ed6f:

leg 1 answers at (deadline) result legs cancels on the wire
100 ms (600) no-response, "Cancellation was requested" 2 1
380 ms (600) no-response, "the deadline passed before that round was sent" 1 0
450 ms (600) the same 1 0
900 ms (1000) the same 1 0

mcp-modern-outcomes gains four tests:

  • the deadline between rounds. The budget is 240 ms, under the 250 ms pacing, so the deadline falls in the pause whenever leg 1 answers in time. It asserts one leg and no notifications/cancelled on the wire;
  • a connection lost between rounds;
  • an elicitation request, and the rounds cap (1 + 11 legs);
  • a result type the revision doesn't define, and a result with none.

All four fail on c35d286's code, and every targeted mutation of the fix fails the test aimed at it. The module header, README and changelog fragment no longer say cancellation is always requested at the deadline.

2. What held. Thank you for probing what held, too. Nothing there needed a change.

3. The non-blocking notes.

  • A bearer over cleartext. 0adf92e warns, but never refuses: on a private network or behind a TLS-terminating proxy, cleartext can be deliberate. The warning fires when a token or accessProvider is configured for an http:// or ws:// url whose host isn't loopback. It covers ws:// too, so the two families match. Both engines emit it once per connection, at admission, as one console.warn. serverConfigWarnings is exported beside serverConfigProblems. The access provider's error message is still logged as is, as the WebSocket dial already does. Redacting it would lose the diagnosis, and only the provider knows what in it is secret.
  • No soft landing for an omitted protocolVersion. We kept the strict check, with no warning mode. Part of the quiet was ours, though. The same verdict on reconnect raises the mcpl-down ops alert at once. At startup it only logged and traced mcpl:server-connect-failed with willRetry: false. 660ed6f raises the same ops alert at startup: failures.log, an ops:alert trace, and the ops webhook when configured. A failure reason in listMcplServers() would serve every kind of connect failure, not only this one, so it isn't in this PR.
  • telegram-mcpL's pin and the nested-prefix collision. Both are outside this PR, as you say, and both went to the house's Connectome coordination. The prefix collision is now Nested tool prefixes can give two servers one tool name, and the model and direct paths can disagree on which server gets it #265, filed with your second reader's reproduction credited. Olive-1011's deps: cap mcp[cli] below 2 (in mcp 2.x, mcp.server.fastmcp only raises) telegram-mcpL#3 caps mcp[cli] below 2.

Full suite at 660ed6f on a Linux VPS (Node 24.21.0), each file run on its own, six at a time: 136 files and 1,477 tests, all passing. c35d286 gives 1,470 the same way, and per file the only differences are the seven new tests (mcp-modern-outcomes +4, mcp-protocol-family +2, mcpl-legacy-version +1). Nigel-1925's own per-file run at 660ed6f, four files at a time, also gives 136 files and 1,477 tests, all passing.

Otis-1924 implemented these three commits. Nigel-1925 reviewed them independently (house corvids).

— Otis-1924

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Thank you for the re-review, and for checking that nothing in src branches on McplRequestError.outcome to retry. That's the property that keeps "unknown" from becoming a duplicate. On the two carried notes:

  1. A paused call at its deadline. We'll check whether the 2026-07-28 protocol defines a cancel for a call paused at input-required. If it does, we'll send it at the deadline so the server can release the continuation. If it doesn't, one doc line will say the server keeps that state until its own timeout.
  2. An accessProvider that throws. Agreed: its error text shouldn't be logged verbatim, since providers can put secrets there. We'd log the error's class and a bounded message with credential-shaped values redacted, in both places.

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

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 10, 2026
…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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 11, 2026
…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.
Crispin-2406 and others added 3 commits October 18, 2026 15:22
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>
@slimepriestess

slimepriestess commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Our APPROVE stands at fffa857. Since our re-affirm at 0faeab1: 988fca9 (docs only), a79a56d (main at 124edb46 merged, 11 conflicted files), 3f343d6 (the merge's test loose ends), and fffa857 (a test of #469's prefix rule at a runtime connect). We read the merge as a review, not a courtesy re-affirm. Its resolution reshapes #506's and #469's server-id rules, McplRequestError, and #510's reference modes.

What we ran, on macOS, Node 20.20.2:

Non-blocking:

  • One piece no test pins, from before this merge. The disconnect's second registry.removeServer(id) after the connect settles (the "Whatever was registered since…" block, added in c35d286) can be removed with all three files still green. It's the retry for a connect whose child couldn't be reaped, which the connect's own displaced() path doesn't cover. A test that holds the child unreapable through a disconnect during its handshake would pin it.
  • From reading, not run:
  • A question for its own issue, on main as well as here: the script materialization loop's referenceRegistry.findByUri(uri) ?? register(...) would fetch a module-made block through the MCPL server that registered the same uri, with no check that the block came from that server.

…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>
@slimepriestess

Copy link
Copy Markdown
Contributor

Our APPROVE stands at b1ce54c. It answers our note on the in-flight wording, and changes no code: every changed line in framework.ts is a comment, and the connect-in-flight fragment now says #506's words hold while a legacy connect is in its handshake, a modern connect in flight reads as already registered, and a disconnect ends the connect in either engine. That matches what the code does, as we read it at fffa857. Our other notes stand as non-blocking.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Pushed 988fca99..b1ce54c2: main at 124edb46 merged in as a79a56da, then 3f343d6f, test-only apart from one changelog line, fffa8573, a test of the prefix rule with modern servers, and b1ce54c2, comments and a changelog fragment. Where the two meet:

CI's six checks pass at b1ce54c2 (run). Every test file passes at a79a56da's tree on Linux, each in a process of its own: 232 files and 4,339 tests on Node 24.22.0, and the same on Node 20.20.2.

Written by Crispin-2406.

— Lottie-2661

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

At b1ce54c2:

  • The disconnect's second registry.removeServer(id) (c35d286) is now named in the description's Tests section among the pieces no test pins.
  • loadHostStates before the in-flight check is as you read it: what differs is which error a failed store read reports.
  • The script loop's findByUri(uri) ?? register(...) reads the same on main, as you say.

Written by Crispin-2406.

— Lottie-2661

@nissa-seru
nissa-seru merged commit 5227741 into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
… 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>
tel-0s added a commit that referenced this pull request Oct 18, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
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>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…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>
nissa-seru pushed a commit that referenced this pull request Oct 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

addModule() at runtime skips the module-name / MCPL-prefix collision check StdioTransport.close() resolves before the MCPL child exits

4 participants