Skip to content

fix(mcp): strip server-internal toolTelemetry at the wire boundary#1083

Draft
vjsai wants to merge 1 commit into
apify:masterfrom
vjsai:fix/strip-tool-telemetry-at-wire-boundary
Draft

fix(mcp): strip server-internal toolTelemetry at the wire boundary#1083
vjsai wants to merge 1 commit into
apify:masterfrom
vjsai:fix/strip-tool-telemetry-at-wire-boundary

Conversation

@vjsai

@vjsai vjsai commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Closes #1052

Problem

INTERNAL tools with no try/catch of their own let a thrown error propagate to the framework's outer catch in the CallToolRequestSchema handler. That path returns via captureResult and never goes through extractToolTelemetry() — the only place toolTelemetry is stripped. Because the MCP SDK's CallToolResultSchema is a z.looseObject (passthrough), the server-internal toolTelemetry: { toolStatus } key is forwarded verbatim to the client.

Confirmed end-to-end, not just by reading the code: driving a real MCP Client over an in-memory transport against a throwing INTERNAL tool, the client-visible result carries toolTelemetry on master.

Fix — option 1 from the issue (single framework boundary)

captureResult is the one point every tool-call response leaves the handler through, so the strip goes there. That closes the leak on every return path — including future ones — rather than only the paths that happen to read the field.

  • src/utils/tool_status.ts — new stripToolTelemetry(); extractToolTelemetry() now delegates its delete to it, so there is a single definition of "remove the internal field".
  • src/mcp/server.tscaptureResult() strips toolTelemetry before the response is captured and returned.
  • src/mcp/server.ts — the outer catch no longer attaches toolTelemetry at all. The ABORTED-aware toolStatus it carried is already reported to telemetry from the handler's local state, so no classification accuracy is lost. (The comment there previously explained the field was kept for byte-identity during refactor: Adopt respond* response constructors across tools #937; that rationale is what this PR retires.)

~10 lines of production code.

I scoped this to the leak only — the issue's second effect (generic getToolCallErrorUserText instead of tool-specific messages, i.e. option 2's per-tool try/catch) is a separate, larger change across several tools and is better as its own PR. Happy to follow up if you want it.

Wire-shape change (please read)

This changes client-facing wire output: the leaked toolTelemetry key is gone from error responses. As you flagged in the issue, apify-mcp-server-internal consumes the stripped wire shape — it should need no change (it now simply always sees the shape it already expected), but it's worth a matching assertion in its contract suite. I can't touch that repo, so flagging it here.

Tests

  • New wire-level test (tests/unit/mcp.server.error_handling.test.ts): real Client + InMemoryTransport linked pair against a throwing INTERNAL tool, asserting the client-visible result has no toolTelemetry. Fails on master, passes here — this is the before/after wire snapshot the issue asked for.
  • The two existing outer-catch tests pinned the leaked shape (expect(result.toolTelemetry).toEqual({ toolStatus })). They now assert the field is absent from the response while the ABORTED / FAILED status still reaches telemetry (spying on logToolCallAndTelemetry) — so the strip is proven not to cost classification.
  • New unit tests for stripToolTelemetry() (tests/unit/utils.tool_status.test.ts): strips in place, leaves clean responses untouched, passes through non-objects.

Local gates, all green:

pnpm run format:check   All matched files use the correct format. (223 files)
pnpm run lint           Found 0 warnings and 0 errors. (190 files)
pnpm run type-check     tsc -p tsconfig.json --noEmit — clean
pnpm run build:core     tsc -b src — clean
pnpm run test:unit      Test Files 70 passed (70)
                        Tests 963 passed | 2 skipped (965)

CHANGELOG is git-cliff-generated from the conventional commit, so it is not hand-edited.


Prepared with AI assistance (Claude Code); every line reviewed and the behaviour verified locally against the repo's own harness. Opened as a draft — happy to adjust the approach (e.g. option 2 instead) before you spend review time on it.

INTERNAL tools without their own try/catch let errors propagate to the
framework's outer catch in the CallToolRequestSchema handler. That path
returns via `captureResult` and never passes through
`extractToolTelemetry()` — the only place that strips `toolTelemetry` —
so the server-internal field reached the client, because the MCP SDK's
`CallToolResultSchema` is a `z.looseObject` (passthrough).

Fix it at the framework boundary (option 1 in apify#1052): `captureResult` is
the single point every tool-call response leaves the handler through, so
strip `toolTelemetry` there. This closes the leak on every current and
future return path, not just the ones that read the field.

The outer catch no longer attaches `toolTelemetry` at all: the
ABORTED-aware `toolStatus` it carried is already reported to telemetry
from the handler's local state, so classification accuracy is unchanged.

Adds a wire-level test that drives a real MCP client over an in-memory
transport and asserts the client-visible result carries no
`toolTelemetry`; it fails on master and passes here. The two existing
outer-catch tests, which pinned the leaked shape, now assert the field is
absent while the ABORTED/FAILED status still reaches telemetry.
@jirispilka

Copy link
Copy Markdown
Collaborator

@vjsai I'm sorry for the delayed response, it looks good, can you please fix the conflicts?

@jirispilka
jirispilka self-requested a review July 16, 2026 07:36
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.

fix: Uncaught tool errors leak toolTelemetry to the wire

3 participants