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..a351cd8811 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -30,6 +30,8 @@ export interface ForestOAuthProviderOptions { envSecret: string; authSecret: string; logger: Logger; + // Pre-normalized by the caller (ForestMCPServer); not re-validated here. + agentUrl?: string; } /** @@ -43,6 +45,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private forestClient: ForestAdminClient; private environmentId?: number; private environmentApiEndpoint?: string; + private agentUrl?: string; private logger: Logger; constructor({ @@ -51,12 +54,14 @@ 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 = agentUrl; this.forestClient = createForestAdminClient({ forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, @@ -408,7 +413,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..4674dfc05b 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'; @@ -126,6 +127,11 @@ export interface ForestMCPServerOptions { * domain root. */ basePath?: string; + /** + * 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; } /** @@ -148,6 +154,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 +164,7 @@ export default class ForestMCPServer { this.logger = options?.logger || defaultLogger; this.enabledTools = this.resolveEnabledTools(options); this.basePath = normalizeMountPath(options?.basePath); + this.agentUrl = normalizeAgentUrl(options?.agentUrl); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -475,6 +483,7 @@ export default class ForestMCPServer { envSecret, authSecret, logger: this.logger, + agentUrl: this.agentUrl, }); await oauthProvider.initialize(); 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 84aa6694e5..cd11f7cdfb 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, }); } @@ -835,6 +836,51 @@ 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('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('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..73e96e6798 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2704,6 +2704,127 @@ 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'); + }); + + 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', () => { const originalFetch = global.fetch; let cleanupServer: ForestMCPServer; 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/); + }); +});