feat(mcp-server): dispatch mounted tool calls to the agent in-process#1743
feat(mcp-server): dispatch mounted tool calls to the agent in-process#1743Scra3 wants to merge 17 commits into
Conversation
MCP tools call back into the agent's data layer over HTTP. By default the URL is the environment's public api_endpoint, so tool calls leave over the public internet even when the MCP server is mounted inside the agent. Add an opt-in agentUrl option (mountAiMcpServer, ForestMCPServerOptions, FOREST_AGENT_URL for the CLI) that overrides only the internal tool->agent channel — the advertised OAuth discovery URLs stay public so external MCP clients still authenticate. Requested by a self-hosted customer (Swaive) who wants MCP callbacks to stay on their private network. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups on the agentUrl option:
- normalizeAgentUrl now parses once, rejects non-http(s) schemes and URLs with
a query string or fragment (which would swallow the request path in
agent-client's `${url}${path}` join), and returns the parsed, whitespace-
normalized form instead of the raw string. Previously such inputs passed
validation and silently misrouted every tool call.
- Add an end-to-end test proving a `tools/call` actually targets agentUrl and
not the environment's public api_endpoint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ctor Extract normalizeAgentUrl to a shared helper and call it from the ForestMCPServer constructor, so a malformed agentUrl / FOREST_AGENT_URL throws at construction — proximate to the misconfiguration — instead of later inside ForestOAuthProvider during run()/start(). Add a verifyAccessToken test for the fallback path (no agentUrl -> environment api_endpoint). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ForestMCPServer is now the sole validator/normalizer of agentUrl; the internal ForestOAuthProvider accepts a pre-normalized value instead of re-running normalizeAgentUrl. Move the validation/normalization unit tests (invalid inputs, trailing slash, http/https, path preservation) into a dedicated normalize-agent-url.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the agentUrl option from agent.mountAiMcpServer() — mounted mode will dispatch tool calls to the agent in-process (follow-up), making a callback URL unnecessary. agentUrl stays available for the standalone MCP server via FOREST_AGENT_URL / ForestMCPServerOptions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ter override Factor HttpRequester's success-deserialize and error-mapping tail into exported buildAgentHttpError/deserializeAgentBody helpers (HTTP path unchanged), and let createRemoteAgentClient accept a pre-built httpRequester. Groundwork for an in-process transport that reuses the exact same AgentHttpError shape (so the approval-gate detection stays identical). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add InProcessDispatcher, which runs an agent-client request through the agent's
own /forest Koa stack in-memory via light-my-request (no socket) and returns a
superagent-shaped {status, body, text}. FrameworkMounter.getInProcessDispatcher
rebuilds the handler on every (re)mount (stable object, mutable target — same
pattern as McpMiddleware); the /forest prefix is fixed since agent-client
hardcodes those paths. Not yet wired to the MCP server (next increment).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the MCP server is mounted in an agent, tool calls now reach the agent's data layer in-process (via the agent's InProcessDispatcher) instead of over the public api_endpoint, removing the need for agentUrl in mounted mode. agentUrl stays for standalone (FOREST_AGENT_URL). Tools now receive a single ToolContext; buildClient builds an InProcessHttpRequester (reusing agent-client's shared parse helpers so the deserialized result and AgentHttpError shape stay byte-identical to the HTTP path) when a dispatcher is present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards against a regression to the HTTP path: a real OAuth-authed tools/call must reach the agent through the InProcessDispatcher, across every host framework. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 new issue
|
There was a problem hiding this comment.
🟡 Medium
agent-nodejs/packages/agent/src/framework-mounter.ts
Lines 73 to 74 in fec6e70
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.
There was a problem hiding this comment.
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.
| // Superagent-shaped so agent-client's HttpRequester helpers parse it identically. | ||
| export type InProcessResponse = { status: number; body: unknown; text: string }; | ||
|
|
||
| function toStringQuery(query: Record<string, unknown>): Record<string, string> { |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
- Bound in-process dispatch with a timeout (default 10s, honors maxTimeAllowed) so a hung agent handler can't hang a tool call forever, matching the HTTP path's superagent timeout. - Thread a logger into InProcessDispatcher; distinguish an expected empty (204) body from a corrupted non-JSON 2xx body, which is now logged instead of silently returning undefined. - Pin the cross-package contract: InProcessDispatcher now `implements InProcessAgentDispatcher` (exported from mcp-server), so drift fails to compile at the definition, not just at one wiring line. - Make ToolContext.collectionNames required and default it once in the server instead of `= []` in all 10 tool factories. - Document on mountAiMcpServer() + log at startup that in-process tool calls bypass any middleware mounted in front of the agent. Tests: approval-gate 403 error-shape parity, maxTimeAllowed forwarding, timeout, 204/non-JSON body handling, payload+content-type forwarding through a real handler, buildClientWithActions dispatcher wiring, and a status assertion on the in-process integration test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| body: responseBody, | ||
| text, | ||
| } = await this.dispatcher.request({ | ||
| method, |
There was a problem hiding this comment.
🟡 Medium src/in-process-http-requester.ts:45
InProcessHttpRequester.query() forwards path directly to the dispatcher without calling HttpRequester.escapeUrlSlug(), so collection names, record ids, or action endpoints containing reserved URI characters (spaces, +, ?) are sent unescaped. This causes the agent's router to fail or hit the wrong route for valid names — for example /forest/Foo Bar or /forest/orders/1/relationships/line?items are sent literally, with ?items truncated as a query string. Consider applying HttpRequester.escapeUrlSlug() to path before dispatching, matching the HTTP path's behavior.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/mcp-server/src/in-process-http-requester.ts around line 45:
`InProcessHttpRequester.query()` forwards `path` directly to the dispatcher without calling `HttpRequester.escapeUrlSlug()`, so collection names, record ids, or action endpoints containing reserved URI characters (spaces, `+`, `?`) are sent unescaped. This causes the agent's router to fail or hit the wrong route for valid names — for example `/forest/Foo Bar` or `/forest/orders/1/relationships/line?items` are sent literally, with `?items` truncated as a query string. Consider applying `HttpRequester.escapeUrlSlug()` to `path` before dispatching, matching the HTTP path's behavior.
There was a problem hiding this comment.
Real but rare, left as a known limitation. escapeUrlSlug does encodeURI(path).replace(/([+?*])/g, '\\$1') — the \+?* backslash convention targets the agent's koa/path-to-regexp router + the HTTP wire, and can't be replayed through light-my-request (its WHATWG new URL parsing treats \ as /), so applying it blindly would break more than it fixes. Spaces/non-ASCII encode identically both ways; the only divergence is slug names containing literal + ? * (e.g. a record id with ?), which are already fragile over HTTP. Deliberately not changing this under this PR to avoid a riskier fix — noting it as a known edge.
…dyParser Mirrors FrameworkMounter.getInProcessDispatcher()'s wrapping and asserts a mutating POST body reaches the route parsed — the driver router's `router.use(bodyParser)` (agent.ts) is included in `.routes()`, so mounted mutating tool calls (create/update/…) get a parsed body, same as HTTP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…HttpRequester No MCP tool exports CSV / calls stream(), so the override guarded a path that cannot happen. Removed it (and its test) per YAGNI; query() is the only method the in-process requester needs to override. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| * 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. |
There was a problem hiding this comment.
Double check with someone more competent to see if this is OK
There was a problem hiding this comment.
It's accurate, and it's OK — here's the double-check.
What the note describes. When the MCP server is mounted in the agent, a tool call reaches the data layer via the in-process dispatcher, which wraps the agent's own driver router (new Router({ prefix: '/forest' }).use(driverRouter.routes())). That stack still includes the agent's cors + bodyParser + JWT auth + permission checks — it just doesn't re-run any middleware the host app registered around the mount (app.use(rateLimiter), request logging, a WAF/proxy layer).
Why it's not a security concern. The external entry point is unchanged: an inbound POST /mcp still traverses the host app's middleware and the OAuth bearer check before any tool runs. Only the internal hop that the already-authenticated tool makes to /forest is in-process — and it still carries the agent's JWT + permissions. So nothing becomes reachable without auth; there's no bypass of an access gate.
The one real consequence (which the note is there to flag): previously the tool call went out to the public api_endpoint and came back in through the front door, so host-level middleware saw it a second time. In-process, that second pass is gone — a client who counts or logs /forest traffic at the host layer won't see MCP-driven data access there anymore. That's the deliberate trade-off of keeping the hop internal (it's the whole point of the feature: no public round-trip). It's verified by the dispatches the tool call to the agent in-process integration test (all 8 host frameworks) + the preserves router-level middleware (bodyParser) test.
If you'd rather, I can soften the wording to make explicit that agent auth/permissions still apply (so "bypass" doesn't read as "unauthenticated") — e.g. "bypass host-app middleware; the agent's own auth/permission checks still run." Happy to push that.
|
Comments describe what, not why — violates project CLAUDE.md style
|
|
Single source of truth for the dispatcher contract The structural InProcessAgentDispatcher interface (mcp-server) re-declares the same shape as InProcessRequest/InProcessResponse (agent), with no mechanical sync. The agent already imports InProcessAgentDispatcher from mcp-server, so exporting named InProcessDispatchRequest/Response types from mcp-server and importing them in the agent would eliminate the duplication with zero new dependency-graph edges. Drift is currently caught only by implements on existing fields, not by additions. |
…st middleware is skipped Addresses Shohan's review: reword so "bypass" doesn't read as "unauthenticated". In-process tool calls skip host-app middleware (rate limit/logging/WAF) but the agent's own JWT auth + permission checks still run, and the inbound MCP request is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| forestServerClient, | ||
| enabledTools: this.mcpEnabledTools, | ||
| basePath: this.mcpBasePath, | ||
| agentDispatcher: this.getInProcessDispatcher(), |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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:
- Forward the inbound
/mcprequest's IP +x-forwarded-fordown through the dispatch so the whitelist sees the real client (cleanest, but plumbs the IP across mcp-server → agent-client → dispatcher). - Exempt in-process
/forestcalls from IP whitelist — the/mcpentry is already OAuth-gated, so the check is redundant on the internal hop (but it's silently skipping a security control). - Document mounted-MCP + IP-whitelist as incompatible for now.
Leaning (1). @albanb — your call on which way to take it; I can implement whichever.
There was a problem hiding this comment.
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-executionexecutor JWT from a loopback socket) — never trustingx-forwarded-for; - the whitelist is enforced at the
/mcpperimeter (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.
There was a problem hiding this comment.
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) :
- On n'exempte pas le dispatch in-process ici. L'exempter sans enforcer la whitelist au périmètre
/mcpreviendrait à ouvrir le trou tout de suite ; ne rien faire le laisse fermé. - Donc mounted MCP in-process + whitelist activée → reste 403, documenté comme limitation connue (PRD-753) plutôt que silencieusement contourné.
- Le volet périmètre
/mcpqui était dans fix(agent): exempt direct loopback callers from the IP whitelist #1744 a été retiré ; fix(agent): exempt direct loopback callers from the IP whitelist #1744 se limite désormais à l'exemption de l'executor loopback (le vrai bug : l'executor ne démarrait pas). Le code du périmètre reste dans l'historique de fix(agent): exempt direct loopback callers from the IP whitelist #1744 comme référence pour PRD-752.
Ce finding est donc acté comme limitation connue, pas corrigé dans cette PR. Rien à changer ici côté in-process.
There was a problem hiding this comment.
…ating comments Review (Shohan): - Export named InProcessDispatchRequest/InProcessDispatchResponse from mcp-server and import them in the agent's InProcessDispatcher instead of re-declaring the shape — one source of truth, no manual sync (adds no dependency-graph edge, the agent already imports from mcp-server). - Remove comments that restate the code (agent-caller.ts branch note, tool-context.ts agentDispatcher note) and fix the misleading `/stream` mention in the timeout note (stream isn't dispatched in-process) per CLAUDE.md style. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@ShohanRahman both addressed in Comment style (what-not-why). Removed the two comments that restate the code:
Single source of truth for the dispatcher contract. Exactly your suggestion: |
…, not exports buildAgentHttpError and deserializeAgentBody were free functions exported from agent-client only so InProcessHttpRequester (mcp-server) could rebuild the exact same AgentHttpError shape on the in-process transport. Since that requester already extends HttpRequester, fold them into protected methods (buildError / deserialize) and drop the two public exports — the shared error/deserialize contract now travels through the subclass, not agent-client's public surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Weak assertion in the dispatcher-present test agent-caller.test.ts:151-157 only uses .not.toThrow(). It should also assert the client was wired for in-process: |
|
The stream()/CSV story is now internally contradictory
|
…atcher test Review (Shohan): - InProcessHttpRequester inherited the base stream(), which would fire a real request at the sentinel host (http://in-process.agent) → ~10s hang. Restore a throwing override so CSV export fails fast in-process (matches the PR description's "throws explicitly") and fix the stale "base url is unused" comment (it is a never-resolved sentinel now that both query() and stream() are overridden). Covered by a new stream()-throws test. - Strengthen the dispatcher-present test: assert createRemoteAgentClient is wired with an InProcessHttpRequester + empty url, instead of only .not.toThrow() (a silently dropped dispatcher would have passed before). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@ShohanRahman les deux points traités dans 1. Assertion faible (dispatcher-present test). Le test expect(mockCreateRemoteAgentClient).toHaveBeenCalledWith(
expect.objectContaining({ httpRequester: expect.any(InProcessHttpRequester), url: '' }),
);Un dispatcher silencieusement perdu échoue désormais le test. 2. stream()/CSV contradictoire. J'ai suivi ta reco (restaurer l'override) — c'est le choix qui rend tout cohérent d'un coup :
|
…+ silent non-JSON 2xx Review (Shohan): - injectWithTimeout raced inject() against a timer with no handler on the injection promise. If the timeout won and inject() later rejected, it surfaced as an unhandledRejection. Attach a catch() that logs the late settlement before racing, so the losing promise is always observed. - parseBody returned undefined for any non-JSON body (warn only). On a 2xx that handed the tool no data and no error. Now throw on a non-JSON 2xx (misbehaving middleware — HTML/redirect), and include a truncated body preview in both the thrown error and the 4xx+ warn log. 4xx+ still returns undefined (buildError falls back to the raw text). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| ); | ||
| }); | ||
|
|
||
| const injection = inject(this.handler as RequestListener, options); |
There was a problem hiding this comment.
🟡 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.

What
When the MCP server is mounted in an agent (
agent.mountAiMcpServer()), tool calls now reach the agent's data layer in-process — dispatched into the agent's own Koa stack vialight-my-request(in-memory, no socket) — instead of calling back over the publicapi_endpoint.This removes the need for
agentUrlin mounted mode (the feedback that motivated #1740).agentUrlstays for the standalone server (FOREST_AGENT_URL), where there is no agent process to dispatch into.Why
Self-hosted setups (Swaive) observed every mounted tool call leaving over the public internet and coming back, even though the MCP server and the agent run in the same process. In-process dispatch keeps that hop internal by construction — nothing to configure.
How
buildAgentHttpError,deserializeAgentBody) and letcreateRemoteAgentClientaccept an injectedhttpRequester.InProcessDispatcherinjects a request into the agent's real router (auth / permissions / serializer all run);getInProcessDispatcher()re-registers the handler on every remount so a captured reference never goes stale.ToolContext; when a dispatcher is present,buildClientbuilds anInProcessHttpRequesterthat reuses the agent-client parse helpers — so the deserialized result andAgentHttpErrorshape are byte-identical to the HTTP path, keeping approval-gate detection and error handling unchanged. CSV export (stream) is not supported in-process and throws explicitly.Tests
mcp-server:InProcessHttpRequesterunit tests +agent-callerwiring (requester built only when a dispatcher is present) — 624 passing.agent: constructor-spy thatmountAiMcpServerpasses anagentDispatcher; and a full-stack guard that a real OAuth-authedtools/callreaches the agent through theInProcessDispatcheracross all 8 host frameworks — 982 passing.should retrieve users via MCP tools/callintegration test now runs through the in-process path and still returns the seeded records.🤖 Generated with Claude Code
Note
Dispatch MCP server tool calls to the agent in-process instead of over HTTP
InProcessDispatcherin the agent andInProcessHttpRequesterin the MCP server so that tool calls bypass the network and go directly into the agent's Koa stack vialight-my-request.agentDispatchertoForestMCPServerOptionsand a sharedToolContext; alldeclare*Toolfunctions are refactored to accept this context so they can route calls in-process when a dispatcher is available.agentUrlsupport (FOREST_AGENT_URLenv var) for standalone mode, normalised via a newnormalizeAgentUrlutility, and plumbed throughForestOAuthProvidersoextra.environmentApiEndpointreflects the agent URL.buildAgentHttpErroranddeserializeAgentBodyhelpers fromagent-clientand extendscreateRemoteAgentClientto accept an injectedHttpRequester, enabling the in-process path to share the same error shapes and deserialization logic.Changes since #1743 opened
agent.Agent.mountAiMcpServermethod to clarify middleware and authentication behavior [929a1c8]InProcessDispatchRequestandInProcessDispatchResponsetypes from@forestadmin/agentto@forestadmin/mcp-server[9db0616]ToolContextinterface,createClientfunction inagent-callermodule, and updated comment onDEFAULT_TIMEOUT_MSconstant to referenceHttpRequester.query[9db0616]buildAgentHttpErroranddeserializeAgentBodyutility functions into protected methods withinHttpRequesterclass [9a1a1c5]InProcessHttpRequester.stream()method that throws an error indicating CSV streaming is not supported over the in-process MCP transport [ac2d6bb]InProcessHttpRequester.stream()error behavior and updated agent caller test to expectInProcessHttpRequesterwiring whenenvironmentApiEndpointis absent [ac2d6bb]InProcessDispatcher.parseBodyto reject with anErrorfor successful HTTP responses (status < 400) containing non-JSON payloads and to log a warning while returningundefinedfor error responses (status >= 400) with non-JSON payloads [c801d5b]InProcessDispatcher.injectWithTimeoutby capturing the injection promise separately and attaching a catch handler to log warnings when the handler rejects after the timeout has fired [c801d5b]mcp-in-process-dispatcher.rejection.test.tsto verify that late injection rejections after timeout are logged as warnings without causing unhandled rejections [c801d5b]mcp-in-process-dispatcher.test.tsto validate that non-JSON 2xx responses throw errors and non-JSON 4xx/5xx responses log warnings while returningundefinedbody [c801d5b]Macroscope summarized 8abefd1.