Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-mcp-tool-schema-draft-2020-12.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/api/src/mcp/__tests__/toolSchemas.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
18 changes: 18 additions & 0 deletions packages/common-utils/src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/common-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
3 changes: 2 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
Loading