diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 085fab45f7..d4dde461cf 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -4,6 +4,7 @@ import type { AgentOptions, AgentOptionsWithDefaults, HttpCallback, + McpRouteMatcher, WorkflowExecutorEmbedOptions, } from './types'; import type { AiProviderDefinition } from '@forestadmin/agent-toolkit'; @@ -55,6 +56,7 @@ export default class Agent extends FrameworkMounter /** Whether MCP server should be mounted */ private mcpEnabled = false; private mcpEnabledTools?: ToolName[]; + private mcpBasePath?: string; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -94,12 +96,12 @@ export default class Agent extends FrameworkMounter */ async start(): Promise { try { - const { router, mcpHttpCallback } = await this.buildRouterAndSendSchema(); + const { router, mcpHttpCallback, mcpIsMcpRoute } = await this.buildRouterAndSendSchema(); await this.options.forestAdminClient.subscribeToServerEvents(); this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this)); - this.setMcpCallback(mcpHttpCallback ?? null); + this.setMcpCallback(mcpHttpCallback ?? null, mcpIsMcpRoute); await this.mount(router); // Boot after mount(): the embedded executor reaches the agent over HTTP, and the @@ -140,9 +142,9 @@ export default class Agent extends FrameworkMounter try { // We force sending schema when restarting - const { router, mcpHttpCallback } = await this.buildRouterAndSendSchema(); + const { router, mcpHttpCallback, mcpIsMcpRoute } = await this.buildRouterAndSendSchema(); - this.setMcpCallback(mcpHttpCallback ?? null); + this.setMcpCallback(mcpHttpCallback ?? null, mcpIsMcpRoute); await this.remount(router); } finally { this.isRestarting = false; @@ -247,10 +249,15 @@ export default class Agent extends FrameworkMounter * agent.mountAiMcpServer(); * // Example: read-only mode (only browse data, no create/update/delete/actions) * agent.mountAiMcpServer({ enabledTools: ['describeCollection', 'list', 'listRelated'] }); + * // Example: scope MCP routes under a prefix to avoid colliding with your app's own OAuth routes. + * // OAuth discovery metadata stays at the origin root (prefix-suffixed), so root `.well-known` + * // traffic must still reach the agent. + * agent.mountAiMcpServer({ basePath: '/ai' }); */ - mountAiMcpServer(options?: { enabledTools?: ToolName[] }): this { + mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; + this.mcpBasePath = options?.basePath; return this; } @@ -350,9 +357,11 @@ export default class Agent extends FrameworkMounter * Create an http handler which can respond to all queries which are expected from an agent. * Returns the main router and optional MCP HTTP callback. */ - private async getRouter( - dataSource: DataSource, - ): Promise<{ router: Router; mcpHttpCallback?: HttpCallback }> { + private async getRouter(dataSource: DataSource): Promise<{ + router: Router; + mcpHttpCallback?: HttpCallback; + mcpIsMcpRoute?: McpRouteMatcher; + }> { // Bootstrap app const services = makeServices(this.options); const routes = this.getRoutes(dataSource, services); @@ -361,9 +370,11 @@ export default class Agent extends FrameworkMounter // Initialize MCP server if enabled via mountAiMcpServer() let mcpHttpCallback: HttpCallback | undefined; + let mcpIsMcpRoute: McpRouteMatcher | undefined; if (this.mcpEnabled) { - mcpHttpCallback = await this.initializeMcpServer(); + ({ httpCallback: mcpHttpCallback, isMcpRoute: mcpIsMcpRoute } = + await this.initializeMcpServer()); } // Build main router @@ -379,7 +390,7 @@ export default class Agent extends FrameworkMounter ); routes.forEach(route => route.setupRoutes(router)); - return { router, mcpHttpCallback }; + return { router, mcpHttpCallback, mcpIsMcpRoute }; } /** @@ -387,12 +398,17 @@ export default class Agent extends FrameworkMounter * Uses dynamic import to defer loading until mountAiMcpServer() is actually used. * This avoids loading the mcp-server dependency at startup for users who don't use MCP. */ - private async initializeMcpServer(): Promise { + private async initializeMcpServer(): Promise<{ + httpCallback: HttpCallback; + isMcpRoute: McpRouteMatcher; + }> { const mcpLogger = (level, message) => this.options.logger(level, `[MCP] ${message}`); try { // Dynamic import to defer loading until actually needed - const { ForestMCPServer, ForestServerClientImpl } = await import('@forestadmin/mcp-server'); + const { ForestMCPServer, ForestServerClientImpl, makeIsMcpRoute } = await import( + '@forestadmin/mcp-server' + ); const forestServerClient = new ForestServerClientImpl( this.options.forestAdminClient.schemaService, @@ -408,13 +424,15 @@ export default class Agent extends FrameworkMounter logger: mcpLogger, forestServerClient, enabledTools: this.mcpEnabledTools, + basePath: this.mcpBasePath, }); const httpCallback = await mcpServer.getHttpCallback(); + const isMcpRoute = makeIsMcpRoute(this.mcpBasePath); mcpLogger('Info', 'Server initialized successfully'); - return httpCallback; + return { httpCallback, isMcpRoute }; } catch (error) { const { message } = error as Error; mcpLogger('Error', `Failed to initialize MCP server: ${message}`); @@ -443,6 +461,7 @@ export default class Agent extends FrameworkMounter private async buildRouterAndSendSchema(): Promise<{ router: Router; mcpHttpCallback?: HttpCallback; + mcpIsMcpRoute?: McpRouteMatcher; }> { const { isProduction, logger, typingsPath, typingsMaxDepth } = this.options; diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 412e53b804..8de7070cec 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { HttpCallback } from './types'; +import type { HttpCallback, McpRouteMatcher } from './types'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type net from 'net'; @@ -40,8 +40,8 @@ export default class FrameworkMounter { /** * Set the MCP HTTP callback. Call this before mount() or remount(). */ - protected setMcpCallback(callback: HttpCallback | null): void { - this.mcpMiddleware.setCallback(callback); + protected setMcpCallback(callback: HttpCallback | null, routeMatcher?: McpRouteMatcher): void { + this.mcpMiddleware.setCallback(callback, routeMatcher); } protected async mount(router: Router): Promise { diff --git a/packages/agent/src/mcp-middleware.ts b/packages/agent/src/mcp-middleware.ts index ad29f0a4a3..64f79b31bc 100644 --- a/packages/agent/src/mcp-middleware.ts +++ b/packages/agent/src/mcp-middleware.ts @@ -1,4 +1,4 @@ -import type { HttpCallback } from './types'; +import type { HttpCallback, McpRouteMatcher } from './types'; import type Koa from 'koa'; import expressToKoa from './utils/express-to-koa'; @@ -22,12 +22,11 @@ function isMcpRoute(url: string): boolean { */ export default class McpMiddleware { private mcpHttpCallback: HttpCallback | null = null; + private routeMatcher: McpRouteMatcher = isMcpRoute; - /** - * Set the MCP HTTP callback. Call this before using the middleware. - */ - setCallback(callback: HttpCallback | null): void { + setCallback(callback: HttpCallback | null, routeMatcher?: McpRouteMatcher): void { this.mcpHttpCallback = callback; + this.routeMatcher = routeMatcher ?? isMcpRoute; } /** @@ -64,8 +63,7 @@ export default class McpMiddleware { next(); } }, - // MCP routes are at root: /.well-known/*, /oauth/*, /mcp - isMcpRoute, + url => this.routeMatcher(url), ); } } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 37faa57585..881a310eb9 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -89,6 +89,8 @@ export type AgentOptionsWithDefaults = Readonly>; export type HttpCallback = (req: IncomingMessage, res: ServerResponse, next?: () => void) => void; +export type McpRouteMatcher = (url: string) => boolean; + export enum HttpCode { BadRequest = 400, Unauthorized = 401, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 30aa5f3456..a656d687aa 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -581,6 +581,33 @@ describe('Agent', () => { ); }); + test('should pass basePath to ForestMCPServer', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + + agent.mountAiMcpServer({ basePath: '/mcp' }); + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' })); + }); + + test('threads a basePath-scoped route matcher to the MCP middleware', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + type SetMcpCallback = (cb: unknown, matcher?: (url: string) => boolean) => void; + const setMcpCallbackSpy = jest.spyOn( + agent as unknown as { setMcpCallback: SetMcpCallback }, + 'setMcpCallback', + ); + + agent.mountAiMcpServer({ basePath: '/ai' }); + await agent.start(); + + const matcher = setMcpCallbackSpy.mock.calls.at(-1)?.[1]; + expect(matcher?.('/ai/mcp')).toBe(true); + expect(matcher?.('/oauth/token')).toBe(false); + }); + test('should log error when MCP initialization fails', async () => { const mockLogger = jest.fn(); const options = factories.forestAdminHttpDriverOptions.build({ logger: mockLogger }); diff --git a/packages/agent/test/mcp-middleware.test.ts b/packages/agent/test/mcp-middleware.test.ts new file mode 100644 index 0000000000..1a9251d968 --- /dev/null +++ b/packages/agent/test/mcp-middleware.test.ts @@ -0,0 +1,35 @@ +import McpMiddleware from '../src/mcp-middleware'; + +describe('McpMiddleware', () => { + function makeCtx(url: string) { + return { url, req: {}, res: { once: jest.fn() }, respond: true } as any; + } + + test('getKoaMiddleware gates requests with the custom route matcher', async () => { + const middleware = new McpMiddleware(); + const callback = jest.fn((req, res, next) => next()); + middleware.setCallback(callback, url => url.startsWith('/custom')); + + const koaMiddleware = middleware.getKoaMiddleware(); + + await koaMiddleware(makeCtx('/mcp'), jest.fn().mockResolvedValue(undefined) as any); + expect(callback).not.toHaveBeenCalled(); + + await koaMiddleware(makeCtx('/custom/x'), jest.fn().mockResolvedValue(undefined) as any); + expect(callback).toHaveBeenCalledTimes(1); + }); + + test('falls back to the default root matcher when none is provided', async () => { + const middleware = new McpMiddleware(); + const callback = jest.fn((req, res, next) => next()); + middleware.setCallback(callback); + + const koaMiddleware = middleware.getKoaMiddleware(); + + await koaMiddleware(makeCtx('/api/other'), jest.fn().mockResolvedValue(undefined) as any); + expect(callback).not.toHaveBeenCalled(); + + await koaMiddleware(makeCtx('/mcp'), jest.fn().mockResolvedValue(undefined) as any); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 7165f683cb..8df997f3a3 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,7 +1,7 @@ // Library exports only - no side effects export { default as ForestMCPServer } from './server'; export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server'; -export { MCP_PATHS, isMcpRoute } from './mcp-paths'; +export { MCP_PATHS, isMcpRoute, makeIsMcpRoute } from './mcp-paths'; export { ForestServerClientImpl, createForestServerClient } from './http-client'; export type { ForestServerClient, diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts index 92dad9b35c..d1eaf3aeb0 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -1,7 +1,56 @@ -/** MCP-specific paths that should be handled by the MCP server */ -export const MCP_PATHS = ['/.well-known/', '/oauth/', '/mcp']; +export type McpRouteMatcher = (url: string) => boolean; -/** Check if a URL is an MCP route */ -export function isMcpRoute(url: string): boolean { - return MCP_PATHS.some(p => url === p || url.startsWith(p)); +export function normalizeMountPath(input?: string): string { + if (!input) return ''; + + const trimmed = input.trim(); + if (trimmed === '' || trimmed === '/') return ''; + + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + const collapsed = withLeadingSlash.replace(/\/+/g, '/').replace(/\/+$/, ''); + + // Allow only unreserved path characters per segment. The prefix is interpolated raw into + // Express/Koa route patterns (path-to-regexp) and resolved through new URL() for the OAuth + // metadata; an allowlist keeps those two interpretations identical and future-proof. + if (!/^(\/[A-Za-z0-9_-]+)+$/.test(collapsed)) { + throw new Error( + `Invalid MCP mount path "${input}": use a plain path prefix like "/mcp" ` + + `(letters, digits, "-" and "_" only).`, + ); + } + + return collapsed; +} + +/** + * Well-known paths stay anchored at the origin root (per RFC 8414/9728) but carry the prefix + * as a suffix, so a host's own root OAuth metadata is not claimed. + */ +export function buildMcpPaths(prefix = ''): string[] { + const normalized = normalizeMountPath(prefix); + + const wellKnown = normalized + ? [ + `/.well-known/oauth-authorization-server${normalized}`, + `/.well-known/oauth-protected-resource${normalized}`, + ] + : ['/.well-known/']; + + return [...wellKnown, `${normalized}/oauth/`, `${normalized}/mcp`]; } + +export function makeIsMcpRoute(prefix = ''): McpRouteMatcher { + const paths = buildMcpPaths(prefix); + + // Match on the pathname (req.url carries the query string) and on a segment boundary, so + // '/mcp?x=1' still matches and '/ai/mcp' does not shadow '/ai/mcp-dashboard'. + return (url: string) => { + const [pathname] = url.split(/[?#]/, 1); + + return paths.some(p => pathname === p || pathname.startsWith(p.endsWith('/') ? p : `${p}/`)); + }; +} + +export const MCP_PATHS = buildMcpPaths(''); + +export const isMcpRoute = makeIsMcpRoute(''); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index e1c1331fed..9ed632b95e 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -6,6 +6,7 @@ import type { ForestServerClient } from './http-client'; import type { Express } from 'express'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js'; +import { metadataHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/metadata.js'; import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js'; import { allowedMethods } from '@modelcontextprotocol/sdk/server/auth/middleware/allowedMethods.js'; import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; @@ -21,7 +22,7 @@ import * as http from 'http'; import ForestOAuthProvider from './forest-oauth-provider'; import { createForestServerClient } from './http-client'; -import { isMcpRoute } from './mcp-paths'; +import { makeIsMcpRoute, normalizeMountPath } from './mcp-paths'; import declareAssociateTool from './tools/associate'; import declareCreateTool from './tools/create'; import declareDeleteTool from './tools/delete'; @@ -117,6 +118,14 @@ export interface ForestMCPServerOptions { forestServerClient?: ForestServerClient; /** List of tool names to enable (allowlist). Only these tools will be exposed. New tools in future releases will NOT be auto-enabled. */ enabledTools?: ToolName[]; + /** + * Opt-in path prefix under which the MCP protocol and OAuth routes are mounted, e.g. '/ai'. + * The `.well-known` discovery documents stay at the origin root (per RFC 8414/9728) but are + * served at prefix-suffixed paths. Defaults to the origin root. Use it to avoid colliding + * with a host app's own OAuth routes when embedded. Requires the agent to be served at the + * domain root. + */ + basePath?: string; } /** @@ -138,6 +147,7 @@ export default class ForestMCPServer { private logger: Logger; private collectionNames: string[] = []; private enabledTools: Set; + private basePath: string; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -146,6 +156,7 @@ export default class ForestMCPServer { this.authSecret = options?.authSecret; this.logger = options?.logger || defaultLogger; this.enabledTools = this.resolveEnabledTools(options); + this.basePath = normalizeMountPath(options?.basePath); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -477,13 +488,28 @@ export default class ForestMCPServer { ); } + const { basePath: prefix } = this; + + // OAuth discovery must be served at the origin root, so a mount prefix only works when the + // agent itself is at the domain root. Fail loudly rather than advertise URLs we can't serve. + if (prefix && effectiveBaseUrl.pathname !== '/') { + throw new Error( + `MCP basePath "${prefix}" requires the agent to be served at the domain root, but its ` + + `base URL has a path ("${effectiveBaseUrl.pathname}"). Remove the basePath or mount ` + + `the agent at the root.`, + ); + } + + const mountBase = prefix ? new URL(`${prefix.slice(1)}/`, effectiveBaseUrl) : effectiveBaseUrl; + const issuerUrl = prefix ? new URL(prefix.slice(1), effectiveBaseUrl) : effectiveBaseUrl; + const scopesSupported = ['mcp:read', 'mcp:write', 'mcp:action', 'mcp:admin']; // Create OAuth metadata with custom registration_endpoint pointing to Forest Admin const oauthMetadata = createOAuthMetadata({ provider: oauthProvider, - issuerUrl: effectiveBaseUrl, - baseUrl: effectiveBaseUrl, + issuerUrl, + baseUrl: issuerUrl, scopesSupported, }); @@ -491,8 +517,8 @@ export default class ForestMCPServer { oauthMetadata.response_types_supported = ['code']; oauthMetadata.code_challenge_methods_supported = ['S256']; - oauthMetadata.token_endpoint = `${effectiveBaseUrl.href}oauth/token`; - oauthMetadata.authorization_endpoint = `${effectiveBaseUrl.href}oauth/authorize`; + oauthMetadata.token_endpoint = `${mountBase.href}oauth/token`; + oauthMetadata.authorization_endpoint = `${mountBase.href}oauth/authorize`; // Override registration_endpoint to point to Forest Admin server oauthMetadata.registration_endpoint = `${this.forestServerUrl}/oauth/register`; // Remove revocation_endpoint from metadata (not supported) @@ -521,34 +547,50 @@ export default class ForestMCPServer { }); app.use( - '/oauth/authorize', + `${prefix}/oauth/authorize`, authorizationHandler({ provider: oauthProvider, }), ); - app.use('/oauth/token', tokenHandler({ provider: oauthProvider })); - - // Mount metadata router with custom metadata - // The resourceServerUrl should include the /mcp path to match RFC 9728 requirements. - // This creates the .well-known/oauth-protected-resource/mcp endpoint. - const mcpResourceUrl = new URL('mcp', effectiveBaseUrl); - app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpResourceUrl, - scopesSupported, - }), - ); + app.use(`${prefix}/oauth/token`, tokenHandler({ provider: oauthProvider })); + + // The resourceServerUrl includes the /mcp path to match RFC 9728 requirements. + const mcpResourceUrl = new URL('mcp', mountBase); + + if (prefix) { + // Serve both discovery documents ourselves at the RFC 8414/9728 prefix-suffixed paths. + // mcpAuthMetadataRouter would additionally mount the authorization-server metadata at the + // bare origin root, which would shadow a host app's own root discovery. + app.use( + `/.well-known/oauth-protected-resource${mcpResourceUrl.pathname}`, + metadataHandler({ + resource: mcpResourceUrl.href, + authorization_servers: [oauthMetadata.issuer], + scopes_supported: scopesSupported, + }), + ); + app.use(`/.well-known/oauth-authorization-server${prefix}`, metadataHandler(oauthMetadata)); + } else { + app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpResourceUrl, + scopesSupported, + }), + ); + } app.use(allowedMethods(['POST'])); app.post( - '/mcp', + `${prefix}/mcp`, requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['mcp:read'], - resourceMetadataUrl: new URL('/.well-known/oauth-protected-resource/mcp', effectiveBaseUrl) - .href, + resourceMetadataUrl: new URL( + `/.well-known/oauth-protected-resource${mcpResourceUrl.pathname}`, + effectiveBaseUrl, + ).href, }), (req, res) => { this.handleMcpRequest(req, res).catch(error => { @@ -608,8 +650,8 @@ export default class ForestMCPServer { /** * Build and return an HTTP callback that can be used as middleware. - * The callback will handle MCP-related routes (/.well-known/*, /oauth/*, /mcp) - * and call next() for other routes. + * The callback handles the MCP OAuth, `.well-known` and protocol routes (scoped under + * `basePath` when set) and calls next() for every other route. * * @param baseUrl - Optional base URL override. If not provided, will use the * environmentApiEndpoint from Forest Admin API. @@ -617,6 +659,7 @@ export default class ForestMCPServer { */ async getHttpCallback(baseUrl?: URL): Promise { const app = await this.buildExpressApp(baseUrl); + const isMcpRoute = makeIsMcpRoute(this.basePath); return (req, res, next) => { const url = req.url || '/'; diff --git a/packages/mcp-server/test/mcp-paths.test.ts b/packages/mcp-server/test/mcp-paths.test.ts new file mode 100644 index 0000000000..398c00bf21 --- /dev/null +++ b/packages/mcp-server/test/mcp-paths.test.ts @@ -0,0 +1,114 @@ +import { + MCP_PATHS, + buildMcpPaths, + isMcpRoute, + makeIsMcpRoute, + normalizeMountPath, +} from '../src/mcp-paths'; + +describe('mcp-paths', () => { + describe('normalizeMountPath', () => { + it.each([undefined, '', '/', ' '])('returns "" for %p (root default)', input => { + expect(normalizeMountPath(input)).toBe(''); + }); + + it.each([ + ['mcp', '/mcp'], + ['/mcp', '/mcp'], + ['/mcp/', '/mcp'], + ['//mcp//', '/mcp'], + ['/api/mcp', '/api/mcp'], + ])('normalizes %p to %p', (input, expected) => { + expect(normalizeMountPath(input)).toBe(expected); + }); + + it.each([ + '/m?cp', + '/m#cp', + '/m cp', + '/mcp\\admin', + '/a/../mcp', + '/mcp*', + '/tenant/:id', + '/m%20cp', + '/foo[bar]', + '/a!b', + '/m|b', + '/m^b', + ])('throws for %p (would desync routes from advertised metadata)', input => { + expect(() => normalizeMountPath(input)).toThrow(/Invalid MCP mount path/); + }); + }); + + describe('buildMcpPaths', () => { + it('claims the root well-known namespace by default', () => { + expect(buildMcpPaths('')).toEqual(['/.well-known/', '/oauth/', '/mcp']); + }); + + it('claims prefix-suffixed well-known paths under a prefix', () => { + expect(buildMcpPaths('/mcp')).toEqual([ + '/.well-known/oauth-authorization-server/mcp', + '/.well-known/oauth-protected-resource/mcp', + '/mcp/oauth/', + '/mcp/mcp', + ]); + }); + + it('claims nested prefix paths', () => { + expect(buildMcpPaths('/api/mcp')).toEqual([ + '/.well-known/oauth-authorization-server/api/mcp', + '/.well-known/oauth-protected-resource/api/mcp', + '/api/mcp/oauth/', + '/api/mcp/mcp', + ]); + }); + + it('normalizes a raw (un-normalized) prefix on entry', () => { + expect(buildMcpPaths('mcp/')).toEqual(buildMcpPaths('/mcp')); + }); + }); + + describe('default exports (root)', () => { + it('MCP_PATHS matches the historical root paths', () => { + expect(MCP_PATHS).toEqual(['/.well-known/', '/oauth/', '/mcp']); + }); + + it.each(['/.well-known/oauth-authorization-server', '/oauth/token', '/mcp', '/mcp?foo=1'])( + 'isMcpRoute claims %p', + url => { + expect(isMcpRoute(url)).toBe(true); + }, + ); + + it.each(['/api/other', '/mcp-dashboard'])('isMcpRoute passes through %p', url => { + expect(isMcpRoute(url)).toBe(false); + }); + }); + + describe('makeIsMcpRoute with prefix /mcp', () => { + const matches = makeIsMcpRoute('/mcp'); + + it.each([ + '/mcp/mcp', + '/mcp/mcp?x=1', + '/mcp/oauth/authorize', + '/mcp/oauth/token', + '/.well-known/oauth-authorization-server/mcp', + '/.well-known/oauth-protected-resource/mcp/mcp', + ])('claims prefixed route %p', url => { + expect(matches(url)).toBe(true); + }); + + it.each([ + '/oauth/token', + '/mcp', + '/.well-known/oauth-authorization-server', + '/.well-known/oauth-protected-resource', + '/api/other', + '/mcp/mcp-dashboard', + '/.well-known/oauth-protected-resource/mcp-dashboard', + ])('passes through host route %p', url => { + expect(matches(url)).toBe(false); + }); + }); +}); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index f1d3c8b9f7..254e0de2cd 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2528,6 +2528,182 @@ describe('getHttpCallback', () => { }); }); +describe('basePath prefix', () => { + const originalFetch = global.fetch; + + function mockForestFetch(): void { + const mockFetchServer = new MockServer(); + mockFetchServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = mockFetchServer.fetch; + } + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('metadata and routes under /mcp', () => { + let app: Awaited>; + + beforeEach(async () => { + mockForestFetch(); + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + basePath: '/mcp', + }); + app = await server.buildExpressApp(new URL('http://localhost:3000')); + }); + + it('serves authorization-server metadata at the RFC 8414 suffixed path with prefixed endpoints', async () => { + const response = await request(app).get('/.well-known/oauth-authorization-server/mcp'); + + expect(response.status).toBe(200); + expect(response.body.issuer).toBe('http://localhost:3000/mcp'); + expect(response.body.authorization_endpoint).toBe( + 'http://localhost:3000/mcp/oauth/authorize', + ); + expect(response.body.token_endpoint).toBe('http://localhost:3000/mcp/oauth/token'); + // registration_endpoint must stay the unprefixed Forest server URL. + expect(response.body.registration_endpoint).toBe( + 'https://api.forestadmin.com/oauth/register', + ); + }); + + it('serves protected-resource metadata pointing at the prefixed issuer', async () => { + const response = await request(app).get('/.well-known/oauth-protected-resource/mcp/mcp'); + + expect(response.status).toBe(200); + expect(response.body.resource).toBe('http://localhost:3000/mcp/mcp'); + // authorization_servers is the field the client follows to reach the issuer. + expect(response.body.authorization_servers).toEqual(['http://localhost:3000/mcp']); + }); + + it('does not serve authorization-server metadata at the bare origin root', async () => { + const response = await request(app).get('/.well-known/oauth-authorization-server'); + + // The root discovery document is left to the host app, not shadowed by the MCP server. + expect(response.status).not.toBe(200); + expect(response.body.issuer).toBeUndefined(); + }); + + it('mounts the MCP endpoint at the prefixed path with a prefixed resource metadata url', async () => { + const response = await request(app) + .post('/mcp/mcp') + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + + expect(response.status).toBe(401); + expect(response.headers['www-authenticate']).toContain( + '/.well-known/oauth-protected-resource/mcp/mcp', + ); + }); + }); + + describe('nested prefix and base-path resolution', () => { + beforeEach(() => { + mockForestFetch(); + }); + + it('scopes routes and metadata under a nested prefix', async () => { + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + basePath: '/api/mcp', + }); + const app = await server.buildExpressApp(new URL('http://localhost:3000')); + + const metadata = await request(app).get('/.well-known/oauth-authorization-server/api/mcp'); + expect(metadata.status).toBe(200); + expect(metadata.body.issuer).toBe('http://localhost:3000/api/mcp'); + expect(metadata.body.token_endpoint).toBe('http://localhost:3000/api/mcp/oauth/token'); + + const mcp = await request(app) + .post('/api/mcp/mcp') + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + expect(mcp.status).toBe(401); + }); + + it('rejects a basePath when the base URL is not at the domain root', async () => { + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + basePath: '/mcp', + }); + + await expect(server.buildExpressApp(new URL('http://localhost:3000/agent'))).rejects.toThrow( + /requires the agent to be served at the domain root/, + ); + }); + }); + + describe('getHttpCallback route filtering under /mcp', () => { + let callback: (req: http.IncomingMessage, res: http.ServerResponse, next?: () => void) => void; + let callbackServer: http.Server; + + beforeEach(async () => { + mockForestFetch(); + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + basePath: '/mcp', + }); + callback = await server.getHttpCallback(new URL('http://localhost:3000')); + }); + + afterEach(async () => { + await shutDownHttpServer(callbackServer); + }); + + it('delegates the prefixed MCP endpoint to the Express app', async () => { + callbackServer = http.createServer(callback); + callbackServer.listen(0); + await new Promise(resolve => { + callbackServer.on('listening', resolve); + }); + + const response = await request(callbackServer) + .post('/mcp/mcp') + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + + expect(response.status).toBe(401); + }); + + it.each(['/oauth/token', '/mcp', '/.well-known/oauth-authorization-server', '/api/other'])( + 'passes host route %p through to next()', + url => { + const req = { url } as http.IncomingMessage; + const res = {} as http.ServerResponse; + const next = jest.fn(); + + callback(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + }, + ); + }); +}); + describe('handleMcpRequest cleanup', () => { const originalFetch = global.fetch; let cleanupServer: ForestMCPServer;