Skip to content

feat(mcp-server): dispatch mounted tool calls to the agent in-process#1743

Open
Scra3 wants to merge 10 commits into
mainfrom
feat/mcp-server-in-process-dispatch
Open

feat(mcp-server): dispatch mounted tool calls to the agent in-process#1743
Scra3 wants to merge 10 commits into
mainfrom
feat/mcp-server-in-process-dispatch

Conversation

@Scra3

@Scra3 Scra3 commented Jul 7, 2026

Copy link
Copy Markdown
Member

⚠️ Stacked on #1740 — base is feat/mcp-server-internal-agent-url, not main. Review/merge #1740 first, then this diff collapses to the in-process work only.

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 via light-my-request (in-memory, no socket) — instead of calling back over the public api_endpoint.

This removes the need for agentUrl in mounted mode (the feedback that motivated #1740). agentUrl stays 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

  • agent-client: extracted shared response parsing (buildAgentHttpError, deserializeAgentBody) and let createRemoteAgentClient accept an injected httpRequester.
  • agent: InProcessDispatcher injects 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.
  • mcp-server: tools now receive a single ToolContext; when a dispatcher is present, buildClient builds an InProcessHttpRequester that reuses the agent-client parse helpers — so the deserialized result and AgentHttpError shape 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: InProcessHttpRequester unit tests + agent-caller wiring (requester built only when a dispatcher is present) — 624 passing.
  • agent: constructor-spy that mountAiMcpServer passes an agentDispatcher; and a full-stack guard that a real OAuth-authed tools/call reaches the agent through the InProcessDispatcher across all 8 host frameworks — 982 passing.
  • The existing should retrieve users via MCP tools/call integration 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 InProcessAgentDispatcher

  • Introduces InProcessDispatcher in the agent package that uses light-my-request to inject HTTP requests directly into the Koa stack, bypassing the network and any middleware mounted in front of the agent.
  • Adds InProcessHttpRequester in the MCP server that implements the HttpRequester interface using the dispatcher, with timeout support and JSON parsing; CSV export via stream() is explicitly unsupported.
  • ForestMCPServer now accepts agentUrl and agentDispatcher options; all tool declare* functions are refactored to accept a shared ToolContext that carries these alongside forestServerClient, logger, and collectionNames.
  • ForestOAuthProvider uses agentUrl as environmentApiEndpoint when provided, falling back to the environment's public api_endpoint.
  • createRemoteAgentClient and buildClient/buildClientWithActions in the MCP server now accept an optional dispatcher and skip URL validation when one is present.
  • Risk: in-process tool calls bypass all middleware mounted in front of the agent; only the agent's own auth and permission checks apply.

Macroscope summarized ae3a5c2.

alban bertolini and others added 9 commits July 7, 2026 12:27
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>
@qltysh

qltysh Bot commented Jul 7, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): deserializeAgentBody 1
qlty Duplication Found 17 lines of identical code in 2 locations (mass = 68) 1

@Scra3 Scra3 changed the base branch from feat/mcp-server-internal-agent-url to main July 7, 2026 18:11
@qltysh

qltysh Bot commented Jul 7, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.02%.

Modified Files with Diff Coverage (20)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/associate.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/execute-action.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/utils/agent-caller.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/list.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/update.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/get-action-form.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/agent.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/forest-oauth-provider.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/http-requester.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/create.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/delete.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/describe-collection.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/dissociate.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/framework-mounter.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/server.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/list-related.ts100.0%
New Coverage rating: A
packages/mcp-server/src/in-process-http-requester.ts100.0%
New Coverage rating: A
packages/agent/src/mcp-in-process-dispatcher.ts100.0%
New Coverage rating: A
packages/mcp-server/src/utils/normalize-agent-url.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

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.

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.

// 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> {

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.

Comment thread packages/mcp-server/src/in-process-http-requester.ts
- 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,

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant