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, 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/agent.ts b/packages/agent/src/agent.ts index d4dde461cf..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(); @@ -425,12 +429,17 @@ export default class Agent extends FrameworkMounter forestServerClient, enabledTools: this.mcpEnabledTools, basePath: this.mcpBasePath, + agentDispatcher: this.getInProcessDispatcher(), }); const httpCallback = await mcpServer.getHttpCallback(); 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 8de7070cec..2b44ba04f2 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: InProcessDispatcher; + private inProcessHookRegistered = false; /** Compute the prefix that the main router should be mounted at in the client's application */ private get completeMountPrefix(): string { @@ -35,6 +38,7 @@ export default class FrameworkMounter { this.logger = logger; this.fastifyAdapter = new FastifyAdapter(logger); this.mcpMiddleware = new McpMiddleware(); + this.inProcessDispatcher = new InProcessDispatcher(logger); } /** @@ -44,6 +48,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..208b95a105 --- /dev/null +++ b/packages/agent/src/mcp-in-process-dispatcher.ts @@ -0,0 +1,100 @@ +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'; + +export type InProcessRequest = { + method: string; + path: string; + 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 = {}; + + 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 implements InProcessAgentDispatcher { + private handler: RequestListener | null = null; + + constructor(private readonly logger?: Logger) {} + + setHandler(handler: RequestListener | null): void { + this.handler = handler; + } + + async request({ + method, + path, + 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.'); + } + + // 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, + ); + + 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 { + 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; + + 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 3a7242693c..1fec67e3fa 100644 --- a/packages/agent/test/agent-integration.test.ts +++ b/packages/agent/test/agent-integration.test.ts @@ -594,6 +594,38 @@ 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 }); + + const response = 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(response.status).toBe(200); + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ method: 'get', path: '/forest/users' }), + ); + } finally { + requestSpy.mockRestore(); + } + }); }); describe('Routes coexistence', () => { 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/agent/test/mcp-in-process-dispatcher.test.ts b/packages/agent/test/mcp-in-process-dispatcher.test.ts new file mode 100644 index 0000000000..f7933ab27e --- /dev/null +++ b/packages/agent/test/mcp-in-process-dispatcher.test.ts @@ -0,0 +1,179 @@ +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('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( + 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, + }); + }); +}); diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 1762be25ec..16dc12e1dd 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -10,6 +10,7 @@ const server = new ForestMCPServer({ envSecret: process.env.FOREST_ENV_SECRET, authSecret: process.env.FOREST_AUTH_SECRET, enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS), + agentUrl: process.env.FOREST_AGENT_URL, }); server.run().catch(error => { diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index 1349889a94..a351cd8811 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -30,6 +30,8 @@ export interface ForestOAuthProviderOptions { envSecret: string; authSecret: string; logger: Logger; + // Pre-normalized by the caller (ForestMCPServer); not re-validated here. + agentUrl?: string; } /** @@ -43,6 +45,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private forestClient: ForestAdminClient; private environmentId?: number; private environmentApiEndpoint?: string; + private agentUrl?: string; private logger: Logger; constructor({ @@ -51,12 +54,14 @@ export default class ForestOAuthProvider implements OAuthServerProvider { envSecret, authSecret, logger, + agentUrl, }: ForestOAuthProviderOptions) { this.forestServerUrl = forestServerUrl; this.forestAppUrl = forestAppUrl; this.envSecret = envSecret; this.authSecret = authSecret; this.logger = logger; + this.agentUrl = agentUrl; this.forestClient = createForestAdminClient({ forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, @@ -408,7 +413,9 @@ export default class ForestOAuthProvider implements OAuthServerProvider { userId: decoded.id, email: decoded.email, renderingId: decoded.renderingId, - environmentApiEndpoint: this.environmentApiEndpoint, + // Tools call back into the agent at this URL. Prefer the configured internal agentUrl + // (self-hosted) and fall back to the environment's public api_endpoint. + environmentApiEndpoint: this.agentUrl ?? this.environmentApiEndpoint, forestServerToken: decoded.serverToken, }, }; diff --git a/packages/mcp-server/src/in-process-agent-dispatcher.ts b/packages/mcp-server/src/in-process-agent-dispatcher.ts new file mode 100644 index 0000000000..b81c9924a5 --- /dev/null +++ b/packages/mcp-server/src/in-process-agent-dispatcher.ts @@ -0,0 +1,12 @@ +// 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; + 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 new file mode 100644 index 0000000000..4594c4552b --- /dev/null +++ b/packages/mcp-server/src/in-process-http-requester.ts @@ -0,0 +1,67 @@ +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, + maxTimeAllowed, + 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, + timeoutMs: maxTimeAllowed, + }); + + 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/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/server.ts b/packages/mcp-server/src/server.ts index 9ed632b95e..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'; @@ -33,6 +35,7 @@ import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; import declareUpdateTool from './tools/update'; +import normalizeAgentUrl from './utils/normalize-agent-url'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; import { NAME, VERSION } from './version'; @@ -126,6 +129,16 @@ export interface ForestMCPServerOptions { * domain root. */ basePath?: string; + /** + * Standalone MCP server only (set via FOREST_AGENT_URL). Internal URL the tools use to reach + * the agent's data layer, defaulting to the environment's public `api_endpoint`. + */ + agentUrl?: string; + /** + * 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; } /** @@ -148,6 +161,8 @@ export default class ForestMCPServer { private collectionNames: string[] = []; private enabledTools: Set; private basePath: string; + private agentUrl?: string; + private agentDispatcher?: InProcessAgentDispatcher; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -157,6 +172,8 @@ export default class ForestMCPServer { this.logger = options?.logger || defaultLogger; 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(); @@ -188,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)); @@ -475,6 +429,7 @@ export default class ForestMCPServer { envSecret, authSecret, logger: this.logger, + agentUrl: this.agentUrl, }); await oauthProvider.initialize(); diff --git a/packages/mcp-server/src/tool-context.ts b/packages/mcp-server/src/tool-context.ts new file mode 100644 index 0000000000..fe5d3c783b --- /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..b4abbf4dac 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..1d2e588a64 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..5c6861596f 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..f8c2fe7b53 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..e0ce0c8ba7 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..a587859474 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..101db7bd18 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..8ff9cdc28e 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..e2748f5971 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..08f4be403f 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/src/utils/normalize-agent-url.ts b/packages/mcp-server/src/utils/normalize-agent-url.ts new file mode 100644 index 0000000000..7096651d2f --- /dev/null +++ b/packages/mcp-server/src/utils/normalize-agent-url.ts @@ -0,0 +1,25 @@ +// agent-client concatenates the request path directly onto this URL, so a query string or fragment +// would swallow it — reject them, and return the parsed, trailing-slash-free form. +export default function normalizeAgentUrl(agentUrl?: string): string | undefined { + if (!agentUrl) return undefined; + + let parsed: URL; + + try { + parsed = new URL(agentUrl); + } catch { + throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute http(s) URL.`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Invalid agentUrl "${agentUrl}": only http and https are supported.`); + } + + if (parsed.search || parsed.hash) { + throw new Error( + `Invalid agentUrl "${agentUrl}": it must not contain a query string or fragment.`, + ); + } + + return parsed.href.replace(/\/+$/, ''); +} diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 84aa6694e5..cd11f7cdfb 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -20,13 +20,14 @@ const TEST_ENV_SECRET = 'test-env-secret'; const TEST_AUTH_SECRET = 'test-auth-secret'; const TEST_FOREST_APP_URL = 'https://app.forestadmin.com'; -function createProvider(forestServerUrl = 'https://api.forestadmin.com') { +function createProvider(forestServerUrl = 'https://api.forestadmin.com', agentUrl?: string) { return new ForestOAuthProvider({ forestServerUrl, forestAppUrl: TEST_FOREST_APP_URL, envSecret: TEST_ENV_SECRET, authSecret: TEST_AUTH_SECRET, logger: console.info, + agentUrl, }); } @@ -835,6 +836,51 @@ describe('ForestOAuthProvider', () => { }); }); + it('uses agentUrl as the tool callback endpoint when configured', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + id: 1, + email: 'user@example.com', + renderingId: 2, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + + const provider = createProvider( + 'https://api.forestadmin.com', + 'http://forest-agent.internal:3310', + ); + const result = await provider.verifyAccessToken('valid-access-token'); + + expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( + 'http://forest-agent.internal:3310', + ); + }); + + it('falls back to the environment api_endpoint when no agentUrl is set', async () => { + mockServer.get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://public.example.com' } }, + }); + global.fetch = mockServer.fetch; + + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + id: 1, + email: 'user@example.com', + renderingId: 2, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }); + + const provider = createProvider(); + await provider.initialize(); + const result = await provider.verifyAccessToken('valid-access-token'); + + expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe( + 'https://public.example.com', + ); + }); + it('should throw error for expired access token', async () => { (jsonwebtoken.verify as jest.Mock).mockImplementation(() => { throw new jsonwebtoken.TokenExpiredError('jwt expired', new Date()); diff --git a/packages/mcp-server/test/in-process-http-requester.test.ts b/packages/mcp-server/test/in-process-http-requester.test.ts new file mode 100644 index 0000000000..59531b848a --- /dev/null +++ b/packages/mcp-server/test/in-process-http-requester.test.ts @@ -0,0 +1,94 @@ +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('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: '' }); + + await expect(requester.stream()).rejects.toThrow('CSV export is not supported'); + }); +}); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 254e0de2cd..73e96e6798 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2704,6 +2704,127 @@ describe('basePath prefix', () => { }); }); +describe('agentUrl option', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('does not affect the advertised OAuth metadata URLs', async () => { + const mockFetchServer = new MockServer(); + mockFetchServer + .get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + global.fetch = mockFetchServer.fetch; + + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + agentUrl: 'http://forest-agent.internal:3310', + }); + const app = await server.buildExpressApp(new URL('http://localhost:3000')); + + const response = await request(app).get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body.issuer).toBe('http://localhost:3000/'); + expect(response.body.authorization_endpoint).toBe('http://localhost:3000/oauth/authorize'); + expect(response.body.token_endpoint).toBe('http://localhost:3000/oauth/token'); + }); + + it('routes tool calls to agentUrl instead of the public api_endpoint', async () => { + clearSchemaCache(); + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + process.env.FOREST_AUTH_SECRET = 'test-auth-secret'; + process.env.MCP_SERVER_PORT = (await getAvailablePort()).toString(); + + let capturedForestUrl: string | undefined; + const mockServer = new MockServer(); + mockServer + // Public endpoint Forest has on file — the tool must NOT use this one. + .get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://public.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { + name: 'users', + fields: [{ field: 'id', type: 'Number', isSortable: true }], + }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .post('/api/activity-logs-requests', { + data: { id: 'log-1', attributes: { index: 'logs' } }, + }) + .get(/\/forest\/\w+/, url => { + capturedForestUrl = url; + + return { data: [] }; + }); + global.fetch = mockServer.fetch; + mockServer.setupSuperagentMock(); + + const server = new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + forestServerUrl: 'https://test.forestadmin.com', + forestServerClient: createMockForestServerClient({ + fetchSchema: jest + .fn() + .mockResolvedValue([ + { name: 'users', fields: [{ field: 'id', type: 'Number', isSortable: true }] }, + ]), + }), + agentUrl: 'http://internal-agent:9999', + }); + server.run(); + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + try { + const token = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: 'forest-server-token', + }, + 'test-auth-secret', + { expiresIn: '1h' }, + ); + + const response = await request(server.httpServer as http.Server) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/call', + id: 1, + params: { name: 'list', arguments: { collectionName: 'users' } }, + }); + + expect(response.status).toBe(200); + expect(capturedForestUrl).toContain('http://internal-agent:9999/forest/'); + expect(capturedForestUrl).not.toContain('public.example.com'); + } finally { + mockServer.restoreSuperagent(); + await shutDownHttpServer(server.httpServer as http.Server); + } + }); +}); + describe('handleMcpRequest cleanup', () => { const originalFetch = global.fetch; let cleanupServer: ForestMCPServer; diff --git a/packages/mcp-server/test/tools/associate.test.ts b/packages/mcp-server/test/tools/associate.test.ts index adab067d31..af97660b87 100644 --- a/packages/mcp-server/test/tools/associate.test.ts +++ b/packages/mcp-server/test/tools/associate.test.ts @@ -42,7 +42,11 @@ describe('declareAssociateTool', () => { describe('tool registration', () => { it('should register a tool named "associate"', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'associate', @@ -52,7 +56,11 @@ describe('declareAssociateTool', () => { }); it('should register tool with correct title and description', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Associate records in a relation'); expect(registeredToolConfig.description).toBe( @@ -61,13 +69,21 @@ describe('declareAssociateTool', () => { }); it('should not be annotated as read-only', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -76,7 +92,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 +118,11 @@ describe('declareAssociateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareAssociateTool(mcpServer, mockForestServerClient, mockLogger); + declareAssociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); 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..acacadc6ce 100644 --- a/packages/mcp-server/test/tools/create.test.ts +++ b/packages/mcp-server/test/tools/create.test.ts @@ -42,7 +42,11 @@ describe('declareCreateTool', () => { describe('tool registration', () => { it('should register a tool named "create"', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'create', @@ -52,7 +56,11 @@ describe('declareCreateTool', () => { }); it('should register tool with correct title and description', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Create a record'); expect(registeredToolConfig.description).toBe( @@ -61,20 +69,32 @@ describe('declareCreateTool', () => { }); it('should not be annotated as read-only', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); 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, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -85,7 +105,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 +133,11 @@ describe('declareCreateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareCreateTool(mcpServer, mockForestServerClient, mockLogger); + declareCreateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClient with the extra parameter', async () => { @@ -125,7 +153,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..8251bad0b7 100644 --- a/packages/mcp-server/test/tools/delete.test.ts +++ b/packages/mcp-server/test/tools/delete.test.ts @@ -42,7 +42,11 @@ describe('declareDeleteTool', () => { describe('tool registration', () => { it('should register a tool named "delete"', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'delete', @@ -52,7 +56,11 @@ describe('declareDeleteTool', () => { }); it('should register tool with correct title and description', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Delete records'); expect(registeredToolConfig.description).toBe( @@ -61,20 +69,32 @@ describe('declareDeleteTool', () => { }); it('should not be annotated as read-only', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); 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, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -85,7 +105,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 +121,11 @@ describe('declareDeleteTool', () => { }); it('should accept array of strings or numbers for recordIds', () => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -121,7 +149,11 @@ describe('declareDeleteTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareDeleteTool(mcpServer, mockForestServerClient, mockLogger); + declareDeleteTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClient with the extra parameter', async () => { @@ -134,7 +166,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..354d3fef96 100644 --- a/packages/mcp-server/test/tools/describe-collection.test.ts +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -57,7 +57,11 @@ describe('declareDescribeCollectionTool', () => { describe('tool registration', () => { it('should register a tool named "describeCollection"', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'describeCollection', @@ -67,7 +71,11 @@ describe('declareDescribeCollectionTool', () => { }); it('should register tool with correct title and description', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Describe a collection'); expect(registeredToolConfig.description).toContain( @@ -78,19 +86,31 @@ describe('declareDescribeCollectionTool', () => { }); it('should be annotated as read-only', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); 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, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -103,11 +123,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 +155,11 @@ describe('declareDescribeCollectionTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareDescribeCollectionTool(mcpServer, mockForestServerClient, mockLogger); + declareDescribeCollectionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClient with the extra parameter', async () => { @@ -151,7 +175,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..acc5a089e8 100644 --- a/packages/mcp-server/test/tools/dissociate.test.ts +++ b/packages/mcp-server/test/tools/dissociate.test.ts @@ -42,7 +42,11 @@ describe('declareDissociateTool', () => { describe('tool registration', () => { it('should register a tool named "dissociate"', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'dissociate', @@ -52,7 +56,11 @@ describe('declareDissociateTool', () => { }); it('should register tool with correct title and description', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Dissociate records from a relation'); expect(registeredToolConfig.description).toBe( @@ -61,13 +69,21 @@ describe('declareDissociateTool', () => { }); it('should not be annotated as read-only', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -76,7 +92,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 +118,11 @@ describe('declareDissociateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareDissociateTool(mcpServer, mockForestServerClient, mockLogger); + declareDissociateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); 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..99e8cd2258 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -47,7 +47,11 @@ describe('declareExecuteActionTool', () => { describe('tool registration', () => { it('should register a tool named "executeAction"', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'executeAction', @@ -57,7 +61,11 @@ describe('declareExecuteActionTool', () => { }); it('should register tool with correct title and description', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Execute an action'); expect(registeredToolConfig.description).toContain( @@ -67,13 +75,21 @@ describe('declareExecuteActionTool', () => { }); it('should not be annotated as read-only', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); @@ -82,7 +98,11 @@ describe('declareExecuteActionTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -93,10 +113,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 +129,11 @@ describe('declareExecuteActionTool', () => { }); it('should accept array of strings or numbers for recordIds', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -120,7 +145,11 @@ describe('declareExecuteActionTool', () => { }); it('should accept null for recordIds (global actions)', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -130,7 +159,11 @@ describe('declareExecuteActionTool', () => { }); it('should accept optional values parameter', () => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -153,7 +186,11 @@ describe('declareExecuteActionTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareExecuteActionTool(mcpServer, mockForestServerClient, mockLogger); + declareExecuteActionTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClientWithActions with the extra parameter and forestServerUrl', async () => { @@ -174,7 +211,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..764ba8df54 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,11 @@ describe('declareGetActionFormTool', () => { describe('tool registration', () => { it('should register a tool named "getActionForm"', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'getActionForm', @@ -51,7 +55,11 @@ describe('declareGetActionFormTool', () => { }); it('should register tool with correct title and description', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Retrieve action form'); expect(registeredToolConfig.description).toContain( @@ -61,13 +69,21 @@ describe('declareGetActionFormTool', () => { }); it('should be annotated as read-only', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); @@ -76,7 +92,11 @@ describe('declareGetActionFormTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -87,10 +107,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 +123,11 @@ describe('declareGetActionFormTool', () => { }); it('should accept array of strings or numbers for recordIds', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -114,7 +139,11 @@ describe('declareGetActionFormTool', () => { }); it('should accept null for recordIds (global actions)', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -124,7 +153,11 @@ describe('declareGetActionFormTool', () => { }); it('should accept optional values parameter', () => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -147,7 +180,11 @@ describe('declareGetActionFormTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareGetActionFormTool(mcpServer, mockForestServerClient, mockLogger); + declareGetActionFormTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClientWithActions with the extra parameter and forestServerUrl', async () => { @@ -168,7 +205,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..1581ef7f9e 100644 --- a/packages/mcp-server/test/tools/list-related.test.ts +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -81,7 +81,11 @@ describe('declareListRelatedTool', () => { describe('tool registration', () => { it('should register a tool named "listRelated"', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'listRelated', @@ -91,7 +95,11 @@ describe('declareListRelatedTool', () => { }); it('should register tool with correct title and description', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('List records from a relation'); expect(registeredToolConfig.description).toBe( @@ -100,13 +108,21 @@ describe('declareListRelatedTool', () => { }); it('should be annotated as read-only', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -117,7 +133,11 @@ describe('declareListRelatedTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -130,7 +150,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 +167,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 +187,11 @@ describe('declareListRelatedTool', () => { }); it('should accept string parentRecordId', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -173,7 +201,11 @@ describe('declareListRelatedTool', () => { }); it('should accept number parentRecordId', () => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -195,7 +227,11 @@ describe('declareListRelatedTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareListRelatedTool(mcpServer, mockForestServerClient, mockLogger); + declareListRelatedTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClient with the extra parameter', async () => { @@ -212,7 +248,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..fa1e468df4 100644 --- a/packages/mcp-server/test/tools/list.test.ts +++ b/packages/mcp-server/test/tools/list.test.ts @@ -79,7 +79,11 @@ describe('declareListTool', () => { describe('tool registration', () => { it('should register a tool named "list"', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'list', @@ -89,7 +93,11 @@ describe('declareListTool', () => { }); it('should register tool with correct title and description', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('List records from a collection'); expect(registeredToolConfig.description).toBe( @@ -98,13 +106,21 @@ describe('declareListTool', () => { }); it('should be annotated as read-only', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); }); it('should define correct input schema', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('search'); @@ -116,7 +132,11 @@ describe('declareListTool', () => { }); it('should have fields schema with description mentioning @@@ separator for relations', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const schema = registeredToolConfig.inputSchema as any; @@ -129,7 +149,11 @@ describe('declareListTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -142,7 +166,11 @@ describe('declareListTool', () => { }); it('should use string type for collectionName when empty array provided', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -155,11 +183,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 +203,11 @@ describe('declareListTool', () => { }); it('should make ascending optional in sort schema with default value true', () => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -199,7 +231,11 @@ describe('declareListTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareListTool(mcpServer, mockForestServerClient, mockLogger); + declareListTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClient with the extra parameter', async () => { @@ -212,7 +248,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..ef626c6b12 100644 --- a/packages/mcp-server/test/tools/update.test.ts +++ b/packages/mcp-server/test/tools/update.test.ts @@ -42,7 +42,11 @@ describe('declareUpdateTool', () => { describe('tool registration', () => { it('should register a tool named "update"', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'update', @@ -52,7 +56,11 @@ describe('declareUpdateTool', () => { }); it('should register tool with correct title and description', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.title).toBe('Update a record'); expect(registeredToolConfig.description).toBe( @@ -61,13 +69,21 @@ describe('declareUpdateTool', () => { }); it('should not be annotated as read-only', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); }); it('should define correct input schema', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('recordId'); @@ -75,7 +91,11 @@ describe('declareUpdateTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -86,7 +106,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 +122,11 @@ describe('declareUpdateTool', () => { }); it('should accept both string and number for recordId', () => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); const schema = registeredToolConfig.inputSchema as Record< string, @@ -121,7 +149,11 @@ describe('declareUpdateTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareUpdateTool(mcpServer, mockForestServerClient, mockLogger); + declareUpdateTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); }); it('should call buildClient with the extra parameter', async () => { @@ -137,7 +169,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..bf815ed306 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', () => { @@ -330,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) }), + ); + }); }); 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/); + }); +});