Skip to content

feat(mcp): classify MCP tool errors by category for alerting#2570

Merged
kodiakhq[bot] merged 6 commits into
mainfrom
claude/mcp-error-categorization
Jul 2, 2026
Merged

feat(mcp): classify MCP tool errors by category for alerting#2570
kodiakhq[bot] merged 6 commits into
mainfrom
claude/mcp-error-categorization

Conversation

@brandon-pereira

@brandon-pereira brandon-pereira commented Jul 1, 2026

Copy link
Copy Markdown
Member

What

Classify MCP tool errors into user vs server categories so we can alert on infrastructure failures without noise from agent input errors.

Changes

Error categorization infrastructure (packages/api/src/mcp/utils/errors.ts):

  • New mcpUserError() / mcpServerError() helpers replace inline error boilerplate across all MCP tools
  • Error category stored via WeakMap side-channel — prevents metadata from leaking through the MCP SDK serialization layer
  • getErrorCategory() retrieves category for tracing/metrics

ClickHouse error classification (packages/api/src/mcp/tools/query/helpers.ts):

  • clickHouseErrorResult() auto-classifies ClickHouse errors: infrastructure types (NETWORK_ERROR, SOCKET_TIMEOUT, etc.) → server; query errors → user
  • isServerError() walks the full .cause chain for both ClickHouse server-side error types and Node.js TCP-level errors (ECONNREFUSED, ENOTFOUND, etc.)
  • isClickHouseError() with constructor-name fallback handles duplicate @clickhouse/client-common packages across the monorepo (root vs common-utils)

Tracing enrichment (packages/api/src/mcp/utils/tracing.ts):

  • error_category attribute added to spans and the hyperdx.mcp.tool.errors counter
  • Defaults to server when category is unset (safe default that surfaces unclassified errors in alerts)

Tool migrations (~25 files):

  • All MCP tools migrated from inline { isError: true, content: [...] } to typed helpers
  • Timeouts in describeMetric, describeSource, listMetricsmcpServerError (infrastructure condition)
  • MongoDB failures in searchDashboardsmcpServerError
  • Pattern mining failures in runEventPatternsmcpServerError
  • Validation errors (assertSourceKindMatchesSelect, validateMetricSelectItems) → mcpUserError

Why

Without error categorization, every isError: true result looks the same in metrics/alerts. Agent typos (wrong source ID, bad query syntax) and real infrastructure outages (ClickHouse down, MongoDB timeout) are indistinguishable. This makes MCP error alerts either too noisy (fire on every user error) or useless (too many false positives to trust).

With this change, alerting rules can filter on error_category = "server" to catch only infrastructure failures.

Testing

Screenshot 2026-07-01 at 3 13 44 PM

Unit tests (query.test.ts):

  • getClickHouseErrorType — ClickHouseError type extraction, cross-package scenarios, duck-typing resilience
  • isServerError — ClickHouse error types, Node.js TCP errors, cause-chain walking
  • clickHouseErrorResult — auto-classification for infrastructure vs query errors, prefix/suffix formatting, hint composition

Tracing tests (tracing.test.ts):

  • Error category propagation through withToolTracing for user, server, and unset categories
  • Wire-format leak prevention (no _errorCategory property on serialized result)

Integration tests (queryTool.test.ts):

  • ClickHouse query errors (syntax, unknown column/table) surface as isError
  • Infrastructure errors (unreachable ClickHouse) surface as isError
  • _errorCategory does not leak on the wire result

- Add error categorization (user vs server) to MCP tool error responses
  using a WeakMap side-channel that can't leak through SDK serialization
- Use instanceof ClickHouseError instead of duck-typing for type detection
- Add isServerError() to detect both ClickHouse server-side error types
  (NETWORK_ERROR, SOCKET_TIMEOUT, etc.) and Node.js TCP errors
  (ECONNREFUSED, ENOTFOUND, etc.) with full cause-chain walking
- Classify errors on OTel spans (mcp.tool.error_category) and metric
  counters so server errors trigger alerts while user errors don't
- Use clickHouseErrorResult in catch blocks across eventDeltas, breakdown,
  and waterfall tool handlers for consistent categorization
- Fall back through e.message → e.cause.message → String(e) for errors
  where common-utils' ClickHouseQueryError wraps with an empty message

Tests:
- Unit: getClickHouseErrorType, isServerError, clickHouseErrorResult
  with getErrorCategory assertions for all error shapes
- Unit: withToolTracing error_category on spans and counters
- Integration: syntax errors, unknown columns/tables across sql, table,
  timeseries tools; unreachable host errors; no _errorCategory leak
- Add isClickHouseError() with constructor-name fallback for duplicate
  @clickhouse/client-common packages across the monorepo
- Migrate assertSourceKindMatchesSelect and validateMetricSelectItems
  from inline error objects to mcpUserError() (fixes WeakMap bypass)
- Reclassify timeouts as server errors in describeMetric, describeSource,
  listMetrics (infrastructure condition, not user input)
- Reclassify getColumns failure to use clickHouseErrorResult for
  auto-classification in describeMetric
- Reclassify MongoDB query failure as server error in searchDashboards
- Reclassify pattern mining failure as server error in runEventPatterns
- Remove dead mcpError() alias, inline mcpUserError in validateObjectId
- Fix TypeScript narrowing in tracing.ts for getErrorCategory call
@changeset-bot

changeset-bot Bot commented Jul 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 56e4a66

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/api Patch
@hyperdx/app Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jul 2, 2026 4:02pm
hyperdx-storybook Ready Ready Preview, Comment Jul 2, 2026 4:02pm

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches auth, data models, config, tasks, OTel pipeline, ClickHouse, or CI/CD.

Why this tier:

  • Large diff: 1162 production lines changed (threshold: 400)

Additional context: agent branch (claude/mcp-error-categorization)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 28
  • Production lines changed: 1162 (+ 532 in test files, excluded from tier calculation)
  • Branch: claude/mcp-error-categorization
  • Author: brandon-pereira

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a structured error categorization system for MCP tools, distinguishing user errors (bad input, not-found) from server errors (infrastructure failures, timeouts) to enable targeted alerting. The implementation uses a WeakMap side-channel to attach category metadata to error results without leaking through the MCP SDK's JSON serialization layer, with error_category propagated to spans and the hyperdx.mcp.tool.errors counter via withToolTracing.

  • New errors.ts utilitiesmcpUserError, mcpServerError, and getErrorCategory replace inline { isError: true, content: [...] } boilerplate across ~25 tool files; validateObjectId is centralized here as well.
  • ClickHouse error classification in helpers.tsclickHouseErrorResult auto-classifies by walking the full .cause chain for both ClickHouse server-side types (NETWORK_ERROR, SOCKET_TIMEOUT, etc.) and Node.js TCP codes (ECONNREFUSED, ENOTFOUND, etc.), with a constructor-name fallback to handle duplicate @clickhouse/client-common installations across the monorepo.
  • Tracing enrichmentwithToolTracing reads the WeakMap category and tags the span and error counter; uncategorized isError results default to server so unclassified failures still surface in alerts.

Confidence Score: 5/5

Safe to merge — the classification logic is well-encapsulated, the WeakMap side-channel correctly prevents metadata leakage, and all new paths have thorough unit and integration test coverage.

The core implementation — WeakMap side-channel, cause-chain walking in isServerError, auto-classification in clickHouseErrorResult, and tracing propagation — is correct and verified by tests covering circular references, cross-package ClickHouseError identity, AggregateError, and the no-leak wire format. The ~25 tool migrations are mechanical and follow a consistent pattern. The only note is the prerelease snapshot for @clickhouse/client-common, which does not affect correctness at runtime (yarn.lock preserves the resolution) but is a long-term maintenance consideration.

packages/api/package.json — the @clickhouse/client-common prerelease pin is worth revisiting when a stable release is available.

Important Files Changed

Filename Overview
packages/api/src/mcp/utils/errors.ts New file introducing McpErrorResult type, WeakMap side-channel for error categories, and mcpUserError/mcpServerError/getErrorCategory/validateObjectId helpers — clean and correct implementation.
packages/api/src/mcp/utils/tracing.ts Adds error_category attribute to spans and the error counter via getErrorCategory; defaults to 'server' for uncategorized isError results; catch block for thrown exceptions also records error_category=server before re-throwing to withSpan.
packages/api/src/mcp/tools/query/helpers.ts Adds isClickHouseError (instanceof + constructor-name fallback), getClickHouseErrorType, isServerError (full cause-chain walk with circular-reference guard), clickHouseErrorResult (auto-classifies by error type), and errorHint enrichment — thoroughly tested and well-structured.
packages/api/package.json Adds @clickhouse/client-common as a direct dependency pinned to a prerelease snapshot version (1.23.0-head.fae5998.1); necessary for instanceof checks but the prerelease pin is a maintenance concern.
packages/api/src/mcp/tests/query.test.ts Comprehensive unit tests for getClickHouseErrorType, isServerError (including cause-chain depth, AggregateError, circular refs, cross-package fallback), and clickHouseErrorResult categorization — good coverage of all classification paths.
packages/api/src/mcp/tests/tracing.test.ts Tests error_category propagation through withToolTracing for user/server/unset categories, confirms WeakMap metadata does not leak as _errorCategory on the serialized result.
packages/api/src/mcp/tests/queryTool.test.ts Integration tests validate that ClickHouse query errors and infrastructure errors surface as isError and that _errorCategory does not appear on the wire result.
packages/api/src/mcp/tools/dashboards/searchDashboards.ts Migrated from inline isError boilerplate to mcpUserError/mcpServerError; MongoDB catch correctly uses mcpServerError.
packages/api/src/mcp/tools/sources/describeMetric.ts Migrated to typed helpers; timeout path uses mcpServerError; ClickHouse query failures use clickHouseErrorResult for auto-classification; not-found/validation paths use mcpUserError.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[MCP Tool Handler] -->|returns result| B{result.isError?}
    B -->|No| C[setStatus OK\nlog info]
    B -->|Yes| D[getErrorCategory via WeakMap]
    D -->|category set| E{category}
    D -->|undefined| F[default: 'server']
    E -->|user| G[span: error_category=user\ncounter: error_category=user]
    E -->|server| H[span: error_category=server\ncounter: error_category=server]
    F --> H

    subgraph Error Creation
        M[mcpUserError] -->|WeakMap.set → 'user'| N[McpErrorResult]
        O[mcpServerError] -->|WeakMap.set → 'server'| N
        P[clickHouseErrorResult] --> Q{isServerError?}
        Q -->|true - NETWORK_ERROR/ECONNREFUSED etc| O
        Q -->|false - SYNTAX_ERROR etc| M
    end

    subgraph isServerError - walks .cause chain
        R[ClickHouseError.type in SERVER_CH_ERROR_TYPES] -->|NETWORK_ERROR etc| S[return true]
        T[Node.js Error.code in SERVER_NODE_ERROR_CODES] -->|ECONNREFUSED etc| S
        U[AggregateError.errors TCP code] --> S
    end

    A -->|throws| V[catch in withToolTracing\nsetAttribute error_category=server\nrethrow → withSpan records exception]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[MCP Tool Handler] -->|returns result| B{result.isError?}
    B -->|No| C[setStatus OK\nlog info]
    B -->|Yes| D[getErrorCategory via WeakMap]
    D -->|category set| E{category}
    D -->|undefined| F[default: 'server']
    E -->|user| G[span: error_category=user\ncounter: error_category=user]
    E -->|server| H[span: error_category=server\ncounter: error_category=server]
    F --> H

    subgraph Error Creation
        M[mcpUserError] -->|WeakMap.set → 'user'| N[McpErrorResult]
        O[mcpServerError] -->|WeakMap.set → 'server'| N
        P[clickHouseErrorResult] --> Q{isServerError?}
        Q -->|true - NETWORK_ERROR/ECONNREFUSED etc| O
        Q -->|false - SYNTAX_ERROR etc| M
    end

    subgraph isServerError - walks .cause chain
        R[ClickHouseError.type in SERVER_CH_ERROR_TYPES] -->|NETWORK_ERROR etc| S[return true]
        T[Node.js Error.code in SERVER_NODE_ERROR_CODES] -->|ECONNREFUSED etc| S
        U[AggregateError.errors TCP code] --> S
    end

    A -->|throws| V[catch in withToolTracing\nsetAttribute error_category=server\nrethrow → withSpan records exception]
Loading

Reviews (5): Last reviewed commit: "Merge branch 'main' into claude/mcp-erro..." | Re-trigger Greptile

Comment thread packages/api/src/mcp/tools/query/helpers.ts
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 224 passed • 3 skipped • 1520s

Status Count
✅ Passed 224
❌ Failed 0
⚠️ Flaky 3
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The migration is clean — all ~25 tools flow through mcpUserError/mcpServerError, no error result is cloned or re-wrapped, so the WeakMap identity contract that withToolTracing depends on holds end-to-end. The findings below are classification-accuracy risks (which matter because accurate alerting is the whole point of this change) and minor nits.

🟡 P2 -- recommended

  • packages/api/src/mcp/tools/sources/describeMetric.ts:747 -- DESCRIBE_METRIC_TIMEOUT is classified mcpServerError (alertable), but its own message says the timeout stems from a high-cardinality attribute set on a user-initiated discovery query, so normal heavy describe_metric calls will page on-call.
    • Fix: Reclassify the wall-clock discovery timeout as mcpUserError, or only promote to server when the abort cause is itself an isServerError.
    • reliability, maintainability
  • packages/api/src/mcp/tools/query/helpers.ts:613 -- SOCKET_TIMEOUT is in SERVER_CH_ERROR_TYPES, so a user's expensive query that trips a client/inter-node socket timeout is classified server and pages on-call, while the normal max_execution_time path (TIMEOUT_EXCEEDED) correctly classifies as user.
    • Fix: Treat SOCKET_TIMEOUT as user (or gate it behind a threshold), and give the direct-query clients the same requestTimeout buffer over max_execution_time that runConfigTile uses so the server returns a clean query timeout first.
    • reliability, adversarial, correctness
  • packages/api/src/mcp/tools/query/helpers.ts:713 -- isServerError's AggregateError branch only runs hasNodeErrorCode on inner errors; a ClickHouseError carrying a SERVER_CH_ERROR_TYPES type nested in AggregateError.errors (rather than via .cause) is classified user, silently dropping a genuine infrastructure failure from alerts.
    • Fix: In the AggregateError branch, recurse isServerError on each inner error (checking both ClickHouse types and .cause) rather than only its Node error code.
    • adversarial, kieran-typescript
🔵 P3 nitpicks (6)
  • packages/api/src/mcp/tools/query/helpers.ts:764 -- When a caught Error has an empty message and a non-Error cause (e.g. a string), raw falls through to String(e) and the agent receives the content-free text "Error".
    • Fix: Stringify a non-Error cause before falling back to String(e).
  • packages/api/src/mcp/utils/tracing.ts:89 -- The catch path sets error_category and re-throws but omits the mcp.tool.error span attribute that the non-throw error path sets, so span queries filtering on that attribute under-count thrown failures.
    • Fix: Set span.setAttribute('mcp.tool.error', true) in the catch block to mirror the returned-error path.
  • packages/api/src/mcp/tools/query/helpers.ts:668 -- getClickHouseErrorType is exported "for testing only" and never called in production; isServerError uses isClickHouseError directly, so the tested function does not exercise a runtime path.
    • Fix: Delete getClickHouseErrorType and its tests, or route production classification through it.
  • packages/api/src/mcp/tools/query/helpers.ts:706 -- The inline isClickHouseError(current.cause) check is redundant with the next loop iteration (which assigns current = current.cause and re-checks), making the cause-walk read as duplicated logic.
    • Fix: Remove the inline block or add a comment noting it only guards the circular-.cause edge.
  • packages/api/src/mcp/utils/tracing.ts:62 -- getErrorCategory(result as McpErrorResult) is an unsound cast (ToolResult is not structurally assignable to McpErrorResult); it is harmless only because WeakMap.get is identity-based.
    • Fix: Type getErrorCategory's parameter as object and drop the cast.
  • .changeset/mcp-error-categorization.md:2 -- The change is committed as feat(mcp) and adds a new observable metric/span dimension, but the changeset declares a patch bump.
    • Fix: Confirm the semver bump matches the feature classification.

Reviewers (7): correctness, testing, maintainability, project-standards, reliability, kieran-typescript, adversarial.

Testing gaps:

  • No test asserts the resulting error category for the migrated tool handlers (describeMetric timeout → server, searchDashboards/runEventPatterns catch → server); the classification could be flipped without any test failing.
  • The .cause.message fallback in clickHouseErrorResult (helpers.ts:767) is unreachable by current tests — every input has a non-empty .message.
  • No test covers a ClickHouseError nested in AggregateError.errors, a circular .cause, or a NETWORK_ERROR at cause-chain depth ≥ 2.
  • No test covers withToolTracing's default-to-server fallback for an unkeyed error result, nor the throw path emitting error_category='server'.
  • The wire-leak assertion in queryTool.test.ts:771 (not.toHaveProperty('_errorCategory')) is vacuous — the implementation never sets that property; the genuine proof is the toEqual in tracing.test.ts.

isServerError now checks for ClickHouseError server-side types at
every depth of the cause chain, not just depth 0-1. Previously a
NETWORK_ERROR nested 2+ levels deep would be missed while a Node.js
ECONNREFUSED at the same depth was correctly caught.
- Fix eventDeltas.ts:343 misclassification: use clickHouseErrorResult
  for 'Failed to build sample queries' catch (matches sibling at 381)
- Pin @clickhouse/client-common to 1.23.0-head.fae5998.1 matching
  common-utils, eliminating duplicate package installs
- Add changeset for @hyperdx/api
- Add tests: cross-package constructor-name fallback, deep cause chain
  ClickHouseError detection, AggregateError TCP errors, circular cause
  guard
Comment thread packages/api/src/mcp/tools/query/runEventPatterns.ts
@kodiakhq kodiakhq Bot merged commit bfc6fb5 into main Jul 2, 2026
20 checks passed
@kodiakhq kodiakhq Bot deleted the claude/mcp-error-categorization branch July 2, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants