From 2fb303a1a4b57069de19901f3e194601b908bee8 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 17 Aug 2026 10:53:11 -0400 Subject: [PATCH] fix(mcp): emit draft-2020-12-valid tool input schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number-tile `colorRules` `between` rule declared `value` as a Zod tuple, which `zod-to-json-schema` renders in the draft-07 tuple form (`items: [ ... ]`). Draft 2020-12 requires `items` to be a schema rather than an array, so `clickstack_save_dashboard` and `clickstack_patch_dashboard` were invalid — and clients that forward MCP tool schemas straight to an LLM provider (e.g. the Anthropic API) rejected the entire tool list with `tools.N.custom.input_schema: JSON schema is invalid`, making the MCP server unusable rather than just degrading those two tools. Use a fixed-length array instead: identical validation, identical `[min, max]` wire format, and it matches the already-published `BetweenColorCondition` external-API schema. Also add a test that validates every registered MCP tool's input schema against the 2020-12 metaschema, so any future Zod construct that emits draft-07-only output fails in CI instead of in a user's client, plus negative cases pinning the between-rule arity and element type. --- .../fix-mcp-tool-schema-draft-2020-12.md | 6 ++ packages/api/package.json | 1 + .../api/src/mcp/__tests__/toolSchemas.test.ts | 56 +++++++++++++++++++ .../common-utils/src/__tests__/types.test.ts | 18 ++++++ packages/common-utils/src/types.ts | 10 +++- yarn.lock | 3 +- 6 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-mcp-tool-schema-draft-2020-12.md create mode 100644 packages/api/src/mcp/__tests__/toolSchemas.test.ts diff --git a/.changeset/fix-mcp-tool-schema-draft-2020-12.md b/.changeset/fix-mcp-tool-schema-draft-2020-12.md new file mode 100644 index 0000000000..0978371567 --- /dev/null +++ b/.changeset/fix-mcp-tool-schema-draft-2020-12.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/api': patch +--- + +Fix MCP tool schemas being rejected by strict JSON Schema draft 2020-12 clients. The number-tile `colorRules` `between` rule declared its `value` as a Zod tuple, which `zod-to-json-schema` renders in the draft-07 tuple form (`items: [ ... ]`). Draft 2020-12 requires `items` to be a schema rather than an array, so `clickstack_save_dashboard` and `clickstack_patch_dashboard` failed validation — and clients that forward MCP tool schemas straight to an LLM provider (e.g. the Anthropic API) rejected the entire tool list with `tools.N.custom.input_schema: JSON schema is invalid`, making the MCP server unusable. `value` is now a fixed-length array, which validates identically and serializes to the same `[min, max]` wire format. A new test validates every MCP tool's input schema against the 2020-12 metaschema so this cannot regress. diff --git a/packages/api/package.json b/packages/api/package.json index ee78648166..aff2bd9615 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -79,6 +79,7 @@ "@types/supertest": "^2.0.12", "@types/swagger-jsdoc": "^6", "@types/uuid": "^8.3.4", + "ajv": "^8.20.0", "jest": "^30.2.0", "migrate-mongo": "^11.0.0", "nodemon": "^2.0.20", diff --git a/packages/api/src/mcp/__tests__/toolSchemas.test.ts b/packages/api/src/mcp/__tests__/toolSchemas.test.ts new file mode 100644 index 0000000000..5b0f98a474 --- /dev/null +++ b/packages/api/src/mcp/__tests__/toolSchemas.test.ts @@ -0,0 +1,56 @@ +import Ajv2020 from 'ajv/dist/2020'; + +import { McpContext } from '@/mcp/tools/types'; + +import { createTestClient } from './mcpTestUtils'; + +/** + * Every MCP tool's `inputSchema` must be valid JSON Schema draft 2020-12. + * + * Clients that forward tool schemas straight to an LLM provider validate them + * against the 2020-12 metaschema and reject the ENTIRE tool list when any one + * schema fails — so a single bad tool takes the whole server down for that + * client, with an error that only identifies the tool by index. + * + * The trap is that the MCP SDK converts our Zod v3 schemas with + * `zod-to-json-schema`, which emits draft-07 output. Most of that output is + * also valid 2020-12, but not all of it: `z.tuple([...])` renders as + * `items: [ ... ]`, and in 2020-12 `items` must be a schema rather than an + * array (tuple validation moved to `prefixItems`). This test exists to catch + * that class of mismatch at CI time instead of in a user's client. + */ +describe('MCP tool input schemas', () => { + // The tools/list response is built purely from the registered Zod schemas, + // so no database or ClickHouse fixtures are needed here. + const context: McpContext = { teamId: 'team-id', userId: 'user-id' }; + + // `strict: false` keeps Ajv from complaining about the vocabulary quirks of + // machine-generated schemas (unknown keywords, `additionalItems`, etc.). + // Schema-level draft-2020-12 conformance is still enforced. + const ajv = new Ajv2020({ strict: false }); + + it('are all valid JSON Schema draft 2020-12', async () => { + const client = await createTestClient(context); + const { tools } = await client.listTools(); + + // Guard against a registration regression silently emptying the list and + // making the assertions below vacuously true. + expect(tools.length).toBeGreaterThan(0); + + const invalid = tools.flatMap(tool => { + // The SDK stamps `$schema: draft-07` on every converted schema; Ajv2020 + // cannot resolve that meta-schema URI, and the declaration itself is not + // what strict clients object to. Drop it and check the schema body. + const { $schema: _$schema, ...schema } = tool.inputSchema; + + try { + ajv.compile(schema); + return []; + } catch (e) { + return [`${tool.name}: ${e instanceof Error ? e.message : String(e)}`]; + } + }); + + expect(invalid).toEqual([]); + }); +}); diff --git a/packages/common-utils/src/__tests__/types.test.ts b/packages/common-utils/src/__tests__/types.test.ts index bcb92f3086..12e4289129 100644 --- a/packages/common-utils/src/__tests__/types.test.ts +++ b/packages/common-utils/src/__tests__/types.test.ts @@ -56,6 +56,24 @@ describe('ColorConditionSchema', () => { }); expect(result.success).toBe(true); }); + + // `value` is a fixed-length array rather than a tuple so the MCP tools can + // publish a draft-2020-12-valid JSON Schema. These guard that the looser + // container still pins arity and element type exactly as a tuple did. + it.each([ + ['too few bounds', [10]], + ['too many bounds', [10, 100, 1000]], + ['a non-numeric bound', [10, '100']], + ['a non-finite bound', [10, Infinity]], + ['a bare number', 10], + ])('rejects %s', (_label, value) => { + const result = ColorConditionSchema.safeParse({ + operator: 'between', + value, + color: 'chart-blue', + }); + expect(result.success).toBe(false); + }); }); describe('eq / neq operators', () => { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index bb211a7d7f..c737524f81 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -333,7 +333,15 @@ const numericOrderedColorCondition = z.object({ const betweenColorCondition = z.object({ operator: z.literal('between'), - value: z.tuple([z.number().finite(), z.number().finite()]), + // A fixed-length array rather than `z.tuple([...])`: the MCP dashboard tools + // (clickstack_save_dashboard / clickstack_patch_dashboard) expose this schema + // as a tool `input_schema`, and `zod-to-json-schema` renders a tuple in the + // draft-07 form (`items: [ ... ]`). In JSON Schema draft 2020-12 `items` must + // be a schema, not an array, so a tuple here makes the whole tool schema + // invalid and any strict draft-2020-12 client (e.g. the Anthropic API) rejects + // the tool list outright. The wire format — `[min, max]` — is identical either + // way, and matches the published `BetweenColorCondition` external-API schema. + value: z.array(z.number().finite()).length(2), color: ChartPaletteTokenSchema, label: z.string().max(40).optional(), }); diff --git a/yarn.lock b/yarn.lock index ce0f05b419..c86fe8d3b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4640,6 +4640,7 @@ __metadata: "@types/swagger-jsdoc": "npm:^6" "@types/uuid": "npm:^8.3.4" ai: "npm:^6.0.116" + ajv: "npm:^8.20.0" aws4: "npm:^1.13.2" chrono-node: "npm:^2.9.0" compression: "npm:^1.7.4" @@ -12012,7 +12013,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.17.1, ajv@npm:^8.9.0": +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.17.1, ajv@npm:^8.20.0, ajv@npm:^8.9.0": version: 8.20.0 resolution: "ajv@npm:8.20.0" dependencies: