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
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -134,6 +135,7 @@ export async function runCli(options: RunCliOptions = {}): Promise<void> {
...(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`);
Expand Down
61 changes: 61 additions & 0 deletions src/json_schema_dialect_workaround.ts
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 39 additions & 1 deletion tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,17 @@ class RecordingTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
readonly sentMessages: JSONRPCMessage[] = [];

constructor(private readonly events: string[]) {}

async start(): Promise<void> {
this.events.push("connected");
}

async send(_message: JSONRPCMessage): Promise<void> {}
async send(message: JSONRPCMessage): Promise<void> {
this.sentMessages.push(message);
}

async close(): Promise<void> {}
}
Expand Down Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion tests/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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");
Expand Down
112 changes: 112 additions & 0 deletions tests/json_schema_dialect_workaround.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {}

async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
}

async close(): Promise<void> {}
}

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]);
});
});