Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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(),

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/agent.ts:432

MCP tool calls dispatched via agentDispatcher: this.getInProcessDispatcher() bypass the agent's IP-whitelist enforcement. IpWhitelist authorizes /forest requests using x-forwarded-for or context.request.ip, but the in-process dispatch path never forwards the original MCP request's IP or forwarded headers, so every tool call is evaluated against the injector's local address rather than the real client. With IP whitelisting enabled, legitimate MCP requests are rejected as 403, and the whitelist no longer enforces the actual caller IP. Consider forwarding the original request's IP and x-forwarded-for headers into the in-process dispatch context so the whitelist check sees the real client.

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

MCP tool calls dispatched via `agentDispatcher: this.getInProcessDispatcher()` bypass the agent's IP-whitelist enforcement. `IpWhitelist` authorizes `/forest` requests using `x-forwarded-for` or `context.request.ip`, but the in-process dispatch path never forwards the original MCP request's IP or forwarded headers, so every tool call is evaluated against the injector's local address rather than the real client. With IP whitelisting enabled, legitimate MCP requests are rejected as 403, and the whitelist no longer enforces the actual caller IP. Consider forwarding the original request's IP and `x-forwarded-for` headers into the in-process dispatch context so the whitelist check sees the real client.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed — this is real. IpWhitelist.checkIp (routes/security/ip-whitelist.ts:27) reads x-forwarded-for ?? context.request.ip; the in-process dispatch injects via light-my-request with no x-forwarded-for and a default remote address of 127.0.0.1. So with the whitelist enabled and loopback not in the rules, every mounted MCP tool call would 403.

Worth noting the pre-existing behaviour: over HTTP the /forest hop already carried the mcp-server's source IP (or the proxy's x-forwarded-for), not the end MCP client's — so the whitelist never saw the real MCP caller. The in-process change turns that into a hard 403 (127.0.0.1) instead of "checks the wrong IP".

Options, and I think this needs a product/security call rather than a silent fix:

  1. Forward the inbound /mcp request's IP + x-forwarded-for down through the dispatch so the whitelist sees the real client (cleanest, but plumbs the IP across mcp-server → agent-client → dispatcher).
  2. Exempt in-process /forest calls from IP whitelist — the /mcp entry is already OAuth-gated, so the check is redundant on the internal hop (but it's silently skipping a security control).
  3. Document mounted-MCP + IP-whitelist as incompatible for now.

Leaning (1). @albanb — your call on which way to take it; I can implement whichever.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Update: went the other way from my earlier "leaning (1)". We do not forward the IP inward (impossible for the executor anyway — no client IP there). Instead, in #1744:

  • the agent's IP whitelist exempts authenticated internal callers (in-process dispatch flag / step-execution executor JWT from a loopback socket) — never trusting x-forwarded-for;
  • the whitelist is enforced at the /mcp perimeter (real client IP), fail-closed.

The in-process flag lands in a follow-up commit here once #1744 merges. So this thread is addressed by #1744.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Décision finale (remplace mon message précédent). Après discussion produit/sécu (thread Slack) : on ne rend pas le MCP soumis à l'IP whitelist pour l'instant.

Constat partagé : c'est bien un trou dans la raquette (restreindre l'accès data par IP via le front devrait valoir aussi pour le MCP), mais ~11 projets seulement utilisent la feature et aucun ne l'a remonté → on attend qu'un client le demande.

Conséquences, assumées et suivies dans PRD-752 (décision + implémentation) et PRD-753 (doc) :

Ce finding est donc acté comme limitation connue, pas corrigé dans cette PR. Rien à changer ici côté in-process.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

});

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Intentional — not resetting to null. The onEachStart hook is registered during initializeMcpServer(), which runs inside buildRouterAndSendSchema() before mount() iterates onEachStart, so the handler is set before the server accepts traffic; the "not mounted yet" throw is only reachable before start() completes (no listener yet).

On restart(), setHandler is a single atomic reference swap: in-flight dispatches keep the old Koa app via closure and new calls get the new router — the same ride-out semantics an in-flight HTTP request has during a remount. Resetting to null at the top of remount() would instead open a window where in-process calls throw "not mounted yet" mid-restart, which is strictly worse. Covered by the "reflects a handler swap (remount)" test.

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
118 changes: 118 additions & 0 deletions packages/agent/src/mcp-in-process-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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';

// Single-sourced from mcp-server (the dispatcher contract) — no re-declaration to drift.
export type InProcessRequest = InProcessDispatchRequest;
export type InProcessResponse = InProcessDispatchResponse;

// Matches superagent's HTTP path, which times out after 10s (HttpRequester.query).
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These are always scalars in practice. agent-client builds the query via QuerySerializer, which emits only strings/numbers/booleans — filters/sort are JSON.stringify'd to strings, and fields[...] projections are comma-joined deliberately (the Ruby agent's Rack collapses repeated params to the last value). So arrays/objects never reach toStringQuery, and String(value) matches the wire encoding for the shapes actually sent. Switching to URLSearchParams multi-value would in fact diverge from the comma-join contract the Ruby backend expects. undefined is dropped, same as superagent.

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.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