From 58ecac51e7e6acca4b57c8c09159973ec4d08572 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 6 Jul 2026 13:46:22 +0200 Subject: [PATCH 1/6] feat(mcp-server): configurable basePath to avoid host OAuth route collisions The embedded MCP server registers its OAuth, .well-known and /mcp routes at the host root, which overrides a host app's own /oauth/authorize and /oauth/token routes. Add an opt-in basePath option to mountAiMcpServer() that scopes every MCP route under a prefix (e.g. /mcp), so the MCP server coexists with the host's OAuth. Default (no basePath) is byte-identical to before. - normalizeMountPath rejects inputs that new URL() rewrites differently from the raw-string route mounts (backslash, .., %-escapes, path-to-regexp metacharacters), preventing a silent advertised-vs-mounted route desync. - authorization-server metadata is mounted manually at the RFC 8414 suffixed path, since the SDK's mcpAuthMetadataRouter only serves it at the root. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/src/agent.ts | 43 ++++-- packages/agent/src/framework-mounter.ts | 6 +- packages/agent/src/mcp-middleware.ts | 12 +- packages/agent/src/types.ts | 2 + packages/agent/test/agent.test.ts | 10 ++ packages/agent/test/mcp-middleware.test.ts | 35 +++++ packages/mcp-server/src/index.ts | 9 +- packages/mcp-server/src/mcp-paths.ts | 58 +++++++- packages/mcp-server/src/server.ts | 49 +++++-- packages/mcp-server/test/mcp-paths.test.ts | 107 ++++++++++++++ packages/mcp-server/test/server.test.ts | 163 +++++++++++++++++++++ 11 files changed, 452 insertions(+), 42 deletions(-) create mode 100644 packages/agent/test/mcp-middleware.test.ts create mode 100644 packages/mcp-server/test/mcp-paths.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 085fab45f7..f99eed38dc 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,13 @@ 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 + * 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 +355,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 +368,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 +388,7 @@ export default class Agent extends FrameworkMounter ); routes.forEach(route => route.setupRoutes(router)); - return { router, mcpHttpCallback }; + return { router, mcpHttpCallback, mcpIsMcpRoute }; } /** @@ -387,12 +396,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 +422,15 @@ export default class Agent extends FrameworkMounter logger: mcpLogger, forestServerClient, enabledTools: this.mcpEnabledTools, + mountPath: 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 +459,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..63e5a4e35c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -581,6 +581,16 @@ describe('Agent', () => { ); }); + test('should pass basePath to ForestMCPServer as mountPath', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + + agent.mountAiMcpServer({ basePath: '/mcp' }); + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ mountPath: '/mcp' })); + }); + 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..2d5282d2f6 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,7 +1,14 @@ // 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, + buildMcpPaths, + normalizeMountPath, +} from './mcp-paths'; +export type { McpRouteMatcher } 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..baa1a7ad8f 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -1,7 +1,55 @@ -/** 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(/\/+$/, ''); + + // The prefix is interpolated raw into Express/Koa route patterns AND resolved through + // new URL() to advertise the OAuth metadata. Reject anything that makes those diverge: + // path-to-regexp metacharacters, or segments new URL() rewrites (backslash, '.', '..', %-escapes). + const urlPath = new URL(collapsed.slice(1), 'http://forestadmin.invalid/').pathname.replace( + /\/+$/, + '', + ); + + if (/[?#\s\\:*()+%]/.test(collapsed) || urlPath !== collapsed) { + throw new Error( + `Invalid MCP mount path "${input}": use a plain path prefix like "/mcp" ` + + `(no "..", backslashes, %-escapes, or route metacharacters).`, + ); + } + + 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); + + return (url: string) => paths.some(p => url === p || url.startsWith(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..a2c3ac5e46 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,12 @@ 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 all MCP routes (OAuth, .well-known, /mcp) are mounted, + * e.g. '/mcp'. Defaults to the origin root. Use it to avoid colliding with a host app's + * own OAuth routes when the server is embedded in an agent. + */ + mountPath?: string; } /** @@ -138,6 +145,7 @@ export default class ForestMCPServer { private logger: Logger; private collectionNames: string[] = []; private enabledTools: Set; + private mountPath: string; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -146,6 +154,7 @@ export default class ForestMCPServer { this.authSecret = options?.authSecret; this.logger = options?.logger || defaultLogger; this.enabledTools = this.resolveEnabledTools(options); + this.mountPath = normalizeMountPath(options?.mountPath); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -477,13 +486,18 @@ export default class ForestMCPServer { ); } + const { mountPath: prefix } = this; + // Relative resolution (not leading-slash) so a base path on effectiveBaseUrl is preserved. + 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 +505,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,17 +535,16 @@ export default class ForestMCPServer { }); app.use( - '/oauth/authorize', + `${prefix}/oauth/authorize`, authorizationHandler({ provider: oauthProvider, }), ); - app.use('/oauth/token', tokenHandler({ provider: oauthProvider })); + app.use(`${prefix}/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); + // The resourceServerUrl includes the /mcp path to match RFC 9728 requirements. + const mcpResourceUrl = new URL('mcp', mountBase); app.use( mcpAuthMetadataRouter({ oauthMetadata, @@ -540,15 +553,24 @@ export default class ForestMCPServer { }), ); + // mcpAuthMetadataRouter hardcodes the authorization-server metadata at the root + // /.well-known/oauth-authorization-server. With a path-scoped issuer the client fetches it + // at the RFC 8414 suffixed path instead, so mount it there ourselves. + if (prefix) { + app.use(`/.well-known/oauth-authorization-server${prefix}`, metadataHandler(oauthMetadata)); + } + 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 => { @@ -617,6 +639,7 @@ export default class ForestMCPServer { */ async getHttpCallback(baseUrl?: URL): Promise { const app = await this.buildExpressApp(baseUrl); + const isMcpRoute = makeIsMcpRoute(this.mountPath); 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..9712aa8941 --- /dev/null +++ b/packages/mcp-server/test/mcp-paths.test.ts @@ -0,0 +1,107 @@ +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', + ])('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'])( + 'isMcpRoute claims %p', + url => { + expect(isMcpRoute(url)).toBe(true); + }, + ); + + it('isMcpRoute passes through unrelated routes', () => { + expect(isMcpRoute('/api/other')).toBe(false); + }); + }); + + describe('makeIsMcpRoute with prefix /mcp', () => { + const matches = makeIsMcpRoute('/mcp'); + + it.each([ + '/mcp/mcp', + '/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', + ])('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..ff96df97b3 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2528,6 +2528,169 @@ describe('getHttpCallback', () => { }); }); +describe('mountPath 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(), + mountPath: '/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'); + }); + + it('serves protected-resource metadata under the prefixed resource path', 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'); + }); + + 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(), + mountPath: '/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('preserves a base path on the origin when it has a trailing slash', async () => { + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + mountPath: '/mcp', + }); + const app = await server.buildExpressApp(new URL('http://localhost:3000/agent/')); + + const metadata = await request(app).get('/.well-known/oauth-authorization-server/mcp'); + expect(metadata.body.issuer).toBe('http://localhost:3000/agent/mcp'); + expect(metadata.body.token_endpoint).toBe('http://localhost:3000/agent/mcp/oauth/token'); + }); + }); + + 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(), + mountPath: '/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; From d872b93dbbd799b596cfe5392d858bbfe55c3ad1 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 6 Jul 2026 14:10:28 +0200 Subject: [PATCH 2/6] fix(mcp-server): harden basePath route matching and base-url resolution Address review findings on the basePath feature: - Preserve a path on the base URL (e.g. https://host/agent) by resolving the prefix against a trailing-slash base, instead of dropping the segment. - Match claimed routes on a segment boundary so a prefix like /ai/mcp no longer shadows unrelated host routes such as /ai/mcp-dashboard. - Reject Express-reserved '[' and ']' in normalizeMountPath. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/src/mcp-paths.ts | 7 +++++-- packages/mcp-server/src/server.ts | 10 +++++++--- packages/mcp-server/test/mcp-paths.test.ts | 7 +++++-- packages/mcp-server/test/server.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts index baa1a7ad8f..025dfbef9f 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -17,7 +17,7 @@ export function normalizeMountPath(input?: string): string { '', ); - if (/[?#\s\\:*()+%]/.test(collapsed) || urlPath !== collapsed) { + if (/[?#\s\\:*()+%[\]]/.test(collapsed) || urlPath !== collapsed) { throw new Error( `Invalid MCP mount path "${input}": use a plain path prefix like "/mcp" ` + `(no "..", backslashes, %-escapes, or route metacharacters).`, @@ -47,7 +47,10 @@ export function buildMcpPaths(prefix = ''): string[] { export function makeIsMcpRoute(prefix = ''): McpRouteMatcher { const paths = buildMcpPaths(prefix); - return (url: string) => paths.some(p => url === p || url.startsWith(p)); + // Match on a segment boundary so a claimed path like '/ai/mcp' does not shadow an + // unrelated host route like '/ai/mcp-dashboard'. + return (url: string) => + paths.some(p => url === p || url.startsWith(p.endsWith('/') ? p : `${p}/`)); } export const MCP_PATHS = buildMcpPaths(''); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index a2c3ac5e46..35f0b3be5c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -487,9 +487,13 @@ export default class ForestMCPServer { } const { mountPath: prefix } = this; - // Relative resolution (not leading-slash) so a base path on effectiveBaseUrl is preserved. - const mountBase = prefix ? new URL(`${prefix.slice(1)}/`, effectiveBaseUrl) : effectiveBaseUrl; - const issuerUrl = prefix ? new URL(prefix.slice(1), effectiveBaseUrl) : effectiveBaseUrl; + // Resolve the prefix relatively against a trailing-slash base so a path on the base URL + // (e.g. https://host/agent) is preserved rather than dropped by URL resolution. + const baseWithSlash = effectiveBaseUrl.href.endsWith('/') + ? effectiveBaseUrl + : new URL(`${effectiveBaseUrl.href}/`); + const mountBase = prefix ? new URL(`${prefix.slice(1)}/`, baseWithSlash) : baseWithSlash; + const issuerUrl = prefix ? new URL(prefix.slice(1), baseWithSlash) : baseWithSlash; const scopesSupported = ['mcp:read', 'mcp:write', 'mcp:action', 'mcp:admin']; diff --git a/packages/mcp-server/test/mcp-paths.test.ts b/packages/mcp-server/test/mcp-paths.test.ts index 9712aa8941..9dc5b115e7 100644 --- a/packages/mcp-server/test/mcp-paths.test.ts +++ b/packages/mcp-server/test/mcp-paths.test.ts @@ -31,6 +31,7 @@ describe('mcp-paths', () => { '/mcp*', '/tenant/:id', '/m%20cp', + '/foo[bar]', ])('throws for %p (would desync routes from advertised metadata)', input => { expect(() => normalizeMountPath(input)).toThrow(/Invalid MCP mount path/); }); @@ -76,8 +77,8 @@ describe('mcp-paths', () => { }, ); - it('isMcpRoute passes through unrelated routes', () => { - expect(isMcpRoute('/api/other')).toBe(false); + it.each(['/api/other', '/mcp-dashboard'])('isMcpRoute passes through %p', url => { + expect(isMcpRoute(url)).toBe(false); }); }); @@ -100,6 +101,8 @@ describe('mcp-paths', () => { '/.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 ff96df97b3..6d34e064de 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2627,14 +2627,14 @@ describe('mountPath prefix', () => { expect(mcp.status).toBe(401); }); - it('preserves a base path on the origin when it has a trailing slash', async () => { + it('preserves a base path on the origin even without a trailing slash', async () => { const server = new ForestMCPServer({ envSecret: 'ENV_SECRET', authSecret: 'AUTH_SECRET', forestServerClient: createMockForestServerClient(), mountPath: '/mcp', }); - const app = await server.buildExpressApp(new URL('http://localhost:3000/agent/')); + const app = await server.buildExpressApp(new URL('http://localhost:3000/agent')); const metadata = await request(app).get('/.well-known/oauth-authorization-server/mcp'); expect(metadata.body.issuer).toBe('http://localhost:3000/agent/mcp'); From 8f18b83de43ba8397f498bccf53e5e5ba4aa4a64 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 6 Jul 2026 15:17:30 +0200 Subject: [PATCH 3/6] refactor(mcp-server): drop unused mcp-paths exports from package index buildMcpPaths, normalizeMountPath and the McpRouteMatcher type are only used inside the package (server.ts and tests import them from ./mcp-paths). Only makeIsMcpRoute is consumed externally (by the agent). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/src/index.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 2d5282d2f6..8df997f3a3 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,14 +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, - makeIsMcpRoute, - buildMcpPaths, - normalizeMountPath, -} from './mcp-paths'; -export type { McpRouteMatcher } from './mcp-paths'; +export { MCP_PATHS, isMcpRoute, makeIsMcpRoute } from './mcp-paths'; export { ForestServerClientImpl, createForestServerClient } from './http-client'; export type { ForestServerClient, From c29317509c86fb74036c5fa0e3e2766b88a56cd3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 6 Jul 2026 15:31:00 +0200 Subject: [PATCH 4/6] fix(mcp-server): address PR review on basePath routing - Match on the pathname in the route matcher so query strings (e.g. /mcp?x=1) no longer fall through to the host app. - Validate mount paths with an unreserved-character allowlist, rejecting path-to-regexp metacharacters (including '!') that crash Express 5 at boot. - Reject a basePath when the base URL is not at the domain root, instead of advertising discovery URLs the mounted routes don't serve. - Rename the mcp-server option mountPath -> basePath to match the agent's public mountAiMcpServer({ basePath }) option. - Clarify in JSDoc that .well-known discovery stays at the origin root. - Add tests: query-string matching, '!' rejection, non-root base rejection, authorization_servers/registration_endpoint metadata, and matcher threading to the Koa middleware. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/src/agent.ts | 6 ++-- packages/agent/test/agent.test.ts | 21 +++++++++-- packages/mcp-server/src/mcp-paths.ts | 26 +++++++------- packages/mcp-server/src/server.ts | 42 +++++++++++++--------- packages/mcp-server/test/mcp-paths.test.ts | 6 +++- packages/mcp-server/test/server.test.ts | 25 +++++++------ 6 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index f99eed38dc..d4dde461cf 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -249,7 +249,9 @@ 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 + * // 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[]; basePath?: string }): this { @@ -422,7 +424,7 @@ export default class Agent extends FrameworkMounter logger: mcpLogger, forestServerClient, enabledTools: this.mcpEnabledTools, - mountPath: this.mcpBasePath, + basePath: this.mcpBasePath, }); const httpCallback = await mcpServer.getHttpCallback(); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 63e5a4e35c..a656d687aa 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -581,14 +581,31 @@ describe('Agent', () => { ); }); - test('should pass basePath to ForestMCPServer as mountPath', async () => { + 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({ mountPath: '/mcp' })); + 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 () => { diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts index 025dfbef9f..d1eaf3aeb0 100644 --- a/packages/mcp-server/src/mcp-paths.ts +++ b/packages/mcp-server/src/mcp-paths.ts @@ -9,18 +9,13 @@ export function normalizeMountPath(input?: string): string { const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; const collapsed = withLeadingSlash.replace(/\/+/g, '/').replace(/\/+$/, ''); - // The prefix is interpolated raw into Express/Koa route patterns AND resolved through - // new URL() to advertise the OAuth metadata. Reject anything that makes those diverge: - // path-to-regexp metacharacters, or segments new URL() rewrites (backslash, '.', '..', %-escapes). - const urlPath = new URL(collapsed.slice(1), 'http://forestadmin.invalid/').pathname.replace( - /\/+$/, - '', - ); - - if (/[?#\s\\:*()+%[\]]/.test(collapsed) || urlPath !== collapsed) { + // 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" ` + - `(no "..", backslashes, %-escapes, or route metacharacters).`, + `(letters, digits, "-" and "_" only).`, ); } @@ -47,10 +42,13 @@ export function buildMcpPaths(prefix = ''): string[] { export function makeIsMcpRoute(prefix = ''): McpRouteMatcher { const paths = buildMcpPaths(prefix); - // Match on a segment boundary so a claimed path like '/ai/mcp' does not shadow an - // unrelated host route like '/ai/mcp-dashboard'. - return (url: string) => - paths.some(p => url === p || url.startsWith(p.endsWith('/') ? p : `${p}/`)); + // 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(''); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 35f0b3be5c..2fed3d20f8 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -119,11 +119,13 @@ export interface ForestMCPServerOptions { /** 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 all MCP routes (OAuth, .well-known, /mcp) are mounted, - * e.g. '/mcp'. Defaults to the origin root. Use it to avoid colliding with a host app's - * own OAuth routes when the server is embedded in an agent. + * 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. */ - mountPath?: string; + basePath?: string; } /** @@ -145,7 +147,7 @@ export default class ForestMCPServer { private logger: Logger; private collectionNames: string[] = []; private enabledTools: Set; - private mountPath: string; + private basePath: string; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -154,7 +156,7 @@ export default class ForestMCPServer { this.authSecret = options?.authSecret; this.logger = options?.logger || defaultLogger; this.enabledTools = this.resolveEnabledTools(options); - this.mountPath = normalizeMountPath(options?.mountPath); + this.basePath = normalizeMountPath(options?.basePath); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -486,14 +488,20 @@ export default class ForestMCPServer { ); } - const { mountPath: prefix } = this; - // Resolve the prefix relatively against a trailing-slash base so a path on the base URL - // (e.g. https://host/agent) is preserved rather than dropped by URL resolution. - const baseWithSlash = effectiveBaseUrl.href.endsWith('/') - ? effectiveBaseUrl - : new URL(`${effectiveBaseUrl.href}/`); - const mountBase = prefix ? new URL(`${prefix.slice(1)}/`, baseWithSlash) : baseWithSlash; - const issuerUrl = prefix ? new URL(prefix.slice(1), baseWithSlash) : baseWithSlash; + 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']; @@ -634,8 +642,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. @@ -643,7 +651,7 @@ export default class ForestMCPServer { */ async getHttpCallback(baseUrl?: URL): Promise { const app = await this.buildExpressApp(baseUrl); - const isMcpRoute = makeIsMcpRoute(this.mountPath); + 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 index 9dc5b115e7..398c00bf21 100644 --- a/packages/mcp-server/test/mcp-paths.test.ts +++ b/packages/mcp-server/test/mcp-paths.test.ts @@ -32,6 +32,9 @@ describe('mcp-paths', () => { '/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/); }); @@ -70,7 +73,7 @@ describe('mcp-paths', () => { expect(MCP_PATHS).toEqual(['/.well-known/', '/oauth/', '/mcp']); }); - it.each(['/.well-known/oauth-authorization-server', '/oauth/token', '/mcp'])( + it.each(['/.well-known/oauth-authorization-server', '/oauth/token', '/mcp', '/mcp?foo=1'])( 'isMcpRoute claims %p', url => { expect(isMcpRoute(url)).toBe(true); @@ -87,6 +90,7 @@ describe('mcp-paths', () => { it.each([ '/mcp/mcp', + '/mcp/mcp?x=1', '/mcp/oauth/authorize', '/mcp/oauth/token', '/.well-known/oauth-authorization-server/mcp', diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 6d34e064de..d3946c7969 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2565,7 +2565,7 @@ describe('mountPath prefix', () => { envSecret: 'ENV_SECRET', authSecret: 'AUTH_SECRET', forestServerClient: createMockForestServerClient(), - mountPath: '/mcp', + basePath: '/mcp', }); app = await server.buildExpressApp(new URL('http://localhost:3000')); }); @@ -2579,13 +2579,19 @@ describe('mountPath prefix', () => { '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 under the prefixed resource path', async () => { + 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('mounts the MCP endpoint at the prefixed path with a prefixed resource metadata url', async () => { @@ -2611,7 +2617,7 @@ describe('mountPath prefix', () => { envSecret: 'ENV_SECRET', authSecret: 'AUTH_SECRET', forestServerClient: createMockForestServerClient(), - mountPath: '/api/mcp', + basePath: '/api/mcp', }); const app = await server.buildExpressApp(new URL('http://localhost:3000')); @@ -2627,18 +2633,17 @@ describe('mountPath prefix', () => { expect(mcp.status).toBe(401); }); - it('preserves a base path on the origin even without a trailing slash', async () => { + 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(), - mountPath: '/mcp', + basePath: '/mcp', }); - const app = await server.buildExpressApp(new URL('http://localhost:3000/agent')); - const metadata = await request(app).get('/.well-known/oauth-authorization-server/mcp'); - expect(metadata.body.issuer).toBe('http://localhost:3000/agent/mcp'); - expect(metadata.body.token_endpoint).toBe('http://localhost:3000/agent/mcp/oauth/token'); + await expect(server.buildExpressApp(new URL('http://localhost:3000/agent'))).rejects.toThrow( + /requires the agent to be served at the domain root/, + ); }); }); @@ -2652,7 +2657,7 @@ describe('mountPath prefix', () => { envSecret: 'ENV_SECRET', authSecret: 'AUTH_SECRET', forestServerClient: createMockForestServerClient(), - mountPath: '/mcp', + basePath: '/mcp', }); callback = await server.getHttpCallback(new URL('http://localhost:3000')); }); From 77cd41e2371f980f20195c571dea0cbf2d6048c8 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 6 Jul 2026 15:45:10 +0200 Subject: [PATCH 5/6] fix(mcp-server): don't mount root authorization-server metadata under basePath In prefix mode, mount both discovery documents ourselves at the RFC 8414/9728 suffixed paths and skip mcpAuthMetadataRouter, which would otherwise also mount the authorization-server metadata at the bare origin root and shadow a host app's own root discovery. Non-prefix mode is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/src/server.ts | 30 ++++++++++++++++--------- packages/mcp-server/test/server.test.ts | 8 +++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 2fed3d20f8..9ed632b95e 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -554,22 +554,30 @@ export default class ForestMCPServer { ); app.use(`${prefix}/oauth/token`, tokenHandler({ provider: oauthProvider })); - // Mount metadata router with custom metadata // The resourceServerUrl includes the /mcp path to match RFC 9728 requirements. const mcpResourceUrl = new URL('mcp', mountBase); - app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpResourceUrl, - scopesSupported, - }), - ); - // mcpAuthMetadataRouter hardcodes the authorization-server metadata at the root - // /.well-known/oauth-authorization-server. With a path-scoped issuer the client fetches it - // at the RFC 8414 suffixed path instead, so mount it there ourselves. 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'])); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index d3946c7969..55244623b5 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2594,6 +2594,14 @@ describe('mountPath prefix', () => { 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') From 5d1e2c80d4ff7555f4125cddc991bad50bf5d31f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 6 Jul 2026 16:01:22 +0200 Subject: [PATCH 6/6] test(mcp-server): rename stale 'mountPath prefix' describe to 'basePath prefix' Follow-up to the mountPath -> basePath option rename. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/test/server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 55244623b5..254e0de2cd 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2528,7 +2528,7 @@ describe('getHttpCallback', () => { }); }); -describe('mountPath prefix', () => { +describe('basePath prefix', () => { const originalFetch = global.fetch; function mockForestFetch(): void {