Skip to content

@objectstack/rest advertises mcp in /discovery with no way to serve it — declared ≠ enforced, and the route-parity gate's mcp: false seam is never exercised #4024

Description

@os-zhuang

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.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions