From 8c7b9628b80998442cb77849dd959208ce39596f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 12:27:24 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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; } From a1bcaf15dbfb2988bb051dc2b185aaa0c0e4f312 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 19:08:03 +0200 Subject: [PATCH 06/10] refactor(agent-client): extract shared response parsing, allow requester override Factor HttpRequester's success-deserialize and error-mapping tail into exported buildAgentHttpError/deserializeAgentBody helpers (HTTP path unchanged), and let createRemoteAgentClient accept a pre-built httpRequester. Groundwork for an in-process transport that reuses the exact same AgentHttpError shape (so the approval-gate detection stays identical). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent-client/src/http-requester.ts | 44 +++++++++++++++------ packages/agent-client/src/index.ts | 8 +++- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/agent-client/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index 14607c153f..b47c475178 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -15,8 +15,31 @@ function parseJson(text: string | undefined): unknown { } } +// Any transport reaching the agent must build this exact AgentHttpError shape — the approval-gate +// detection in domains/action.ts routes on it. +export function buildAgentHttpError(status: number, body: unknown, text?: string): AgentHttpError { + const hasBody = !!body && typeof body === 'object' && Object.keys(body).length > 0; + + return new AgentHttpError(status, hasBody ? body : parseJson(text), text); +} + +export async function deserializeAgentBody( + deserializer: Deserializer, + body: unknown, + text: string | undefined, + skipDeserialization?: boolean, +): Promise { + if (skipDeserialization) return body as Data; + + try { + return (await deserializer.deserialize(body)) as Data; + } catch { + return (body ?? text) as Data; + } +} + export default class HttpRequester { - private readonly deserializer: Deserializer; + protected readonly deserializer: Deserializer; private get baseUrl() { const prefix = this.options.prefix ? `/${this.options.prefix}` : ''; @@ -62,25 +85,20 @@ export default class HttpRequester { const response = await req; - if (skipDeserialization) { - return response.body as Data; - } - - try { - return (await this.deserializer.deserialize(response.body)) as Data; - } catch { - return (response.body ?? response.text) as Data; - } + return await deserializeAgentBody( + this.deserializer, + response.body, + response.text, + skipDeserialization, + ); } catch (error) { const res = (error as { response?: { status?: number; body?: unknown; text?: string } }) .response; if (!res) throw error; // network/timeout/abort → no HTTP response, propagate raw const text = typeof res.text === 'string' ? res.text : undefined; - const hasBody = - !!res.body && typeof res.body === 'object' && Object.keys(res.body).length > 0; - throw new AgentHttpError(res.status ?? 0, hasBody ? res.body : parseJson(text), text); + throw buildAgentHttpError(res.status ?? 0, res.body, text); } } diff --git a/packages/agent-client/src/index.ts b/packages/agent-client/src/index.ts index 0b4665c186..c452060396 100644 --- a/packages/agent-client/src/index.ts +++ b/packages/agent-client/src/index.ts @@ -14,7 +14,7 @@ import AgentHttpError, { ActionRequiresApprovalError, ApprovalRequestCreationError, } from './errors'; -import HttpRequester from './http-requester'; +import HttpRequester, { buildAgentHttpError, deserializeAgentBody } from './http-requester'; export { ActionFieldJson, @@ -22,6 +22,8 @@ export { makeCreateApprovalRequest, RemoteAgentClient, HttpRequester, + buildAgentHttpError, + deserializeAgentBody, AgentHttpError, ActionRequiresApprovalError, ActionFormValidationError, @@ -46,8 +48,10 @@ export function createRemoteAgentClient(params: { * agent (e.g. tests). `serverUrl` is the Forest server, distinct from the agent `url` above. */ forestServer?: { serverUrl: string; serverToken: string; renderingId: number | string }; + httpRequester?: HttpRequester; }) { - const httpRequester = new HttpRequester(params.token, { url: params.url }); + const httpRequester = + params.httpRequester ?? new HttpRequester(params.token, { url: params.url }); return new RemoteAgentClient({ actionEndpoints: params.actionEndpoints, From 4ba880e80064069d94605740bfe077c696fd5fc3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 19:35:25 +0200 Subject: [PATCH 07/10] feat(agent): in-process dispatcher for MCP tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add InProcessDispatcher, which runs an agent-client request through the agent's own /forest Koa stack in-memory via light-my-request (no socket) and returns a superagent-shaped {status, body, text}. FrameworkMounter.getInProcessDispatcher rebuilds the handler on every (re)mount (stable object, mutable target — same pattern as McpMiddleware); the /forest prefix is fixed since agent-client hardcodes those paths. Not yet wired to the MCP server (next increment). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/package.json | 1 + packages/agent/src/framework-mounter.ts | 20 ++++ .../agent/src/mcp-in-process-dispatcher.ts | 67 +++++++++++ .../test/mcp-in-process-dispatcher.test.ts | 104 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 packages/agent/src/mcp-in-process-dispatcher.ts create mode 100644 packages/agent/test/mcp-in-process-dispatcher.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 7ce255fb5f..40a4dee91a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -29,6 +29,7 @@ "jsonwebtoken": "^9.0.3", "koa": "^3.0.1", "koa-jwt": "^4.0.4", + "light-my-request": "^5.14.0", "luxon": "^3.2.1", "object-hash": "^3.0.0", "superagent": "^10.3.0", diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 8de7070cec..b671b5b583 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -9,6 +9,7 @@ import Koa from 'koa'; import path from 'path'; import FastifyAdapter from './fastify-adapter'; +import InProcessDispatcher from './mcp-in-process-dispatcher'; import McpMiddleware from './mcp-middleware'; export default class FrameworkMounter { @@ -24,6 +25,8 @@ export default class FrameworkMounter { private readonly fastifyAdapter: FastifyAdapter; private readonly mcpMiddleware: McpMiddleware; + private readonly inProcessDispatcher = new InProcessDispatcher(); + private inProcessHookRegistered = false; /** Compute the prefix that the main router should be mounted at in the client's application */ private get completeMountPrefix(): string { @@ -44,6 +47,23 @@ export default class FrameworkMounter { this.mcpMiddleware.setCallback(callback, routeMatcher); } + /** + * Dispatcher that runs agent-client requests against the agent's own `/forest` stack in-memory. + * Its handler is rebuilt on every (re)mount; the `/forest` prefix is fixed (not the external + * mount prefix) because agent-client hardcodes `/forest/...` paths. + */ + protected getInProcessDispatcher(): InProcessDispatcher { + if (!this.inProcessHookRegistered) { + this.inProcessHookRegistered = true; + this.onEachStart.push(async driverRouter => { + const router = new Router({ prefix: '/forest' }).use(driverRouter.routes()); + this.inProcessDispatcher.setHandler(new Koa().use(router.routes()).callback()); + }); + } + + return this.inProcessDispatcher; + } + protected async mount(router: Router): Promise { for (const task of this.onFirstStart) await task(); // eslint-disable-line no-await-in-loop diff --git a/packages/agent/src/mcp-in-process-dispatcher.ts b/packages/agent/src/mcp-in-process-dispatcher.ts new file mode 100644 index 0000000000..30a5e234ba --- /dev/null +++ b/packages/agent/src/mcp-in-process-dispatcher.ts @@ -0,0 +1,67 @@ +import type { RequestListener } from 'http'; + +import inject, { type InjectOptions, type InjectPayload } from 'light-my-request'; + +export type InProcessRequest = { + method: string; + path: string; + headers: Record; + query?: Record; + payload?: unknown; +}; + +// Superagent-shaped so agent-client's HttpRequester helpers parse it identically. +export type InProcessResponse = { status: number; body: unknown; text: string }; + +function toStringQuery(query: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) result[key] = String(value); + } + + return result; +} + +/** + * Dispatches an agent-client request into the agent's own Koa stack in-memory (no socket), so a + * mounted MCP server's tool calls run through the real auth/permission/logging middleware. + * `setHandler` is refreshed on every (re)mount, so a captured reference never goes stale. + */ +export default class InProcessDispatcher { + private handler: RequestListener | null = null; + + setHandler(handler: RequestListener | null): void { + this.handler = handler; + } + + async request({ + method, + path, + headers, + query, + payload, + }: InProcessRequest): Promise { + if (!this.handler) { + throw new Error('Cannot dispatch to the agent in-process: it is not mounted yet.'); + } + + const response = await inject(this.handler, { + method: method.toUpperCase() as InjectOptions['method'], + url: path, + headers, + ...(query && { query: toStringQuery(query) }), + ...(payload !== undefined && { payload: payload as InjectPayload }), + }); + + let body: unknown; + + try { + body = response.json(); + } catch { + body = undefined; + } + + return { status: response.statusCode, body, text: response.payload }; + } +} diff --git a/packages/agent/test/mcp-in-process-dispatcher.test.ts b/packages/agent/test/mcp-in-process-dispatcher.test.ts new file mode 100644 index 0000000000..27bdabe40a --- /dev/null +++ b/packages/agent/test/mcp-in-process-dispatcher.test.ts @@ -0,0 +1,104 @@ +import Router from '@koa/router'; +import Koa from 'koa'; + +import InProcessDispatcher from '../src/mcp-in-process-dispatcher'; + +function handlerFor(build: (router: Router) => void) { + const router = new Router(); + build(router); + + return new Koa().use(router.routes()).callback(); +} + +describe('InProcessDispatcher', () => { + it('throws when no handler is mounted yet', async () => { + const dispatcher = new InProcessDispatcher(); + + await expect(dispatcher.request({ method: 'get', path: '/ping', headers: {} })).rejects.toThrow( + /not mounted/, + ); + }); + + it('dispatches to the handler and returns status, body and text', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler( + handlerFor(r => + r.get('/ping', ctx => { + ctx.body = { pong: true }; + }), + ), + ); + + const response = await dispatcher.request({ method: 'get', path: '/ping', headers: {} }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ pong: true }); + expect(JSON.parse(response.text)).toEqual({ pong: true }); + }); + + it('forwards headers (e.g. Authorization) to the handler', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler( + handlerFor(r => + r.get('/whoami', ctx => { + ctx.body = { auth: ctx.headers.authorization }; + }), + ), + ); + + const response = await dispatcher.request({ + method: 'get', + path: '/whoami', + headers: { Authorization: 'Bearer tok' }, + }); + + expect(response.body).toEqual({ auth: 'Bearer tok' }); + }); + + it('forwards query params and preserves non-2xx status', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler( + handlerFor(r => + r.get('/q', ctx => { + ctx.status = 422; + ctx.body = { got: ctx.query.foo }; + }), + ), + ); + + const response = await dispatcher.request({ + method: 'get', + path: '/q', + headers: {}, + query: { foo: 'bar' }, + }); + + expect(response.status).toBe(422); + expect(response.body).toEqual({ got: 'bar' }); + }); + + it('reflects a handler swap (remount) instead of a stale reference', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler( + handlerFor(r => + r.get('/v', ctx => { + ctx.body = { v: 1 }; + }), + ), + ); + expect((await dispatcher.request({ method: 'get', path: '/v', headers: {} })).body).toEqual({ + v: 1, + }); + + dispatcher.setHandler( + handlerFor(r => + r.get('/v', ctx => { + ctx.body = { v: 2 }; + }), + ), + ); + expect((await dispatcher.request({ method: 'get', path: '/v', headers: {} })).body).toEqual({ + v: 2, + }); + }); +}); From d6a46e2dfc31db6eda11c0b56b8ff5e25c8945fd Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 20:01:08 +0200 Subject: [PATCH 08/10] feat(mcp-server): dispatch mounted tool calls to the agent in-process When the MCP server is mounted in an agent, tool calls now reach the agent's data layer in-process (via the agent's InProcessDispatcher) instead of over the public api_endpoint, removing the need for agentUrl in mounted mode. agentUrl stays for standalone (FOREST_AGENT_URL). Tools now receive a single ToolContext; buildClient builds an InProcessHttpRequester (reusing agent-client's shared parse helpers so the deserialized result and AgentHttpError shape stay byte-identical to the HTTP path) when a dispatcher is present. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/src/agent.ts | 1 + packages/agent/test/agent.test.ts | 14 +++ .../src/in-process-agent-dispatcher.ts | 11 ++ .../src/in-process-http-requester.ts | 65 +++++++++++ packages/mcp-server/src/server.ts | 106 +++++------------- packages/mcp-server/src/tool-context.ts | 11 ++ packages/mcp-server/src/tools/associate.ts | 13 +-- packages/mcp-server/src/tools/create.ts | 13 +-- packages/mcp-server/src/tools/delete.ts | 13 +-- .../src/tools/describe-collection.ts | 9 +- packages/mcp-server/src/tools/dissociate.ts | 13 +-- .../mcp-server/src/tools/execute-action.ts | 17 ++- .../mcp-server/src/tools/get-action-form.ts | 17 ++- packages/mcp-server/src/tools/list-related.ts | 11 +- packages/mcp-server/src/tools/list.ts | 13 +-- packages/mcp-server/src/tools/update.ts | 13 +-- packages/mcp-server/src/utils/agent-caller.ts | 20 +++- .../test/in-process-http-requester.test.ts | 65 +++++++++++ .../mcp-server/test/tools/associate.test.ts | 31 ++++- packages/mcp-server/test/tools/create.test.ts | 38 +++++-- packages/mcp-server/test/tools/delete.test.ts | 43 +++++-- .../test/tools/describe-collection.test.ts | 42 +++++-- .../mcp-server/test/tools/dissociate.test.ts | 31 ++++- .../test/tools/execute-action.test.ts | 60 +++++++--- .../test/tools/get-action-form.test.ts | 60 +++++++--- .../test/tools/list-related.test.ts | 58 +++++++--- packages/mcp-server/test/tools/list.test.ts | 57 +++++++--- packages/mcp-server/test/tools/update.test.ts | 43 +++++-- .../test/utils/agent-caller.test.ts | 40 +++++++ 29 files changed, 652 insertions(+), 276 deletions(-) create mode 100644 packages/mcp-server/src/in-process-agent-dispatcher.ts create mode 100644 packages/mcp-server/src/in-process-http-requester.ts create mode 100644 packages/mcp-server/src/tool-context.ts create mode 100644 packages/mcp-server/test/in-process-http-requester.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index d4dde461cf..33465274a9 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -425,6 +425,7 @@ export default class Agent extends FrameworkMounter forestServerClient, enabledTools: this.mcpEnabledTools, basePath: this.mcpBasePath, + agentDispatcher: this.getInProcessDispatcher(), }); const httpCallback = await mcpServer.getHttpCallback(); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index a656d687aa..ffe113ff7b 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -591,6 +591,20 @@ describe('Agent', () => { expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' })); }); + test('passes an in-process agentDispatcher to ForestMCPServer', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + + agent.mountAiMcpServer(); + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + agentDispatcher: expect.objectContaining({ request: expect.any(Function) }), + }), + ); + }); + 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/in-process-agent-dispatcher.ts b/packages/mcp-server/src/in-process-agent-dispatcher.ts new file mode 100644 index 0000000000..901f9176e9 --- /dev/null +++ b/packages/mcp-server/src/in-process-agent-dispatcher.ts @@ -0,0 +1,11 @@ +// Structural contract satisfied by the agent's InProcessDispatcher. Declared here (not imported +// from @forestadmin/agent) to avoid a dependency cycle — agent already depends on mcp-server. +export interface InProcessAgentDispatcher { + request(request: { + method: string; + path: string; + headers: Record; + query?: Record; + payload?: unknown; + }): Promise<{ status: number; body: unknown; text: string }>; +} diff --git a/packages/mcp-server/src/in-process-http-requester.ts b/packages/mcp-server/src/in-process-http-requester.ts new file mode 100644 index 0000000000..d70996a2ea --- /dev/null +++ b/packages/mcp-server/src/in-process-http-requester.ts @@ -0,0 +1,65 @@ +import type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; + +import { + HttpRequester, + buildAgentHttpError, + deserializeAgentBody, +} from '@forestadmin/agent-client'; + +/** + * HttpRequester that reaches the agent in-process (via the dispatcher) instead of over the network. + * Reuses agent-client's shared parse helpers so the deserialized results and AgentHttpError shape + * are identical to the HTTP path — keeping approval-gate detection and error handling unchanged. + */ +export default class InProcessHttpRequester extends HttpRequester { + constructor( + private readonly bearerToken: string, + private readonly dispatcher: InProcessAgentDispatcher, + ) { + // Base url is unused: query()/stream() are overridden to dispatch in-process. + super(bearerToken, { url: 'http://in-process.agent' }); + } + + override async query({ + method, + path, + body, + query, + contentType, + skipDeserialization, + }: { + method: 'get' | 'post' | 'put' | 'delete'; + path: string; + body?: Record; + query?: Record; + maxTimeAllowed?: number; + contentType?: 'application/json' | 'text/csv'; + skipDeserialization?: boolean; + }): Promise { + const { + status, + body: responseBody, + text, + } = await this.dispatcher.request({ + method, + path, + headers: { + Authorization: `Bearer ${this.bearerToken}`, + 'Content-Type': contentType ?? 'application/json', + Accept: contentType ?? 'application/json', + }, + query: { timezone: 'Europe/Paris', ...query }, + payload: body, + }); + + if (status >= 400) throw buildAgentHttpError(status, responseBody, text); + + return deserializeAgentBody(this.deserializer, responseBody, text, skipDeserialization); + } + + override async stream(): Promise { + throw new Error( + 'CSV export is not supported when the MCP server runs in-process in the agent.', + ); + } +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 4674dfc05b..449ba3a9c3 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -3,6 +3,8 @@ import './polyfills'; import type { ForestServerClient } from './http-client'; +import type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; +import type { ToolContext } from './tool-context'; import type { Express } from 'express'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js'; @@ -132,6 +134,11 @@ export interface ForestMCPServerOptions { * the agent's data layer, defaulting to the environment's public `api_endpoint`. */ agentUrl?: string; + /** + * Set by the agent when mounted: tool calls then dispatch to the agent in-process (no socket) + * instead of reaching its public `api_endpoint` over HTTP. + */ + agentDispatcher?: InProcessAgentDispatcher; } /** @@ -155,6 +162,7 @@ export default class ForestMCPServer { private enabledTools: Set; private basePath: string; private agentUrl?: string; + private agentDispatcher?: InProcessAgentDispatcher; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -165,6 +173,7 @@ export default class ForestMCPServer { this.enabledTools = this.resolveEnabledTools(options); this.basePath = normalizeMountPath(options?.basePath); this.agentUrl = normalizeAgentUrl(options?.agentUrl); + this.agentDispatcher = options?.agentDispatcher; // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -196,87 +205,24 @@ export default class ForestMCPServer { icons: [{ src: LOGO_URL, mimeType: 'image/png' }], }); + const ctx: ToolContext = { + forestServerClient: this.forestServerClient, + logger: this.logger, + collectionNames: this.collectionNames, + agentDispatcher: this.agentDispatcher, + }; + const allTools: Array<{ name: ToolName; register: () => string }> = [ - { - name: 'describeCollection', - register: () => - declareDescribeCollectionTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - }, - { - name: 'list', - register: () => - declareListTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - }, - { - name: 'listRelated', - register: () => - declareListRelatedTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - }, - { - name: 'create', - register: () => - declareCreateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - }, - { - name: 'update', - register: () => - declareUpdateTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - }, - { - name: 'delete', - register: () => - declareDeleteTool(mcpServer, this.forestServerClient, this.logger, this.collectionNames), - }, - { - name: 'associate', - register: () => - declareAssociateTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - }, - { - name: 'dissociate', - register: () => - declareDissociateTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - }, - { - name: 'getActionForm', - register: () => - declareGetActionFormTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - }, - { - name: 'executeAction', - register: () => - declareExecuteActionTool( - mcpServer, - this.forestServerClient, - this.logger, - this.collectionNames, - ), - }, + { name: 'describeCollection', register: () => declareDescribeCollectionTool(mcpServer, ctx) }, + { name: 'list', register: () => declareListTool(mcpServer, ctx) }, + { name: 'listRelated', register: () => declareListRelatedTool(mcpServer, ctx) }, + { name: 'create', register: () => declareCreateTool(mcpServer, ctx) }, + { name: 'update', register: () => declareUpdateTool(mcpServer, ctx) }, + { name: 'delete', register: () => declareDeleteTool(mcpServer, ctx) }, + { name: 'associate', register: () => declareAssociateTool(mcpServer, ctx) }, + { name: 'dissociate', register: () => declareDissociateTool(mcpServer, ctx) }, + { name: 'getActionForm', register: () => declareGetActionFormTool(mcpServer, ctx) }, + { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); diff --git a/packages/mcp-server/src/tool-context.ts b/packages/mcp-server/src/tool-context.ts new file mode 100644 index 0000000000..97deb6c54a --- /dev/null +++ b/packages/mcp-server/src/tool-context.ts @@ -0,0 +1,11 @@ +import type { ForestServerClient } from './http-client'; +import type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; +import type { Logger } from './server'; + +export interface ToolContext { + forestServerClient: ForestServerClient; + logger: Logger; + collectionNames?: string[]; + // Present only when the MCP server is mounted in an agent — tool calls then dispatch in-process. + agentDispatcher?: InProcessAgentDispatcher; +} diff --git a/packages/mcp-server/src/tools/associate.ts b/packages/mcp-server/src/tools/associate.ts index ba2f71d052..88116a4538 100644 --- a/packages/mcp-server/src/tools/associate.ts +++ b/packages/mcp-server/src/tools/associate.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -29,12 +28,8 @@ function createAssociateArgumentShape(collectionNames: string[]) { }; } -export default function declareAssociateTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareAssociateTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createAssociateArgumentShape(collectionNames); return registerToolWithLogging( @@ -47,7 +42,7 @@ export default function declareAssociateTool( inputSchema: argumentShape, }, async (options: AssociateArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); return withActivityLog({ forestServerClient, diff --git a/packages/mcp-server/src/tools/create.ts b/packages/mcp-server/src/tools/create.ts index f9bd31de55..6bbf635b5b 100644 --- a/packages/mcp-server/src/tools/create.ts +++ b/packages/mcp-server/src/tools/create.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -34,12 +33,8 @@ function createArgumentShape(collectionNames: string[]) { }; } -export default function declareCreateTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareCreateTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createArgumentShape(collectionNames); return registerToolWithLogging( @@ -51,7 +46,7 @@ export default function declareCreateTool( inputSchema: argumentShape, }, async (options: CreateArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); return withActivityLog({ forestServerClient, diff --git a/packages/mcp-server/src/tools/delete.ts b/packages/mcp-server/src/tools/delete.ts index 522944a719..3fbfd0dc41 100644 --- a/packages/mcp-server/src/tools/delete.ts +++ b/packages/mcp-server/src/tools/delete.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -23,12 +22,8 @@ function createArgumentShape(collectionNames: string[]) { }; } -export default function declareDeleteTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareDeleteTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createArgumentShape(collectionNames); return registerToolWithLogging( @@ -40,7 +35,7 @@ export default function declareDeleteTool( inputSchema: argumentShape, }, async (options: DeleteArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); // Cast to satisfy the type system - the API accepts both string[] and number[] const recordIds = options.recordIds as string[] | number[]; diff --git a/packages/mcp-server/src/tools/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts index 0cb3979c2a..1159ac59e0 100644 --- a/packages/mcp-server/src/tools/describe-collection.ts +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -1,5 +1,5 @@ -import type { ForestServerClient } from '../http-client'; import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -80,10 +80,9 @@ function mapRelationType(relationship: string | undefined): string { export default function declareDescribeCollectionTool( mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], + ctx: ToolContext, ): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createDescribeCollectionArgumentShape(collectionNames); return registerToolWithLogging( @@ -105,7 +104,7 @@ Check \`_meta\` for data availability context.`, inputSchema: argumentShape, }, async (options: DescribeCollectionArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); return withActivityLog({ forestServerClient, diff --git a/packages/mcp-server/src/tools/dissociate.ts b/packages/mcp-server/src/tools/dissociate.ts index cbb00958c7..d73eebdd0b 100644 --- a/packages/mcp-server/src/tools/dissociate.ts +++ b/packages/mcp-server/src/tools/dissociate.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -29,12 +28,8 @@ function createDissociateArgumentShape(collectionNames: string[]) { }; } -export default function declareDissociateTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareDissociateTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createDissociateArgumentShape(collectionNames); return registerToolWithLogging( @@ -47,7 +42,7 @@ export default function declareDissociateTool( inputSchema: argumentShape, }, async (options: DissociateArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); return withActivityLog({ forestServerClient, diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index 7b28c2c910..f85b592d53 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -17,12 +16,8 @@ interface ExecuteActionArgument { reasoning?: string; } -export default function declareExecuteActionTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareExecuteActionTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = { ...createActionArgumentShape(collectionNames), reasoning: z @@ -50,7 +45,11 @@ If you call executeAction with missing required fields, it will return an error inputSchema: argumentShape, }, async (options: ExecuteActionArgument, extra) => { - const { rpcClient } = await buildClientWithActions(extra, forestServerClient); + const { rpcClient } = await buildClientWithActions( + extra, + forestServerClient, + ctx.agentDispatcher, + ); // Cast to satisfy the type system - the API accepts both string[] and number[] const recordIds = (options.recordIds ?? []) as string[] | number[]; diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 390a23e80a..886cad640c 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createActionArgumentShape } from '../utils/action-helpers'; @@ -13,12 +12,8 @@ interface GetActionFormArgument { values?: Record; } -export default function declareGetActionFormTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareGetActionFormTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createActionArgumentShape(collectionNames); return registerToolWithLogging( @@ -44,7 +39,11 @@ The response includes: inputSchema: argumentShape, }, async (options: GetActionFormArgument, extra) => { - const { rpcClient } = await buildClientWithActions(extra, forestServerClient); + const { rpcClient } = await buildClientWithActions( + extra, + forestServerClient, + ctx.agentDispatcher, + ); // TODO: Enhance when more methods are available in the agent client helper // https://github.com/ForestAdmin/forestadmin-experimental/pull/140 diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts index 5564bf1da5..2b548d4947 100644 --- a/packages/mcp-server/src/tools/list-related.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -1,6 +1,7 @@ import type { ListArgument } from './list'; import type { ForestServerClient } from '../http-client'; import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { SelectOptions } from '@forestadmin/agent-client'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -70,12 +71,8 @@ function createErrorEnhancer( }; } -export default function declareListRelatedTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareListRelatedTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const listArgumentShape = createHasManyArgumentShape(collectionNames); return registerToolWithLogging( @@ -88,7 +85,7 @@ export default function declareListRelatedTool( inputSchema: listArgumentShape, }, async (options: HasManyArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); const labelParts = []; diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts index 15df9874c3..4cdfcce17c 100644 --- a/packages/mcp-server/src/tools/list.ts +++ b/packages/mcp-server/src/tools/list.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { SelectOptions } from '@forestadmin/agent-client'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -75,12 +74,8 @@ export function createListArgumentShape(collectionNames: string[]) { }; } -export default function declareListTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareListTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const listArgumentShape = createListArgumentShape(collectionNames); return registerToolWithLogging( @@ -93,7 +88,7 @@ export default function declareListTool( inputSchema: listArgumentShape, }, async (options: ListArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); let actionType: 'index' | 'search' | 'filter' = 'index'; diff --git a/packages/mcp-server/src/tools/update.ts b/packages/mcp-server/src/tools/update.ts index 75081bcf1c..a13ed966e9 100644 --- a/packages/mcp-server/src/tools/update.ts +++ b/packages/mcp-server/src/tools/update.ts @@ -1,5 +1,4 @@ -import type { ForestServerClient } from '../http-client'; -import type { Logger } from '../server'; +import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -36,12 +35,8 @@ function createArgumentShape(collectionNames: string[]) { }; } -export default function declareUpdateTool( - mcpServer: McpServer, - forestServerClient: ForestServerClient, - logger: Logger, - collectionNames: string[] = [], -): string { +export default function declareUpdateTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames = [] } = ctx; const argumentShape = createArgumentShape(collectionNames); return registerToolWithLogging( @@ -53,7 +48,7 @@ export default function declareUpdateTool( inputSchema: argumentShape, }, async (options: UpdateArgument, extra) => { - const { rpcClient } = buildClient(extra); + const { rpcClient } = buildClient(extra, ctx.agentDispatcher); return withActivityLog({ forestServerClient, diff --git a/packages/mcp-server/src/utils/agent-caller.ts b/packages/mcp-server/src/utils/agent-caller.ts index 4a533d0214..2da04c2a2e 100644 --- a/packages/mcp-server/src/utils/agent-caller.ts +++ b/packages/mcp-server/src/utils/agent-caller.ts @@ -1,16 +1,19 @@ import type { ActionEndpoints } from './schema-fetcher'; import type { ForestServerClient } from '../http-client'; +import type { InProcessAgentDispatcher } from '../in-process-agent-dispatcher'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; import { createRemoteAgentClient } from '@forestadmin/agent-client'; +import InProcessHttpRequester from '../in-process-http-requester'; import { fetchForestSchema, getActionEndpoints } from './schema-fetcher'; interface BuildClientOptions { request: RequestHandlerExtra; actionEndpoints?: ActionEndpoints; forestServerUrl?: string; + agentDispatcher?: InProcessAgentDispatcher; } export type AuthData = { @@ -23,7 +26,7 @@ export type AuthData = { }; function createClient(options: BuildClientOptions) { - const { request, actionEndpoints = {}, forestServerUrl } = options; + const { request, actionEndpoints = {}, forestServerUrl, agentDispatcher } = options; const token = request.authInfo?.token; const url = request.authInfo?.extra?.environmentApiEndpoint; const { forestServerToken, renderingId } = (request.authInfo?.extra ?? {}) as AuthData; @@ -32,7 +35,12 @@ function createClient(options: BuildClientOptions) { throw new Error('Authentication token is missing'); } - if (!url || typeof url !== 'string') { + // Mounted in an agent: reach it in-process. Standalone: reach the public api_endpoint over HTTP. + const httpRequester = agentDispatcher + ? new InProcessHttpRequester(token, agentDispatcher) + : undefined; + + if (!httpRequester && (!url || typeof url !== 'string')) { throw new Error('Environment API endpoint is missing or invalid'); } @@ -43,9 +51,10 @@ function createClient(options: BuildClientOptions) { const rpcClient = createRemoteAgentClient({ token, - url, + url: (url as string) ?? '', actionEndpoints, forestServer, + httpRequester, }); return { @@ -56,8 +65,9 @@ function createClient(options: BuildClientOptions) { export default function buildClient( request: RequestHandlerExtra, + agentDispatcher?: InProcessAgentDispatcher, ) { - return createClient({ request }); + return createClient({ request, agentDispatcher }); } /** @@ -67,6 +77,7 @@ export default function buildClient( export async function buildClientWithActions( request: RequestHandlerExtra, forestServerClient: ForestServerClient, + agentDispatcher?: InProcessAgentDispatcher, ) { const schema = await fetchForestSchema(forestServerClient); const actionEndpoints = getActionEndpoints(schema); @@ -75,5 +86,6 @@ export async function buildClientWithActions( request, actionEndpoints, forestServerUrl: forestServerClient.forestServerUrl, + agentDispatcher, }); } diff --git a/packages/mcp-server/test/in-process-http-requester.test.ts b/packages/mcp-server/test/in-process-http-requester.test.ts new file mode 100644 index 0000000000..383906ddf3 --- /dev/null +++ b/packages/mcp-server/test/in-process-http-requester.test.ts @@ -0,0 +1,65 @@ +import type { InProcessAgentDispatcher } from '../src/in-process-agent-dispatcher'; + +import { AgentHttpError } from '@forestadmin/agent-client'; + +import InProcessHttpRequester from '../src/in-process-http-requester'; + +describe('InProcessHttpRequester', () => { + function setup(response: { status: number; body: unknown; text: string }) { + const request = jest.fn().mockResolvedValue(response); + const dispatcher: InProcessAgentDispatcher = { request }; + const requester = new InProcessHttpRequester('bearer-token', dispatcher); + + return { request, requester }; + } + + it('dispatches the request in-process with auth, content-type and timezone', async () => { + const { request, requester } = setup({ status: 200, body: { records: [] }, text: '' }); + + await requester.query({ + method: 'post', + path: '/forest/books', + body: { name: 'x' }, + query: { search: 'foo' }, + skipDeserialization: true, + }); + + expect(request).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/books', + headers: { + Authorization: 'Bearer bearer-token', + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + query: { timezone: 'Europe/Paris', search: 'foo' }, + payload: { name: 'x' }, + }); + }); + + it('returns the raw body when skipDeserialization is set', async () => { + const { requester } = setup({ status: 200, body: { id: 1 }, text: '' }); + + const result = await requester.query({ + method: 'get', + path: '/forest/books/1', + skipDeserialization: true, + }); + + expect(result).toEqual({ id: 1 }); + }); + + it('throws an AgentHttpError carrying the status when the agent responds >= 400', async () => { + const { requester } = setup({ status: 403, body: { error: 'forbidden' }, text: 'forbidden' }); + + await expect( + requester.query({ method: 'post', path: '/forest/books/1/actions/x' }), + ).rejects.toMatchObject({ constructor: AgentHttpError, status: 403 }); + }); + + it('does not support CSV export in-process', async () => { + const { requester } = setup({ status: 200, body: null, text: '' }); + + await expect(requester.stream()).rejects.toThrow('CSV export is not supported'); + }); +}); diff --git a/packages/mcp-server/test/tools/associate.test.ts b/packages/mcp-server/test/tools/associate.test.ts index adab067d31..ea136f42a4 100644 --- a/packages/mcp-server/test/tools/associate.test.ts +++ b/packages/mcp-server/test/tools/associate.test.ts @@ -42,7 +42,10 @@ describe('declareAssociateTool', () => { describe('tool registration', () => { it('should register a tool named "associate"', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'associate', @@ -52,7 +55,10 @@ describe('declareAssociateTool', () => { }); it('should register tool with correct title and description', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Associate records in a relation'); expect(registeredToolConfig.description).toBe( @@ -61,13 +67,19 @@ describe('declareAssociateTool', () => { }); it('should not be annotated as read-only', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -76,7 +88,11 @@ describe('declareAssociateTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger, ['users', 'posts']); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'posts'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -98,7 +114,10 @@ describe('declareAssociateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call associate on the relation', async () => { diff --git a/packages/mcp-server/test/tools/create.test.ts b/packages/mcp-server/test/tools/create.test.ts index 6033acd52c..c932bdf5c9 100644 --- a/packages/mcp-server/test/tools/create.test.ts +++ b/packages/mcp-server/test/tools/create.test.ts @@ -42,7 +42,10 @@ describe('declareCreateTool', () => { describe('tool registration', () => { it('should register a tool named "create"', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'create', @@ -52,7 +55,10 @@ describe('declareCreateTool', () => { }); it('should register tool with correct title and description', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Create a record'); expect(registeredToolConfig.description).toBe( @@ -61,20 +67,29 @@ describe('declareCreateTool', () => { }); it('should not be annotated as read-only', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('attributes'); }); it('should use string type for collectionName when no collection names provided', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -85,7 +100,11 @@ describe('declareCreateTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger, ['users', 'products']); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -109,7 +128,10 @@ describe('declareCreateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClient with the extra parameter', async () => { @@ -125,7 +147,7 @@ describe('declareCreateTool', () => { mockExtra, ); - expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, undefined); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/tools/delete.test.ts b/packages/mcp-server/test/tools/delete.test.ts index a4e3688ebe..6107c34fb9 100644 --- a/packages/mcp-server/test/tools/delete.test.ts +++ b/packages/mcp-server/test/tools/delete.test.ts @@ -42,7 +42,10 @@ describe('declareDeleteTool', () => { describe('tool registration', () => { it('should register a tool named "delete"', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'delete', @@ -52,7 +55,10 @@ describe('declareDeleteTool', () => { }); it('should register tool with correct title and description', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Delete records'); expect(registeredToolConfig.description).toBe( @@ -61,20 +67,29 @@ describe('declareDeleteTool', () => { }); it('should not be annotated as read-only', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('recordIds'); }); it('should use string type for collectionName when no collection names provided', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -85,7 +100,11 @@ describe('declareDeleteTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger, ['users', 'products']); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -97,7 +116,10 @@ describe('declareDeleteTool', () => { }); it('should accept array of strings or numbers for recordIds', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -121,7 +143,10 @@ describe('declareDeleteTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClient with the extra parameter', async () => { @@ -134,7 +159,7 @@ describe('declareDeleteTool', () => { await registeredToolHandler({ collectionName: 'users', recordIds: [1, 2] }, mockExtra); - expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, undefined); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/tools/describe-collection.test.ts b/packages/mcp-server/test/tools/describe-collection.test.ts index 6044f42887..dceb91d956 100644 --- a/packages/mcp-server/test/tools/describe-collection.test.ts +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -57,7 +57,10 @@ describe('declareDescribeCollectionTool', () => { describe('tool registration', () => { it('should register a tool named "describeCollection"', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'describeCollection', @@ -67,7 +70,10 @@ describe('declareDescribeCollectionTool', () => { }); it('should register tool with correct title and description', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Describe a collection'); expect(registeredToolConfig.description).toContain( @@ -78,19 +84,28 @@ describe('declareDescribeCollectionTool', () => { }); it('should be annotated as read-only', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); }); it('should use string type for collectionName when no collection names provided', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -103,11 +118,11 @@ describe('declareDescribeCollectionTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger, [ - 'users', - 'products', - 'orders', - ]); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products', 'orders'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -135,7 +150,10 @@ describe('declareDescribeCollectionTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClient with the extra parameter', async () => { @@ -151,7 +169,7 @@ describe('declareDescribeCollectionTool', () => { await registeredToolHandler({ collectionName: 'users' }, mockExtra); - expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, undefined); }); it('should fetch forest schema', async () => { diff --git a/packages/mcp-server/test/tools/dissociate.test.ts b/packages/mcp-server/test/tools/dissociate.test.ts index fb2079d759..459ce18871 100644 --- a/packages/mcp-server/test/tools/dissociate.test.ts +++ b/packages/mcp-server/test/tools/dissociate.test.ts @@ -42,7 +42,10 @@ describe('declareDissociateTool', () => { describe('tool registration', () => { it('should register a tool named "dissociate"', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'dissociate', @@ -52,7 +55,10 @@ describe('declareDissociateTool', () => { }); it('should register tool with correct title and description', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Dissociate records from a relation'); expect(registeredToolConfig.description).toBe( @@ -61,13 +67,19 @@ describe('declareDissociateTool', () => { }); it('should not be annotated as read-only', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -76,7 +88,11 @@ describe('declareDissociateTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger, ['users', 'posts']); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'posts'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -98,7 +114,10 @@ describe('declareDissociateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call dissociate on the relation', async () => { diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index ca606ad68d..41c4a1d899 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -47,7 +47,10 @@ describe('declareExecuteActionTool', () => { describe('tool registration', () => { it('should register a tool named "executeAction"', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'executeAction', @@ -57,7 +60,10 @@ describe('declareExecuteActionTool', () => { }); it('should register tool with correct title and description', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Execute an action'); expect(registeredToolConfig.description).toContain( @@ -67,13 +73,19 @@ describe('declareExecuteActionTool', () => { }); it('should not be annotated as read-only', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); @@ -82,7 +94,10 @@ describe('declareExecuteActionTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -93,10 +108,11 @@ describe('declareExecuteActionTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger, [ - 'users', - 'products', - ]); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -108,7 +124,10 @@ describe('declareExecuteActionTool', () => { }); it('should accept array of strings or numbers for recordIds', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -120,7 +139,10 @@ describe('declareExecuteActionTool', () => { }); it('should accept null for recordIds (global actions)', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -130,7 +152,10 @@ describe('declareExecuteActionTool', () => { }); it('should accept optional values parameter', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -153,7 +178,10 @@ describe('declareExecuteActionTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClientWithActions with the extra parameter and forestServerUrl', async () => { @@ -174,7 +202,11 @@ describe('declareExecuteActionTool', () => { mockExtra, ); - expect(mockBuildClientWithActions).toHaveBeenCalledWith(mockExtra, mockForestServerClient); + expect(mockBuildClientWithActions).toHaveBeenCalledWith( + mockExtra, + mockForestServerClient, + undefined, + ); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index c412f772f0..fd23aa5da7 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -41,7 +41,10 @@ describe('declareGetActionFormTool', () => { describe('tool registration', () => { it('should register a tool named "getActionForm"', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'getActionForm', @@ -51,7 +54,10 @@ describe('declareGetActionFormTool', () => { }); it('should register tool with correct title and description', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Retrieve action form'); expect(registeredToolConfig.description).toContain( @@ -61,13 +67,19 @@ describe('declareGetActionFormTool', () => { }); it('should be annotated as read-only', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); @@ -76,7 +88,10 @@ describe('declareGetActionFormTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -87,10 +102,11 @@ describe('declareGetActionFormTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger, [ - 'users', - 'products', - ]); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -102,7 +118,10 @@ describe('declareGetActionFormTool', () => { }); it('should accept array of strings or numbers for recordIds', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -114,7 +133,10 @@ describe('declareGetActionFormTool', () => { }); it('should accept null for recordIds (global actions)', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -124,7 +146,10 @@ describe('declareGetActionFormTool', () => { }); it('should accept optional values parameter', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -147,7 +172,10 @@ describe('declareGetActionFormTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClientWithActions with the extra parameter and forestServerUrl', async () => { @@ -168,7 +196,11 @@ describe('declareGetActionFormTool', () => { mockExtra, ); - expect(mockBuildClientWithActions).toHaveBeenCalledWith(mockExtra, mockForestServerClient); + expect(mockBuildClientWithActions).toHaveBeenCalledWith( + mockExtra, + mockForestServerClient, + undefined, + ); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/tools/list-related.test.ts b/packages/mcp-server/test/tools/list-related.test.ts index 24e1c0bcf9..a0084b6a02 100644 --- a/packages/mcp-server/test/tools/list-related.test.ts +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -81,7 +81,10 @@ describe('declareListRelatedTool', () => { describe('tool registration', () => { it('should register a tool named "listRelated"', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'listRelated', @@ -91,7 +94,10 @@ describe('declareListRelatedTool', () => { }); it('should register tool with correct title and description', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('List records from a relation'); expect(registeredToolConfig.description).toBe( @@ -100,13 +106,19 @@ describe('declareListRelatedTool', () => { }); it('should be annotated as read-only', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -117,7 +129,10 @@ describe('declareListRelatedTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -130,7 +145,11 @@ describe('declareListRelatedTool', () => { }); it('should use string type for collectionName when empty array provided', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger, []); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -143,11 +162,11 @@ describe('declareListRelatedTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger, [ - 'users', - 'products', - 'orders', - ]); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products', 'orders'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -163,7 +182,10 @@ describe('declareListRelatedTool', () => { }); it('should accept string parentRecordId', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -173,7 +195,10 @@ describe('declareListRelatedTool', () => { }); it('should accept number parentRecordId', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -195,7 +220,10 @@ describe('declareListRelatedTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClient with the extra parameter', async () => { @@ -212,7 +240,7 @@ describe('declareListRelatedTool', () => { mockExtra, ); - expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, undefined); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/tools/list.test.ts b/packages/mcp-server/test/tools/list.test.ts index 82b62fa091..5822ff85e7 100644 --- a/packages/mcp-server/test/tools/list.test.ts +++ b/packages/mcp-server/test/tools/list.test.ts @@ -79,7 +79,10 @@ describe('declareListTool', () => { describe('tool registration', () => { it('should register a tool named "list"', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'list', @@ -89,7 +92,10 @@ describe('declareListTool', () => { }); it('should register tool with correct title and description', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('List records from a collection'); expect(registeredToolConfig.description).toBe( @@ -98,13 +104,19 @@ describe('declareListTool', () => { }); it('should be annotated as read-only', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('search'); @@ -116,7 +128,10 @@ describe('declareListTool', () => { }); it('should have fields schema with description mentioning @@@ separator for relations', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const schema = registeredToolConfig.inputSchema as any; @@ -129,7 +144,10 @@ describe('declareListTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -142,7 +160,10 @@ describe('declareListTool', () => { }); it('should use string type for collectionName when empty array provided', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -155,11 +176,11 @@ describe('declareListTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger, [ - 'users', - 'products', - 'orders', - ]); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products', 'orders'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -175,7 +196,10 @@ describe('declareListTool', () => { }); it('should make ascending optional in sort schema with default value true', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -199,7 +223,10 @@ describe('declareListTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClient with the extra parameter', async () => { @@ -212,7 +239,7 @@ describe('declareListTool', () => { await registeredToolHandler({ collectionName: 'users' }, mockExtra); - expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, undefined); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/tools/update.test.ts b/packages/mcp-server/test/tools/update.test.ts index 8901a3f000..8f1e3b2109 100644 --- a/packages/mcp-server/test/tools/update.test.ts +++ b/packages/mcp-server/test/tools/update.test.ts @@ -42,7 +42,10 @@ describe('declareUpdateTool', () => { describe('tool registration', () => { it('should register a tool named "update"', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'update', @@ -52,7 +55,10 @@ describe('declareUpdateTool', () => { }); it('should register tool with correct title and description', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.title).toBe('Update a record'); expect(registeredToolConfig.description).toBe( @@ -61,13 +67,19 @@ describe('declareUpdateTool', () => { }); it('should not be annotated as read-only', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('recordId'); @@ -75,7 +87,10 @@ describe('declareUpdateTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -86,7 +101,11 @@ describe('declareUpdateTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger, ['users', 'products']); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['users', 'products'], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -98,7 +117,10 @@ describe('declareUpdateTool', () => { }); it('should accept both string and number for recordId', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -121,7 +143,10 @@ describe('declareUpdateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + }); }); it('should call buildClient with the extra parameter', async () => { @@ -137,7 +162,7 @@ describe('declareUpdateTool', () => { mockExtra, ); - expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, undefined); }); it('should call rpcClient.collection with the collection name', async () => { diff --git a/packages/mcp-server/test/utils/agent-caller.test.ts b/packages/mcp-server/test/utils/agent-caller.test.ts index ca7445be82..5e42a6556e 100644 --- a/packages/mcp-server/test/utils/agent-caller.test.ts +++ b/packages/mcp-server/test/utils/agent-caller.test.ts @@ -4,6 +4,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { createRemoteAgentClient } from '@forestadmin/agent-client'; +import InProcessHttpRequester from '../../src/in-process-http-requester'; import buildClient, { buildClientWithActions } from '../../src/utils/agent-caller'; import { fetchForestSchema, getActionEndpoints } from '../../src/utils/schema-fetcher'; import createMockForestServerClient from '../helpers/forest-server-client'; @@ -129,6 +130,45 @@ describe('buildClient', () => { expect(() => buildClient(request)).toThrow('Environment API endpoint is missing or invalid'); }); + + describe('when an agentDispatcher is provided', () => { + const agentDispatcher = { request: jest.fn() }; + + beforeEach(() => mockCreateRemoteAgentClient.mockClear()); + + it('builds an in-process httpRequester instead of reaching a url', () => { + const request = { + authInfo: { token: 'test-token', extra: { environmentApiEndpoint: 'http://public:3310' } }, + } as unknown as RequestHandlerExtra; + + buildClient(request, agentDispatcher); + + expect(mockCreateRemoteAgentClient).toHaveBeenCalledWith( + expect.objectContaining({ httpRequester: expect.any(InProcessHttpRequester) }), + ); + }); + + it('does not require environmentApiEndpoint', () => { + const request = { + authInfo: { token: 'test-token', extra: {} }, + } as unknown as RequestHandlerExtra; + + expect(() => buildClient(request, agentDispatcher)).not.toThrow(); + }); + }); + + it('does not build an httpRequester when no agentDispatcher is provided', () => { + mockCreateRemoteAgentClient.mockClear(); + const request = { + authInfo: { token: 'test-token', extra: { environmentApiEndpoint: 'http://localhost:3310' } }, + } as unknown as RequestHandlerExtra; + + buildClient(request); + + expect(mockCreateRemoteAgentClient).toHaveBeenCalledWith( + expect.objectContaining({ httpRequester: undefined }), + ); + }); }); describe('buildClientWithActions', () => { From fec6e70f012e0e4f7a195b1f7ecaa310e6243134 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 20:04:39 +0200 Subject: [PATCH 09/10] test(agent): assert mounted MCP tool calls dispatch in-process Guards against a regression to the HTTP path: a real OAuth-authed tools/call must reach the agent through the InProcessDispatcher, across every host framework. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/test/agent-integration.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/agent/test/agent-integration.test.ts b/packages/agent/test/agent-integration.test.ts index 3a7242693c..4a545e120f 100644 --- a/packages/agent/test/agent-integration.test.ts +++ b/packages/agent/test/agent-integration.test.ts @@ -594,6 +594,37 @@ describe('Agent Integration Tests', () => { lastName: 'Wilson', }); }); + + it('dispatches the tool call to the agent in-process, not over a socket', async () => { + const dispatcher = ( + testContext.agent as unknown as { + getInProcessDispatcher: () => { request: (...args: unknown[]) => Promise }; + } + ).getInProcessDispatcher(); + const requestSpy = jest.spyOn(dispatcher, 'request'); + + try { + const token = createTestToken({ scopes: ['mcp:read'], renderingId: 1 }); + + await superagent + .post(`${testContext.baseUrl}/mcp`) + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .set('Accept', 'text/event-stream, application/json') + .send({ + jsonrpc: '2.0', + method: 'tools/call', + id: 3, + params: { name: 'list', arguments: { collectionName: 'users' } }, + }); + + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ method: 'get', path: '/forest/users' }), + ); + } finally { + requestSpy.mockRestore(); + } + }); }); describe('Routes coexistence', () => { From ae3a5c29fb8b8679bdde20da3ff5ccf8f54c87f8 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 7 Jul 2026 20:38:18 +0200 Subject: [PATCH 10/10] fix(mcp-server): address in-process dispatch review findings - Bound in-process dispatch with a timeout (default 10s, honors maxTimeAllowed) so a hung agent handler can't hang a tool call forever, matching the HTTP path's superagent timeout. - Thread a logger into InProcessDispatcher; distinguish an expected empty (204) body from a corrupted non-JSON 2xx body, which is now logged instead of silently returning undefined. - Pin the cross-package contract: InProcessDispatcher now `implements InProcessAgentDispatcher` (exported from mcp-server), so drift fails to compile at the definition, not just at one wiring line. - Make ToolContext.collectionNames required and default it once in the server instead of `= []` in all 10 tool factories. - Document on mountAiMcpServer() + log at startup that in-process tool calls bypass any middleware mounted in front of the agent. Tests: approval-gate 403 error-shape parity, maxTimeAllowed forwarding, timeout, 204/non-JSON body handling, payload+content-type forwarding through a real handler, buildClientWithActions dispatcher wiring, and a status assertion on the in-process integration test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent/src/agent.ts | 8 ++ packages/agent/src/framework-mounter.ts | 3 +- .../agent/src/mcp-in-process-dispatcher.ts | 59 +++++++++++---- packages/agent/test/agent-integration.test.ts | 3 +- .../test/mcp-in-process-dispatcher.test.ts | 75 +++++++++++++++++++ .../src/in-process-agent-dispatcher.ts | 1 + .../src/in-process-http-requester.ts | 2 + packages/mcp-server/src/index.ts | 1 + packages/mcp-server/src/tool-context.ts | 2 +- packages/mcp-server/src/tools/associate.ts | 2 +- packages/mcp-server/src/tools/create.ts | 2 +- packages/mcp-server/src/tools/delete.ts | 2 +- .../src/tools/describe-collection.ts | 2 +- packages/mcp-server/src/tools/dissociate.ts | 2 +- .../mcp-server/src/tools/execute-action.ts | 2 +- .../mcp-server/src/tools/get-action-form.ts | 2 +- packages/mcp-server/src/tools/list-related.ts | 2 +- packages/mcp-server/src/tools/list.ts | 2 +- packages/mcp-server/src/tools/update.ts | 2 +- .../test/in-process-http-requester.test.ts | 29 +++++++ .../mcp-server/test/tools/associate.test.ts | 5 ++ packages/mcp-server/test/tools/create.test.ts | 6 ++ packages/mcp-server/test/tools/delete.test.ts | 7 ++ .../test/tools/describe-collection.test.ts | 6 ++ .../mcp-server/test/tools/dissociate.test.ts | 5 ++ .../test/tools/execute-action.test.ts | 9 +++ .../test/tools/get-action-form.test.ts | 9 +++ .../test/tools/list-related.test.ts | 8 ++ packages/mcp-server/test/tools/list.test.ts | 9 +++ packages/mcp-server/test/tools/update.test.ts | 7 ++ .../test/utils/agent-caller.test.ts | 16 ++++ 31 files changed, 264 insertions(+), 26 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 33465274a9..dfad6e4e30 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -244,6 +244,10 @@ export default class Agent extends FrameworkMounter * Enable MCP (Model Context Protocol) server support. * This allows AI assistants to interact with your Forest Admin data. * + * Tool calls reach your data in-process (no socket), so they bypass any middleware you mounted + * in front of the agent (rate limiting, request logging, WAF): only the agent's own JWT auth and + * permission checks run on them. + * * @see {@link https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/ai/mcp-server} * @example * agent.mountAiMcpServer(); @@ -432,6 +436,10 @@ export default class Agent extends FrameworkMounter const isMcpRoute = makeIsMcpRoute(this.mcpBasePath); mcpLogger('Info', 'Server initialized successfully'); + mcpLogger( + 'Info', + 'Tool calls dispatch in-process and skip any middleware mounted in front of the agent', + ); return { httpCallback, isMcpRoute }; } catch (error) { diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index b671b5b583..2b44ba04f2 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -25,7 +25,7 @@ export default class FrameworkMounter { private readonly fastifyAdapter: FastifyAdapter; private readonly mcpMiddleware: McpMiddleware; - private readonly inProcessDispatcher = new InProcessDispatcher(); + private readonly inProcessDispatcher: InProcessDispatcher; private inProcessHookRegistered = false; /** Compute the prefix that the main router should be mounted at in the client's application */ @@ -38,6 +38,7 @@ export default class FrameworkMounter { this.logger = logger; this.fastifyAdapter = new FastifyAdapter(logger); this.mcpMiddleware = new McpMiddleware(); + this.inProcessDispatcher = new InProcessDispatcher(logger); } /** diff --git a/packages/agent/src/mcp-in-process-dispatcher.ts b/packages/agent/src/mcp-in-process-dispatcher.ts index 30a5e234ba..208b95a105 100644 --- a/packages/agent/src/mcp-in-process-dispatcher.ts +++ b/packages/agent/src/mcp-in-process-dispatcher.ts @@ -1,3 +1,5 @@ +import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { InProcessAgentDispatcher } from '@forestadmin/mcp-server'; import type { RequestListener } from 'http'; import inject, { type InjectOptions, type InjectPayload } from 'light-my-request'; @@ -8,11 +10,15 @@ export type InProcessRequest = { headers: Record; query?: Record; payload?: unknown; + timeoutMs?: number; }; // Superagent-shaped so agent-client's HttpRequester helpers parse it identically. export type InProcessResponse = { status: number; body: unknown; text: string }; +// Matches superagent's HTTP path, which times out after 10s (HttpRequester.query/stream). +const DEFAULT_TIMEOUT_MS = 10_000; + function toStringQuery(query: Record): Record { const result: Record = {}; @@ -28,9 +34,11 @@ function toStringQuery(query: Record): Record { * mounted MCP server's tool calls run through the real auth/permission/logging middleware. * `setHandler` is refreshed on every (re)mount, so a captured reference never goes stale. */ -export default class InProcessDispatcher { +export default class InProcessDispatcher implements InProcessAgentDispatcher { private handler: RequestListener | null = null; + constructor(private readonly logger?: Logger) {} + setHandler(handler: RequestListener | null): void { this.handler = handler; } @@ -41,27 +49,52 @@ export default class InProcessDispatcher { headers, query, payload, + timeoutMs = DEFAULT_TIMEOUT_MS, }: InProcessRequest): Promise { if (!this.handler) { throw new Error('Cannot dispatch to the agent in-process: it is not mounted yet.'); } - const response = await inject(this.handler, { - method: method.toUpperCase() as InjectOptions['method'], - url: path, - headers, - ...(query && { query: toStringQuery(query) }), - ...(payload !== undefined && { payload: payload as InjectPayload }), - }); + // No socket to abort, so a hung agent handler would hang the tool call forever without this. + const response = await this.injectWithTimeout( + { + method: method.toUpperCase() as InjectOptions['method'], + url: path, + headers, + ...(query && { query: toStringQuery(query) }), + ...(payload !== undefined && { payload: payload as InjectPayload }), + }, + timeoutMs, + ); - let body: unknown; + return { status: response.statusCode, body: this.parseBody(response), text: response.payload }; + } + + private async injectWithTimeout(options: InjectOptions, timeoutMs: number) { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`In-process dispatch timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); try { - body = response.json(); - } catch { - body = undefined; + return await Promise.race([inject(this.handler as RequestListener, options), timeout]); + } finally { + if (timer) clearTimeout(timer); } + } + + private parseBody(response: { payload: string }): unknown { + if (response.payload === '') return undefined; - return { status: response.statusCode, body, text: response.payload }; + try { + return JSON.parse(response.payload); + } catch (error) { + this.logger?.('Warn', `In-process dispatch received a non-JSON response body: ${error}`); + + return undefined; + } } } diff --git a/packages/agent/test/agent-integration.test.ts b/packages/agent/test/agent-integration.test.ts index 4a545e120f..1fec67e3fa 100644 --- a/packages/agent/test/agent-integration.test.ts +++ b/packages/agent/test/agent-integration.test.ts @@ -606,7 +606,7 @@ describe('Agent Integration Tests', () => { try { const token = createTestToken({ scopes: ['mcp:read'], renderingId: 1 }); - await superagent + const response = await superagent .post(`${testContext.baseUrl}/mcp`) .set('Authorization', `Bearer ${token}`) .set('Content-Type', 'application/json') @@ -618,6 +618,7 @@ describe('Agent Integration Tests', () => { params: { name: 'list', arguments: { collectionName: 'users' } }, }); + expect(response.status).toBe(200); expect(requestSpy).toHaveBeenCalledWith( expect.objectContaining({ method: 'get', path: '/forest/users' }), ); diff --git a/packages/agent/test/mcp-in-process-dispatcher.test.ts b/packages/agent/test/mcp-in-process-dispatcher.test.ts index 27bdabe40a..f7933ab27e 100644 --- a/packages/agent/test/mcp-in-process-dispatcher.test.ts +++ b/packages/agent/test/mcp-in-process-dispatcher.test.ts @@ -77,6 +77,81 @@ describe('InProcessDispatcher', () => { expect(response.body).toEqual({ got: 'bar' }); }); + it('forwards the payload and content-type to the handler (mutating call)', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler( + handlerFor(r => + r.post('/echo', async ctx => { + const raw = await new Promise(resolve => { + let data = ''; + ctx.req.on('data', chunk => { + data += chunk; + }); + ctx.req.on('end', () => resolve(data)); + }); + + ctx.body = { received: JSON.parse(raw), contentType: ctx.headers['content-type'] }; + }), + ), + ); + + const response = await dispatcher.request({ + method: 'post', + path: '/echo', + headers: { 'Content-Type': 'application/json' }, + payload: { name: 'Ada' }, + }); + + expect(response.body).toEqual({ + received: { name: 'Ada' }, + contentType: 'application/json', + }); + }); + + it('rejects when the handler does not respond within the timeout', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler(handlerFor(r => r.get('/hang', () => new Promise(() => {})))); + + await expect( + dispatcher.request({ method: 'get', path: '/hang', headers: {}, timeoutMs: 50 }), + ).rejects.toThrow(/timed out after 50ms/); + }); + + it('returns an undefined body for an empty (204) response', async () => { + const dispatcher = new InProcessDispatcher(); + dispatcher.setHandler( + handlerFor(r => + r.delete('/x', ctx => { + ctx.status = 204; + }), + ), + ); + + const response = await dispatcher.request({ method: 'delete', path: '/x', headers: {} }); + + expect(response.status).toBe(204); + expect(response.body).toBeUndefined(); + }); + + it('warns and returns an undefined body when a non-empty response body is not JSON', async () => { + const logger = jest.fn(); + const dispatcher = new InProcessDispatcher(logger); + dispatcher.setHandler( + handlerFor(r => + r.get('/broken', ctx => { + ctx.type = 'text/plain'; + ctx.body = 'not json'; + }), + ), + ); + + const response = await dispatcher.request({ method: 'get', path: '/broken', headers: {} }); + + expect(response.body).toBeUndefined(); + expect(response.text).toBe('not json'); + expect(logger).toHaveBeenCalledWith('Warn', expect.stringContaining('non-JSON response body')); + }); + it('reflects a handler swap (remount) instead of a stale reference', async () => { const dispatcher = new InProcessDispatcher(); dispatcher.setHandler( diff --git a/packages/mcp-server/src/in-process-agent-dispatcher.ts b/packages/mcp-server/src/in-process-agent-dispatcher.ts index 901f9176e9..b81c9924a5 100644 --- a/packages/mcp-server/src/in-process-agent-dispatcher.ts +++ b/packages/mcp-server/src/in-process-agent-dispatcher.ts @@ -7,5 +7,6 @@ export interface InProcessAgentDispatcher { headers: Record; query?: Record; payload?: unknown; + timeoutMs?: number; }): Promise<{ status: number; body: unknown; text: string }>; } diff --git a/packages/mcp-server/src/in-process-http-requester.ts b/packages/mcp-server/src/in-process-http-requester.ts index d70996a2ea..4594c4552b 100644 --- a/packages/mcp-server/src/in-process-http-requester.ts +++ b/packages/mcp-server/src/in-process-http-requester.ts @@ -25,6 +25,7 @@ export default class InProcessHttpRequester extends HttpRequester { path, body, query, + maxTimeAllowed, contentType, skipDeserialization, }: { @@ -50,6 +51,7 @@ export default class InProcessHttpRequester extends HttpRequester { }, query: { timezone: 'Europe/Paris', ...query }, payload: body, + timeoutMs: maxTimeAllowed, }); if (status >= 400) throw buildAgentHttpError(status, responseBody, text); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 8df997f3a3..3055f20058 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,6 +1,7 @@ // Library exports only - no side effects export { default as ForestMCPServer } from './server'; export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server'; +export type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; export { MCP_PATHS, isMcpRoute, makeIsMcpRoute } from './mcp-paths'; export { ForestServerClientImpl, createForestServerClient } from './http-client'; export type { diff --git a/packages/mcp-server/src/tool-context.ts b/packages/mcp-server/src/tool-context.ts index 97deb6c54a..fe5d3c783b 100644 --- a/packages/mcp-server/src/tool-context.ts +++ b/packages/mcp-server/src/tool-context.ts @@ -5,7 +5,7 @@ import type { Logger } from './server'; export interface ToolContext { forestServerClient: ForestServerClient; logger: Logger; - collectionNames?: string[]; + collectionNames: string[]; // Present only when the MCP server is mounted in an agent — tool calls then dispatch in-process. agentDispatcher?: InProcessAgentDispatcher; } diff --git a/packages/mcp-server/src/tools/associate.ts b/packages/mcp-server/src/tools/associate.ts index 88116a4538..b4abbf4dac 100644 --- a/packages/mcp-server/src/tools/associate.ts +++ b/packages/mcp-server/src/tools/associate.ts @@ -29,7 +29,7 @@ function createAssociateArgumentShape(collectionNames: string[]) { } export default function declareAssociateTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createAssociateArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/create.ts b/packages/mcp-server/src/tools/create.ts index 6bbf635b5b..1d2e588a64 100644 --- a/packages/mcp-server/src/tools/create.ts +++ b/packages/mcp-server/src/tools/create.ts @@ -34,7 +34,7 @@ function createArgumentShape(collectionNames: string[]) { } export default function declareCreateTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/delete.ts b/packages/mcp-server/src/tools/delete.ts index 3fbfd0dc41..5c6861596f 100644 --- a/packages/mcp-server/src/tools/delete.ts +++ b/packages/mcp-server/src/tools/delete.ts @@ -23,7 +23,7 @@ function createArgumentShape(collectionNames: string[]) { } export default function declareDeleteTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts index 1159ac59e0..f8c2fe7b53 100644 --- a/packages/mcp-server/src/tools/describe-collection.ts +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -82,7 +82,7 @@ export default function declareDescribeCollectionTool( mcpServer: McpServer, ctx: ToolContext, ): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createDescribeCollectionArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/dissociate.ts b/packages/mcp-server/src/tools/dissociate.ts index d73eebdd0b..e0ce0c8ba7 100644 --- a/packages/mcp-server/src/tools/dissociate.ts +++ b/packages/mcp-server/src/tools/dissociate.ts @@ -29,7 +29,7 @@ function createDissociateArgumentShape(collectionNames: string[]) { } export default function declareDissociateTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createDissociateArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index f85b592d53..a587859474 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -17,7 +17,7 @@ interface ExecuteActionArgument { } export default function declareExecuteActionTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = { ...createActionArgumentShape(collectionNames), reasoning: z diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 886cad640c..101db7bd18 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -13,7 +13,7 @@ interface GetActionFormArgument { } export default function declareGetActionFormTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createActionArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts index 2b548d4947..8ff9cdc28e 100644 --- a/packages/mcp-server/src/tools/list-related.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -72,7 +72,7 @@ function createErrorEnhancer( } export default function declareListRelatedTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const listArgumentShape = createHasManyArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts index 4cdfcce17c..e2748f5971 100644 --- a/packages/mcp-server/src/tools/list.ts +++ b/packages/mcp-server/src/tools/list.ts @@ -75,7 +75,7 @@ export function createListArgumentShape(collectionNames: string[]) { } export default function declareListTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const listArgumentShape = createListArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/src/tools/update.ts b/packages/mcp-server/src/tools/update.ts index a13ed966e9..08f4be403f 100644 --- a/packages/mcp-server/src/tools/update.ts +++ b/packages/mcp-server/src/tools/update.ts @@ -36,7 +36,7 @@ function createArgumentShape(collectionNames: string[]) { } export default function declareUpdateTool(mcpServer: McpServer, ctx: ToolContext): string { - const { forestServerClient, logger, collectionNames = [] } = ctx; + const { forestServerClient, logger, collectionNames } = ctx; const argumentShape = createArgumentShape(collectionNames); return registerToolWithLogging( diff --git a/packages/mcp-server/test/in-process-http-requester.test.ts b/packages/mcp-server/test/in-process-http-requester.test.ts index 383906ddf3..59531b848a 100644 --- a/packages/mcp-server/test/in-process-http-requester.test.ts +++ b/packages/mcp-server/test/in-process-http-requester.test.ts @@ -57,6 +57,35 @@ describe('InProcessHttpRequester', () => { ).rejects.toMatchObject({ constructor: AgentHttpError, status: 403 }); }); + it('preserves the approval-gate error shape (403 + CustomActionRequiresApprovalError)', async () => { + const approvalBody = { errors: [{ name: 'CustomActionRequiresApprovalError' }] }; + const approvalText = JSON.stringify(approvalBody); + const { requester } = setup({ status: 403, body: approvalBody, text: approvalText }); + + const error = (await requester + .query({ method: 'post', path: '/forest/books/1/actions/x' }) + .catch(e => e)) as AgentHttpError; + + // domains/action.ts routes on both of these to raise ActionRequiresApprovalError. + expect(error).toBeInstanceOf(AgentHttpError); + expect(error.status).toBe(403); + expect(error.body).toEqual(approvalBody); + expect(error.responseText).toContain('CustomActionRequiresApprovalError'); + }); + + it('forwards maxTimeAllowed to the dispatcher as timeoutMs', async () => { + const { request, requester } = setup({ status: 200, body: {}, text: '{}' }); + + await requester.query({ + method: 'get', + path: '/forest/books', + maxTimeAllowed: 2000, + skipDeserialization: true, + }); + + expect(request).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2000 })); + }); + it('does not support CSV export in-process', async () => { const { requester } = setup({ status: 200, body: null, text: '' }); diff --git a/packages/mcp-server/test/tools/associate.test.ts b/packages/mcp-server/test/tools/associate.test.ts index ea136f42a4..af97660b87 100644 --- a/packages/mcp-server/test/tools/associate.test.ts +++ b/packages/mcp-server/test/tools/associate.test.ts @@ -45,6 +45,7 @@ describe('declareAssociateTool', () => { declareAssociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -58,6 +59,7 @@ describe('declareAssociateTool', () => { declareAssociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Associate records in a relation'); @@ -70,6 +72,7 @@ describe('declareAssociateTool', () => { declareAssociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); @@ -79,6 +82,7 @@ describe('declareAssociateTool', () => { declareAssociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -117,6 +121,7 @@ describe('declareAssociateTool', () => { declareAssociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/create.test.ts b/packages/mcp-server/test/tools/create.test.ts index c932bdf5c9..acacadc6ce 100644 --- a/packages/mcp-server/test/tools/create.test.ts +++ b/packages/mcp-server/test/tools/create.test.ts @@ -45,6 +45,7 @@ describe('declareCreateTool', () => { declareCreateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -58,6 +59,7 @@ describe('declareCreateTool', () => { declareCreateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Create a record'); @@ -70,6 +72,7 @@ describe('declareCreateTool', () => { declareCreateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); @@ -79,6 +82,7 @@ describe('declareCreateTool', () => { declareCreateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -89,6 +93,7 @@ describe('declareCreateTool', () => { declareCreateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -131,6 +136,7 @@ describe('declareCreateTool', () => { declareCreateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/delete.test.ts b/packages/mcp-server/test/tools/delete.test.ts index 6107c34fb9..8251bad0b7 100644 --- a/packages/mcp-server/test/tools/delete.test.ts +++ b/packages/mcp-server/test/tools/delete.test.ts @@ -45,6 +45,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -58,6 +59,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Delete records'); @@ -70,6 +72,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); @@ -79,6 +82,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -89,6 +93,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -119,6 +124,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -146,6 +152,7 @@ describe('declareDeleteTool', () => { declareDeleteTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/describe-collection.test.ts b/packages/mcp-server/test/tools/describe-collection.test.ts index dceb91d956..354d3fef96 100644 --- a/packages/mcp-server/test/tools/describe-collection.test.ts +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -60,6 +60,7 @@ describe('declareDescribeCollectionTool', () => { declareDescribeCollectionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -73,6 +74,7 @@ describe('declareDescribeCollectionTool', () => { declareDescribeCollectionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Describe a collection'); @@ -87,6 +89,7 @@ describe('declareDescribeCollectionTool', () => { declareDescribeCollectionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); @@ -96,6 +99,7 @@ describe('declareDescribeCollectionTool', () => { declareDescribeCollectionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -105,6 +109,7 @@ describe('declareDescribeCollectionTool', () => { declareDescribeCollectionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -153,6 +158,7 @@ describe('declareDescribeCollectionTool', () => { declareDescribeCollectionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/dissociate.test.ts b/packages/mcp-server/test/tools/dissociate.test.ts index 459ce18871..acc5a089e8 100644 --- a/packages/mcp-server/test/tools/dissociate.test.ts +++ b/packages/mcp-server/test/tools/dissociate.test.ts @@ -45,6 +45,7 @@ describe('declareDissociateTool', () => { declareDissociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -58,6 +59,7 @@ describe('declareDissociateTool', () => { declareDissociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Dissociate records from a relation'); @@ -70,6 +72,7 @@ describe('declareDissociateTool', () => { declareDissociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); @@ -79,6 +82,7 @@ describe('declareDissociateTool', () => { declareDissociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -117,6 +121,7 @@ describe('declareDissociateTool', () => { declareDissociateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 41c4a1d899..99e8cd2258 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -50,6 +50,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -63,6 +64,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Execute an action'); @@ -76,6 +78,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); @@ -85,6 +88,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -97,6 +101,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -127,6 +132,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -142,6 +148,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -155,6 +162,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -181,6 +189,7 @@ describe('declareExecuteActionTool', () => { declareExecuteActionTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index fd23aa5da7..764ba8df54 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -44,6 +44,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -57,6 +58,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Retrieve action form'); @@ -70,6 +72,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); @@ -79,6 +82,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -91,6 +95,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -121,6 +126,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -136,6 +142,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -149,6 +156,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -175,6 +183,7 @@ describe('declareGetActionFormTool', () => { declareGetActionFormTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/list-related.test.ts b/packages/mcp-server/test/tools/list-related.test.ts index a0084b6a02..1581ef7f9e 100644 --- a/packages/mcp-server/test/tools/list-related.test.ts +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -84,6 +84,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -97,6 +98,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('List records from a relation'); @@ -109,6 +111,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); @@ -118,6 +121,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -132,6 +136,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -185,6 +190,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -198,6 +204,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -223,6 +230,7 @@ describe('declareListRelatedTool', () => { declareListRelatedTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/list.test.ts b/packages/mcp-server/test/tools/list.test.ts index 5822ff85e7..fa1e468df4 100644 --- a/packages/mcp-server/test/tools/list.test.ts +++ b/packages/mcp-server/test/tools/list.test.ts @@ -82,6 +82,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -95,6 +96,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('List records from a collection'); @@ -107,6 +109,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); @@ -116,6 +119,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -131,6 +135,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -147,6 +152,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -163,6 +169,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -199,6 +206,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -226,6 +234,7 @@ describe('declareListTool', () => { declareListTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/tools/update.test.ts b/packages/mcp-server/test/tools/update.test.ts index 8f1e3b2109..ef626c6b12 100644 --- a/packages/mcp-server/test/tools/update.test.ts +++ b/packages/mcp-server/test/tools/update.test.ts @@ -45,6 +45,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(mcpServer.registerTool).toHaveBeenCalledWith( @@ -58,6 +59,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.title).toBe('Update a record'); @@ -70,6 +72,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); @@ -79,6 +82,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); @@ -90,6 +94,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -120,6 +125,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); const schema = registeredToolConfig.inputSchema as Record< @@ -146,6 +152,7 @@ describe('declareUpdateTool', () => { declareUpdateTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, + collectionNames: [], }); }); diff --git a/packages/mcp-server/test/utils/agent-caller.test.ts b/packages/mcp-server/test/utils/agent-caller.test.ts index 5e42a6556e..bf815ed306 100644 --- a/packages/mcp-server/test/utils/agent-caller.test.ts +++ b/packages/mcp-server/test/utils/agent-caller.test.ts @@ -370,4 +370,20 @@ describe('buildClientWithActions', () => { expect.objectContaining({ forestServer: undefined }), ); }); + + it('builds an in-process httpRequester when an agentDispatcher is provided', async () => { + mockFetchForestSchema.mockResolvedValue({ collections: [] } as never); + mockGetActionEndpoints.mockReturnValue({}); + const agentDispatcher = { request: jest.fn() }; + + const request = { + authInfo: { token: 'test-token', extra: { environmentApiEndpoint: 'http://public:3310' } }, + } as unknown as RequestHandlerExtra; + + await buildClientWithActions(request, mockForestServerClient, agentDispatcher); + + expect(mockCreateRemoteAgentClient).toHaveBeenLastCalledWith( + expect.objectContaining({ httpRequester: expect.any(InProcessHttpRequester) }), + ); + }); });