@@ -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// ---------------------------------------------------------------------------
0 commit comments