diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 9adaa851a..43cee5809 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to `@pascal-app/mcp` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Tool schemas in `tools/list` now declare the JSON Schema 2020-12 dialect + instead of the SDK default `draft-07`, so clients that enforce 2020-12 no + longer reject every tool call. + ## [0.1.0] - 2026-04-18 ### Added diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 3cbda9ad8..0781efcc1 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,6 +5,7 @@ import { registerPrompts } from './prompts' import { registerResources } from './resources' import type { SceneStore } from './storage/types' import { registerTools } from './tools' +import { normalizeToolSchemaDialect } from './tools/normalize-schema-dialect' import { registerVisionTools } from './tools/vision' import { version } from './version' @@ -28,5 +29,6 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe registerVisionTools(server, operations) registerResources(server, operations) registerPrompts(server, operations) + normalizeToolSchemaDialect(server) return server } diff --git a/packages/mcp/src/tools/normalize-schema-dialect.ts b/packages/mcp/src/tools/normalize-schema-dialect.ts new file mode 100644 index 000000000..b41dbc68b --- /dev/null +++ b/packages/mcp/src/tools/normalize-schema-dialect.ts @@ -0,0 +1,35 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +const DIALECT_2020_12 = 'https://json-schema.org/draft/2020-12/schema' + +type RequestHandler = (request: unknown, extra: unknown) => Promise + +type HandlerRegistry = { + _requestHandlers: Map +} + +function retargetDialect(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) retargetDialect(item) + return + } + if (value && typeof value === 'object') { + const record = value as Record + if (typeof record.$schema === 'string') record.$schema = DIALECT_2020_12 + for (const key of Object.keys(record)) retargetDialect(record[key]) + } +} + +export function normalizeToolSchemaDialect(server: McpServer): void { + const registry = server.server as unknown as HandlerRegistry + const original = registry._requestHandlers.get('tools/list') + if (!original) return + + server.server.removeRequestHandler('tools/list') + server.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => { + const result = (await original(request, extra)) as { tools?: unknown } + retargetDialect(result.tools) + return result + }) +} diff --git a/packages/mcp/src/tools/schema-dialect.test.ts b/packages/mcp/src/tools/schema-dialect.test.ts new file mode 100644 index 000000000..8c9181e9b --- /dev/null +++ b/packages/mcp/src/tools/schema-dialect.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { SceneBridge } from '../bridge/scene-bridge' +import { createPascalMcpServer } from '../server' + +const DIALECT_2020_12 = 'https://json-schema.org/draft/2020-12/schema' + +async function listRawSchemas() { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + + const server = createPascalMcpServer({ bridge }) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'schema-dialect', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + const { tools } = await client.listTools() + + const dialects = new Set() + for (const tool of tools) { + const input = tool.inputSchema as Record | undefined + const output = tool.outputSchema as Record | undefined + if (typeof input?.$schema === 'string') dialects.add(input.$schema) + if (typeof output?.$schema === 'string') dialects.add(output.$schema) + } + return dialects +} + +describe('tools/list schema dialect', () => { + test('every declared $schema is JSON Schema 2020-12', async () => { + const dialects = await listRawSchemas() + + expect(dialects.size).toBeGreaterThan(0) + expect([...dialects]).toEqual([DIALECT_2020_12]) + }) +})