Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/rest-discovery-mcp.md
Original file line number Diff line number Diff line change
@@ -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).
18 changes: 18 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 44 additions & 1 deletion packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void>;
}

async function invoke(handler: (req: any, res: any) => Promise<void>) {
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();
});
});