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 10 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>
2 new issues
|
There was a problem hiding this comment.
🟠 High
agent-nodejs/packages/agent/src/agent.ts
Line 433 in fec6e70
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.
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.
| // 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.
- 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.

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 tool calls to the agent in-process via
InProcessAgentDispatcherInProcessDispatcherin the agent package that useslight-my-requestto inject HTTP requests directly into the Koa stack, bypassing the network and any middleware mounted in front of the agent.InProcessHttpRequesterin the MCP server that implements theHttpRequesterinterface using the dispatcher, with timeout support and JSON parsing; CSV export viastream()is explicitly unsupported.ForestMCPServernow acceptsagentUrlandagentDispatcheroptions; all tooldeclare*functions are refactored to accept a sharedToolContextthat carries these alongsideforestServerClient,logger, andcollectionNames.ForestOAuthProviderusesagentUrlasenvironmentApiEndpointwhen provided, falling back to the environment's publicapi_endpoint.createRemoteAgentClientandbuildClient/buildClientWithActionsin the MCP server now accept an optional dispatcher and skip URL validation when one is present.Macroscope summarized ae3a5c2.