From c731cafc87ccc06df0b48f99e8fd51e160d07880 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 7 Oct 2026 13:29:18 -0400 Subject: [PATCH 1/2] fix: publish tool schemas as JSON Schema 2020-12 Claude Desktop rejects any tool whose outputSchema declares draft-07 ("JSON Schema declares an unsupported dialect"). McpServer in SDK 1.x always generates draft-07, so wrap setRequestHandler on our server instance and convert the tools/list schemas to 2020-12: relabel $schema, tuple items -> prefixItems, definitions -> $defs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + src/index.ts | 4 + src/utils/jsonSchema2020.ts | 149 +++++++++++++++++++++ test/utils/jsonSchema2020.test.ts | 214 ++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 src/utils/jsonSchema2020.ts create mode 100644 test/utils/jsonSchema2020.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6dba7f..b7467bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ ### Fixed +- **Tool schemas are now published as JSON Schema 2020-12, so Claude Desktop can call the tools again.** Claude Desktop rejected calls to every tool that declares an output schema with "Tool 'search_and_geocode_tool' has an invalid outputSchema: JSON Schema declares an unsupported dialect (\"$schema\": \"http://json-schema.org/draft-07/schema#\"). The default validator supports JSON Schema 2020-12 only". The MCP SDK's `McpServer` (1.30.0 here, and still in 1.32.1, the latest on npm) generates every tool's `inputSchema` and `outputSchema` as draft-07, with no option to change it, while MCP specifies 2020-12 as the default dialect. Changing only the `$schema` label would not have been enough: 30 schema nodes are tuples (`bbox`, coordinate pairs), which draft-07 writes as `items: [...]`, a form 2020-12 does not allow. + + `publishJsonSchema2020()` (`src/utils/jsonSchema2020.ts`) wraps `setRequestHandler` on our own server instance before any tool is registered, so the SDK's `tools/list` handler is installed wrapped. Its schemas are converted on the way out: the label becomes 2020-12, tuple `items` becomes `prefixItems` (with `additionalItems` becoming `items`), and `definitions` becomes `$defs` with refs rewritten to match. The conversion walks only real subschema positions, so a property named `items` or `definitions` is left alone. It isn't a global patch, and the SDK's own validation of tool input and output still runs against the zod schemas as before. + + Checked against the built server over stdio: all 29 tools list with the 2020-12 label, all 57 schemas compile under Ajv's 2020-12 validator, and a live `search_and_geocode_tool` result validates against its converted output schema. In Ajv's strict mode, which isn't the default, converted tuples still raise length warnings; zod's own 2020-12 output does the same. Added tests for the converter, for every registered tool's listed schemas, for the SDK's unwrapped draft-07 output (so the wrapper can be removed once the SDK emits 2020-12 itself), and for a tool call whose structured output includes a tuple. + - **`render_map_tool`: `baseStyle` (e.g. `"standard-satellite"`) was silently ignored on every real call.** `baseStyle` was only ever applied at map-construction time in `mapAppHtml.ts`'s `initMap()`, seeded from a server-embedded `#initial-data` script tag — but `MapAppUIResource` never actually passes `initialData` when serving that HTML, so the tag is always empty and the map always constructs with the default `"standard"` style. The real payload (with `baseStyle`) arrives afterward via the `ui/notifications/tool-result` → `render(payload)` path, which only ever handled `baseMapConfig` (color/theme properties via `setConfigProperty`), never `baseStyle` itself. Caught live: asking for a `"standard-satellite"` render produced a map correctly labeled "satellite view" that was actually still the standard vector style. `render()` now detects a `baseStyle` change from what the map is currently on and switches via `map.setStyle(...)`, deferring the rest of the render until `style.load` fires (matching how a style is normally swapped in GL JS, since it replaces sources/layers). Added regression tests reproducing the original silent-ignore bug (confirmed to fail without the fix) and covering the no-op case when a render repeats the already-active style. ### New Features diff --git a/src/index.ts b/src/index.ts index b8cedf5..457ffd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { getAllResources } from './resources/resourceRegistry.js'; import { getAllPrompts, getPromptByName } from './prompts/promptRegistry.js'; import { completePromptArgument } from './completions/index.js'; import { getVersionInfo } from './utils/versionUtils.js'; +import { publishJsonSchema2020 } from './utils/jsonSchema2020.js'; import { handleCliMetadataArgs } from './cli.js'; import { initializeTracing, @@ -127,6 +128,9 @@ const server = new McpServer( } ); +// Must run before the first tool is registered — see jsonSchema2020.ts. +publishJsonSchema2020(server); + // Register only core tools before connection // Capability-dependent tools will be registered dynamically after connection enabledCoreTools.forEach((tool) => { diff --git a/src/utils/jsonSchema2020.ts b/src/utils/jsonSchema2020.ts new file mode 100644 index 0000000..1b9d93b --- /dev/null +++ b/src/utils/jsonSchema2020.ts @@ -0,0 +1,149 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +export const JSON_SCHEMA_2020_12 = + 'https://json-schema.org/draft/2020-12/schema'; +const JSON_SCHEMA_DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; + +type Schema = Record; + +function isObject(value: unknown): value is Schema { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// Keywords whose value is a single subschema. +const SUBSCHEMA_KEYWORDS = [ + 'additionalProperties', + 'contains', + 'propertyNames', + 'not', + 'if', + 'then', + 'else' +]; +// Keywords whose value is an array of subschemas. +const SUBSCHEMA_ARRAY_KEYWORDS = ['anyOf', 'oneOf', 'allOf']; +// Keywords whose value maps names to subschemas — the names themselves are +// data (e.g. a property literally called "items"), never keywords. +const SUBSCHEMA_MAP_KEYWORDS = [ + 'properties', + 'patternProperties', + '$defs', + 'dependentSchemas' +]; + +/** + * Convert one draft-07 schema node (and everything under it) to its JSON + * Schema 2020-12 equivalent. Covers what zod's draft-07 output can contain: + * tuple `items: [...]` → `prefixItems` (with `additionalItems` → `items`), + * `definitions` → `$defs` (and `#/definitions/` refs to match). Walks only + * real subschema positions, so property names are never mistaken for + * keywords. + */ +function convertNode(node: unknown): unknown { + if (!isObject(node)) return node; + const out: Schema = { ...node }; + + if ('definitions' in out) { + out.$defs = { ...(out.$defs as Schema), ...(out.definitions as Schema) }; + delete out.definitions; + } + if (typeof out.$ref === 'string' && out.$ref.startsWith('#/definitions/')) { + out.$ref = '#/$defs/' + out.$ref.slice('#/definitions/'.length); + } + + if (Array.isArray(out.items)) { + out.prefixItems = out.items.map(convertNode); + if ('additionalItems' in out) { + out.items = convertNode(out.additionalItems); + delete out.additionalItems; + } else { + delete out.items; + } + } else if ('items' in out) { + out.items = convertNode(out.items); + } + + for (const key of SUBSCHEMA_KEYWORDS) { + if (key in out) out[key] = convertNode(out[key]); + } + for (const key of SUBSCHEMA_ARRAY_KEYWORDS) { + if (Array.isArray(out[key])) { + out[key] = (out[key] as unknown[]).map(convertNode); + } + } + for (const key of SUBSCHEMA_MAP_KEYWORDS) { + if (isObject(out[key])) { + out[key] = Object.fromEntries( + Object.entries(out[key] as Schema).map(([name, sub]) => [ + name, + convertNode(sub) + ]) + ); + } + } + return out; +} + +/** + * Convert a draft-07 root schema to JSON Schema 2020-12 and label it as + * such. A schema that already declares another dialect is returned + * unchanged. + */ +export function toJsonSchema2020(schema: unknown): unknown { + if (!isObject(schema)) return schema; + if (schema.$schema !== undefined && schema.$schema !== JSON_SCHEMA_DRAFT_07) { + return schema; + } + const converted = convertNode(schema) as Schema; + converted.$schema = JSON_SCHEMA_2020_12; + return converted; +} + +/** + * Make `tools/list` publish JSON Schema 2020-12 instead of draft-07. + * + * MCP SDK 1.x's McpServer generates every tool's inputSchema/outputSchema + * as draft-07 (with no option to change it), but MCP specifies 2020-12 as + * the default dialect, and newer clients enforce that — Claude Desktop + * refuses to call any tool whose outputSchema declares draft-07 ("JSON + * Schema declares an unsupported dialect ... The default validator + * supports JSON Schema 2020-12 only"). + * + * McpServer installs its tools/list handler through the public + * setRequestHandler on first tool registration, so this wraps that method + * on this one server instance (not a global patch) and converts the + * schemas in the handler's result. Must be called before any tool is + * registered. + */ +export function publishJsonSchema2020(server: McpServer): void { + const inner = server.server; + const listToolsMethod = ListToolsRequestSchema.shape.method.value; + const original = inner.setRequestHandler.bind(inner); + + // setRequestHandler's generic signature can't be expressed for a wrapper + // without re-declaring the SDK's internal schema types. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (inner as any).setRequestHandler = (requestSchema: any, handler: any) => { + if (requestSchema?.shape?.method?.value !== listToolsMethod) { + return original(requestSchema, handler); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return original(requestSchema, async (request: any, extra: any) => { + const result = await handler(request, extra); + return { + ...result, + tools: result.tools.map((tool: Schema) => ({ + ...tool, + inputSchema: toJsonSchema2020(tool.inputSchema), + ...(tool.outputSchema !== undefined && { + outputSchema: toJsonSchema2020(tool.outputSchema) + }) + })) + }; + }); + }; +} diff --git a/test/utils/jsonSchema2020.test.ts b/test/utils/jsonSchema2020.test.ts new file mode 100644 index 0000000..d88e9d1 --- /dev/null +++ b/test/utils/jsonSchema2020.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { + JSON_SCHEMA_2020_12, + publishJsonSchema2020, + toJsonSchema2020 +} from '../../src/utils/jsonSchema2020.js'; +import { getAllTools } from '../../src/tools/toolRegistry.js'; + +const DRAFT_07 = 'http://json-schema.org/draft-07/schema#'; + +// Collects every draft-07-only construct left anywhere in a schema. +function draft07Leftovers(node: unknown, path = '$'): string[] { + if (typeof node !== 'object' || node === null) return []; + if (Array.isArray(node)) { + return node.flatMap((n, i) => draft07Leftovers(n, `${path}[${i}]`)); + } + const obj = node as Record; + const found: string[] = []; + if (Array.isArray(obj.items)) found.push(`${path}.items[]`); + if ('additionalItems' in obj) found.push(`${path}.additionalItems`); + if ('definitions' in obj) found.push(`${path}.definitions`); + if (obj.$schema === DRAFT_07) found.push(`${path}.$schema`); + for (const [k, v] of Object.entries(obj)) { + found.push(...draft07Leftovers(v, `${path}.${k}`)); + } + return found; +} + +describe('toJsonSchema2020', () => { + it('relabels the root $schema', () => { + expect( + toJsonSchema2020({ $schema: DRAFT_07, type: 'object', properties: {} }) + ).toEqual({ $schema: JSON_SCHEMA_2020_12, type: 'object', properties: {} }); + }); + + it('converts tuple items to prefixItems, nested anywhere', () => { + const out = toJsonSchema2020({ + $schema: DRAFT_07, + type: 'object', + properties: { + bbox: { + type: 'array', + items: [{ type: 'number' }, { type: 'number' }] + }, + lines: { + anyOf: [ + { + type: 'array', + items: { + type: 'array', + items: [{ type: 'number' }, { type: 'number' }] + } + } + ] + } + } + }); + expect(out).toEqual({ + $schema: JSON_SCHEMA_2020_12, + type: 'object', + properties: { + bbox: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'number' }] + }, + lines: { + anyOf: [ + { + type: 'array', + items: { + type: 'array', + prefixItems: [{ type: 'number' }, { type: 'number' }] + } + } + ] + } + } + }); + }); + + it('maps additionalItems on a tuple to items', () => { + expect( + toJsonSchema2020({ + type: 'array', + items: [{ type: 'string' }], + additionalItems: { type: 'number' } + }) + ).toEqual({ + $schema: JSON_SCHEMA_2020_12, + type: 'array', + prefixItems: [{ type: 'string' }], + items: { type: 'number' } + }); + }); + + it('moves definitions to $defs and rewrites refs', () => { + expect( + toJsonSchema2020({ + $schema: DRAFT_07, + definitions: { pt: { type: 'number' } }, + properties: { a: { $ref: '#/definitions/pt' } } + }) + ).toEqual({ + $schema: JSON_SCHEMA_2020_12, + $defs: { pt: { type: 'number' } }, + properties: { a: { $ref: '#/$defs/pt' } } + }); + }); + + it('never treats a property named like a keyword as a keyword', () => { + const schema = { + $schema: DRAFT_07, + type: 'object', + properties: { + items: { type: 'array', items: { type: 'string' } }, + definitions: { type: 'string' } + } + }; + expect(toJsonSchema2020(schema)).toEqual({ + ...schema, + $schema: JSON_SCHEMA_2020_12 + }); + }); + + it('leaves a schema that declares another dialect untouched', () => { + const schema = { + $schema: JSON_SCHEMA_2020_12, + type: 'array', + prefixItems: [{ type: 'number' }] + }; + expect(toJsonSchema2020(schema)).toBe(schema); + }); +}); + +describe('publishJsonSchema2020', () => { + async function listTools(publish: boolean) { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + if (publish) publishJsonSchema2020(server); + for (const tool of getAllTools()) tool.installTo(server); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport) + ]); + const { tools } = await client.listTools(); + await client.close(); + return tools; + } + + // Regression: Claude Desktop rejected every tool call with "Tool + // 'search_and_geocode_tool' has an invalid outputSchema: JSON Schema + // declares an unsupported dialect" because the SDK emits draft-07. + it('publishes every tool schema as JSON Schema 2020-12', async () => { + const tools = await listTools(true); + expect(tools.length).toBe(getAllTools().length); + for (const tool of tools) { + expect(tool.inputSchema.$schema, tool.name).toBe(JSON_SCHEMA_2020_12); + expect(draft07Leftovers(tool.inputSchema), tool.name).toEqual([]); + if (tool.outputSchema) { + expect(tool.outputSchema.$schema, tool.name).toBe(JSON_SCHEMA_2020_12); + expect(draft07Leftovers(tool.outputSchema), tool.name).toEqual([]); + } + } + }); + + it('is needed: the SDK alone publishes draft-07 with tuple items', async () => { + const tools = await listTools(false); + const leftovers = tools.flatMap((t) => [ + ...draft07Leftovers(t.inputSchema), + ...draft07Leftovers(t.outputSchema) + ]); + expect(leftovers.some((l) => l.endsWith('.$schema'))).toBe(true); + expect(leftovers.some((l) => l.endsWith('.items[]'))).toBe(true); + }); + + it('keeps tool calls working with structured output validation', async () => { + const server = new McpServer({ name: 'test', version: '0.0.0' }); + publishJsonSchema2020(server); + const bbox = getAllTools().find((t) => t.name === 'bbox_tool')!; + bbox.installTo(server); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '0.0.0' }); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport) + ]); + await client.listTools(); + const result = await client.callTool({ + name: 'bbox_tool', + arguments: { + geometry: [ + [-122.4, 37.7], + [-122.3, 37.8] + ] + } + }); + expect(result.isError).toBeFalsy(); + // bbox is a tuple in the output schema — the field the conversion + // rewrites to prefixItems. + expect(result.structuredContent).toEqual({ + bbox: [-122.4, 37.7, -122.3, 37.8] + }); + await client.close(); + }); +}); From cb58e712f3be3eb76f35b7ffbd4ae0fccfaed740 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 7 Oct 2026 13:35:34 -0400 Subject: [PATCH 2/2] docs(changelog): add PR number for JSON Schema 2020-12 fix Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7467bf..36b073d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ ### Fixed -- **Tool schemas are now published as JSON Schema 2020-12, so Claude Desktop can call the tools again.** Claude Desktop rejected calls to every tool that declares an output schema with "Tool 'search_and_geocode_tool' has an invalid outputSchema: JSON Schema declares an unsupported dialect (\"$schema\": \"http://json-schema.org/draft-07/schema#\"). The default validator supports JSON Schema 2020-12 only". The MCP SDK's `McpServer` (1.30.0 here, and still in 1.32.1, the latest on npm) generates every tool's `inputSchema` and `outputSchema` as draft-07, with no option to change it, while MCP specifies 2020-12 as the default dialect. Changing only the `$schema` label would not have been enough: 30 schema nodes are tuples (`bbox`, coordinate pairs), which draft-07 writes as `items: [...]`, a form 2020-12 does not allow. +- **Tool schemas are now published as JSON Schema 2020-12, so Claude Desktop can call the tools again (#268).** Claude Desktop rejected calls to every tool that declares an output schema with "Tool 'search_and_geocode_tool' has an invalid outputSchema: JSON Schema declares an unsupported dialect (\"$schema\": \"http://json-schema.org/draft-07/schema#\"). The default validator supports JSON Schema 2020-12 only". The MCP SDK's `McpServer` (1.30.0 here, and still in 1.32.1, the latest on npm) generates every tool's `inputSchema` and `outputSchema` as draft-07, with no option to change it, while MCP specifies 2020-12 as the default dialect. Changing only the `$schema` label would not have been enough: 30 schema nodes are tuples (`bbox`, coordinate pairs), which draft-07 writes as `items: [...]`, a form 2020-12 does not allow. `publishJsonSchema2020()` (`src/utils/jsonSchema2020.ts`) wraps `setRequestHandler` on our own server instance before any tool is registered, so the SDK's `tools/list` handler is installed wrapped. Its schemas are converted on the way out: the label becomes 2020-12, tuple `items` becomes `prefixItems` (with `additionalItems` becoming `items`), and `definitions` becomes `$defs` with refs rewritten to match. The conversion walks only real subschema positions, so a property named `items` or `definitions` is left alone. It isn't a global patch, and the SDK's own validation of tool input and output still runs against the zod schemas as before.