Skip to content

refactor(mcp): split hyperdx_query into 5 display-type-specific tools#2294

Open
brandon-pereira wants to merge 8 commits into
mainfrom
brandon/query-split
Open

refactor(mcp): split hyperdx_query into 5 display-type-specific tools#2294
brandon-pereira wants to merge 8 commits into
mainfrom
brandon/query-split

Conversation

@brandon-pereira
Copy link
Copy Markdown
Member

@brandon-pereira brandon-pereira commented May 15, 2026

Summary

Replaces the monolithic hyperdx_query MCP tool with five narrow, display-type-specific tools. Each tool's schema contains only the parameters relevant to its use case — no displayType discriminator, no fields from other modes, no conditional required parameters.

  • hyperdx_timeseries — line + stacked_bar charts (shape field, defaults to line)
  • hyperdx_table — table + number + pie aggregations (shape field, auto-upgrades number/pietable when select.length > 1)
  • hyperdx_search — raw event/log row browsing
  • hyperdx_event_patterns — Drain algorithm pattern mining
  • hyperdx_sql — raw ClickHouse SQL (the only tool requiring connectionId)

hyperdx_query is removed from the tool surface.

What changed

New files (packages/api/src/mcp/tools/query/):

  • timeseries.ts, table.ts, search.ts, eventPatterns.ts, sql.ts — one tool registration per file
  • runEventPatterns.ts — extracted event pattern mining logic (pure code move, no logic changes)
  • schemas.ts — rewritten with shared schema fragments (sourceIdSchema, whereSchema, mcpSelectItemSchema, etc.) and targeted where/whereLanguage guidance with WRONG/RIGHT examples

Modified files:

  • index.ts — barrel now registers 5 tools instead of 1
  • queryTool.test.ts — updated to test all 5 tools: schema serialization, functional queries, auto-upgrade behavior, required-field rejection, seeded pattern mining
  • listSources.ts — description and usage hints reference new tool names
  • saveDashboard.ts — hint fixed from hyperdx_queryhyperdx_query_tile (pre-existing bug)
  • content.ts — prompt guide updated for new tool names and event patterns call signature

Breaking change

hyperdx_query is removed. Any external MCP consumer calling it by name will get a tool-not-found error. The five replacement tools cover all the same functionality with narrower, less error-prone schemas.

Changeset is a patch because MCP is still considered beta and subject to change.

Ref: HDX-4252

Replace the monolithic hyperdx_query tool (one schema carrying fields for
all display types) with five narrow tools, each with only its relevant fields:

- hyperdx_timeseries (line + stacked_bar)
- hyperdx_table (table + number + pie, with shape auto-upgrade)
- hyperdx_search (raw event browsing)
- hyperdx_event_patterns (Drain pattern mining)
- hyperdx_sql (raw ClickHouse SQL)

hyperdx_query is removed from the tool surface.

HDX-4252
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

🦋 Changeset detected

Latest commit: 386984d

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
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

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

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment May 15, 2026 10:25pm

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label May 15, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

🔴 Tier 4 — Critical

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

Why this tier:

  • Large diff: 1699 production lines changed (threshold: 1000)

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: 12
  • Production lines changed: 1699 (+ 373 in test files, excluded from tier calculation)
  • Branch: brandon/query-split
  • 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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

E2E Test Results

All tests passed • 178 passed • 3 skipped • 1191s

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

Tests ran across 4 shards in parallel.

View full report →

- table.ts: narrow displayType to union type instead of string
- search.ts: remove redundant ?? '' after zod .default('')
- table.ts: note in shape description that groupBy is ignored for number
- sql.ts: note in description that results are always table rows
Also assert on sourceId in missing-field rejection tests to catch
schema-required-field regressions.
@brandon-pereira brandon-pereira added review/tier-3 Standard — full human review required and removed review/tier-4 Critical — deep review + domain expert sign-off labels May 15, 2026
@brandon-pereira
Copy link
Copy Markdown
Member Author

Downgrading tier - changed lots of production code but mostly just moving logic not changing logic.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

PR Review

Clean, well-scoped refactor. Tests cover schema serialization, functional execution per shape, the auto-upgrade path, and rejection of missing required fields. Breaking change is correctly captured in .changeset/split-query-tools.md as a patch (consistent with MCP beta status).

Issues

  • ⚠️ Documented granularity param on hyperdx_timeseries is silently no-op'd (pre-existing latent bug, but now prominently advertised). externalDashboardTimeChartConfigSchema (packages/api/src/utils/zod.ts:244) and convertToInternalTileConfig (packages/api/src/routers/external-api/v2/utils/dashboards.ts:566-585pick(externalConfig, ['groupBy', 'numberFormat', 'alignDateRangeToGranularity', 'compareToPreviousPeriod'])) both omit granularity, so it is stripped before reaching ClickHouse. The new schema (timeseries.ts:37-46) gives detailed format guidance ("1 minute" vs "1m"), and the test only asserts isError is false — it never verifies the bucket size in the resulting SQL. → Either add granularity to the external schema + pick list, or remove the parameter/explicit format docs to avoid misleading agents.
  • ⚠️ orderBy is also silently stripped on line/stacked_bar/number/pie shapes. It only lands in the internal config for table. Description on schemas.ts:127-131 hedges with "mainly useful for table shape," but timeseries.ts:36 and table.ts accept it for all shapes. → Either move orderBy into table.ts only, or tighten the description so callers know it's a no-op elsewhere.
  • ⚠️ Invalid sourceId/connectionId throws an uncaught ZodError from buildTile (helpers.ts:35 calls externalDashboardTileSchemaWithId.parse(...) synchronously, and sourceIdSchema in schemas.ts:99 only enforces z.string(), not Types.ObjectId.isValid). withToolTracing re-throws, so the client sees an MCP framework error rather than the friendly "Source not found" path. Pre-existing, but the new surface multiplies the entry points. → Wrap buildTile in try/catch in each tool handler and return a structured error result, or upgrade sourceIdSchema to objectIdSchema.

Nice catches in this PR

  • Shape auto-upgrade for number/pie when select.length > 1 is a real UX win — eliminates the most common Zod failure class from agents.
  • bodyExpression allowlist in runEventPatterns.ts:42 is preserved and still rejects injection like "Body) OR (1=1".
  • runConfigTile (helpers.ts:222-240, 297-313) now wraps the ClickHouse call in try/catch — strictly better than the pre-PR behavior.
  • All external hyperdx_query_tile/hyperdx_query references in dashboards prompts/hints were swept (saveDashboard.ts, content.ts, listSources.ts), so the new tool names are internally consistent.

@hyperdxio hyperdxio deleted a comment from github-actions Bot May 15, 2026
…rces description, simplify select passthrough
@github-actions
Copy link
Copy Markdown
Contributor

Deep Review

🟡 P2 — recommended

  • .changeset/split-query-tools.md:2 — Changeset bumps @hyperdx/api as patch despite removing the public hyperdx_query tool; the prior precedent in .changeset/mcp-alert-saved-search-tools.md treated additive tool-surface changes as minor, so a removal-and-replace warrants at least minor and should carry a BREAKING CHANGE: trailer so changelog consumers do not miss it.
    • Fix: Bump the changeset to minor and add an explicit BREAKING CHANGE: note naming hyperdx_query as removed.
    • project-standards, api-contract
  • packages/api/src/mcp/__tests__/queryTool.test.ts:270-302 — The two auto-upgrade tests for shape:'number'/'pie''table' only assert isError is falsy and never verify the upgrade actually fired, so the test would still pass if the upgrade branch in table.ts were deleted.
    • Fix: Assert on the resolved tile / response shape (multi-row table output, dispatched displayType) to prove the upgrade occurred.
    • testing, project-standards
  • packages/api/src/mcp/tools/query/helpers.ts:222-240,296-314 — The new try/catch blocks that return ClickHouse query failed: … from runConfigTile have no sad-path test, so a regression that swallows errors or re-throws past the wrapper would not be caught.
    • Fix: Add a hyperdx_sql test with an intentionally invalid query asserting isError === true and content matches ClickHouse query failed.
    • testing
  • packages/api/src/mcp/__tests__/queryTool.test.ts:89-158 — Schema-serialization tests check property keys but never assert the .default(…) values (shape, sampleSize, maxResults, whereLanguage, where); the old code had a comment specifically warning the MCP SDK can mishandle defaults during JSON-Schema serialization.
    • Fix: Add assertions like expect(tool!.inputSchema.properties.shape.default).toBe('line') for each newly defaulted field.
    • testing, api-contract
  • packages/api/src/mcp/__tests__/queryTool.test.ts — Required-field rejection is only covered for hyperdx_event_patterns; hyperdx_timeseries, hyperdx_table, and hyperdx_sql have no negative tests confirming missing sourceId/connectionId/sql produces a structured error.
    • Fix: Add three small tests calling each tool without its required field and asserting isError === true with a message naming the missing field.
    • testing
  • packages/api/src/mcp/tools/query/runEventPatterns.ts:60-82 — The SAFE_BODY_EXPR_CHARS allowlist (moved verbatim from the old file) is the only defense against agent-controlled bodyExpression injection and has no negative-path test in its new location.
    • Fix: Add a test passing a malicious bodyExpression like "Body) OR (1=1" and assert the validation error fires.
    • testing, security
  • packages/api/src/mcp/tools/query/helpers.ts:29-44buildTile accepts config: Record<string, unknown>, erasing the per-shape type information so the as const literals at call sites (sql.ts:85, search.ts:74, table.ts:90) are no-ops; mistyped fields like sqlTemplate/sql are caught only at runtime by externalDashboardTileSchemaWithId.parse, and the parse throw is not caught by the handlers' try/catch (which only wraps the ClickHouse call).
    • Fix: Type config as a discriminated union of the supported tile configs (or generic over the Zod-inferred input) so field-name drift fails at compile time.
    • maintainability, kieran-typescript, api-contract
  • packages/api/src/mcp/tools/query/timeseries.ts:57, table.ts:50, search.ts:49, sql.ts:59, eventPatterns.ts:51 — New tool names (hyperdx_timeseries, hyperdx_table, hyperdx_sql, hyperdx_event_patterns) are bare nouns and diverge from the existing verb_noun MCP naming used by every other tool in the repo (hyperdx_list_sources, hyperdx_get_dashboard, hyperdx_save_dashboard, hyperdx_query_tile, etc.).
    • Fix: Rename to verb-prefixed forms (e.g. hyperdx_query_timeseries, hyperdx_query_table, hyperdx_run_sql, hyperdx_mine_event_patterns) or document the new convention in MCP.md.
    • project-standards
🔵 P3 nitpicks (15)
  • packages/api/src/mcp/tools/query/table.ts:30-37 — The shape description says groupBy is ignored when shape:'number', but after auto-upgrade to 'table' (select.length > 1) the groupBy is actually applied; agents will misread the post-upgrade behavior.
    • Fix: Reword to clarify groupBy is ignored only when the effective shape after auto-upgrade is number.
  • packages/api/src/mcp/tools/query/table.ts:30-37shape:'pie' with select.length === 1 and no groupBy is accepted and produces a degenerate single-slice pie because the downstream pie config also makes groupBy optional.
    • Fix: Require groupBy when shape:'pie', or auto-upgrade pie-with-no-groupBy to number.
  • packages/api/src/mcp/tools/query/table.ts:81-92 — The auto-upgrade logic (let displayType reassignment + silent groupBy strip when displayType resolves to number) is invisible to the caller; agents do not learn their shape or groupBy was rewritten.
    • Fix: Surface a one-line note in the response when the upgrade or strip occurs, or move the rule into a validateTableInput helper so it lives in one obvious place.
  • packages/api/src/mcp/tools/query/timeseries.ts:36-46granularity is documented and tested but is not present in externalDashboardLineChartConfigSchema/externalDashboardBarChartConfigSchema, so user input is silently stripped before reaching ClickHouse (pre-existing).
    • Fix: Add granularity to the external time-chart configs (or thread it via ChartConfigWithDateRange) so user input controls the bucket size.
  • packages/api/src/mcp/tools/query/runEventPatterns.ts:285-292 — When bodyColumn is a map-attribute expression like SpanAttributes['http.url'], the generated whereSnippet is invalid Lucene per WHERE_DESCRIPTION, so the documented "chain pattern → search" workflow breaks for map-attribute bodies (pre-existing pattern moved unchanged).
    • Fix: When bodyColumn matches map-attribute bracket syntax, emit a SQL whereSnippet and tag the pattern with whereLanguage:'sql' so the caller forwards it correctly.
  • packages/api/src/mcp/tools/query/timeseries.ts:71-78, table.ts:70-77, search.ts:64-71, eventPatterns.ts:70-77, sql.ts:75-82 — Each handler repeats the same parseTimeRange(input) + 'error' in timeRange early-return block; any change to the error envelope must be made five times.
    • Fix: Have parseTimeRange return an MCP-formatted error directly, or add a parseTimeRangeOrError helper.
    • maintainability
  • packages/api/src/mcp/tools/query/runEventPatterns.ts:1-346 — The file is asymmetric with the other four tools (which keep their handler inline inside register*), forcing readers to know that this one tool's execution lives in a sibling module.
    • Fix: Either inline runEventPatterns back into eventPatterns.ts or apply the same split-file pattern to all five tools.
  • packages/api/src/mcp/tools/query/schemas.ts:5-20,105-115WHERE_DESCRIPTION and WHERE_LANGUAGE_DESCRIPTION are extracted as module-level constants but each has a single consumer.
    • Fix: Inline both strings; extract again when a second consumer appears.
  • packages/api/src/mcp/tools/query/sql.ts:85, search.ts:74, table.ts:90as const casts (configType: 'sql' as const, displayType: 'search' as const) contribute no type information because buildTile widens the parameter to Record<string, unknown>; they read as if they did.
    • Fix: Remove the casts, or tighten buildTile's config type so they become meaningful.
  • packages/api/src/mcp/tools/query/helpers.ts:222,296let result; without initializer or annotation relies on flow inference; an explicit let result: Awaited<ReturnType<typeof clickhouseClient.queryChartConfig>>; (the pattern already used in runEventPatterns.ts) locks in the contract.
    • Fix: Add the explicit annotation at both sites.
  • packages/api/src/mcp/tools/query/runEventPatterns.ts:194Number(countResult.data?.[0]?.total ?? 0) reads .total off an untyped row and silently coerces undefined/NaN to 0 if the count query is ever realiased.
    • Fix: Define a small row interface or assert the column name with a runtime check that surfaces a "count query returned unexpected shape" error.
  • packages/api/src/mcp/__tests__/queryTool.test.ts:190-198 — The "should default to line shape when shape is omitted" test only asserts isError is falsy and never confirms line was actually selected.
    • Fix: Inspect the dispatched displayType / response shape to assert line was used.
    • testing
  • packages/api/src/mcp/__tests__/queryTool.test.ts — No negative assertion that hyperdx_query is absent from client.listTools() after the split; accidental re-registration would not be caught.
    • Fix: Add expect(tools.find(t => t.name === 'hyperdx_query')).toBeUndefined() to the schema-serialization block.
    • testing, project-standards
  • MCP.md:115hyperdx_event_patterns row description leaks the "Drain clustering" implementation detail, breaking abstraction parity with the other table rows.
    • Fix: Drop "using Drain clustering" from the table entry; the tool's own description still mentions it.
    • project-standards
  • .changeset/split-query-tools.md:5 — Summary uses refactor(mcp): but the change is user-visible (removes one public tool, adds five).
    • Fix: Retag as feat(mcp): (or feat!(mcp): with a BREAKING CHANGE: trailer).
    • project-standards

Reviewers (7): correctness, testing, maintainability, project-standards, api-contract, kieran-typescript, security.

Testing gaps:

  • No assertion that the number/pietable auto-upgrade actually rewrites displayType (current tests only check isError).
  • No sad-path test for the new try/catch in runConfigTile; invalid SQL via hyperdx_sql should be exercised.
  • No required-field rejection tests for hyperdx_timeseries, hyperdx_table, or hyperdx_sql.
  • SAFE_BODY_EXPR_CHARS allowlist has no negative-path test in its new location.
  • Schema-default values (shape, sampleSize, maxResults, whereLanguage) are not asserted in the serialization tests.
  • hyperdx_query is not negatively asserted as absent from listTools() after the split.

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

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant