From afcf1b5860eeac43dacb5b2fe88d8c589c55361d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:49:54 +0800 Subject: [PATCH] fix(rest): advertise routes.mcp in /discovery when MCP enabled (cloud#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui's Integrations page reads discovery.routes.mcp to show the connect card, but it was absent on live envs. Root cause (not a cache): @objectstack/rest serves its OWN /discovery (protocol.getDiscovery), separate from the dispatcher's getDiscoveryInfo where mcp was added — so the REST discovery never advertised it. REST discovery handler now adds routes.mcp (unscoped /api/v1/mcp — the route is mounted bare) when OS_MCP_SERVER_ENABLED=true, omits otherwise. 2 tests. rest 90 green. Co-Authored-By: Claude Opus 4.8 --- .changeset/rest-discovery-mcp.md | 17 ++++++++++++ packages/rest/src/rest-server.ts | 18 +++++++++++++ packages/rest/src/rest.test.ts | 45 +++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .changeset/rest-discovery-mcp.md diff --git a/.changeset/rest-discovery-mcp.md b/.changeset/rest-discovery-mcp.md new file mode 100644 index 000000000..e6dbe1b1a --- /dev/null +++ b/.changeset/rest-discovery-mcp.md @@ -0,0 +1,17 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): advertise `routes.mcp` in /discovery when MCP is enabled (cloud#152) + +The objectui Integrations page reads `discovery.routes.mcp` to show the "Connect +an AI agent" card, but it stayed absent on live envs even with MCP enabled. Root +cause (NOT a cache, as first suspected): `@objectstack/rest` serves its OWN +`/discovery` (`protocol.getDiscovery()`), separate from the dispatcher's +`getDiscoveryInfo` where the `mcp` field was added — so the REST-served discovery +never advertised it. + +The REST discovery handler now adds `routes.mcp` (pointing at the unscoped +`/api/v1/mcp`, since the MCP route is mounted bare) when +`OS_MCP_SERVER_ENABLED=true`, and omits it otherwise — mirroring the dispatcher +discovery and the opt-in gate. 2 tests (enabled → advertised, disabled → absent). diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index cb8b32ef5..beb862fa7 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1324,6 +1324,24 @@ export class RestServer { discovery.routes.ui = `${realBase}/ui`; } + // MCP (Streamable HTTP) is opt-in per env — advertise it + // only when OS_MCP_SERVER_ENABLED=true so the objectui + // Integrations page surfaces the connect card. The /mcp + // route is mounted bare (not project-scoped), so point at + // the unscoped base. This `/discovery` (served by + // @objectstack/rest) is separate from the dispatcher's + // getDiscoveryInfo — both must advertise `mcp`. + const mcpEnabled = + (globalThis as any)?.process?.env?.OS_MCP_SERVER_ENABLED === 'true'; + if (mcpEnabled) { + const unscopedBase = isScoped + ? basePath.replace(/\/(environments|projects)\/:environmentId$/, '') + : basePath; + (discovery.routes as any).mcp = `${unscopedBase}/mcp`; + } else { + delete (discovery.routes as any).mcp; + } + // Align auth route with the versioned base path if present. // Auth is a control-plane concern, so use the unscoped base. if (discovery.routes.auth) { diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 70af99d9f..d31ca0a91 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { RouteManager, RouteGroupBuilder } from './route-manager'; import { RestServer, mapDataError } from './rest-server'; import { createRestApiPlugin } from './rest-api-plugin'; @@ -1552,3 +1552,46 @@ describe('mapDataError — schema/constraint envelopes', () => { expect(r.body.code).toBe('UNIQUE_VIOLATION'); }); }); + +// --------------------------------------------------------------------------- +// Discovery — MCP advertisement (#152) +// --------------------------------------------------------------------------- + +describe('discovery — routes.mcp (ADR-0036, #152)', () => { + function discoveryHandler() { + const server = createMockServer(); + const protocol = createMockProtocol(); + // protocol discovery carries a `routes` object the server augments. + (protocol.getDiscovery as any) = vi.fn().mockResolvedValue({ routes: { data: '', metadata: '' } }); + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + const entry = rest.getRouteManager().get('GET', '/api/v1/discovery'); + if (!entry) throw new Error('discovery route not registered'); + return entry.handler as (req: any, res: any) => Promise; + } + + async function invoke(handler: (req: any, res: any) => Promise) { + let body: any; + const res: any = { json: (b: any) => { body = b; }, status: () => res }; + await handler({ params: {} }, res); + return body; + } + + const prev = process.env.OS_MCP_SERVER_ENABLED; + afterEach(() => { + if (prev === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prev; + }); + + it('advertises routes.mcp when OS_MCP_SERVER_ENABLED=true', async () => { + process.env.OS_MCP_SERVER_ENABLED = 'true'; + const body = await invoke(discoveryHandler()); + expect(body.routes.mcp).toBe('/api/v1/mcp'); + }); + + it('omits routes.mcp when MCP is not enabled (opt-in)', async () => { + delete process.env.OS_MCP_SERVER_ENABLED; + const body = await invoke(discoveryHandler()); + expect(body.routes.mcp).toBeUndefined(); + }); +});