From 8c7b9628b80998442cb77849dd959208ce39596f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 12:27:24 +0200 Subject: [PATCH 1/5] feat(mcp-server): agentUrl option to route tool calls internally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP tools call back into the agent's data layer over HTTP. By default the URL is the environment's public api_endpoint, so tool calls leave over the public internet even when the MCP server is mounted inside the agent. Add an opt-in agentUrl option (mountAiMcpServer, ForestMCPServerOptions, FOREST_AGENT_URL for the CLI) that overrides only the internal tool->agent channel — the advertised OAuth discovery URLs stay public so external MCP clients still authenticate. Requested by a self-hosted customer (Swaive) who wants MCP callbacks to stay on their private network. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/src/agent.ts | 12 ++++- packages/agent/test/agent.test.ts | 12 +++++ packages/mcp-server/src/cli.ts | 1 + .../mcp-server/src/forest-oauth-provider.ts | 22 ++++++++- packages/mcp-server/src/server.ts | 17 +++++++ .../test/forest-oauth-provider.test.ts | 48 ++++++++++++++++++- packages/mcp-server/test/server.test.ts | 33 +++++++++++++ 7 files changed, 142 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index d4dde461cf..2e8e43f1e4 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -57,6 +57,7 @@ export default class Agent extends FrameworkMounter private mcpEnabled = false; private mcpEnabledTools?: ToolName[]; private mcpBasePath?: string; + private mcpAgentUrl?: string; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -253,11 +254,19 @@ export default class Agent extends FrameworkMounter * // OAuth discovery metadata stays at the origin root (prefix-suffixed), so root `.well-known` * // traffic must still reach the agent. * agent.mountAiMcpServer({ basePath: '/ai' }); + * // Example: in a self-hosted setup, keep the tools' callbacks to the agent on the internal + * // network instead of the public URL Forest has for this environment. + * agent.mountAiMcpServer({ agentUrl: 'http://forest-agent.internal:3310' }); */ - mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string }): this { + mountAiMcpServer(options?: { + enabledTools?: ToolName[]; + basePath?: string; + agentUrl?: string; + }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; this.mcpBasePath = options?.basePath; + this.mcpAgentUrl = options?.agentUrl; return this; } @@ -425,6 +434,7 @@ export default class Agent extends FrameworkMounter forestServerClient, enabledTools: this.mcpEnabledTools, basePath: this.mcpBasePath, + agentUrl: this.mcpAgentUrl, }); const httpCallback = await mcpServer.getHttpCallback(); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index a656d687aa..9eea440a1c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -591,6 +591,18 @@ describe('Agent', () => { expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' })); }); + test('should pass agentUrl to ForestMCPServer', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + + agent.mountAiMcpServer({ agentUrl: 'http://forest-agent.internal:3310' }); + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ agentUrl: 'http://forest-agent.internal:3310' }), + ); + }); + test('threads a basePath-scoped route matcher to the MCP middleware', async () => { const options = factories.forestAdminHttpDriverOptions.build(); const agent = new Agent(options); diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 1762be25ec..16dc12e1dd 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -10,6 +10,7 @@ const server = new ForestMCPServer({ envSecret: process.env.FOREST_ENV_SECRET, authSecret: process.env.FOREST_AUTH_SECRET, enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS), + agentUrl: process.env.FOREST_AGENT_URL, }); server.run().catch(error => { diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index 1349889a94..e9e58ddd9b 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -30,6 +30,7 @@ export interface ForestOAuthProviderOptions { envSecret: string; authSecret: string; logger: Logger; + agentUrl?: string; } /** @@ -43,6 +44,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private forestClient: ForestAdminClient; private environmentId?: number; private environmentApiEndpoint?: string; + private agentUrl?: string; private logger: Logger; constructor({ @@ -51,18 +53,34 @@ export default class ForestOAuthProvider implements OAuthServerProvider { envSecret, authSecret, logger, + agentUrl, }: ForestOAuthProviderOptions) { this.forestServerUrl = forestServerUrl; this.forestAppUrl = forestAppUrl; this.envSecret = envSecret; this.authSecret = authSecret; this.logger = logger; + this.agentUrl = ForestOAuthProvider.normalizeAgentUrl(agentUrl); this.forestClient = createForestAdminClient({ forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, }); } + private static normalizeAgentUrl(agentUrl?: string): string | undefined { + if (!agentUrl) return undefined; + + try { + // eslint-disable-next-line no-new + new URL(agentUrl); + } catch { + throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute URL.`); + } + + // agent-client concatenates the path directly onto this URL, so drop any trailing slash. + return agentUrl.replace(/\/+$/, ''); + } + async initialize(): Promise { try { await this.fetchEnvironmentId(); @@ -408,7 +426,9 @@ export default class ForestOAuthProvider implements OAuthServerProvider { userId: decoded.id, email: decoded.email, renderingId: decoded.renderingId, - environmentApiEndpoint: this.environmentApiEndpoint, + // Tools call back into the agent at this URL. Prefer the configured internal agentUrl + // (self-hosted) and fall back to the environment's public api_endpoint. + environmentApiEndpoint: this.agentUrl ?? this.environmentApiEndpoint, forestServerToken: decoded.serverToken, }, }; diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 9ed632b95e..7cf818cf59 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -126,6 +126,20 @@ export interface ForestMCPServerOptions { * domain root. */ basePath?: string; + /** + * URL the MCP tools use to call back into the agent's data layer (list/create/execute actions…). + * + * By default this is the environment's public `api_endpoint` (as registered in Forest), which + * means every tool call leaves over the public internet — even when the MCP server is mounted + * inside the agent (same process). Set `agentUrl` to an internal address (e.g. a loopback port + * or a Kubernetes service URL) to keep that traffic on your private network in self-hosted + * deployments. + * + * This only affects the internal tool→agent channel. The advertised OAuth URLs (issuer, + * `.well-known` metadata, authorize/token endpoints) stay public so external MCP clients can + * still authenticate. + */ + agentUrl?: string; } /** @@ -148,6 +162,7 @@ export default class ForestMCPServer { private collectionNames: string[] = []; private enabledTools: Set; private basePath: string; + private agentUrl?: string; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -157,6 +172,7 @@ export default class ForestMCPServer { this.logger = options?.logger || defaultLogger; this.enabledTools = this.resolveEnabledTools(options); this.basePath = normalizeMountPath(options?.basePath); + this.agentUrl = options?.agentUrl; // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -475,6 +491,7 @@ export default class ForestMCPServer { envSecret, authSecret, logger: this.logger, + agentUrl: this.agentUrl, }); await oauthProvider.initialize(); diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 84aa6694e5..dd65b938cf 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -20,13 +20,14 @@ const TEST_ENV_SECRET = 'test-env-secret'; const TEST_AUTH_SECRET = 'test-auth-secret'; const TEST_FOREST_APP_URL = 'https://app.forestadmin.com'; -function createProvider(forestServerUrl = 'https://api.forestadmin.com') { +function createProvider(forestServerUrl = 'https://api.forestadmin.com', agentUrl?: string) { return new ForestOAuthProvider({ forestServerUrl, forestAppUrl: TEST_FOREST_APP_URL, envSecret: TEST_ENV_SECRET, authSecret: TEST_AUTH_SECRET, logger: console.info, + agentUrl, }); } @@ -72,6 +73,12 @@ describe('ForestOAuthProvider', () => { expect(customProvider).toBeDefined(); }); + + it('throws for an invalid agentUrl', () => { + expect(() => createProvider('https://api.forestadmin.com', 'not-a-url')).toThrow( + /Invalid agentUrl/, + ); + }); }); describe('initialize', () => { @@ -835,6 +842,45 @@ describe('ForestOAuthProvider', () => { }); }); + it('uses agentUrl as the tool callback endpoint when configured', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + id: 1, + email: 'user@example.com', + renderingId: 2, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + + const provider = createProvider( + 'https://api.forestadmin.com', + 'http://forest-agent.internal:3310', + ); + const result = await provider.verifyAccessToken('valid-access-token'); + + expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( + 'http://forest-agent.internal:3310', + ); + }); + + it('strips a trailing slash from agentUrl', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + id: 1, + email: 'user@example.com', + renderingId: 2, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + + const provider = createProvider('https://api.forestadmin.com', 'http://internal:3310/'); + const result = await provider.verifyAccessToken('valid-access-token'); + + expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( + 'http://internal:3310', + ); + }); + it('should throw error for expired access token', async () => { (jsonwebtoken.verify as jest.Mock).mockImplementation(() => { throw new jsonwebtoken.TokenExpiredError('jwt expired', new Date()); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 254e0de2cd..c86a1318f5 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2704,6 +2704,39 @@ describe('basePath prefix', () => { }); }); +describe('agentUrl option', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('does not affect the advertised OAuth metadata URLs', async () => { + const mockFetchServer = new MockServer(); + mockFetchServer + .get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + global.fetch = mockFetchServer.fetch; + + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + agentUrl: 'http://forest-agent.internal:3310', + }); + const app = await server.buildExpressApp(new URL('http://localhost:3000')); + + const response = await request(app).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body.issuer).toBe('http://localhost:3000/'); + expect(response.body.authorization_endpoint).toBe('http://localhost:3000/oauth/authorize'); + expect(response.body.token_endpoint).toBe('http://localhost:3000/oauth/token'); + }); +}); + describe('handleMcpRequest cleanup', () => { const originalFetch = global.fetch; let cleanupServer: ForestMCPServer; From c476514c043bfaa8b57a4d339d4d257c8b4352ad Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 12:39:34 +0200 Subject: [PATCH 2/5] fix(mcp-server): harden agentUrl validation and prove tool routing Review follow-ups on the agentUrl option: - normalizeAgentUrl now parses once, rejects non-http(s) schemes and URLs with a query string or fragment (which would swallow the request path in agent-client's `${url}${path}` join), and returns the parsed, whitespace- normalized form instead of the raw string. Previously such inputs passed validation and silently misrouted every tool call. - Add an end-to-end test proving a `tools/call` actually targets agentUrl and not the environment's public api_endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-server/src/forest-oauth-provider.ts | 23 +++-- .../test/forest-oauth-provider.test.ts | 9 +- packages/mcp-server/test/server.test.ts | 88 +++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index e9e58ddd9b..4fca97512e 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -70,15 +70,28 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private static normalizeAgentUrl(agentUrl?: string): string | undefined { if (!agentUrl) return undefined; + let parsed: URL; + try { - // eslint-disable-next-line no-new - new URL(agentUrl); + parsed = new URL(agentUrl); } catch { - throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute URL.`); + throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute http(s) URL.`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Invalid agentUrl "${agentUrl}": only http and https are supported.`); + } + + // agent-client concatenates the request path directly onto this URL, so a query string or + // fragment would swallow it — reject them, and return the parsed (whitespace-normalized) + // form rather than the raw input. Drop any trailing slash to avoid a double slash on join. + if (parsed.search || parsed.hash) { + throw new Error( + `Invalid agentUrl "${agentUrl}": it must not contain a query string or fragment.`, + ); } - // agent-client concatenates the path directly onto this URL, so drop any trailing slash. - return agentUrl.replace(/\/+$/, ''); + return parsed.href.replace(/\/+$/, ''); } async initialize(): Promise { diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index dd65b938cf..204faca702 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -74,8 +74,13 @@ describe('ForestOAuthProvider', () => { expect(customProvider).toBeDefined(); }); - it('throws for an invalid agentUrl', () => { - expect(() => createProvider('https://api.forestadmin.com', 'not-a-url')).toThrow( + it.each([ + 'not-a-url', + 'ftp://internal:3310', + 'http://internal:3310?x=1', + 'http://internal:3310/#frag', + ])('throws for an invalid agentUrl %p', agentUrl => { + expect(() => createProvider('https://api.forestadmin.com', agentUrl)).toThrow( /Invalid agentUrl/, ); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index c86a1318f5..73e96e6798 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2735,6 +2735,94 @@ describe('agentUrl option', () => { expect(response.body.authorization_endpoint).toBe('http://localhost:3000/oauth/authorize'); expect(response.body.token_endpoint).toBe('http://localhost:3000/oauth/token'); }); + + it('routes tool calls to agentUrl instead of the public api_endpoint', async () => { + clearSchemaCache(); + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + process.env.FOREST_AUTH_SECRET = 'test-auth-secret'; + process.env.MCP_SERVER_PORT = (await getAvailablePort()).toString(); + + let capturedForestUrl: string | undefined; + const mockServer = new MockServer(); + mockServer + // Public endpoint Forest has on file — the tool must NOT use this one. + .get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://public.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { + name: 'users', + fields: [{ field: 'id', type: 'Number', isSortable: true }], + }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .post('/api/activity-logs-requests', { + data: { id: 'log-1', attributes: { index: 'logs' } }, + }) + .get(/\/forest\/\w+/, url => { + capturedForestUrl = url; + + return { data: [] }; + }); + global.fetch = mockServer.fetch; + mockServer.setupSuperagentMock(); + + const server = new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + forestServerUrl: 'https://test.forestadmin.com', + forestServerClient: createMockForestServerClient({ + fetchSchema: jest + .fn() + .mockResolvedValue([ + { name: 'users', fields: [{ field: 'id', type: 'Number', isSortable: true }] }, + ]), + }), + agentUrl: 'http://internal-agent:9999', + }); + server.run(); + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + try { + const token = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: 'forest-server-token', + }, + 'test-auth-secret', + { expiresIn: '1h' }, + ); + + const response = await request(server.httpServer as http.Server) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/call', + id: 1, + params: { name: 'list', arguments: { collectionName: 'users' } }, + }); + + expect(response.status).toBe(200); + expect(capturedForestUrl).toContain('http://internal-agent:9999/forest/'); + expect(capturedForestUrl).not.toContain('public.example.com'); + } finally { + mockServer.restoreSuperagent(); + await shutDownHttpServer(server.httpServer as http.Server); + } + }); }); describe('handleMcpRequest cleanup', () => { From c26d56b1f4e1ba1dfcfc5b4108207ac3cccbf5aa Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 17:34:38 +0200 Subject: [PATCH 3/5] refactor(mcp-server): validate agentUrl eagerly in the server constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract normalizeAgentUrl to a shared helper and call it from the ForestMCPServer constructor, so a malformed agentUrl / FOREST_AGENT_URL throws at construction — proximate to the misconfiguration — instead of later inside ForestOAuthProvider during run()/start(). Add a verifyAccessToken test for the fallback path (no agentUrl -> environment api_endpoint). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-server/src/forest-oauth-provider.ts | 31 ++----------------- packages/mcp-server/src/server.ts | 17 +++------- .../src/utils/normalize-agent-url.ts | 25 +++++++++++++++ .../test/forest-oauth-provider.test.ts | 24 ++++++++++++++ 4 files changed, 57 insertions(+), 40 deletions(-) create mode 100644 packages/mcp-server/src/utils/normalize-agent-url.ts diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index 4fca97512e..64fc445057 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -24,6 +24,8 @@ import { } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import jsonwebtoken from 'jsonwebtoken'; +import normalizeAgentUrl from './utils/normalize-agent-url'; + export interface ForestOAuthProviderOptions { forestServerUrl: string; forestAppUrl: string; @@ -60,40 +62,13 @@ export default class ForestOAuthProvider implements OAuthServerProvider { this.envSecret = envSecret; this.authSecret = authSecret; this.logger = logger; - this.agentUrl = ForestOAuthProvider.normalizeAgentUrl(agentUrl); + this.agentUrl = normalizeAgentUrl(agentUrl); this.forestClient = createForestAdminClient({ forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, }); } - private static normalizeAgentUrl(agentUrl?: string): string | undefined { - if (!agentUrl) return undefined; - - let parsed: URL; - - try { - parsed = new URL(agentUrl); - } catch { - throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute http(s) URL.`); - } - - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error(`Invalid agentUrl "${agentUrl}": only http and https are supported.`); - } - - // agent-client concatenates the request path directly onto this URL, so a query string or - // fragment would swallow it — reject them, and return the parsed (whitespace-normalized) - // form rather than the raw input. Drop any trailing slash to avoid a double slash on join. - if (parsed.search || parsed.hash) { - throw new Error( - `Invalid agentUrl "${agentUrl}": it must not contain a query string or fragment.`, - ); - } - - return parsed.href.replace(/\/+$/, ''); - } - async initialize(): Promise { try { await this.fetchEnvironmentId(); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 7cf818cf59..a022f6040f 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -33,6 +33,7 @@ import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; import declareUpdateTool from './tools/update'; +import normalizeAgentUrl from './utils/normalize-agent-url'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; import { NAME, VERSION } from './version'; @@ -127,17 +128,9 @@ export interface ForestMCPServerOptions { */ basePath?: string; /** - * URL the MCP tools use to call back into the agent's data layer (list/create/execute actions…). - * - * By default this is the environment's public `api_endpoint` (as registered in Forest), which - * means every tool call leaves over the public internet — even when the MCP server is mounted - * inside the agent (same process). Set `agentUrl` to an internal address (e.g. a loopback port - * or a Kubernetes service URL) to keep that traffic on your private network in self-hosted - * deployments. - * - * This only affects the internal tool→agent channel. The advertised OAuth URLs (issuer, - * `.well-known` metadata, authorize/token endpoints) stay public so external MCP clients can - * still authenticate. + * Internal URL the MCP tools use to call back into the agent's data layer, e.g. + * 'http://forest-agent.internal:3310'. Defaults to the environment's public `api_endpoint`. + * Only affects the tool→agent channel; the advertised OAuth URLs stay public. */ agentUrl?: string; } @@ -172,7 +165,7 @@ export default class ForestMCPServer { this.logger = options?.logger || defaultLogger; this.enabledTools = this.resolveEnabledTools(options); this.basePath = normalizeMountPath(options?.basePath); - this.agentUrl = options?.agentUrl; + this.agentUrl = normalizeAgentUrl(options?.agentUrl); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); diff --git a/packages/mcp-server/src/utils/normalize-agent-url.ts b/packages/mcp-server/src/utils/normalize-agent-url.ts new file mode 100644 index 0000000000..7096651d2f --- /dev/null +++ b/packages/mcp-server/src/utils/normalize-agent-url.ts @@ -0,0 +1,25 @@ +// agent-client concatenates the request path directly onto this URL, so a query string or fragment +// would swallow it — reject them, and return the parsed, trailing-slash-free form. +export default function normalizeAgentUrl(agentUrl?: string): string | undefined { + if (!agentUrl) return undefined; + + let parsed: URL; + + try { + parsed = new URL(agentUrl); + } catch { + throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute http(s) URL.`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Invalid agentUrl "${agentUrl}": only http and https are supported.`); + } + + if (parsed.search || parsed.hash) { + throw new Error( + `Invalid agentUrl "${agentUrl}": it must not contain a query string or fragment.`, + ); + } + + return parsed.href.replace(/\/+$/, ''); +} diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 204faca702..8b0a551e6c 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -868,6 +868,30 @@ describe('ForestOAuthProvider', () => { ); }); + it('falls back to the environment api_endpoint when no agentUrl is set', async () => { + mockServer.get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://public.example.com' } }, + }); + global.fetch = mockServer.fetch; + + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + id: 1, + email: 'user@example.com', + renderingId: 2, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + + const provider = createProvider(); + await provider.initialize(); + const result = await provider.verifyAccessToken('valid-access-token'); + + expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( + 'https://public.example.com', + ); + }); + it('strips a trailing slash from agentUrl', async () => { (jsonwebtoken.verify as jest.Mock).mockReturnValue({ id: 1, From 0507fb92e75167256dcbe9253685c59c6358f87c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 17:54:16 +0200 Subject: [PATCH 4/5] refactor(mcp-server): single owner for agentUrl normalization ForestMCPServer is now the sole validator/normalizer of agentUrl; the internal ForestOAuthProvider accepts a pre-normalized value instead of re-running normalizeAgentUrl. Move the validation/normalization unit tests (invalid inputs, trailing slash, http/https, path preservation) into a dedicated normalize-agent-url.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-server/src/forest-oauth-provider.ts | 5 ++-- .../test/forest-oauth-provider.test.ts | 29 ------------------- .../test/utils/normalize-agent-url.test.ts | 27 +++++++++++++++++ 3 files changed, 29 insertions(+), 32 deletions(-) create mode 100644 packages/mcp-server/test/utils/normalize-agent-url.test.ts diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index 64fc445057..a351cd8811 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -24,14 +24,13 @@ import { } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import jsonwebtoken from 'jsonwebtoken'; -import normalizeAgentUrl from './utils/normalize-agent-url'; - export interface ForestOAuthProviderOptions { forestServerUrl: string; forestAppUrl: string; envSecret: string; authSecret: string; logger: Logger; + // Pre-normalized by the caller (ForestMCPServer); not re-validated here. agentUrl?: string; } @@ -62,7 +61,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { this.envSecret = envSecret; this.authSecret = authSecret; this.logger = logger; - this.agentUrl = normalizeAgentUrl(agentUrl); + this.agentUrl = agentUrl; this.forestClient = createForestAdminClient({ forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 8b0a551e6c..cd11f7cdfb 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -73,17 +73,6 @@ describe('ForestOAuthProvider', () => { expect(customProvider).toBeDefined(); }); - - it.each([ - 'not-a-url', - 'ftp://internal:3310', - 'http://internal:3310?x=1', - 'http://internal:3310/#frag', - ])('throws for an invalid agentUrl %p', agentUrl => { - expect(() => createProvider('https://api.forestadmin.com', agentUrl)).toThrow( - /Invalid agentUrl/, - ); - }); }); describe('initialize', () => { @@ -892,24 +881,6 @@ describe('ForestOAuthProvider', () => { ); }); - it('strips a trailing slash from agentUrl', async () => { - (jsonwebtoken.verify as jest.Mock).mockReturnValue({ - id: 1, - email: 'user@example.com', - renderingId: 2, - serverToken: 'forest-server-token', - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000), - }); - - const provider = createProvider('https://api.forestadmin.com', 'http://internal:3310/'); - const result = await provider.verifyAccessToken('valid-access-token'); - - expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( - 'http://internal:3310', - ); - }); - it('should throw error for expired access token', async () => { (jsonwebtoken.verify as jest.Mock).mockImplementation(() => { throw new jsonwebtoken.TokenExpiredError('jwt expired', new Date()); diff --git a/packages/mcp-server/test/utils/normalize-agent-url.test.ts b/packages/mcp-server/test/utils/normalize-agent-url.test.ts new file mode 100644 index 0000000000..b34b068948 --- /dev/null +++ b/packages/mcp-server/test/utils/normalize-agent-url.test.ts @@ -0,0 +1,27 @@ +import normalizeAgentUrl from '../../src/utils/normalize-agent-url'; + +describe('normalizeAgentUrl', () => { + it.each([undefined, ''])('returns undefined for %p', input => { + expect(normalizeAgentUrl(input)).toBeUndefined(); + }); + + it.each([ + ['http://forest-agent.internal:3310', 'http://forest-agent.internal:3310'], + ['https://forest-agent.internal', 'https://forest-agent.internal'], + ])('accepts %p and returns it unchanged', (input, expected) => { + expect(normalizeAgentUrl(input)).toBe(expected); + }); + + it('preserves a base path and strips a trailing slash', () => { + expect(normalizeAgentUrl('http://internal:3310/backend/')).toBe('http://internal:3310/backend'); + }); + + it.each([ + 'not-a-url', + 'ftp://internal:3310', + 'http://internal:3310?x=1', + 'http://internal:3310/#frag', + ])('throws for %p', input => { + expect(() => normalizeAgentUrl(input)).toThrow(/Invalid agentUrl/); + }); +}); From 0327a3c83f1cfea4e1028a220d1698e9d50f4316 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 18:50:25 +0200 Subject: [PATCH 5/5] refactor(mcp-server): restrict agentUrl to the standalone server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the agentUrl option from agent.mountAiMcpServer() — mounted mode will dispatch tool calls to the agent in-process (follow-up), making a callback URL unnecessary. agentUrl stays available for the standalone MCP server via FOREST_AGENT_URL / ForestMCPServerOptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/src/agent.ts | 12 +----------- packages/agent/test/agent.test.ts | 12 ------------ packages/mcp-server/src/server.ts | 5 ++--- 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 2e8e43f1e4..d4dde461cf 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -57,7 +57,6 @@ export default class Agent extends FrameworkMounter private mcpEnabled = false; private mcpEnabledTools?: ToolName[]; private mcpBasePath?: string; - private mcpAgentUrl?: string; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -254,19 +253,11 @@ export default class Agent extends FrameworkMounter * // OAuth discovery metadata stays at the origin root (prefix-suffixed), so root `.well-known` * // traffic must still reach the agent. * agent.mountAiMcpServer({ basePath: '/ai' }); - * // Example: in a self-hosted setup, keep the tools' callbacks to the agent on the internal - * // network instead of the public URL Forest has for this environment. - * agent.mountAiMcpServer({ agentUrl: 'http://forest-agent.internal:3310' }); */ - mountAiMcpServer(options?: { - enabledTools?: ToolName[]; - basePath?: string; - agentUrl?: string; - }): this { + mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; this.mcpBasePath = options?.basePath; - this.mcpAgentUrl = options?.agentUrl; return this; } @@ -434,7 +425,6 @@ export default class Agent extends FrameworkMounter forestServerClient, enabledTools: this.mcpEnabledTools, basePath: this.mcpBasePath, - agentUrl: this.mcpAgentUrl, }); const httpCallback = await mcpServer.getHttpCallback(); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 9eea440a1c..a656d687aa 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -591,18 +591,6 @@ describe('Agent', () => { expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' })); }); - test('should pass agentUrl to ForestMCPServer', async () => { - const options = factories.forestAdminHttpDriverOptions.build(); - const agent = new Agent(options); - - agent.mountAiMcpServer({ agentUrl: 'http://forest-agent.internal:3310' }); - await agent.start(); - - expect(mcpServerSpy).toHaveBeenCalledWith( - expect.objectContaining({ agentUrl: 'http://forest-agent.internal:3310' }), - ); - }); - test('threads a basePath-scoped route matcher to the MCP middleware', async () => { const options = factories.forestAdminHttpDriverOptions.build(); const agent = new Agent(options); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index a022f6040f..4674dfc05b 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -128,9 +128,8 @@ export interface ForestMCPServerOptions { */ basePath?: string; /** - * Internal URL the MCP tools use to call back into the agent's data layer, e.g. - * 'http://forest-agent.internal:3310'. Defaults to the environment's public `api_endpoint`. - * Only affects the tool→agent channel; the advertised OAuth URLs stay public. + * Standalone MCP server only (set via FOREST_AGENT_URL). Internal URL the tools use to reach + * the agent's data layer, defaulting to the environment's public `api_endpoint`. */ agentUrl?: string; }