Skip to content

Commit 9e2e682

Browse files
fix(rest): route probeMcpServeable through the shared environment entry point (#9120) (#9291)
`resolveRequestEnvironmentId` is, by its own doc-comment, "THE single entry point for every unscoped-route environment decision ... so they can never disagree about which kernel a request belongs to". Eight consumers go through it; `probeMcpServeable` -- the ninth site needing the request's environment, and the one deciding whether /discovery advertises `routes.mcp` -- re-derived its own from `params.environmentId` else `defaultEnvironmentIdProvider`. That is the shared chain minus the host's ADR-0006 `kernel-resolver` seam and the legacy hostname / X-Environment-Id steps. Single-environment boots agreed anyway (the default provider is step 3 of the shared chain), so the defect was multi-tenant-only: on a hostname-routed host an unscoped /discovery request has no `params.environmentId` and no default provider is registered, so the probe fell through to `serviceExistsProvider` -- the HOST kernel's answer, not the request environment's. Both misadvertisement directions were reachable and are now pinned. The `'platform'` guard and the `serviceExistsProvider` fallback are unchanged. The unsubstituted `':environmentId'` route pattern is normalised to "no id" before the call, since the entry point short-circuits on any truthy explicit value. Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza Co-authored-by: Claude <noreply@anthropic.com>
1 parent 00777a0 commit 9e2e682

3 files changed

Lines changed: 278 additions & 5 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `/discovery`'s `mcp` advertisement follows the request's environment — `probeMcpServeable` routes through the shared resolution entry point (#9120)
6+
7+
`RestServer.resolveRequestEnvironmentId` calls itself, in its own doc-comment,
8+
"THE single entry point for every unscoped-route environment decision (protocol,
9+
i18n, exec-ctx, analytics, …) so they can never disagree about which kernel a
10+
request belongs to." Eight consumers go through it. `probeMcpServeable` — the
11+
ninth site that needs the request's environment, and the one whose answer decides
12+
whether `/discovery` advertises `routes.mcp` — re-derived its own:
13+
14+
```ts
15+
let environmentId: string | undefined = req?.params?.environmentId;
16+
if ((!environmentId || environmentId === ':environmentId') && this.defaultEnvironmentIdProvider) {
17+
try { environmentId = this.defaultEnvironmentIdProvider() || undefined; } catch { /* ignore */ }
18+
}
19+
```
20+
21+
That is the shared chain minus its first and middle steps: the host's ADR-0006
22+
`kernel-resolver` seam (wired through `RestRequestEnvResolver`), and the legacy
23+
hostname / `X-Environment-Id` chain beneath it.
24+
25+
**Single-environment boots were correct throughout** — there
26+
`defaultEnvironmentIdProvider` is registered, and it is also step 3 of the shared
27+
chain, so both spellings agreed. The defect is multi-tenant-only: on a
28+
hostname-routed host an unscoped `/discovery` request carries no
29+
`params.environmentId`, and no default provider is registered (that is
30+
`createSingleEnvironmentPlugin`'s wiring). Neither input the probe read was
31+
present, so it fell through to `serviceExistsProvider` — which answers for the
32+
**host** kernel, not the request's environment. Both misadvertisement directions
33+
were reachable, and are now pinned as regression tests:
34+
35+
- the host kernel has `mcp` and the request's environment does not ⇒ `/discovery`
36+
advertised `routes.mcp` for an environment whose `/mcp` answers 501 — the
37+
`declared ≠ enforced` shape the probe was added to close;
38+
- the host kernel lacks it and the environment has it ⇒ the route was withheld
39+
from an environment that would have served it (`mcpServeable !== false` fails
40+
open only for a `null` probe, never for a confident `false` computed against
41+
the wrong kernel).
42+
43+
The probe now calls `resolveRequestEnvironmentId` like its eight siblings. The
44+
`'platform'` guard and the `serviceExistsProvider` fallback are unchanged, and
45+
the unsubstituted `':environmentId'` route pattern is normalised to "no id"
46+
before the call — the entry point short-circuits on any truthy explicit value,
47+
so passing the pattern through would have sent it to `getOrCreate`. This also
48+
makes good the parity the probe's doc-comment already claimed with
49+
`resolveRegisteredServices`, whose kernel arrives as `ctx.__kernel` — set
50+
downstream of the same entry point.

packages/rest/src/rest-env-resolution.test.ts

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ type RestServerArgs = {
5858
defaultEnvironmentIdProvider?: () => string | undefined;
5959
requestEnvResolver?: RestRequestEnvResolver;
6060
kernelManager?: { getOrCreate: (id: string) => Promise<any> };
61+
/** Single-env service-existence probe — answers for the HOST kernel. */
62+
serviceExistsProvider?: (name: string) => boolean;
6163
};
6264

6365
/** Build a RestServer with only the seams under test wired. */
@@ -88,7 +90,7 @@ function buildRest(args: RestServerArgs = {}) {
8890
undefined, // i18nServiceProvider
8991
undefined, // analyticsServiceProvider
9092
undefined, // settingsServiceProvider
91-
undefined, // serviceExistsProvider
93+
args.serviceExistsProvider,
9294
undefined, // securityServiceProvider
9395
args.requestEnvResolver,
9496
);
@@ -203,6 +205,204 @@ describe('resolveRequestEnvironmentId (D11④ seam)', () => {
203205
});
204206
});
205207

208+
// ---------------------------------------------------------------------------
209+
// probeMcpServeable — the NINTH consumer of the shared entry point (#9120)
210+
//
211+
// `/discovery`'s `mcp` advertisement is computed from this probe. It used to
212+
// re-derive the environment itself (`req.params.environmentId`, else
213+
// `defaultEnvironmentIdProvider`), so it saw neither the host's ADR-0006
214+
// `kernel-resolver` seam nor the legacy hostname / `X-Environment-Id` chain.
215+
// On a hostname-routed multi-tenant host neither of its two inputs is present
216+
// — the route is unscoped and the default provider is `createSingleEnvironment
217+
// Plugin`'s wiring — so it fell through to `serviceExistsProvider`, which
218+
// answers for the HOST kernel. Both misadvertisement directions were reachable
219+
// from there; single-environment boots were correct throughout, which is why
220+
// every pin below is written on the multi-tenant shape.
221+
// ---------------------------------------------------------------------------
222+
223+
/** A kernel whose `mcp` slot holds a service of the shape `/mcp` needs. */
224+
function kernelWithMcp() {
225+
return { getServiceAsync: vi.fn().mockResolvedValue({ handleHttpRequest: () => undefined }) };
226+
}
227+
/** A kernel with no `mcp` service at all — `/mcp` would 501 here. */
228+
function kernelWithoutMcp() {
229+
return { getServiceAsync: vi.fn().mockResolvedValue(undefined) };
230+
}
231+
232+
describe('probeMcpServeable (D11④ seam, ninth consumer)', () => {
233+
/**
234+
* A hostname-routed multi-tenant host: the injected resolver answers for the
235+
* request, and NO `defaultEnvironmentIdProvider` is registered — that is
236+
* single-environment wiring, and its absence is what left the old derivation
237+
* with nothing but the host-wide `serviceExistsProvider`.
238+
*/
239+
function multiTenantHost(envKernel: any, hostHasMcp: boolean) {
240+
const getOrCreate = vi.fn().mockResolvedValue(envKernel);
241+
const { rest } = buildRest({
242+
requestEnvResolver: { resolveRequestEnvironmentId: vi.fn().mockResolvedValue('tenant-b') },
243+
kernelManager: { getOrCreate },
244+
serviceExistsProvider: () => hostHasMcp,
245+
});
246+
return { getOrCreate, probe: (req: any) => (rest as any).probeMcpServeable(req) };
247+
}
248+
249+
it('answers for the REQUEST environment when the host kernel serves mcp and that environment does not', async () => {
250+
const { probe, getOrCreate } = multiTenantHost(kernelWithoutMcp(), true);
251+
252+
// The over-advertisement direction #4024 was filed to close: the host says
253+
// yes, the request's own kernel would 501. `false` withholds `routes.mcp`.
254+
await expect(probe(mockReq())).resolves.toBe(false);
255+
expect(getOrCreate).toHaveBeenCalledWith('tenant-b');
256+
});
257+
258+
it('answers for the REQUEST environment when it serves mcp and the host kernel does not', async () => {
259+
const { probe, getOrCreate } = multiTenantHost(kernelWithMcp(), false);
260+
261+
// The other direction: a confident `false` computed against the wrong
262+
// kernel withholds a route that would have served (`mcpServeable !== false`
263+
// fails open only for `null`).
264+
await expect(probe(mockReq())).resolves.toBe(true);
265+
expect(getOrCreate).toHaveBeenCalledWith('tenant-b');
266+
});
267+
268+
it('reaches the request environment through the legacy hostname chain when no resolver is injected', async () => {
269+
const getOrCreate = vi.fn().mockResolvedValue(kernelWithMcp());
270+
const { rest } = buildRest({
271+
envRegistry: legacyRegistry(),
272+
kernelManager: { getOrCreate },
273+
serviceExistsProvider: () => false,
274+
});
275+
276+
await expect((rest as any).probeMcpServeable(mockReq())).resolves.toBe(true);
277+
expect(getOrCreate).toHaveBeenCalledWith('legacy-env');
278+
});
279+
280+
it('follows X-Environment-Id, which the hand-rolled derivation never read', async () => {
281+
const getOrCreate = vi.fn().mockResolvedValue(kernelWithMcp());
282+
const { rest } = buildRest({
283+
envRegistry: {
284+
resolveByHostname: vi.fn().mockResolvedValue(null),
285+
resolveById: vi.fn().mockImplementation(async (id: string) => (id === 'header-env' ? {} : null)),
286+
},
287+
kernelManager: { getOrCreate },
288+
serviceExistsProvider: () => false,
289+
});
290+
291+
await expect(
292+
(rest as any).probeMcpServeable(mockReq({ 'x-environment-id': 'header-env' })),
293+
).resolves.toBe(true);
294+
expect(getOrCreate).toHaveBeenCalledWith('header-env');
295+
});
296+
297+
it('keeps the single-environment answer unchanged (default provider → that kernel)', async () => {
298+
const getOrCreate = vi.fn().mockResolvedValue(kernelWithoutMcp());
299+
const { rest } = buildRest({
300+
defaultEnvironmentIdProvider: () => 'default-env',
301+
kernelManager: { getOrCreate },
302+
serviceExistsProvider: () => true,
303+
});
304+
305+
// Correct before this change and correct after: the shared entry point's
306+
// step 3 IS the default provider, so single-env boots keep their answer.
307+
await expect((rest as any).probeMcpServeable(mockReq())).resolves.toBe(false);
308+
expect(getOrCreate).toHaveBeenCalledWith('default-env');
309+
});
310+
311+
it("keeps the 'platform' guard — the reserved id is never handed to getOrCreate", async () => {
312+
const getOrCreate = vi.fn().mockResolvedValue(kernelWithMcp());
313+
const { rest } = buildRest({
314+
kernelManager: { getOrCreate },
315+
serviceExistsProvider: (name: string) => name === 'mcp',
316+
});
317+
318+
// `platform` is a virtual id, not a row in the environments table.
319+
await expect(
320+
(rest as any).probeMcpServeable({ ...mockReq(), params: { environmentId: 'platform' } }),
321+
).resolves.toBe(true);
322+
expect(getOrCreate).not.toHaveBeenCalled();
323+
});
324+
325+
it('never mistakes the literal ":environmentId" placeholder for an environment', async () => {
326+
const getOrCreate = vi.fn().mockResolvedValue(kernelWithMcp());
327+
const { rest } = buildRest({
328+
requestEnvResolver: { resolveRequestEnvironmentId: vi.fn().mockResolvedValue('tenant-b') },
329+
kernelManager: { getOrCreate },
330+
serviceExistsProvider: () => false,
331+
});
332+
333+
// An unsubstituted route pattern is the absence of an id, not an id — the
334+
// shared entry point short-circuits on any truthy explicit value, so the
335+
// placeholder must be normalised away before it is passed in.
336+
await expect(
337+
(rest as any).probeMcpServeable({ ...mockReq(), params: { environmentId: ':environmentId' } }),
338+
).resolves.toBe(true);
339+
expect(getOrCreate).toHaveBeenCalledWith('tenant-b');
340+
expect(getOrCreate).not.toHaveBeenCalledWith(':environmentId');
341+
});
342+
343+
it('keeps the serviceExistsProvider fallback when no environment resolves at all', async () => {
344+
const getOrCreate = vi.fn().mockResolvedValue(kernelWithMcp());
345+
const { rest } = buildRest({
346+
kernelManager: { getOrCreate },
347+
serviceExistsProvider: (name: string) => name === 'mcp',
348+
});
349+
350+
await expect((rest as any).probeMcpServeable(mockReq())).resolves.toBe(true);
351+
expect(getOrCreate).not.toHaveBeenCalled();
352+
});
353+
354+
it('still reports null ("cannot probe") when nothing in either path can answer', async () => {
355+
const { rest } = buildRest({ kernelManager: { getOrCreate: vi.fn() } });
356+
await expect((rest as any).probeMcpServeable(mockReq())).resolves.toBeNull();
357+
});
358+
});
359+
360+
// ---------------------------------------------------------------------------
361+
// /discovery end to end — the advertisement the probe feeds, driven through
362+
// the real handler so the `req` it receives is the one the route was given.
363+
// ---------------------------------------------------------------------------
364+
365+
describe('/discovery mcp advertisement (#9120)', () => {
366+
function driveDiscovery(args: RestServerArgs) {
367+
const { rest, server } = buildRest(args);
368+
rest.registerRoutes();
369+
const route = server.get.mock.calls.find((c: any[]) => c[0] === '/api/v1/discovery');
370+
expect(route, 'GET /api/v1/discovery must be registered').toBeDefined();
371+
const res = {
372+
json: vi.fn(),
373+
status: vi.fn().mockReturnThis(),
374+
send: vi.fn(),
375+
setHeader: vi.fn(),
376+
headersSent: false,
377+
};
378+
return async (req: any) => {
379+
await route![1](req, res);
380+
expect(res.json).toHaveBeenCalledTimes(1);
381+
return res.json.mock.calls[0][0];
382+
};
383+
}
384+
385+
it('withholds routes.mcp when the request environment cannot serve it, though the host kernel can', async () => {
386+
const discovery = await driveDiscovery({
387+
requestEnvResolver: { resolveRequestEnvironmentId: vi.fn().mockResolvedValue('tenant-b') },
388+
kernelManager: { getOrCreate: vi.fn().mockResolvedValue(kernelWithoutMcp()) },
389+
serviceExistsProvider: () => true,
390+
})(mockReq());
391+
392+
expect(discovery.routes.mcp).toBeUndefined();
393+
});
394+
395+
it('advertises routes.mcp when the request environment serves it, though the host kernel does not', async () => {
396+
const discovery = await driveDiscovery({
397+
requestEnvResolver: { resolveRequestEnvironmentId: vi.fn().mockResolvedValue('tenant-b') },
398+
kernelManager: { getOrCreate: vi.fn().mockResolvedValue(kernelWithMcp()) },
399+
serviceExistsProvider: () => false,
400+
})(mockReq());
401+
402+
expect(discovery.routes.mcp).toBe('/api/v1/mcp');
403+
});
404+
});
405+
206406
// ---------------------------------------------------------------------------
207407
// RestApiPlugin adapter — binds the host's `kernel-resolver` service
208408
// ---------------------------------------------------------------------------

packages/rest/src/rest-server.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2862,13 +2862,36 @@ export class RestServer {
28622862
* kernel we can check the SHAPE; the single-env provider answers existence
28632863
* only, which is the dominant case (the dispatcher's own service-aware
28642864
* discovery covers the wrong-shape case).
2865+
*
2866+
* [#9120] The first of those two paths goes through
2867+
* {@link resolveRequestEnvironmentId} — THE shared entry point, like every
2868+
* other consumer that needs the request's environment. It used to re-derive
2869+
* one here (`params.environmentId`, else `defaultEnvironmentIdProvider`),
2870+
* which is the same chain minus the host's ADR-0006 `kernel-resolver` seam
2871+
* and the legacy hostname / `X-Environment-Id` steps. On a hostname-routed
2872+
* multi-tenant host neither of the two inputs it read is present — the
2873+
* `/discovery` route is unscoped, and the default provider is
2874+
* `createSingleEnvironmentPlugin`'s wiring — so the probe fell through to
2875+
* `serviceExistsProvider` and answered for the HOST kernel: `routes.mcp`
2876+
* advertised for an environment whose route 501s, or withheld from one that
2877+
* would have served it. `resolveRegisteredServices` was never exposed to
2878+
* this because its kernel arrives as `ctx.__kernel`, set downstream of the
2879+
* shared entry point — so routing through it is what makes the parity this
2880+
* doc-comment claims actually hold. Single-environment boots are unaffected:
2881+
* the default provider is step 3 of the shared chain.
28652882
*/
28662883
private async probeMcpServeable(req: any): Promise<boolean | null> {
28672884
try {
2868-
let environmentId: string | undefined = req?.params?.environmentId;
2869-
if ((!environmentId || environmentId === ':environmentId') && this.defaultEnvironmentIdProvider) {
2870-
try { environmentId = this.defaultEnvironmentIdProvider() || undefined; } catch { /* ignore */ }
2871-
}
2885+
// An unsubstituted route pattern is the ABSENCE of an id, not an id.
2886+
// The shared entry point short-circuits on any truthy explicit
2887+
// value, so the placeholder must be normalised away before it — or
2888+
// `getOrCreate(':environmentId')` would go looking for a kernel
2889+
// named after the pattern.
2890+
const routeParam: string | undefined = req?.params?.environmentId;
2891+
const environmentId = await this.resolveRequestEnvironmentId(
2892+
routeParam === ':environmentId' ? undefined : routeParam,
2893+
req,
2894+
);
28722895
if (environmentId && environmentId !== 'platform' && this.kernelManager) {
28732896
const kernel: any = await this.kernelManager.getOrCreate(environmentId);
28742897
if (kernel && typeof kernel.getServiceAsync === 'function') {

0 commit comments

Comments
 (0)