Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8c7b962
feat(mcp-server): agentUrl option to route tool calls internally
Jul 7, 2026
c476514
fix(mcp-server): harden agentUrl validation and prove tool routing
Jul 7, 2026
c26d56b
refactor(mcp-server): validate agentUrl eagerly in the server constru…
Jul 7, 2026
0507fb9
refactor(mcp-server): single owner for agentUrl normalization
Jul 7, 2026
0327a3c
refactor(mcp-server): restrict agentUrl to the standalone server
Jul 7, 2026
a1bcaf1
refactor(agent-client): extract shared response parsing, allow reques…
Jul 7, 2026
4ba880e
feat(agent): in-process dispatcher for MCP tool calls
Jul 7, 2026
d6a46e2
feat(mcp-server): dispatch mounted tool calls to the agent in-process
Jul 7, 2026
fec6e70
test(agent): assert mounted MCP tool calls dispatch in-process
Jul 7, 2026
ae3a5c2
fix(mcp-server): address in-process dispatch review findings
Jul 7, 2026
e30cfe4
test(agent): guard that in-process dispatch preserves router-level bo…
Jul 8, 2026
8abefd1
refactor(mcp-server): drop unreachable stream() override on InProcess…
Jul 8, 2026
929a1c8
docs(agent): clarify mountAiMcpServer note — auth still runs, only ho…
Jul 8, 2026
9db0616
refactor(mcp-server): single-source dispatcher types + drop code-rest…
Jul 8, 2026
9a1a1c5
refactor(agent-client): share error/deserialize via protected methods…
Jul 8, 2026
ac2d6bb
fix(mcp-server): restore throwing stream() override + strengthen disp…
Jul 8, 2026
c801d5b
fix(agent): harden in-process dispatcher against unhandled rejection …
Jul 8, 2026
6636d46
refactor(mcp): trim narrating comments flagged in review
Jul 22, 2026
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
38 changes: 25 additions & 13 deletions packages/agent-client/src/http-requester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function parseJson(text: string | undefined): unknown {
}

export default class HttpRequester {
private readonly deserializer: Deserializer;
protected readonly deserializer: Deserializer;

private get baseUrl() {
const prefix = this.options.prefix ? `/${this.options.prefix}` : '';
Expand All @@ -31,6 +31,28 @@ export default class HttpRequester {
this.deserializer = new Deserializer({ keyForAttribute: 'camelCase' });
}

// Any transport reaching the agent must build this exact AgentHttpError shape — the approval-gate
// detection in domains/action.ts routes on it. Shared with in-process transports via subclassing.
protected buildError(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);
}

protected async deserialize<Data>(
body: unknown,
text: string | undefined,
skipDeserialization?: boolean,
): Promise<Data> {
if (skipDeserialization) return body as Data;

try {
return (await this.deserializer.deserialize(body)) as Data;
} catch {
return (body ?? text) as Data;
}
}

async query<Data = unknown>({
method,
path,
Expand Down Expand Up @@ -62,25 +84,15 @@ 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 this.deserialize<Data>(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 this.buildError(res.status ?? 0, res.body, text);
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/agent-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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 skip any host-app middleware you
* mounted in front of the agent (rate limiting, request logging, WAF). The agent's own JWT auth
* and permission checks still run on them, and the inbound MCP request itself is unaffected.
*
* @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(),
Comment thread
EnkiP marked this conversation as resolved.
});

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
20 changes: 20 additions & 0 deletions packages/agent/src/framework-mounter.ts
Comment thread
EnkiP marked this conversation as resolved.
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,22 @@ export default class FrameworkMounter {
this.mcpMiddleware.setCallback(callback, routeMatcher);
}

/**
* Dispatcher that runs agent-client requests against the agent's own `/forest` stack in-memory.
* The handler is rebuilt on every (re)mount so a captured reference never goes stale.
*/
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
116 changes: 116 additions & 0 deletions packages/agent/src/mcp-in-process-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { Logger } from '@forestadmin/datasource-toolkit';
import type {
InProcessAgentDispatcher,
InProcessDispatchRequest,
InProcessDispatchResponse,
} from '@forestadmin/mcp-server';
import type { RequestListener } from 'http';

import inject, { type InjectOptions, type InjectPayload } from 'light-my-request';

export type InProcessRequest = InProcessDispatchRequest;
export type InProcessResponse = InProcessDispatchResponse;

// Matches the HTTP path's 10s bound (HttpRequester.query).
const DEFAULT_TIMEOUT_MS = 10_000;

function toStringQuery(query: Record<string, unknown>): Record<string, string> {
Comment thread
EnkiP marked this conversation as resolved.
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.
*/
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.payload, response.statusCode),
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,
);
});

const injection = inject(this.handler as RequestListener, options);

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:82

When the agent handler throws before the timeout fires, injectWithTimeout logs it as "settled after the ${timeoutMs}ms timeout" even though the timeout never elapsed. This emits a bogus timeout warning that obscures the real handler failure. The .catch on injection fires unconditionally on rejection, so any normal-path exception is mislabeled as a late settlement. Consider attaching the late-settlement logger only after the timeout actually wins the race.

🚀 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 82:

When the agent handler throws before the timeout fires, `injectWithTimeout` logs it as `"settled after the ${timeoutMs}ms timeout"` even though the timeout never elapsed. This emits a bogus timeout warning that obscures the real handler failure. The `.catch` on `injection` fires unconditionally on rejection, so any normal-path exception is mislabeled as a late settlement. Consider attaching the late-settlement logger only after the timeout actually wins the race.

// If the timeout wins the race, `injection` is left unobserved; swallow+log a late settlement so
// a handler that rejects after the timeout can't surface as an unhandledRejection.
injection.catch(error =>
this.logger?.(
'Warn',
`In-process handler settled after the ${timeoutMs}ms timeout: ${error}`,
),
);

try {
return await Promise.race([injection, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}

private parseBody(payload: string, status: number): unknown {
if (payload === '') return undefined;

try {
return JSON.parse(payload);
} catch {
const preview = payload.length > 200 ? `${payload.slice(0, 200)}…` : payload;
const message = `In-process dispatch received a non-JSON ${status} response body: ${preview}`;

// A 2xx must carry a JSON:API body here; a non-JSON success body means misbehaving middleware
// (HTML, redirect) — fail loudly rather than hand the tool `undefined`. On 4xx+ the raw text
// is preserved by the error path (buildError falls back to parseJson(text)), so drop the body.
if (status < 400) throw new Error(message);

this.logger?.('Warn', message);

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
39 changes: 39 additions & 0 deletions packages/agent/test/mcp-in-process-dispatcher.rejection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { RequestListener } from 'http';

const mockInject = jest.fn();

jest.mock('light-my-request', () => ({
__esModule: true,
default: (...args: unknown[]) => mockInject(...args),
}));

// eslint-disable-next-line import/first
import InProcessDispatcher from '../src/mcp-in-process-dispatcher';

describe('InProcessDispatcher — injection rejection safety', () => {
it('observes a late injection rejection (logs it) instead of leaking an unhandled rejection', async () => {
const logger = jest.fn();
// inject() rejects after the timeout has already won the race — the losing promise must not
// become an unhandledRejection.
mockInject.mockReturnValue(
new Promise((_resolve, reject) => {
setTimeout(() => reject(new Error('inject blew up')), 30);
}),
);
const dispatcher = new InProcessDispatcher(logger);
dispatcher.setHandler((() => {}) as unknown as RequestListener);

await expect(
dispatcher.request({ method: 'get', path: '/x', headers: {}, timeoutMs: 10 }),
).rejects.toThrow(/timed out after 10ms/);

await new Promise(resolve => {
setTimeout(resolve, 40);
});

expect(logger).toHaveBeenCalledWith(
'Warn',
expect.stringContaining('settled after the 10ms timeout'),
);
});
});
Loading
Loading