From 31e7b2a53d49927a66be41460e41b614fcd21f92 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Tue, 8 Sep 2026 09:52:33 +0200 Subject: [PATCH 1/2] fix(server): relabel tools/list schema dialect for Claude Desktop compatibility @modelcontextprotocol/sdk (1.30.0, latest) always advertises tool schemas with $schema: draft-07, since its tools/list handler never passes a target to the schema converter. Claude Desktop's tool runner only accepts JSON Schema 2020-12 and rejects every call as a result. Wrap the transport's send to relabel the dialect in transit until the upstream fix (modelcontextprotocol/typescript-sdk#2084, #2721) lands in a release; safe here since this server never emits tuple-style items arrays, the one keyword that differs between the two dialects. Co-Authored-By: Claude Sonnet 5 --- src/cli.ts | 2 + src/json_schema_dialect_workaround.ts | 61 ++++++++++ tests/cli.test.ts | 40 ++++++- tests/json_schema_dialect_workaround.test.ts | 112 +++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/json_schema_dialect_workaround.ts create mode 100644 tests/json_schema_dialect_workaround.test.ts diff --git a/src/cli.ts b/src/cli.ts index 4808468..7e05546 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import type { Config } from "./config.js"; import { loadConfig } from "./config.js"; import { runDoctor, validateDoctorArguments } from "./doctor.js"; import { CommandError } from "./errors.js"; +import { applyJsonSchemaDialectWorkaround } from "./json_schema_dialect_workaround.js"; import { createDefaultOAuthCredentialStore, createDefaultPersonalAccessTokenStore, @@ -134,6 +135,7 @@ export async function runCli(options: RunCliOptions = {}): Promise { ...(options.requestContext === undefined ? {} : { requestContext: options.requestContext }), }); const transport = options.transport ?? new StdioServerTransport(); + applyJsonSchemaDialectWorkaround(transport); await server.connect(transport); const mode = config.readOnly ? "read-only" : "read-write"; stderr.write(`Asana Command MCP server ready (${mode} mode)\n`); diff --git a/src/json_schema_dialect_workaround.ts b/src/json_schema_dialect_workaround.ts new file mode 100644 index 0000000..2fbd262 --- /dev/null +++ b/src/json_schema_dialect_workaround.ts @@ -0,0 +1,61 @@ +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +const DRAFT_07_SCHEMA_URI = "http://json-schema.org/draft-07/schema#"; +const DRAFT_2020_12_SCHEMA_URI = "https://json-schema.org/draft/2020-12/schema"; + +function relabelSchemaDialect(schema: unknown): void { + if ( + typeof schema !== "object" || + schema === null || + (schema as { $schema?: unknown }).$schema !== DRAFT_07_SCHEMA_URI + ) { + return; + } + (schema as { $schema: string }).$schema = DRAFT_2020_12_SCHEMA_URI; +} + +function relabelToolsListResult(result: unknown): void { + if (typeof result !== "object" || result === null) { + return; + } + const { tools } = result as { tools?: unknown }; + if (!Array.isArray(tools)) { + return; + } + for (const tool of tools) { + if (typeof tool !== "object" || tool === null) { + continue; + } + relabelSchemaDialect((tool as { inputSchema?: unknown }).inputSchema); + relabelSchemaDialect((tool as { outputSchema?: unknown }).outputSchema); + } +} + +/** + * WORKAROUND: @modelcontextprotocol/sdk (as of 1.30.0, the latest release) always advertises + * `tools/list` schemas with `$schema: "http://json-schema.org/draft-07/schema#"` — the SDK's + * `tools/list` handler never passes a `target` to its schema converter, which defaults to + * draft-07 (see `mapMiniTarget` in the SDK's `zod-json-schema-compat.js`). Claude Desktop's tool + * runner rejects draft-07 schemas and only accepts JSON Schema 2020-12, so every tool call fails. + * + * This relabels the dialect in transit rather than fixing the schema shape, which is only safe + * because this server never emits tuple-style `items` arrays (2020-12 requires `prefixItems` for + * tuples instead, so a shape-changing fix would be needed if that ever changes). + * + * Upstream bug: https://github.com/modelcontextprotocol/typescript-sdk/issues/2084 (root cause) + * and https://github.com/modelcontextprotocol/typescript-sdk/issues/2721 (this exact symptom). + * A fix has been proposed but not merged/released (PRs #2085, #2653). + * + * TODO: delete this file and its call site once a released SDK version emits 2020-12 by default. + */ +export function applyJsonSchemaDialectWorkaround(transport: Transport): Transport { + const originalSend = transport.send.bind(transport); + transport.send = (message: JSONRPCMessage, options) => { + if ("result" in message) { + relabelToolsListResult(message.result); + } + return originalSend(message, options); + }; + return transport; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 346d5c2..be1a825 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -58,6 +58,7 @@ class RecordingTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; + readonly sentMessages: JSONRPCMessage[] = []; constructor(private readonly events: string[]) {} @@ -65,7 +66,9 @@ class RecordingTransport implements Transport { this.events.push("connected"); } - async send(_message: JSONRPCMessage): Promise {} + async send(message: JSONRPCMessage): Promise { + this.sentMessages.push(message); + } async close(): Promise {} } @@ -138,6 +141,41 @@ describe("CLI", () => { expect(events).toEqual(["connected"]); }); + it("applies the JSON Schema dialect workaround to the connected transport", async () => { + const events: string[] = []; + const transport = new RecordingTransport(events); + + await runCli({ + args: [], + env: {}, + personalAccessTokenStore: createPersonalAccessTokenStore("personal-access-token"), + oauthCredentialStore: createOAuthCredentialStore(), + services: createDoctorServices(), + transport, + stdout: createWriter([]), + stderr: createWriter([]), + }); + + await transport.send({ + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { + name: "get_context", + inputSchema: { $schema: "http://json-schema.org/draft-07/schema#", type: "object" }, + }, + ], + }, + } as JSONRPCMessage); + + expect(transport.sentMessages[0]).toMatchObject({ + result: { + tools: [{ inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" } }], + }, + }); + }); + it("falls back to stored OAuth credentials when no personal access token exists", async () => { const events: string[] = []; const credentialStore: OAuthCredentialStore = { diff --git a/tests/json_schema_dialect_workaround.test.ts b/tests/json_schema_dialect_workaround.test.ts new file mode 100644 index 0000000..a7bbe97 --- /dev/null +++ b/tests/json_schema_dialect_workaround.test.ts @@ -0,0 +1,112 @@ +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { describe, expect, it } from "vitest"; +import { applyJsonSchemaDialectWorkaround } from "../src/json_schema_dialect_workaround.js"; + +class RecordingTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + readonly sent: JSONRPCMessage[] = []; + + async start(): Promise {} + + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + } + + async close(): Promise {} +} + +function toolsListResponse(tools: unknown[]): JSONRPCMessage { + return { + jsonrpc: "2.0", + id: 1, + result: { tools }, + } as JSONRPCMessage; +} + +describe("applyJsonSchemaDialectWorkaround", () => { + it("relabels a draft-07 inputSchema and outputSchema to 2020-12", async () => { + const recorder = new RecordingTransport(); + const transport = applyJsonSchemaDialectWorkaround(recorder); + + await transport.send( + toolsListResponse([ + { + name: "get_context", + inputSchema: { $schema: "http://json-schema.org/draft-07/schema#", type: "object" }, + outputSchema: { $schema: "http://json-schema.org/draft-07/schema#", type: "object" }, + }, + ]), + ); + + expect(recorder.sent[0]).toMatchObject({ + result: { + tools: [ + { + inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" }, + outputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" }, + }, + ], + }, + }); + }); + + it("leaves a schema that already declares a different dialect untouched", async () => { + const recorder = new RecordingTransport(); + const transport = applyJsonSchemaDialectWorkaround(recorder); + + await transport.send( + toolsListResponse([ + { + name: "get_context", + inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object" }, + }, + ]), + ); + + expect(recorder.sent[0]).toMatchObject({ + result: { + tools: [{ inputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema" } }], + }, + }); + }); + + it("leaves a tool without $schema untouched", async () => { + const recorder = new RecordingTransport(); + const transport = applyJsonSchemaDialectWorkaround(recorder); + + await transport.send( + toolsListResponse([{ name: "get_context", inputSchema: { type: "object" } }]), + ); + + expect(recorder.sent[0]).toMatchObject({ + result: { tools: [{ inputSchema: { type: "object" } }] }, + }); + }); + + it("passes through messages with no result unchanged", async () => { + const recorder = new RecordingTransport(); + const transport = applyJsonSchemaDialectWorkaround(recorder); + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: {}, + } as JSONRPCMessage; + + await transport.send(notification); + + expect(recorder.sent).toEqual([notification]); + }); + + it("passes through a result with no tools array unchanged", async () => { + const recorder = new RecordingTransport(); + const transport = applyJsonSchemaDialectWorkaround(recorder); + const response: JSONRPCMessage = { jsonrpc: "2.0", id: 1, result: {} } as JSONRPCMessage; + + await transport.send(response); + + expect(recorder.sent).toEqual([response]); + }); +}); From d5f53399b711b2db73cafb5ec7cbdecc0716befa Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Tue, 8 Sep 2026 09:52:40 +0200 Subject: [PATCH 2/2] test(installer): raise timeout for the three-invocation end-to-end test This test runs install.sh three times in a row (fresh install, upgrade, no-op rerun), which was intermittently exceeding vitest's default 5s timeout on a loaded machine. No other installer test invokes the script more than twice. Co-Authored-By: Claude Sonnet 5 --- tests/installer.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 321dcb5..56a6db2 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -265,6 +265,8 @@ afterEach(() => { describe("install.sh", () => { it("installs, updates, and configures every detected client without damaging Cursor config", () => { + // Runs the installer script three times end-to-end; the default 5s vitest timeout is too + // tight for that on a loaded machine. const root = temporaryDirectory("command-installer-all"); const home = join(root, "home with spaces"); mkdirSync(join(home, ".cursor"), { recursive: true }); @@ -327,7 +329,7 @@ describe("install.sh", () => { expect(third.result.stdout).toContain("Installed version: 2.0.0"); expect(third.result.stdout).toContain("Already up to date; skipping reinstall."); expect(readFileSync(join(first.log, "npm"), "utf8").trim().split("\n")).toHaveLength(2); - }); + }, 20_000); it("uses wget and can install without configuring clients", () => { const root = temporaryDirectory("command-installer-wget");