Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions packages/agent-client/src/http-requester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data>(
deserializer: Deserializer,
body: unknown,
text: string | undefined,
skipDeserialization?: boolean,
): Promise<Data> {
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}` : '';
Expand Down Expand Up @@ -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<Data>(
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);
}
}

Expand Down
8 changes: 6 additions & 2 deletions packages/agent-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import AgentHttpError, {
ActionRequiresApprovalError,
ApprovalRequestCreationError,
} from './errors';
import HttpRequester from './http-requester';
import HttpRequester, { buildAgentHttpError, deserializeAgentBody } from './http-requester';

export {
ActionFieldJson,
ActionFieldStringList,
makeCreateApprovalRequest,
RemoteAgentClient,
HttpRequester,
buildAgentHttpError,
deserializeAgentBody,
AgentHttpError,
ActionRequiresApprovalError,
ActionFormValidationError,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/agent.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

Passing agentDispatcher: this.getInProcessDispatcher() routes MCP tool calls through the in-process dispatcher, which only mounts driverRouter.routes() and omits the bodyParser(...) middleware that Agent.getRouter() applies for normal HTTP traffic. As a result, any MCP tool that sends a JSON request body (e.g. create, update, delete, associate, dissociate, getActionForm, executeAction) reaches route handlers that read context.request.body while it is still unset, so those requests fail or are processed with missing inputs — even though the same operations work correctly over HTTP. Consider applying the same bodyParser configuration to the in-process dispatcher path so MCP tool calls parse JSON bodies before reaching the driver routes.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent/src/agent.ts around line 433:

Passing `agentDispatcher: this.getInProcessDispatcher()` routes MCP tool calls through the in-process dispatcher, which only mounts `driverRouter.routes()` and omits the `bodyParser(...)` middleware that `Agent.getRouter()` applies for normal HTTP traffic. As a result, any MCP tool that sends a JSON request body (e.g. `create`, `update`, `delete`, `associate`, `dissociate`, `getActionForm`, `executeAction`) reaches route handlers that read `context.request.body` while it is still unset, so those requests fail or are processed with missing inputs — even though the same operations work correctly over HTTP. Consider applying the same `bodyParser` configuration to the in-process dispatcher path so MCP tool calls parse JSON bodies before reaching the driver routes.

Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ export default class Agent<S extends TSchema = TSchema> 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();
Expand Down Expand Up @@ -425,12 +429,17 @@ export default class Agent<S extends TSchema = TSchema> 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) {
Expand Down
21 changes: 21 additions & 0 deletions packages/agent/src/framework-mounter.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

protected async remount(router: Router): Promise<void> {
for (const task of this.onEachStart) await task(router); // eslint-disable-line no-await-in-loop

getInProcessDispatcher() returns the InProcessDispatcher immediately, but its handler is only populated by an onEachStart hook that runs later inside mount()/remount(). Any MCP tools/call that arrives before those hooks fire hits a dispatcher whose handler is still null and throws it is not mounted yet. During restart, the handler can still reference the previous router and serve stale /forest routes/schema. Consider resetting the handler to null at the start of remount() and/or blocking in-process calls until the hook has run.

  protected async remount(router: Router): Promise<void> {
+    this.inProcessDispatcher.setHandler(null);
    for (const task of this.onEachStart) await task(router); // eslint-disable-line no-await-in-loop
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent/src/framework-mounter.ts around lines 73-74:

`getInProcessDispatcher()` returns the `InProcessDispatcher` immediately, but its handler is only populated by an `onEachStart` hook that runs later inside `mount()`/`remount()`. Any MCP `tools/call` that arrives before those hooks fire hits a dispatcher whose handler is still `null` and throws `it is not mounted yet`. During restart, the handler can still reference the previous router and serve stale `/forest` routes/schema. Consider resetting the handler to `null` at the start of `remount()` and/or blocking in-process calls until the hook has run.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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);
}

/**
Expand All @@ -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<void> {
for (const task of this.onFirstStart) await task(); // eslint-disable-line no-await-in-loop

Expand Down
100 changes: 100 additions & 0 deletions packages/agent/src/mcp-in-process-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
query?: Record<string, unknown>;
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<string, unknown>): Record<string, string> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/mcp-in-process-dispatcher.ts:16

toStringQuery() stringifies every query value with String(value), so array and object query parameters are corrupted before reaching the Koa router. For example, { ids: ['1','2'] } becomes ids=1,2 and { filter: { ... } } becomes filter=[object Object], which does not match the normal HTTP encoding used by HttpRequester.query(). Any MCP tool or in-process caller that sends array/object query params will silently hit the agent with different semantics and get wrong results. Consider using URLSearchParams or qs-style serialization so multi-value and nested params encode the same way they would over real HTTP.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent/src/mcp-in-process-dispatcher.ts around line 16:

`toStringQuery()` stringifies every query value with `String(value)`, so array and object query parameters are corrupted before reaching the Koa router. For example, `{ ids: ['1','2'] }` becomes `ids=1,2` and `{ filter: { ... } }` becomes `filter=[object Object]`, which does not match the normal HTTP encoding used by `HttpRequester.query()`. Any MCP tool or in-process caller that sends array/object query params will silently hit the agent with different semantics and get wrong results. Consider using `URLSearchParams` or `qs`-style serialization so multi-value and nested params encode the same way they would over real HTTP.

const result: Record<string, string> = {};

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<InProcessResponse> {
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<never>((_, 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;
}
}
}
32 changes: 32 additions & 0 deletions packages/agent/test/agent-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> };
}
).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', () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading