Repository navigation
fix(cli): a serve boot that binds no HTTP server announces no address, and MCP: prints only when its route is mounted - #23947
Conversation
…, and MCP: prints only when its route is mounted On a boot with no HTTP transport composed (`--no-server` and no config-mounted transport), `os serve` no longer sends `objectstack:listening` and no longer writes `runtime.<environment>.<project>.json`: both used to carry the requested port, which nothing listened on. The banner still prints. `objectstack:seed-settled` is still sent and is then the boot's only message. The gate is the same `httpServer` composition the banner's address rows read. The banner's `MCP:` row and its connect hint now also require the route's owner, the runtime dispatcher, to be composed (`kernel.hasPlugin`), so a config-mounted `HonoServerPlugin` under `--no-server` no longer advertises `/api/v1/mcp`, which answers 404 there. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
A unit file drives readComposedMcpRouteOwner against a real ObjectKernel and pins the run() wiring (comments masked); an integration file boots the built CLI three times with an ipc channel and a watched OS_HOME. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
…tegration budget reading Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
… budget from three local runs Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
…needle serve-observability-registration.test.ts finds the transport by the first occurrence of the HonoServerPlugin constructor call in serve.ts's raw source; the new docblock spelled that call and sat above the registration. Claude-Session: https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 789330410aa4c163d19862dbf89d97d83444b44b && git checkout 789330410aa4c163d19862dbf89d97d83444b44b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 11fea6b000894eb6c4cf6d79f28fe3109cca46bb cccb00ea51453ea8cc05e2d98bf457ec39cd466b && git checkout -B drift-repro 11fea6b000894eb6c4cf6d79f28fe3109cca46bb && git merge --no-ff cccb00ea51453ea8cc05e2d98bf457ec39cd466b
node scripts/docs-audit/affected-docs.mjs --json 11fea6b000894eb6c4cf6d79f28fe3109cca46bb
|
✅ ACCEPT: PR #23947 at
|
Fixes #23743
Clause-②: no
What this changes
This is the remainder of #23623 / PR #23741. That PR gated the ready banner's address rows on a composed HTTP server. It left two things as they were, and this PR fixes both.
1. The machine channels on a boot that binds no HTTP server.
os serve --no-server, with no transport composed, sent{ type: 'objectstack:listening', port, url }and wroteruntime.ENV.PROJECT.jsonwith the sameportandurl. Nothing listened on that port. The docs row (content/docs/deployment/cli.mdx) says: "objectstack:listening|os serve| The HTTP server is bound."packages/cli/src/commands/serve.ts, the publish site.publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol)now runs only whenhttpServer === 'composed'. Otherwise only the banner prints.httpServeris theresolveHttpServerComposition(kernel, flags.server)value the banner's address rows already read. One predicate, read once, decides all three channels, so the banner, the IPC message and the state file cannot disagree about whether there is an address.publishBoundPort, its three channels and their order are unchanged.resolveBoundPortis unchanged too: it still falls back to the requested port, and the publish site no longer publishes that fallback.objectstack:seed-settledon that boot: still sent, and it is the only message. Consumers measured in this repository: theos devparent (dev.ts, read, not edited) relays it and never spawns a--no-serverchild, becauseos devhas noserverflag. The docs example waits forseed-settledalone.SeedSettledMessage's own rule is that a waiting parent must never hang. A kernel-only boot still seeds, so dropping the message would hang exactly the parent that rule protects. Both docs rows are updated together. The listening row now says the message is not sent by a boot that binds no HTTP server. The seed-settled row keeps "always afterobjectstack:listening" and adds that on such a boot it is still sent and is the only message.scripts/publish-smoke.sh(reads.port, then curls/api/v1/health; it spawnsobjectstack dev, which never runs--no-server), plus the CLI's own tests (serve-bound-port-publication.test.ts,test/serve-bound-port-publish-order.test.ts,test/serve-publishes-bound-port.e2e.test.ts,test/serve-runtime-state-project-key.test.ts,test/helpers/runtime-state-child.ts). No docs page states a contract for the file. I chose "not written" over "written with no URL or an unbound marker" for three reasons. The file's whole payload answers "is my server running, and where?", andruntimeStateFileName's docblock already calls a missing file "a loud, correct answer". An unbound record would add a second payload shape that every reader would have to handle;publish-smoke.sh'sjq '.port | numbers'would read it as "no port yet" and probe until its timeout. And the payload stays the shape the project-key ruling (serve.ts,projectStateKey) chose not to widen.os startnever passes--no-server, andos devnever does either, so every in-repo consumer of these two channels sees the same bytes as before.2. The banner's
MCP:row under a config-mounted server. Under--no-server, with a config that mounts its ownHonoServerPlugin, the banner printedMCP: http://localhost:PORT/api/v1/mcpand its connect hint, butGET /api/v1/mcpanswered 404. The plugin that mounts that route is the runtime dispatcher,createDispatcherPlugin, andservecomposes it only insideif (flags.server).serve.ts:readComposedMcpRouteOwner(kernel)asks the booted kernelhasPlugin('com.objectstack.runtime.dispatcher'). This is a kernel read likereadComposedHttpServer, so a config that mounts its own dispatcher is answered as well. The dispatcher registers no service; it only consumeshttp.server. So the kernel's plugin registry is the read, the same oneHonoServerPluginalready asks before it warns that no data or discovery API is mounted. The banner getsmcpEnabled: isMcpServerEnabled() && mcpRouteMounted, wheremcpRouteMounted = httpServer === 'composed' && readComposedMcpRouteOwner(kernel). If the kernel cannot answer, the result isfalse.packages/cli/src/utils/format.ts: doc comment only, on themcpEnabledfield theMCP:row reads, saying whatservenow passes.printServerReadyis unchanged.⛔ Not changed: exports of
@objectstack/cli, flags, exit codes, process lifetime,packages/spec,packages/runtime,dev.ts.Measured at the door
Each boot runs
node packages/cli/bin/run.js serve objectstack.config.ts --port PORT ARGSon the built CLI, spawned with anipcchannel.NODE_ENVis unset except under--dev, andOS_CLOUD_URL=off. The fixture is a throwaway host config with one object and one plugin instance. Before is7a6ba03109, after iscccb00ea51. "State file" meansOS_HOME/runtime.env_local.PROJECT.jsonas read when the listening message arrived.--no-server --preset minimal(the card's command)objectstack:listeningwithurl: 'http://localhost:38393', thenseed-settledseed-settledonlyurl/ noneNo HTTP server: …line (unchanged from #23623)ECONNREFUSEDon/,/api/v1/health,/api/v1/mcp--dev --no-serverseed-settledseed-settledonlyECONNREFUSEDseed-settledportandurlAPI:,MCP:and the hint (unchanged)/api/v1/health200,/api/v1/mcp401--no-server --preset minimal+ config-mountedHonoServerPluginseed-settledAPI:only; before:API:,MCP:and the hint/api/v1/mcp404,/api/v1/data/fx_task401HonoServerPlugin,--server(control)seed-settledAPI:,MCP:and the hint/api/v1/health200,/api/v1/mcp401Tests
src/commands/serve-no-transport-announcements.test.ts(new, unit, 10 cases).readComposedMcpRouteOwneragainst a realObjectKernel:truewithcreateDispatcherPlugin()registered, andfalsewith a transport-only composition.MCP_ROUTE_OWNER_PLUGINis read offcreateDispatcherPlugin().name, so a rename of the dispatcher reds here instead of silently dropping the row.falsefor no kernel, nohasPlugin, a throwinghasPlugin, and a non-boolean answer.run()wiring, with comments masked byscripts/js-comment-mask.mjs: the publish call sits in thehttpServer === 'composed'arm withprintBanner()alone in the other arm;httpServeris resolved once and is what the banner reads; themcpEnabled/mcpRouteMountedspelling is pinned; the settle latch is released once, after the gated publication on both arms. An anti-vacuity case checks that the mask blanked comments.src/commands/serve-no-transport-announcements.integration.test.ts(new).vitest-tiers.tsputs it in the integration tier. It runs three boots of the built CLI, each with anipcchannel and anOS_HOMEdirectory watched from before the spawn.--no-server: noobjectstack:listening; the messages are exactly['objectstack:seed-settled']; and noruntime.*.jsonwas ever created. The watch matters: this shape exits on its own and its exit handler deletes the file, so a listing taken afterwards cannot tell "never written" from "cleaned up".{ port, url }of theAPI:row; the state file present at that moment carries the sameportandurl; anMCP:row prints; health answers 200 and/api/v1/mcpanswers something other than 404.--no-server: noMCP:row and no connect hint;/api/v1/mcpanswers 404 (the reason the row must not print); the IPC message and the state file both carry theAPI:address.expect.soft, so each channel reports on its own. The file declares// test-budget: 30: the three local runs took 19.4 s, 18.9 s and 20.9 s on a shared container; 20.9 × 1.25, rounded up.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 --shard=K/4. It is sharded because one unsharded run does not fit the container's foreground cap. Shards 1 to 3 ran on23b3033ffc(this branch merged withorigin/main); shard 4 also ran there, and its one failure was fixed incccb00ea51.Test timed out in 5000mswhile my unlocked gate battery shared the cores. The two files (src/utils/boot-preparation-parity.test.ts,test/hook-timeout-override-refusal.test.ts) re-run alone: 2 files, 41 tests passed.serve-observability-registration.test.tsfinds the transport by the first raw-source occurrence of theHonoServerPluginconstructor call, and my new docblock spelled that call above the registration.cccb00ea51rewords the docblock.cccb00ea51, after rebuilding the CLI:serve-observability-registration, the new unit file,serve-bound-port-publication,serve-http-server-composition,format.server-ready-no-http-server,test/serve-bound-port-publish-order): 6 files, 82 tests passed;serve.ts): the new file,serve-no-server-banner.integration,test/serve-no-storage-boot-guard.integration,test/dev-boot-output-order.integration: 4 files, 15 tests passed;OS_TEST_TIERS=nightly,test/serve-publishes-bound-port.e2e.test.ts(the publication's own end-to-end pin, which runs nightly and not on this PR): 1 file, 6 tests passed;Ablation
The fix was committed first. Each leg went through
node scripts/ablation-replace.mjsin wrap mode (it restores on exit, and a secondtraprestores fromHEAD). Then the CLI was rebuilt,node scripts/ablation-dist-preflight.mjs @objectstack/cli MARKERwas run, and the two new files were run.if (httpServer === 'composed') {was replaced with an always-true condition carrying the markerablation-23743-gate.8de5619d0a58tof04d083f4255. The marker was present in 1 built file (dist/commands/serve.js).Tests 3 failed | 10 passed (13). Integration case 1 reported all three soft assertions: "objectstack:listening was sent by a boot that bound no HTTP server", "seed-settled is not the boot's one message", "a runtime state file was written by a boot that bound no HTTP server". So the watched-directory read does see a file that the exit handler later deletes. The two wiring source pins also failed. Both controls passed.8de5619d0a58, which is HEAD;git diff HEADempty;git status --porcelainempty. After a rebuild,--absentfound the marker in none of the 640 built files.Tests 13 passed (13).MCP:gate.mcpEnabled: isMcpServerEnabled() && mcpRouteMounted,was replaced with an always-true right operand carryingablation-23743-mcp.8de5619d0a58to630307660e27; the marker was present in 1 built file.Tests 2 failed | 11 passed (13): "an MCP: row printed for an unmounted route" (Hono case) and themcpEnabledsource pin. The no-transport case and the default control passed.--absentin all 640 files;Tests 13 passed (13).The ablations ran against the
serve.tsblob8de5619d0a58, which is unchanged from99c982b4cdthrough23b3033ffc.cccb00ea51changes only a docblock in it.Gates
All on the final head
cccb00ea51.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derives 96 commands. Its stderr names the tree asobjectstack-ai/objectstackatcccb00ea51, with 6 paths changed against merge base944f03850. Each command was run ascmd > log 2>&1; ec=$?, and all 96 exited 0.23b3033ffc), three gates exited 3 withPREREQUISITE NOT MET:spec check:skill-examples,check:dual-build-cjs-loadsandcheck:i18n-coverage. The packages they read had nodist/: studio, client-react, connector-slack, the example apps and others outside the CLI's closure. Afterturbo run buildof those (75 tasks, exit 0), all three exit 0 oncccb00ea51.--ran ran.listprints:96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED (a DERIVED zero — all 96 recorded an exit code and none of them is 3).pnpm --filter @objectstack/cli typecheckexits 0:tsc --noEmit, thencheck:test-typecheck: OK.tsc -p tsconfig.json --noEmit --listFilesOnlylists both new files (2 of 2).pnpm exec eslint --no-inline-configover the four changed.tsfiles exits 0 with no output. The repo-widepnpm lintis CI's.check:dev-prereqsandcheck:skill-frame-freshness(nopackages/specbuild or frame file is touched), andcheck-platform-checklist.mjs --changed-from(no exported symbol is renamed or removed; two are added).Acceptance notes
--no-server --preset minimalprints the ready banner ending "Press Ctrl+C to stop" and then exits 0 on its own about 40 ms later, removing the runtime state file it had just written #23744, not touched here). Measured on base7a6ba03109and aftercccb00ea51: production--no-server --preset minimalprints its banner about 5.1 s to 5.8 s after spawn, and then exits 0 by itself within milliseconds, with or without anipcchannel.seed-settledarrives before that exit in every run measured.--dev --no-serverstays up until stopped. This PR changes neither.runtime.ENV.PROJECT.jsonsurvives. Before, a later--no-serverboot overwrote it with its own record, carrying a URL nothing listened on. Now that boot writes nothing, so the stale record stays, naming the deadpid. A reader that checkspidliveness is unaffected. The file is documented as best-effort and was never cleaned up on a hard kill. Observation only; no carrier.start.tscomment. A comment inpackages/cli/src/commands/start.tssaysservepublishesobjectstack:listening"unconditionally". It is now conditional on a composed transport.os startnever passes--no-server, so for its child the sentence still holds in practice. It is outside this card's file surface and is left as is. Observation only.createDispatcherPlugin({ prefix })with a non-default prefix gets anMCP:row naming/api/v1/mcpwhatever the prefix.serve's own dispatcher always uses the default. No in-repo producer of that shape was found, and the behaviour is unchanged by this PR. Observation only.readComposedMcpRouteOwneranswers "is the route's owner composed", not "is the route mounted". The dispatcher mounts/api/v1/mcpunconditionally in itsstart()once anhttp.serveris registered, and the row also requires a composed transport. So with today's dispatcher the two are the same. No live route table exists onIHttpServerto read instead.Generated by Claude Code