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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

### Fixed

- **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.

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`: a malformed isochrone ref no longer "renders" as an empty globe that the model reports as a working map (#267).** Caught live in the ChatGPT app: asking for a 15-minute isochrone around an address drew a zoomed-out globe with the error "Could not fetch isochrone: You must supply one of contours_meters or contours_minutes", while the model told the user "the map above shows the 15-minute driving isochrone". `isochrone_tool` always writes `contours_minutes`/`contours_meters` into its `mapbox://selffetch/isochrone` ref (checked against the hosted server), so the ref the map got was most likely one the model wrote by hand. `resolveSelfFetchRef()` only checked that the ref decoded to JSON, so a ref with no contours, a scalar (`contours_minutes: 15`), or the wrong key name still resolved. `render_map_tool` then reported success, and the iframe's Isochrone request quietly left out the bad field and got a 422 back. Isochrone refs now resolve only when they have numeric coordinates and a non-empty numeric `contours_minutes` or `contours_meters` array. A ref that fails goes down the existing unresolved-refs path, which now says a ref may be malformed and tells the model to re-run the source tool and pass its `mapboxRender.ref` verbatim. The iframe's `isSafeIsochroneParams` does the same check, so a bad ref fails before any network call.

Running the built server over stdio showed that rejecting the ref alone wasn't enough. The screenshot's call also passed a `summary`, and a summary by itself counted as renderable, so a call whose only ref failed still returned `isError: false` with "Rendered map with 0 layers" and a parenthetical note. That would have drawn an empty globe under the summary. `render_map_tool` now returns an error whenever a ref failed to resolve and no layers, markers, or self-fetched layers are left to draw. Calls with no refs that only set `baseMapConfig` or `baseStyle` are unaffected. Two existing tests used an isochrone fixture with `coordinates` as an array, a shape `isochrone_tool` never produces; they now use the real `{longitude, latitude}` shape. Added regression tests that fail without the fix.
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
149 changes: 149 additions & 0 deletions src/utils/jsonSchema2020.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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)
})
}))
};
});
};
}
214 changes: 214 additions & 0 deletions test/utils/jsonSchema2020.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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();
});
});
Loading