Skip to content

Commit 99b4392

Browse files
os-zhuangclaude
andauthored
fix(runtime,rest): advertise mcp only when it is actually serveable (#4032)
* fix(runtime,rest): advertise `mcp` only when it is actually serveable Fixes #4024. Both discovery producers gated the `/mcp` route on `isMcpServerEnabled()` alone. The dispatcher documented why that was safe: `os serve` auto-loads plugin-mcp from the same flag, so on that path advertised did imply mounted. That reasoning is sound but it describes the CLI, not the dispatcher — and the same comment claimed `@objectstack/rest` stayed "symmetric", which it did not. `@objectstack/rest` has no `@objectstack/mcp` dependency, mounts no `/mcp` route and performs no auto-load, so a host embedding it without plugin-mcp advertised `/mcp` in `/discovery` and then answered 501 on it. Every OTHER optional route in that discovery table is service-presence gated (hasUi, hasFiles, hasAnalytics, hasI18n, …). `mcp` was the lone exception, and the exception is what broke. That matters beyond tidiness: `/discovery` is the contract a third-party client reads to decide what exists, and the objectui Integrations page renders its MCP connect card from this field. Advertising an endpoint that 501s makes discovery unreliable for exactly the consumers that depend on it. Now: flag AND serveable, on both producers. - Dispatcher gates on the handler's OWN predicate — `typeof mcpSvc?.handleHttpRequest === 'function'`, byte-identical to what handleMcpRequest 501s on. Gating on mere service presence would still over-promise when a wrong-shaped service is registered. - `@objectstack/rest` probes via the per-request kernel (shape) or the single-env `serviceExistsProvider` (existence) — the same two paths `resolveRegisteredServices` already uses. `rest-api-plugin` always wires the latter, so the gate is live in real deployments rather than decorative. When it genuinely cannot probe it keeps the prior flag-only answer: "unknown" is not "absent", and hiding a working endpoint would be the opposite over-correction (fail-open, ADR-0057 D10). `os serve` / `os dev` are unaffected — they load the plugin, so the service resolves and `/mcp` is still advertised. ## Reproduced, then fixed #4024 was filed as static analysis with the repro explicitly listed as not done. It reproduces. With the flag on and the service absent, the old gate failed the new pairing assertion with the defect stated verbatim: AssertionError: discovery advertised mcp (true) while POST /mcp returned 501 — declared ≠ enforced ## The seam that should have caught it `route-parity.integration.test.ts` has carried an `mcp` opt-out in `stubServicesPlugin` since it was written, and nothing ever passed it — `bootServe()` was called with no args and with `{ notification: false }`, never `{ mcp: false }`. Its MCP test ("MCP is advertised AND reachable") stubs the service in unconditionally, proving the lockstep only under the one condition where it cannot fail. So the single capability whose advertisement was not service-presence gated was also the single one whose absence was never tested. The seam is now used. Also added rest-side coverage for absent / present / unprobeable, since the three existing `routes.mcp` tests construct RestServer with three args and therefore all take the unprobeable path — without the new cases the rest half would have shipped with no coverage at all. Verified each new assertion fails against the old gate and passes against the new one. runtime + rest: 906 passed (65 files); rest alone 494 (34 files); repo eslint clean; 0 type errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F * docs(ai): record the enabled-but-unserveable MCP symptom The connect-MCP troubleshooting table had a `501 Not Implemented` row ("the MCP plugin isn't part of this build"). That row is still accurate — the route stays mounted and still 501s when called directly; only the advertisement changed. But it is now incomplete: after the #4024 fix the first signal an integrator sees is usually the *absence* of `mcp` from `/discovery` and a missing Connect-an-Agent card, not a 501, because discovery no longer points at a route it cannot serve. Added the other-side-of-the-same-cause row, including the distinction that matters for embedders: `os serve` / `os dev` load the plugin for you, while a host embedding `@objectstack/rest` directly must add `@objectstack/mcp` itself. Note: the docs-drift advisory on #4032 listed 22 files derived from the dependency graph and did NOT include this one — the doc that actually needed the change. Recording that here rather than filing it; the advisory is explicitly best-effort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f98c2d commit 99b4392

6 files changed

Lines changed: 229 additions & 22 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/runtime": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
Advertise `mcp` in `/discovery` only when it is actually serveable (#4024).
7+
8+
Both discovery producers gated the `/mcp` route on `isMcpServerEnabled()` alone.
9+
The stated justification was a lockstep — `os serve` auto-loads plugin-mcp from
10+
the same flag, so on that path advertised did imply mounted. But the lockstep is
11+
a property of the CLI, not of the dispatcher: `@objectstack/rest` has no
12+
`@objectstack/mcp` dependency, mounts no `/mcp` route and performs no auto-load,
13+
so a host that embedded it without plugin-mcp advertised `/mcp` in `/discovery`
14+
and then answered 501 on it — the `declared ≠ enforced` failure #3369 forbids,
15+
and a broken contract for third-party clients that read `/discovery` to decide
16+
what exists.
17+
18+
Both producers now require the flag AND a serveable MCP service. The runtime
19+
dispatcher gates on the handler's own predicate (`typeof
20+
mcp.handleHttpRequest === 'function'`), so a wrong-shaped service can't
21+
over-promise either. `@objectstack/rest` probes via the per-request kernel or the
22+
single-env `serviceExistsProvider`; when it genuinely cannot probe it keeps the
23+
prior flag-only answer rather than hiding a working endpoint (fail-open,
24+
ADR-0057 D10). The `os serve` / `os dev` path is unchanged — it loads the plugin,
25+
so the service resolves and `/mcp` is still advertised.
26+
27+
Also exercises the `mcp: false` seam in `route-parity.integration.test.ts`, which
28+
had existed unused since the file was written: `bootServe()` was only ever called
29+
with no args or `{ notification: false }`. The one capability whose advertisement
30+
was not service-presence gated was also the one whose absence was never tested.

content/docs/ai/connect-mcp.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ skill and a guided `/objectstack:connect` command.
191191
|:---|:---|
192192
| `404` on `/api/v1/mcp` | The HTTP surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) |
193193
| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins |
194+
| `mcp` missing from `GET /api/v1/discovery` and no Connect-an-Agent card, but `OS_MCP_SERVER_ENABLED` is on | The same cause as the `501` above, seen from the other side: the surface is *enabled* but not *serveable*, so discovery declines to advertise a route that would 501 rather than over-promising it (`declared === enforced`). Load the MCP plugin — `os serve` / `os dev` do it for you; a host that embeds `@objectstack/rest` directly must add `@objectstack/mcp` itself |
194195
| stdio won't start / boot fails closed | `OS_MCP_STDIO_ENABLED=true` but `OS_MCP_STDIO_API_KEY` is missing, unknown, revoked, or expired — fail-closed by design (ADR-0101). Set a valid `osk_` key; there is no unscoped or `system` fallback |
195196
| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling |
196197
| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope |

packages/rest/src/rest-server.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,6 +2127,41 @@ export class RestServer {
21272127
}
21282128
}
21292129

2130+
/**
2131+
* Is `/mcp` actually serveable — i.e. is the MCP service registered with
2132+
* the shape `handleMcpRequest` needs?
2133+
*
2134+
* `true`/`false` are answers; `null` means "could not probe". The route
2135+
* itself is served by the runtime dispatcher (`domains/mcp.ts`), which
2136+
* 501s on `!mcp || typeof mcp.handleHttpRequest !== 'function'` — so this
2137+
* probe exists to keep our `/discovery` from advertising a route that
2138+
* would 501 (#4024).
2139+
*
2140+
* Same two probe paths as {@link resolveRegisteredServices} (ADR-0057
2141+
* D10): the per-request kernel for multi-env hosts, else the single-env
2142+
* `serviceExistsProvider` — which `rest-api-plugin` always wires. Via the
2143+
* kernel we can check the SHAPE; the single-env provider answers existence
2144+
* only, which is the dominant case (the dispatcher's own service-aware
2145+
* discovery covers the wrong-shape case).
2146+
*/
2147+
private async probeMcpServeable(req: any): Promise<boolean | null> {
2148+
try {
2149+
let environmentId: string | undefined = req?.params?.environmentId;
2150+
if ((!environmentId || environmentId === ':environmentId') && this.defaultEnvironmentIdProvider) {
2151+
try { environmentId = this.defaultEnvironmentIdProvider() || undefined; } catch { /* ignore */ }
2152+
}
2153+
if (environmentId && environmentId !== 'platform' && this.kernelManager) {
2154+
const kernel: any = await this.kernelManager.getOrCreate(environmentId);
2155+
if (kernel && typeof kernel.getServiceAsync === 'function') {
2156+
const svc: any = await kernel.getServiceAsync('mcp').catch(() => undefined);
2157+
return typeof svc?.handleHttpRequest === 'function';
2158+
}
2159+
}
2160+
if (this.serviceExistsProvider) return this.serviceExistsProvider('mcp') === true;
2161+
} catch { /* fall through to "cannot probe" */ }
2162+
return null;
2163+
}
2164+
21302165
/**
21312166
* Register discovery endpoints
21322167
*/
@@ -2166,8 +2201,22 @@ export class RestServer {
21662201
// project-scoped), so point at the unscoped base. This
21672202
// `/discovery` (served by @objectstack/rest) is separate
21682203
// from the dispatcher's getDiscoveryInfo — both must
2169-
// advertise `mcp` (single source: isMcpServerEnabled).
2170-
if (isMcpServerEnabled()) {
2204+
// advertise `mcp` on the same terms.
2205+
//
2206+
// Enabled is NOT the same as serveable (#4024). The flag
2207+
// alone used to gate this, on the reasoning that `os serve`
2208+
// auto-loads plugin-mcp from the same flag. But that
2209+
// lockstep belongs to the CLI: `@objectstack/rest` has no
2210+
// `@objectstack/mcp` dependency, mounts no /mcp route and
2211+
// performs no auto-load, so an embedder that skips
2212+
// plugin-mcp had `mcp` advertised here while the route
2213+
// 501'd — the `declared ≠ enforced` failure #3369 forbids.
2214+
// A `null` probe means we genuinely cannot tell; keep the
2215+
// old flag-only answer there rather than hiding a working
2216+
// endpoint (fail-open, ADR-0057 D10) — the dispatcher's own
2217+
// discovery is service-aware and stays authoritative.
2218+
const mcpServeable = await this.probeMcpServeable(req);
2219+
if (isMcpServerEnabled() && mcpServeable !== false) {
21712220
const unscopedBase = isScoped
21722221
? basePath.replace(/\/(environments|projects)\/:environmentId$/, '')
21732222
: basePath;

packages/rest/src/rest.test.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2423,12 +2423,33 @@ describe('mapDataError — schema/constraint envelopes', () => {
24232423
// ---------------------------------------------------------------------------
24242424

24252425
describe('discovery — routes.mcp (ADR-0036, #152)', () => {
2426-
function discoveryHandler() {
2426+
/**
2427+
* `serviceExists` omitted → the server cannot probe whether MCP is actually
2428+
* serveable, so it keeps the flag-only answer (fail-open, ADR-0057 D10).
2429+
* Pass one to exercise the service-aware gate added for #4024.
2430+
*/
2431+
function discoveryHandler(serviceExists?: (name: string) => boolean) {
24272432
const server = createMockServer();
24282433
const protocol = createMockProtocol();
24292434
// protocol discovery carries a `routes` object the server augments.
24302435
(protocol.getDiscovery as any) = vi.fn().mockResolvedValue({ routes: { data: '', metadata: '' } });
2431-
const rest = new RestServer(server as any, protocol as any, ANON_API as any);
2436+
const rest = new RestServer(
2437+
server as any, protocol as any, ANON_API as any,
2438+
undefined, // kernelManager
2439+
undefined, // envRegistry
2440+
undefined, // defaultEnvironmentIdProvider
2441+
undefined, // authServiceProvider
2442+
undefined, // objectQLProvider
2443+
undefined, // emailServiceProvider
2444+
undefined, // sharingServiceProvider
2445+
undefined, // reportsServiceProvider
2446+
undefined, // approvalsServiceProvider
2447+
undefined, // sharingRulesServiceProvider
2448+
undefined, // i18nServiceProvider
2449+
undefined, // analyticsServiceProvider
2450+
undefined, // settingsServiceProvider
2451+
serviceExists,
2452+
);
24322453
rest.registerRoutes();
24332454
const entry = rest.getRouteManager().get('GET', '/api/v1/discovery');
24342455
if (!entry) throw new Error('discovery route not registered');
@@ -2465,6 +2486,37 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => {
24652486
const body = await invoke(discoveryHandler());
24662487
expect(body.routes.mcp).toBeUndefined();
24672488
});
2489+
2490+
// ── #4024: enabled ≠ serveable ──────────────────────────────────────────
2491+
// The flag alone used to decide this. But @objectstack/rest has no
2492+
// @objectstack/mcp dependency, mounts no /mcp route and performs no
2493+
// auto-load — that lockstep belongs to `os serve`. So an embedder without
2494+
// plugin-mcp advertised /mcp here and got 501 from the runtime dispatcher,
2495+
// which is the `declared ≠ enforced` failure #3369 forbids.
2496+
2497+
it('omits routes.mcp when enabled but the mcp service is absent (#4024)', async () => {
2498+
delete process.env.OS_MCP_SERVER_ENABLED; // default-on
2499+
const body = await invoke(discoveryHandler((name) => name !== 'mcp'));
2500+
expect(
2501+
body.routes.mcp,
2502+
'enabled-but-unserveable must not be advertised — it would 501',
2503+
).toBeUndefined();
2504+
});
2505+
2506+
it('advertises routes.mcp when enabled AND the mcp service is present (#4024)', async () => {
2507+
delete process.env.OS_MCP_SERVER_ENABLED;
2508+
const body = await invoke(discoveryHandler(() => true));
2509+
expect(body.routes.mcp).toBe('/api/v1/mcp');
2510+
});
2511+
2512+
it('keeps the flag-only answer when the host cannot be probed (fail-open, ADR-0057 D10)', async () => {
2513+
// No kernelManager and no serviceExistsProvider → "unknown", not "absent".
2514+
// Hiding a working endpoint here would be the opposite over-correction, and
2515+
// the dispatcher's own service-aware discovery stays authoritative.
2516+
delete process.env.OS_MCP_SERVER_ENABLED;
2517+
const body = await invoke(discoveryHandler());
2518+
expect(body.routes.mcp).toBe('/api/v1/mcp');
2519+
});
24682520
});
24692521

24702522
// ──────────────────────────────────────────────────────────────────────────

packages/runtime/src/http-dispatcher.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,7 @@ export class HttpDispatcher {
856856
const [
857857
authSvc, searchSvc, realtimeSvc, filesSvc,
858858
analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc,
859-
uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc,
859+
uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc, mcpSvc,
860860
] = await Promise.all([
861861
this.resolveService(CoreServiceName.enum.auth),
862862
this.resolveService(CoreServiceName.enum.search),
@@ -872,6 +872,9 @@ export class HttpDispatcher {
872872
this.resolveService(CoreServiceName.enum.cache),
873873
this.resolveService(CoreServiceName.enum.queue),
874874
this.resolveService(CoreServiceName.enum.job),
875+
// Not a CoreServiceName — plugin-mcp registers under the bare
876+
// 'mcp' key, the same string handleMcpRequest resolves.
877+
this.resolveService('mcp'),
875878
]);
876879

877880
const hasAuth = !!authSvc;
@@ -887,6 +890,11 @@ export class HttpDispatcher {
887890
const hasCache = !!cacheSvc;
888891
const hasQueue = !!queueSvc;
889892
const hasJob = !!jobSvc;
893+
// Mirrors handleMcpRequest's OWN guard byte for byte (domains/mcp.ts):
894+
// it 501s on `!mcp || typeof mcp.handleHttpRequest !== 'function'`, so
895+
// advertising on mere service presence would still over-promise when a
896+
// wrong-shaped service is registered. Same predicate ⇒ same answer.
897+
const hasMcp = typeof mcpSvc?.handleHttpRequest === 'function';
890898

891899
// Routes are only exposed when a plugin provides the service
892900
const routes = {
@@ -908,21 +916,22 @@ export class HttpDispatcher {
908916
notifications: hasNotification ? `${prefix}/notifications` : undefined,
909917
ai: hasAi ? `${prefix}/ai` : undefined,
910918
i18n: hasI18n ? `${prefix}/i18n` : undefined,
911-
// MCP (Streamable HTTP) is a default-on core capability —
912-
// advertised unless OS_MCP_SERVER_ENABLED=false opts the env
913-
// out. The objectui Integrations page reads this.
919+
// MCP (Streamable HTTP) is a default-on core capability — but
920+
// "enabled" and "serveable" are two different facts and both
921+
// must hold. The objectui Integrations page reads this.
914922
//
915-
// `declared === enforced` here is guaranteed by a LOCKSTEP, not
916-
// by service-presence gating like the routes above (#3369 /
917-
// #2698): `os serve` auto-loads plugin-mcp from the SAME
918-
// `isMcpServerEnabled()` flag that gates this advertisement, so
919-
// whenever `/mcp` is advertised the handler is mounted (a key /
920-
// token yields 401, never a 404/501). Kept flag-based on purpose
921-
// — `@objectstack/rest` advertises `mcp` from the identical
922-
// single source (rest-server.ts), so the two discovery producers
923-
// stay symmetric. The route-parity gate asserts the lockstep
924-
// holds (advertised ⇒ reachable, never 501).
925-
mcp: isMcpServerEnabled() ? `${prefix}/mcp` : undefined,
923+
// Service-presence gated like every other optional route above
924+
// (#3369 / #2698), NOT flag-only (#4024). This used to trust a
925+
// LOCKSTEP instead: `os serve` auto-loads plugin-mcp from the
926+
// same `isMcpServerEnabled()` flag, so on that path advertised
927+
// did imply mounted. But the lockstep is a property of the CLI,
928+
// not of the dispatcher — an embedder that mounts the dispatcher
929+
// (or `@objectstack/rest`) without plugin-mcp got `/mcp`
930+
// advertised here and 501 from handleMcpRequest, which is
931+
// exactly the `declared ≠ enforced` failure #3369 forbids.
932+
// Gating on the handler's own predicate makes the invariant hold
933+
// by construction on every host instead of by convention on one.
934+
mcp: isMcpServerEnabled() && hasMcp ? `${prefix}/mcp` : undefined,
926935
};
927936

928937
// Build per-service status map

0 commit comments

Comments
 (0)