Found while chasing an unexplained 501 from /api/v1/mcp during the #3913 dogfood. The 501 I originally saw turned out to be a local install artifact, not a platform bug — but tracing why it couldn't happen surfaced a path where it genuinely can.
The invariant that is claimed
packages/runtime/src/http-dispatcher.ts advertises mcp on a flag rather than on service presence, and documents why that is safe:
declared === enforced here is guaranteed by a LOCKSTEP, not by service-presence gating like the routes above (#3369 / #2698): os serve auto-loads plugin-mcp from the SAME isMcpServerEnabled() flag that gates this advertisement, so whenever /mcp is advertised the handler is mounted (a key / token yields 401, never a 404/501). Kept flag-based on purpose — @objectstack/rest advertises mcp from the identical single source (rest-server.ts), so the two discovery producers stay symmetric. The route-parity gate asserts the lockstep holds (advertised ⇒ reachable, never 501).
Every other optional capability in that same discovery table is service-presence-gated (hasUi, hasFiles, hasAnalytics, hasAutomation, hasI18n, …). mcp is the one exception, and the exception rests entirely on the lockstep above.
Why the lockstep holds on the CLI path
It does, and I want to be explicit about it so this isn't misread as a CLI bug:
packages/cli/src/commands/serve.ts:724 auto-pushes the capability when the flag is on:
if(isMcpServerEnabled()&&!requires.includes('mcp'))requires.push('mcp');- the loader does
await import(spec.pkg) (serve.ts:1986) from inside the CLI package, and packages/cli/package.json:56 declares "@objectstack/mcp": "workspace:*" — so it resolves regardless of what the app depends on. os dev spawns os serve --dev (dev.ts:260), so it inherits all of the above.
So on the os serve / os dev path: advertised ⇒ mounted. No defect.
Where it breaks
The claim of symmetry with @objectstack/rest does not hold. In packages/rest:
rest-server.ts:2067 advertises it on the flag alone:
if(isMcpServerEnabled()){(discovery.routesasany).mcp=`${unscopedBase}/mcp`;}else{delete(discovery.routesasany).mcp;}packages/rest/package.json has no @objectstack/mcp dependency.packages/rest/src/rest-server.ts has no /mcp mount, no resolveService('mcp'), and no auto-load — that logic exists only in the CLI's serve command.- the dispatcher's
/mcp branch answers 501 when the service is absent (packages/runtime/src/domains/mcp.ts:56):
constmcp: any=awaitdeps.resolveService('mcp',context.environmentId);if(!mcp||typeofmcp.handleHttpRequest!=='function'){return{handled: true,response: deps.error('MCP server is not available',501)};}
So an app embedding @objectstack/rest directly — not booting through os serve — gets mcp advertised in /discovery while POST /mcp returns 501. That is precisely the declared ≠ enforced failure the route-parity gate exists to forbid, on the exact producer the comment names as symmetric.
This matters for a third-party-facing platform: /discovery is the contract a client reads to decide what exists. An advertised endpoint that 501s makes discovery unreliable for the consumers most dependent on it (the objectui Integrations page reads this field, per the same comment).
Why CI cannot catch it
packages/runtime/src/route-parity.integration.test.ts states the right invariant —
every route the server ADVERTISES must be ENFORCED … 404 (route not mounted), 405 (wrong method sink) and 501 (advertised but no backing handler/service) all mean declared ≠ enforced
— and has a test named “MCP is advertised AND reachable — the discovery/route lockstep holds (not 501)”. But it runs with the service stubbed in unconditionally:
functionstubServicesPlugin(opts: {notification?: boolean;mcp?: boolean}={}){
...
if(opts.mcp!==false)ctx.registerService('mcp',fakeMcp());The mcp seam exists and is never used: bootServe() is called only at line 135 (all services) and line 218 ({ notification: false }). mcp: false appears nowhere in the repo. Contrast the notification case, which is covered on both sides — absent ⇒ not advertised and 404 — in the “discovery is service-aware (#3369)” block.
So the one capability whose advertisement is not service-presence-gated is also the one whose absence is never tested.
Suggested fix
Two options; (1) is my recommendation.
- Make the
@objectstack/rest advertisement service-aware, like every other capability in the table — advertise mcp only when isMcpServerEnabled()and the mcp service resolves. This restores declared === enforced by construction on both producers instead of by convention on one, and needs no dependency change. (The CLI path is unaffected: it loads the plugin, so the service resolves and it still advertises.) - Alternatively keep it flag-based and have
@objectstack/rest own the auto-load the way serve.ts does — but that pushes an @objectstack/mcp dependency into @objectstack/rest for every embedder, including those that deliberately don't want an MCP surface.
Either way: exercise the seam that already exists — add bootServe({ mcp: false }) coverage asserting mcp is not advertised and the route does not 501-while-advertised, mirroring the existing notification: false block. That is the assertion that would have made this visible.
Not reproduced live — deliberately stated
This is a static read of the three code paths, not a live repro. This checkout has no node_modules, and a real repro needs a small app that embeds @objectstack/rest without @objectstack/mcp installed, then diffs /discovery's routes.mcp against POST /mcp's status. Worth doing before the fix lands so the regression test is written against observed behaviour. Flagging it as analysis rather than asserting a reproduction.
Related: the same "fully-implemented read path with no producer" shape as #3913's object-less action key, which is what prompted looking here at all.
Found while chasing an unexplained
501from/api/v1/mcpduring the #3913 dogfood. The 501 I originally saw turned out to be a local install artifact, not a platform bug — but tracing why it couldn't happen surfaced a path where it genuinely can.The invariant that is claimed
packages/runtime/src/http-dispatcher.tsadvertisesmcpon a flag rather than on service presence, and documents why that is safe:Every other optional capability in that same discovery table is service-presence-gated (
hasUi,hasFiles,hasAnalytics,hasAutomation,hasI18n, …).mcpis the one exception, and the exception rests entirely on the lockstep above.Why the lockstep holds on the CLI path
It does, and I want to be explicit about it so this isn't misread as a CLI bug:
packages/cli/src/commands/serve.ts:724auto-pushes the capability when the flag is on:await import(spec.pkg)(serve.ts:1986) from inside the CLI package, andpackages/cli/package.json:56declares"@objectstack/mcp": "workspace:*"— so it resolves regardless of what the app depends on.os devspawnsos serve --dev(dev.ts:260), so it inherits all of the above.So on the
os serve/os devpath: advertised ⇒ mounted. No defect.Where it breaks
The claim of symmetry with
@objectstack/restdoes not hold. Inpackages/rest:rest-server.ts:2067advertises it on the flag alone:packages/rest/package.jsonhas no@objectstack/mcpdependency.packages/rest/src/rest-server.tshas no/mcpmount, noresolveService('mcp'), and no auto-load — that logic exists only in the CLI'sservecommand./mcpbranch answers 501 when the service is absent (packages/runtime/src/domains/mcp.ts:56):So an app embedding
@objectstack/restdirectly — not booting throughos serve— getsmcpadvertised in/discoverywhilePOST /mcpreturns 501. That is precisely thedeclared ≠ enforcedfailure the route-parity gate exists to forbid, on the exact producer the comment names as symmetric.This matters for a third-party-facing platform:
/discoveryis the contract a client reads to decide what exists. An advertised endpoint that 501s makes discovery unreliable for the consumers most dependent on it (the objectui Integrations page reads this field, per the same comment).Why CI cannot catch it
packages/runtime/src/route-parity.integration.test.tsstates the right invariant —— and has a test named “MCP is advertised AND reachable — the discovery/route lockstep holds (not 501)”. But it runs with the service stubbed in unconditionally:
The
mcpseam exists and is never used:bootServe()is called only at line 135 (all services) and line 218 ({ notification: false }).mcp: falseappears nowhere in the repo. Contrast thenotificationcase, which is covered on both sides — absent ⇒ not advertised and 404 — in the “discovery is service-aware (#3369)” block.So the one capability whose advertisement is not service-presence-gated is also the one whose absence is never tested.
Suggested fix
Two options; (1) is my recommendation.
@objectstack/restadvertisement service-aware, like every other capability in the table — advertisemcponly whenisMcpServerEnabled()and the mcp service resolves. This restoresdeclared === enforcedby construction on both producers instead of by convention on one, and needs no dependency change. (The CLI path is unaffected: it loads the plugin, so the service resolves and it still advertises.)@objectstack/restown the auto-load the wayserve.tsdoes — but that pushes an@objectstack/mcpdependency into@objectstack/restfor every embedder, including those that deliberately don't want an MCP surface.Either way: exercise the seam that already exists — add
bootServe({ mcp: false })coverage assertingmcpis not advertised and the route does not 501-while-advertised, mirroring the existingnotification: falseblock. That is the assertion that would have made this visible.Not reproduced live — deliberately stated
This is a static read of the three code paths, not a live repro. This checkout has no
node_modules, and a real repro needs a small app that embeds@objectstack/restwithout@objectstack/mcpinstalled, then diffs/discovery'sroutes.mcpagainstPOST /mcp's status. Worth doing before the fix lands so the regression test is written against observed behaviour. Flagging it as analysis rather than asserting a reproduction.Related: the same "fully-implemented read path with no producer" shape as #3913's object-less action key, which is what prompted looking here at all.