Skip to content

feat(mcp-server): agentUrl option to route MCP tool calls internally#1740

Open
Scra3 wants to merge 5 commits into
mainfrom
feat/mcp-server-internal-agent-url
Open

feat(mcp-server): agentUrl option to route MCP tool calls internally#1740
Scra3 wants to merge 5 commits into
mainfrom
feat/mcp-server-internal-agent-url

Conversation

@Scra3

@Scra3 Scra3 commented Jul 7, 2026

Copy link
Copy Markdown
Member

Context

MCP tools don't touch the datasource directly — each tool call (list, create, executeAction…) calls back into the agent's data layer over HTTP via agent-client. The URL used is the environment's public api_endpoint (as registered in Forest), read from AuthInfo.extra.environmentApiEndpoint.

Consequence: even when the MCP server is mounted inside the agent (same process), every tool call leaves over the public internet and comes back. A self-hosted customer (Swaive, KYB prod use case with n8n) asked to keep this traffic on their private network.

Change

Opt-in agentUrl option that overrides only the internal tool→agent channel:

agent.mountAiMcpServer({ agentUrl: 'http://forest-agent.internal:3310' });
  • forest-oauth-provider.ts — new agentUrl (validated as an absolute URL, trailing slash stripped); verifyAccessToken puts agentUrl ?? environmentApiEndpoint into extra.environmentApiEndpoint (the single consumer, in agent-caller.ts). getBaseUrl() untouched.
  • server.tsForestMCPServerOptions.agentUrl, threaded to the provider.
  • cli.tsFOREST_AGENT_URL env var for the standalone binary.
  • agent.tsmountAiMcpServer({ agentUrl }) threading.

The advertised OAuth URLs (issuer, .well-known metadata, authorize/token) stay public — external MCP clients (n8n) still authenticate from outside. agentUrl only changes where tools reach the agent.

Same concept and name as addWorkflowExecutor({ agentUrl }). Default unchanged (public api_endpoint), so zero impact on existing deployments.

Tests

  • forest-oauth-provider.test.tsextra.environmentApiEndpoint = agentUrl when set (fallback to api_endpoint otherwise), trailing slash stripped, invalid URL throws.
  • server.test.ts — with agentUrl set, advertised OAuth metadata URLs stay public.
  • agent.test.tsmountAiMcpServer({ agentUrl }) reaches ForestMCPServer.

mcp-server: 608 tests pass, lint clean.

Definition of Done

General

  • Explicit title following Conventional Commits
  • Test manually the implemented changes
  • Validate the code quality

Security

  • Consider the security impact — agentUrl is validated; default behavior unchanged; the option improves security posture (keeps tool traffic off the public internet).

🤖 Generated with Claude Code

Note

Add agentUrl option to ForestMCPServer to route MCP tool calls internally

  • Adds an optional agentUrl to ForestMCPServer (and propagates it through Agent.mountAiMcpServer and the CLI via FOREST_AGENT_URL) so tool callbacks can be directed to an internal agent URL instead of the public api_endpoint.
  • A new normalizeAgentUrl utility validates that the URL is an absolute http(s) URL with no query string or fragment, stripping trailing slashes; invalid values throw at construction time.
  • When set, ForestOAuthProvider places agentUrl in extra.environmentApiEndpoint during token verification, overriding the environment's api_endpoint for tool routing only — OAuth metadata endpoints are unaffected.
  • Risk: passing a malformed agentUrl throws an error at server startup.

Changes since #1740 opened

  • Removed agentUrl parameter support from Agent.mountAiMcpServer method and internal MCP server instantiation [0327a3c]
  • Removed test verification for agentUrl passthrough in MCP server configuration [0327a3c]
  • Updated JSDoc documentation for agentUrl in ForestMCPServerOptions interface [0327a3c]

Macroscope summarized 0507fb9.

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>
Comment thread packages/mcp-server/src/forest-oauth-provider.ts Outdated
@qltysh

qltysh Bot commented Jul 7, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/mcp-server/src/forest-oauth-provider.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/server.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.

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>
@ShohanRahman

Copy link
Copy Markdown
Contributor
  1. agentUrl validation is deferred and fails at a confusing point (converged: silent-failure-hunter, type-design-analyzer, code-simplifier — high confidence)
    ForestMCPServer stores agentUrl raw (server.ts:175). The only validator, normalizeAgentUrl, runs inside ForestOAuthProvider's constructor — which is created lazily in buildExpressApp(). So a malformed FOREST_AGENT_URL / agentUrl:
  • passes new ForestMCPServer({...}) silently,
  • throws only at server.run() / agent.start() time, with a stack trace pointing at ForestOAuthProvider and no hint it came from the env var or the mountAiMcpServer call.

Fix: extract normalizeAgentUrl to a shared/exported helper and call it in the ForestMCPServer constructor so invalid values throw eagerly, proximate to the misconfiguration. forest-oauth-provider.test.ts already tests the throw via the direct constructor path, so moving the throw site earlier won't break existing tests.

  1. Missing test for the fallback path (agentUrl absent → environmentApiEndpoint) (pr-test-analyzer)
    The logic is this.agentUrl ?? this.environmentApiEndpoint. Only the agentUrl-present side is tested for this feature. A ??→|| regression, or agentUrl accidentally set on the no-agentUrl path, would go uncaught. Add a verifyAccessToken test: no agentUrl, initialize() with a mock api_endpoint, assert extra.environmentApiEndpoint equals the public endpoint.

…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>
@Scra3

Scra3 commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Thanks @ShohanRahman — both addressed in c26d56b:

  1. Eager validation — extracted normalizeAgentUrl to a shared helper (src/utils/normalize-agent-url.ts) and called it in the ForestMCPServer constructor, so a malformed agentUrl/FOREST_AGENT_URL now throws at construction instead of later in ForestOAuthProvider. The provider still calls the same helper, so its direct-constructor throw tests stay green.
  2. Fallback test — added a verifyAccessToken test: no agentUrl, initialize() with a mock api_endpoint, asserts extra.environmentApiEndpoint equals the public endpoint.

613 tests green, coverage 100%.

@ShohanRahman

Copy link
Copy Markdown
Contributor

No direct unit test for normalize-agent-url.ts; https:// and path-preserving URLs uncovered

Confirmed: no test/utils/normalize-agent-url.test.ts exists, and every test agentUrl is http://. Two silent-regression risks:

  • https:// acceptance is untested — the protocol guard (normalize-agent-url.ts:14) could drop https: support and all tests would still pass.
  • Path preservation is untested — the util returns parsed.href (keeps a base path like http://host/base); if it ever changed to parsed.origin, tool calls would route to the wrong path and no test would catch it. This is the highest-risk gap since the util is the single enforcement point.

Add a focused unit test covering: valid https://, path-preserving base, trailing-slash strip, empty/undefined → undefined, and the four throw cases. Also worth a new ForestMCPServer({ agentUrl: 'not-a-url' }) throws test in server.test.ts to lock the eager-fail guarantee (currently only proven via the provider test).

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>
@Scra3

Scra3 commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

@ShohanRahman both addressed in 0507fb9:

  • Single ownerForestMCPServer now validates/normalizes agentUrl; ForestOAuthProvider accepts the pre-normalized value (dropped its normalizeAgentUrl call). No more double-normalization.
  • Direct unit test — added test/utils/normalize-agent-url.test.ts covering: http:// and https:// acceptance (locks the protocol guard), base-path preservation + trailing-slash strip (locks the parsed.href behavior), and the invalid cases (non-http(s), query, fragment, garbage). Moved the invalid/trailing-slash cases out of the provider test since the provider no longer owns that.

616 tests green.

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>
@ShohanRahman

Copy link
Copy Markdown
Contributor
  1. Timeout-raced inject() promise has no .catch() → unhandled rejection risk
    packages/agent/src/mcp-in-process-dispatcher.ts:69-83
    Promise.race([inject(...), timeout]) — when the timeout wins, the inject() promise keeps running with no observer. If the hung agent handler later rejects (e.g. a DB call fails 30s after the tool call already timed out), it becomes an unhandled promise rejection, which can crash processes with a strict unhandledRejection handler. Fix: attach a .catch() that logs, before racing.
    const injectPromise = inject(this.handler as RequestListener, options);
    injectPromise.catch(err => this.logger?.('Warn', In-process handler settled after timeout: ${err}));
    return await Promise.race([injectPromise, timeout]);

  2. Non-JSON 2xx body silently deserializes to undefined
    packages/agent/src/mcp-in-process-dispatcher.ts:85-95 → in-process-http-requester.ts:59
    parseBody returns undefined for a non-JSON body (warn-logged only). On a 2xx, deserializeAgentBody then returns undefined as Data — the tool hands the LLM no data and no error. (On 4xx+ it's fine: buildAgentHttpError falls back to parseJson(text).) The agent's JSON:API routes normally return JSON, so this is an edge case (unexpected HTML/redirect from misbehaving middleware), but it fails invisibly. Fix: include the raw text (truncated) in the warn log, and treat a non-empty non-JSON 2xx body as an error rather than returning undefined.

@Scra3

Scra3 commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

@ShohanRahman les deux corrigés dans c801d5b38 (sur la branche in-process dispatcher) :

1. Unhandled rejection sur inject(). injectWithTimeout attache maintenant un .catch() à la promesse inject() avant la race — si le timeout gagne et qu'inject() rejette ensuite, le rejet est observé (loggé en Warn « settled after the … timeout ») au lieu de fuir en unhandledRejection. Couvert par un test dédié qui mocke inject pour rejeter après le timeout.

À noter : dans la vraie stack Koa, une erreur du handler est catchée par Koa → réponse 500 → inject() résout (un résolu ignoré ne fuit pas). Le .catch couvre donc le cas plus rare où inject() lui-même rejette — cheap insurance, comme tu le suggérais.

2. Non-JSON 2xx → undefined silencieux. parseBody prend désormais le status :

  • 2xx non-JSONthrow (au lieu de rendre undefined) : un corps de succès non-JSON = middleware qui déraille (HTML/redirect), on échoue bruyamment.
  • 4xx+ non-JSONundefined + Warn (comportement conservé : buildError retombe sur le text brut).
  • Dans les deux cas, un extrait tronqué (200 car.) du corps est inclus dans l'erreur/le log.

Tests mis à jour en conséquence (le cas 200-non-JSON devient un throw, ajout d'un cas 4xx-non-JSON).

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.

2 participants