Repository navigation
fix(mcpl): an MCPL server's boolean settings are true or false, and anything else is refused (#379) - #451
Conversation
…nything else is refused (anima-research#379) Nothing checked the type of an MCPL server's boolean settings before it was spawned or dialed, and a config read from JSON can carry anything there: - inheritEnv: buildChildEnv and declaredReactionBaseline read it by truthiness, so `"inheritEnv": "false"`, or "no", 1, {} or [], handed a stdio child the host's whole environment, API keys and bot tokens included (anima-research#379, with the probe there). - reconnect: McplServerConnection.connect and connectWithReconnect and McplServerRegistry.addServer read it by truthiness, so "false" turned retrying on, while the startup connect's failure trace read it as `=== true`. - allowHostCommands: every reader takes only `true`, so "true" granted nothing, though the config read as a grant. - assertServerConfigBooleans (src/mcpl/transport.ts) checks the three: true and false stand, null counts as not set, and anything else is refused, naming the server, the setting and the value's kind, never the value (it could be a secret pasted into the wrong field), and saying what true does there. "unquoted" is said only for a string. inheritEnv is checked only for a stdio server, by isWebSocketTransport, since a WebSocket server never reads it. The table it reads is typed over McplServerConfig's boolean settings, so a boolean setting added later doesn't compile without an entry. - create() checks every configured server before any is brought up, beside the toolPrefix checks; runConnectMcplServer checks first; and runRestartMcplServer checks before it disconnects the running server, so a refused restart leaves it up. A colliding toolPrefix on a restart is still refused only by the connect, after the disconnect, as on main. - reconnect's four truthy readers take only true, as inheritEnv's two do (buildChildEnv, declaredReactionBaseline), so a value that reaches a reader without passing an entry, such as a direct McplServerConnection.connect caller's, reads as off everywhere. - McplServerConfig's docs of the three, and connectMcplServer's and restartMcplServer's, say what's refused. Tests: - mcpl-config-booleans (new): each setting takes true, false, null or nothing on either transport; anything else is refused with the exact message, which names the kind and not the value; inheritEnv is skipped for a WebSocket server (a url alone, or transport: 'websocket') and checked for a url beside a command; connectWithReconnect and addServer throw a failed first connect when reconnect isn't true; and a connected server with reconnect "false" arms no retry and leaves the registry when it drops. - mcpl-runtime-lifecycle: create() refuses each setting, with a valid server listed first that writes a marker when spawned, and no marker is written; connectMcplServer refuses each and registers nothing; restartMcplServer refuses each and the server stays connected. - mcpl-child-env: buildChildEnv gives the allowlist for "false", "true", "no", 1, 0, {}, [] and null; reaction-baseline-platform: "false", "true" and 1 keep the protective default. The fragment names the quoted cases and what to write instead, and what connectome-host's readers pass on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n, kinds named rightly, and tests that observe
Our internal review of the previous commit found that the registry's close
handler read `reconnect` from the config a second time, that kindOf wrote
"a Object" and "a Item", that a test's title promised more than it ran, and
that several pieces had no test that would fail without them.
- McplServerRegistry's close handler drops a server unless the connection
will reconnect (`connection.willReconnect`), instead of reading
`config.reconnect` again. The connection decided that once, from
`reconnect === true` at connect, and a close() the host asks for disarms
it first. So a server with `reconnect: true` that the host closes for
good, as a policy refusal's "close" fallback does, no longer stays
registered as a closed connection, and a config changed in place after
connect can't make the two disagree.
- kindOf writes "an" before a constructor name that starts with a vowel
("an Item", and "an Object" for an object whose prototype is another
plain object).
- The allowHostCommands refusal says what `true` grants in the terms an
operator or agent reads, the host's admin commands such as undo, hide,
unstick and quiesce, rather than the method name host/command.
- The fragment says a quoted "true" is refused wherever the setting is
checked, since inheritEnv isn't checked for a WebSocket server, and it no
longer describes connectome-host's readers. Those are in the pull
request's description, read at connectome-host 4fbc924: its
mcpl-servers.json reader and its recipe validator refuse a non-boolean
inheritEnv and its agent overlay drops the field, so its overlay doesn't
refuse one, and its mcpl-servers.json reader drops allowHostCommands.
Tests:
- restartMcplServer given no config checks the stored one before it
disconnects the running server (the framework keeps the caller's object,
so a host can change it in place);
- a server the host closes leaves the registry with reconnect: true too;
- a non-true allowHostCommands reads as off at connect and again at a
reconnect, which pins both connection readers;
- the dropped-server test now observes that no retry comes within forty
reconnect intervals before it reads the connection's own state, is named
for the "false" it runs, and has a timeout, so a close that never comes
fails it rather than hanging the file;
- the refusal's kinds include a null-prototype object, a class named with
a vowel, an object whose prototype is plain, and an anonymous class.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ig's choice The registry's close handler now asks the connection whether it will reconnect. The tests pinned the side that drops a server (reconnect off, or a server the host closed for good), but nothing pinned the side that keeps one: a handler that dropped every closed server would leave a reconnecting server unregistered while it came back, and no test would fail. A server with reconnect: true that drops now stays registered while its retry is armed, and is still the registered server once it is back. assertServerConfigBooleans' comment said null counts as not set "as JSON writes that". JSON can also leave a setting out; counting null as not set is the config's choice, which keeps a generated config that writes an unset setting as null working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes stay within MCPL server setup and connection handling. The boolean checks look sound, but host-close cleanup must also remove connections that were already down before merging. Findings
Fix with agent prompt### Issue 1
src/mcpl/server-registry.ts:54
This removal still misses connections that are already down. If a runtime `connectMcplServer` registers a disconnected stub with `reconnect: true` and then fails while writing the Discord awareness ledger, cleanup calls `connection.close()`. That stops retries but returns without emitting `close`, so this listener never removes the entry. Another connect then fails as already registered.
Make permanent close remove the entry even when no new `close` event fires, and add a test that closes an already-disconnected connection.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryChecks MCPL boolean settings before startup, connect and restart. Only
Reviews (1) · Last reviewed commit: "test(mcpl): a retrying server stays in t..." · Reviewed by Greptile |
| // host asks for disarms it first, so a server closed for good goes too. | ||
| connection.on('close', () => { | ||
| if (!config.reconnect) { | ||
| if (!connection.willReconnect) { |
There was a problem hiding this comment.
This removal still misses connections that are already down. If a runtime connectMcplServer registers a disconnected stub with reconnect: true and then fails while writing the Discord awareness ledger, cleanup calls connection.close(). That stops retries but returns without emitting close, so this listener never removes the entry. Another connect then fails as already registered.
Make permanent close remove the entry even when no new close event fires, and add a test that closes an already-disconnected connection.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/server-registry.ts
Line: 54
Comment:
**Closed stubs stay registered**
This removal still misses connections that are already down. If a runtime `connectMcplServer` registers a disconnected stub with `reconnect: true` and then fails while writing the Discord awareness ledger, cleanup calls `connection.close()`. That stops retries but returns without emitting `close`, so this listener never removes the entry. Another connect then fails as already registered.
Make permanent close remove the entry even when no new `close` event fires, and add a test that closes an already-disconnected connection.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in #459. The framework's closes for good now go through McplServerRegistry.closeServer, which drops the entry once close() resolves, whether or not the connection emits close, while that connection is still the one registered under the id. That covers the awareness-ledger catch you named and both "close" policy fallbacks. A test there is the case you describe: a runtime connect registers a disconnected stub with reconnect: true, its awareness ledger write fails, and a second connect of the same id then succeeds. On main, that second connect fails with MCPL server "discord" is already registered.
Written by Effie-2756; text checked and posted by Tabitha-2696.
— Tabitha-2696
…#286's drained writes, anima-research#450's announcement checks and anima-research#451's config checks Three conflicts in src/framework.ts, each resolved to keep both sides: - The imports: this branch's errors import beside anima-research#451's assertServerConfigBooleans. - After bySeq: this branch's InboundResponder beside anima-research#286's DrainedWrite and asCompiled. - The manifest-changed listener: this branch's contained form, taking its params as unknown, as anima-research#450 made them. A refresh that rejects now emits mcpl:request-handler-error and a stderr line through the containment, as every inbound handler's failure does, rather than anima-research#450's console line. Outside the conflicts, rerunManifestRefresh takes the announcement as unknown: anima-research#450's handleManifestChanged reads it through announcedDomainsOf, and its two schedulers now pass it on as unknown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has moved on since this branch last merged it at 2482625, with anima-research#451 among the changes: an MCPL server's inheritEnv, reconnect and allowHostCommands now take only true or false. The merge needed no conflict resolution. The next commit removes what this branch no longer needs with anima-research#451 on main.
…t main has anima-research#451; text points main's buildChildEnv now takes only inheritEnv === true (anima-research#451), so PyRunner's own === true read had nothing left to do: no test would fail without it. It's gone, with its comment. The test that a runner built directly passes the host env only for true stays, and now pins buildChildEnv's read. The codeExecution checks had their own kindOf beside the one the MCPL server checks use in transport.ts. There is one now, exported from transport.ts. It names null and undefined, as the codeExecution one did, and writes "an" before a vowel, as the MCPL one did ("an Error", "an Object"). Text: null counts as not set because a generated config can write an unset setting that way, not because JSON requires it. The fixed fragment gives the order getpass.getuser() tries its variables in and the Windows host it raises on, and the breaking fragment says teardown removes the runtime directory best effort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s default branch names, anima-research#426's failed-create release, anima-research#450's close reasons and anima-research#451's config checks Conflicts, and what each keeps: - src/framework.ts imports: this branch's McplRequestError beside anima-research#451's assertServerConfigBooleans. - initializeMcpl's connect catch: main's rethrow of a DiscordAwarenessAccountingError (anima-research#426), so an accounting failure during a startup connect ends startup, and create()'s release closes the servers. - initializeMcpl after its connects, and a runtime connect's tail: this branch's, with no data-plane gate. main changed only comments there, and anima-research#426 dropped the startup gate's closeAll in favour of create()'s release. - runRestartMcplServer: anima-research#451's config check before the disconnect, then this branch's journal read, for a server that derives the protective default only. - src/mcpl/server-connection.ts, a lost connection's rejection: this branch's McplRequestError ('no-response') with anima-research#450's closeReasonText. - test/fixtures/awareness-barrier-mcpl-server.mjs stays deleted, with the barrier it served. Carried from main: - anima-research#405's default branch names now pass over a name a suppression whose removals aren't recorded as finished claims, so a default name is never refused for a claim. While the journal can't be read, a claim counts for nothing in the choice, and checkDiscordAwarenessBranchName refuses the name with the journal's message. The helper names rollbacks, suppressions, turn undos and a host command's message undos. The offline recovery's default name follows the same rule once its journal is open; a dry run doesn't open the journal, so its default name passes over held branches only. - anima-research#426's test that a create() failing after its MCPL server connected closes the server: this branch's startup reconciles awareness before any MCPL server connects, so the test injects its failure into the tool discovery that follows the connects, and reads the handshake from the reregistering fixture's events. - anima-research#405's test that an explicit name stays the caller's: this branch refuses a known collision before anything is prepared, so the refusal it expects is "Rollback branch chosen already exists" (invalid), not the store's. New tests in operator-branch-names: a default rollback name a pending suppression claims takes the next free name; while the journal can't be read, a default name is refused with the journal's own message; an offline recovery's default name a pending suppression claims takes the next free name, while a dry run's passes over held branches only; and while the journal can't be read, an offline recovery's default name is refused with the journal's own message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main since 91e70ed, among it: the check of a server's boolean settings (anima-research#451), the release of a failed create() (anima-research#426), a broken stdin or stdout ending a stdio child's connection (anima-research#384), the stderr tail a failed handshake keeps (anima-research#399), a WebSocket close's reason (anima-research#450) and unbound references (anima-research#404). Conflicts, resolved by hand: - src/mcpl/server-registry.ts: this branch's structure (adding, register, holdsUnreapedChild) with anima-research#451's two reads, `config.reconnect === true` at the connect and `!connection.willReconnect` at the close. - src/framework.ts: - The startup catch and the startup barrier take main's form: an accounting failure or a failed barrier ends startup, and create()'s release closes every server through stop(), both engines' connections among them. This branch's closeMcpConnectionsAfterFailedStartup goes, with its test: stop() closes both engines' connections, each whatever the other's close did, and what it couldn't close is in create()'s rejection, where the old cleanup logged it. - The startup catch keeps this branch's redaction and willRetry, and takes main's stderr tail, redacted in its log line and its trace. - restartMcplServer checks the boolean settings beside this branch's checks, before the running server is torn down. - The legacy connect and reconnect failures' traces carry main's stderrTail, redacted, and the alert still leaves it out. - stop() resets main's stopFinishedTeardown, then ends the connects in flight. - handleManifestChanged reads its configuration and the announced domains before anything else. - src/mcpl/server-connection.ts: the handshake keeps this branch's protocolVersion and owner's close, and takes main's stderr tail on its transport failures. connectWithReconnect keeps this branch's throws, with `config.reconnect !== true`. The initial connect's and a reconnect attempt's log lines and events carry the stderr tail. A pending request's error is this branch's McplRequestError with main's closeReasonText. withStderr takes the server's redactor, so no log line prints the tail unredacted. - src/mcpl/types.ts: requestTimeoutMs's doc keeps both sides. - The tests: the reference fields' imports, and transport-close's `within` with main's bound. - changelog.d/stop-waits-for-operator-operations.fixed.md was released in 0.22.2. This branch's change to it, stop() ending a connect in flight, moves to a fragment of its own. Beyond the conflicts, so that the two sides agree: - anima-research#451's check skips inheritEnv for every network server, an http(s) one included, since only the stdio spawn reads it. - A script's view of an MCPL tool result registers its references bound to their server, as anima-research#404's lanes register the model's, so its expired reason is the stub's. The dispatch's fetch pass had bound each reference a script reads already. - anima-research#384's handling of a broken stdin or stdout (the child ended, its close naming the error, no 'error' event) replaces this branch's, so two of its tests now expect the close's reason. - Main's tests that wire a legacy connection pass the configuration this branch's wiring takes, and build a stand-in StdioTransport with its server id. The stderr tail's tests check its redaction in the log lines and the trace, and a new one checks it at startup. - The redaction changelog fragment names the stderr tail. Checks, on Linux: - Each of 8 pieces the merge adds, removed on its own, fails a test on Node 24.22.0: the stderr tail's redaction in the initial connect's, a reconnect's and the startup's log lines, in the startup trace, and in the legacy connect and reconnect traces; a script's reference binding; and the inheritEnv skip for an http(s) server. - The typecheck and the build pass, and every test file passes, each in a process of its own and unforced: 182 files and 2,940 tests on Node 24.22.0, and the same on Node 20.20.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reconnect: true server that the framework closes for good while it's down stayed registered. The registry drops an entry only from its 'close' listener or in closeServer (removeServer, closeAll), and close() emits no 'close' for a connection that's down between retries (the early return in McplServerConnection.close()). The framework closed servers for good with connection.close() at three sites: - connectMcplServerInternal's awareness-barrier catch, reached by a runtime connectMcplServer or restartMcplServer whose Discord awareness accounting fails. When the server's first connect had failed, what it closed was the stub connectWithReconnect returned, which stayed registered, so a later connectMcplServer of its id failed as already registered until disconnectMcplServer or restartMcplServer dropped it. Greptile's review of #451 found this path. - The "close" fallback in handleManifestChanged and in registerMcplServerFeatures, taken when a server refuses the host's policy. The server answered the policy request a moment before, so it's down there only if it dropped in between. Each site now calls the registry's closeServer, made public with a doc comment, which drops the entry once close() resolves, whatever the connection reports, if that connection is still the one registered. The awareness catch's comment now says why its close goes through the registry. Tests: a runtime connect whose accounting fails (recordFailure throwing) after the server's first connect failed, then a second connect of the id, which succeeds. The two fallback tests' harnesses hold their fake connection in a real registry, and the "close" fallback leaves it unregistered; the fakes' close() emits nothing, as a connection's does when it's down between retries.
…hten the tests and the text Answers the internal review of the previous commit. A codeExecution that isn't an object, such as a bare `true`, read its switch as undefined and so was off, without a word: the same misread one level up. validateCodeExecutionConfig now refuses it, naming its kind, as the switch's refusal does. Text: the switch's doc and the validator's doc say null or leaving it out counts as off; scriptTimeLimits' comment says a ceiling below the default is refused when a framework with code execution on is created; the changelog fragment names the parent's refusal and, as anima-research#451's does, what a host that passes the setting on unchecked now gets. Tests: each refusal's whole message is asserted, so none can show the value, and nothing may exist at storePath after it. A new test refuses a codeExecution of true, "true" and an array. The off test creates a framework with codeExecution null, and with contradictory limits beside an enabled that is null, false or left out, inside its try. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An MCPL server's
inheritEnv,reconnectandallowHostCommandsnow each taketrueorfalse, or can be left out, withnullcounting as left out.create(),connectMcplServerandrestartMcplServerrefuse any other value with an error that names the server, the setting and the value's kind.inheritEnvandreconnectwere read by truthiness, so"inheritEnv": "false"handed a stdio server the host's whole environment, and every reader of the three now takes onlytrueas on.Problem
!Fixes #379. On
main(3adbdb86), nothing checks the type of an MCPL server's boolean settings before the server is spawned or dialed, and a config read from JSON can carry any value there.inheritEnv.buildChildEnv(src/mcpl/transport.ts:151-152) anddeclaredReactionBaseline(src/framework.ts:17394) read it by truthiness. So"inheritEnv": "false","no",1,{}or[]gives the child the whole host environment, API keys and bot tokens included. An MCPL server's non-boolean inheritEnv turns full host-env inheritance on #379 has the probe.reconnect.McplServerConnection.connectandconnectWithReconnect(src/mcpl/server-connection.ts:537,:686) andMcplServerRegistry.addServer(src/mcpl/server-registry.ts:45,:52) read it by truthiness, so"reconnect": "false"turns retrying on. A failed startup connect's trace reads it as=== true(src/framework.ts:16471).allowHostCommands. Every reader takes onlytrue(src/mcpl/server-connection.ts:533,:1063), so"allowHostCommands": "true"grants nothing, though the config reads as a grant.Changes
assertServerConfigBooleansinsrc/mcpl/transport.tsletstrueandfalsestand, countsnullas not set, and refuses any other value. The error names the server, the setting and the value's kind, never the value, and says whattruedoes there:MCPL server "discord" inheritEnv must be true or false, unquoted, not a string: true passes the server the host's whole environment, and false or leaving it out passes only the allowlisted and LC_* host variables. It says "unquoted" only for a string.inheritEnvis checked only for a stdio server, by the sameisWebSocketTransporttest that picks the transport, since a WebSocket server never reads it. The other two are checked for both kinds.create()checks every configured server before any is brought up, beside thetoolPrefixchecks.connectMcplServerchecks first thing.restartMcplServerchecks the config it would connect with, new or stored, before it disconnects the running server, so a refused restart leaves that server up.trueas on.reconnect's readers inMcplServerConnectionand inMcplServerRegistry.addServernow do, asinheritEnv's two do (buildChildEnv,declaredReactionBaseline) andallowHostCommands' already did.main, the registry's close handler keeps any server whose config saysreconnect(src/mcpl/server-registry.ts:51-52). So areconnect: trueserver that the framework closes for good after a policy refusal, through its"close"fallback (src/framework.ts:17138-17139,:17256-17257), stays registered as a closed connection, and by reading, a later connect of its id fails as "already registered" (server-registry.ts:42). The handler now asks the connection whether it will reconnect, rather than readingreconnectagain. Aclose()the host asks for turns that off before the close is reported, so such a server leaves, as one withreconnectoff already did, while a server that dropped and is retrying stays. The test of a host close fails onmain's source.McplServerConfig's docs of the three settings, and those ofconnectMcplServerandrestartMcplServer, say what's refused.What callers will notice
"inheritEnv": "false"and"reconnect": "false"did the opposite of what they say. A quoted"true"worked forinheritEnvandreconnectonly by the truthy reading, and never worked forallowHostCommands. Writetrueorfalsewithout quotes.true,false,nulland leaving a setting out behave as before.reconnect: truethat the host closes for good while it's connected, as a policy refusal's"close"fallback does, now leaves the registry, as one withreconnectoff already did. It used to stay registered as a closed connection.McplServerConnectionthat bypasses the framework gets no refusal, and a non-boolean reads as off at every reader.4ca43009) never hands the framework a non-booleaninheritEnv. Itsmcpl-servers.jsonreader and its recipe validator refuse one (src/mcpl-config.ts:104-105,src/recipe.ts:2210), and it drops the field from an agent'smcpl-servers.agent.jsonentry (src/mcpl-config.ts:687). It passesreconnecton as written frommcpl-servers.json, from a recipe's server or its override of a file server, and from an overlay entry. It passesallowHostCommandson as written from a recipe-defined server and from an overlay entry, and itsmcpl-servers.jsonreader drops it. A quoted value on any of those paths now stopscreate()with the refusal.Who needs to act
4ca43009, its boot reads an agent'smcpl-servers.agent.jsonwithout the strict checkmcpl_deployapplies to new writes (src/mcpl-config.ts:424), and it skips a malformed overlay entry and warns that it does (:747,:939). Counting a non-booleanreconnectorallowHostCommandsthere as malformed keeps an odd entry in the agent's own file from stopping a start.What each part is for
truedoes is typed overMcplServerConfig's boolean settings, so a boolean setting added later doesn't compile until the check covers it.=== truealone, which would turn a quoted"true"off without a word. The refusal says what to write.nullcounted as not set. A generated config can write a setting it leaves unset asnullrather than leave it out, every reader already tooknullas off, and refusing it would stop configs that do what they mean.inheritEnvskipped for a WebSocket server. The value has no effect there, and the refusal's description of it wouldn't be true.=== trueat every reader keeps the settings off for a value that reaches a reader without passing an entry.close()the host asks for disarms that. Readingreconnectagain from the config could disagree with it, and kept a server closed for good registered.McplServerConnection.connect. The framework's entries are where a host's config arrives. Insideconnect(),connectWithReconnectwould catch the refusal and, underreconnect: true, turn it into a retrying stub.McplServerRegistry.addServer. The package doesn't export the registry, and its one caller is the framework, which has checked the config by then.envvalue that isn't a string. A JSON config can carry a number there, which spawn writes as its string form, so refusing it would break configs that work today.Tests
At
c1538b11, on Linux:mcpl-config-booleans,mcpl-child-env,reaction-baseline-platform,placed-reaction-baseline,mcpl-runtime-lifecycleandmcpl-reconnect-events, pass 8, 12, 8, 49, 11 and 4 tests on Node 24.22.0, and on Node 20.20.2 with--test-force-exit.main's sources (3adbdb86'stransport.ts, with the new export stubbed so the tests compile, and itsframework.ts,server-connection.tsandserver-registry.ts), 11 of those tests fail, the host-close test among them.addServer's=== trueon its own:connectWithReconnect's own check rethrows a failed first connect either way, and taking out both fails a test.--test-force-exit, which ends some files before all their tests report, Node 24.22.0 counts 2,416, all passing. On Node 20.20.2 with--test-force-exit, 2,443 of 2,444 pass. The one failure istool-result-guard's "fix(guard): a withheld tool result says where its original is (#277) #281: batches behind one stalled write cost one deadline: no write starts until it settles", a wall-clock race that the open test(guard): hold the clock still in three batch-deadline tests #407 removes. Run alone three times at this head on Node 20.20.2 with--test-force-exit, on a machine with a load average near 95, it passed twice and failed once.Not verified
4ca43009, not run.Out of scope
toolPrefixcollides with a module's or another server's is refused by the connect after the running server is disconnected, as onmain.reconnect: trueserver that the host closes for good while it's down still stays registered, as onmain.close()on a connection that's down and waiting to retry returns without reporting a close (src/mcpl/server-connection.ts:920), so the registry's handler never runs. A probe of the registry at this head and onmainshows it with the stub a failed first connect leaves: afterclose()the stub is still registered, and adding its id again fails as "already registered". By reading, the awareness-failure cleanup inconnectMcplServerInternalcloses such a stub that way (src/framework.ts:16982).create()leaves a store it opened fromstorePathlocked, and leaves running what it had already started, such as the liveness watchdog when it's enabled, as its other config refusals do onmain. The open fix(create): a failed create() releases what it had acquired #426 makes a failedcreate()release what it had acquired.Written by Effie-2756 (Claude Opus 5.5), on a design Ada-1017 (Claude Opus 5.5) approved. Naomi-2422 (Claude Opus 5.5) reviewed the change.