Repository navigation
[v2] Incompatibility with refresh_tokens from Azure when client_id=resource_id #2718
Description
What happened?
I have some MCP servers which require access_tokens from login.microsoftonline.com, and for which the OAuth client ID and the OAuth resource ID are the same Azure app.
All AI agent harnesses can successfully authenticate to these MCP servers initially, but several fail to fetch new access_tokens when they expire using the refresh_token.
This is because some MCP client libraries (including this one) don't handle the scope parameter when using a refresh_token in the way that Azure requires. This leads to the following misleading error message:
$ node repro.mjs "${my_tenantid}" "${my_clientid}" "api://${my_clientid}/access_as_user"
...
InvalidRequestError: AADSTS90009: Application 'REDACTED'(REDACTED) is requesting a token for it
self. This scenario is supported only if resource is specified using the GUID based App Identifier. Trace ID: REDACTED Correlation ID: REDACTED Timestamp: 2026-08-26 01:06:39ZOne way to fix this error actually has nothing to do with whether an app is requesting a token for itself or not - it is to pass along the same scope parameter with the refresh request as was used in the original request.
How's the support for other harnesses?
opencodeandpidepend on this library, so they don't workcodexworks well- The rust version of this SDK, upon which codex depends, does add scopes to the refresh requests: https://github.com/modelcontextprotocol/rust-sdk/blob/rmcp-v3.1.2/crates/rmcp/src/transport/auth.rs#L2184
claudedoesn't work- they made some related changes just last week but it still doesn't fix the refresh flow - [BUG] Missing
scopeParameter in Dynamic Client Registration and Authorization Requests anthropics/claude-code#4540 (comment) - However, they do have a workaround by way of
headersHelper- https://code.claude.com/docs/en/mcp#use-dynamic-headers-for-custom-authentication
- they made some related changes just last week but it still doesn't fix the refresh flow - [BUG] Missing
Other related issues:
What did you expect?
No response
Code to reproduce
import { randomUUID } from "node:crypto";
import http from "node:http";
import { exchangeAuthorization, refreshAuthorization, startAuthorization } from "@modelcontextprotocol/client";
const [tenant, clientId, apiScope] = process.argv.slice(2);
if (!tenant || !clientId || !apiScope) {
console.error('Usage: node repro.mjs TENANT_ID CLIENT_ID "API_SCOPE"');
process.exit(2);
}
const root = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0`;
const redirectUri = "http://localhost:53682/callback";
const scope = `openid profile offline_access ${apiScope}`;
const clientInformation = { client_id: clientId };
const metadata = {
authorization_endpoint: `${root}/authorize`,
token_endpoint: `${root}/token`,
response_types_supported: ["code"],
code_challenge_methods_supported: ["S256"],
};
const state = randomUUID();
const { authorizationUrl, codeVerifier } = await startAuthorization(root, {
metadata, clientInformation, redirectUrl: redirectUri, scope, state,
});
const authorizationCode = await new Promise((resolve, reject) => {
const server = http.createServer((request, response) => {
const url = new URL(request.url, redirectUri);
if (url.pathname !== "/callback") return response.writeHead(404).end();
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
const valid = url.searchParams.get("state") === state;
response.end(valid && code ? "Authorization complete." : "Authorization failed.");
server.close();
if (error) reject(new Error(error));
else if (!valid || !code) reject(new Error("Invalid OAuth callback"));
else resolve(code);
});
server.once("error", reject);
server.listen(53682, () => console.log(`Open this URL:\n\n${authorizationUrl}\n`));
});
const trace = (label) => async (url, init) => {
const body = new URLSearchParams(init.body.toString());
console.log(`${label}:`, { grant_type: body.get("grant_type"), scope: body.get("scope") ?? "<redacted>" });
return fetch(url, init);
};
const initial = await exchangeAuthorization(root, {
metadata, clientInformation, authorizationCode, codeVerifier, redirectUri,
addClientAuthentication: async (_headers, body) => {
body.set("client_id", clientId);
body.set("scope", scope);
},
fetchFn: trace("initial request"),
});
if (!initial.refresh_token) throw new Error("No refresh token returned");
console.log("Initial access and refresh tokens received.");
const refreshed = await refreshAuthorization(root, {
metadata, clientInformation, refreshToken: initial.refresh_token,
fetchFn: trace("refresh request"),
});
console.log("Refreshed access token received:", !!refreshed.access_token);SDK version
1.29.0 and also 2.0.0
Area
Auth
Activity
fix(auth): send the requested scope on refresh_token grants
chore(deps): update dependency mcp-remote to v0.14.2 (#900)
thegodshatetexas commentedon Oct 2, 2026
Independent confirmation from a second Entra tenant, plus two findings that I think change what has to land for this to reach Claude Code users.
Measured against a live tenant
Same shape as your repro: one app registration acting as both the OAuth client and the resource, public client,
requestedAccessTokenVersion: 2. I ran six refresh grants by hand against a fresh PKCE-obtained refresh token, varying onlyresourceandscope.resourcescopeAADSTS90009AADSTS90009AADSTS9010010api://<client-id>AADSTS90009Every failure omits
scope. Every success includes it. The three failures differ only inresourceand fail identically, so the "GUID based App Identifier" wording in the error is a red herring. A single registration serving as both client and resource refreshes fine, with noresourceparameter at all, as long asscopeis present.The Graph row is the useful one for mechanism. Entra does read
resourceat/token, because it returned a resource-specific error instead of falling through to 90009. It just cannot resolve a target fromresourcealone when no scope is present.Claude Code has a second refresh path that
auth()does not coverI read the shipped Claude Code 2.1.287 binary. There are two call sites of
refreshAuthorizationin it, and neither passes a scope.The first is the SDK's own
auth(), unchanged from v1.x:The second is Claude Code's own proactive refresh, which calls
refreshAuthorizationdirectly and never goes throughauth():This matters for the shape of the fix. #2720 changes
auth()to sendtokens.scope, which covers the first path but not the second. The second is the one that fires in practice. Across my local logs there are 196 refresh failures in 30 files, and all 196 were triggered byToken expires in <n>s, attempting proactive refreshrunning into that direct call. None came throughauth().So the additive
scopeoption onrefreshAuthorizationis the part that matters for this client, and Claude Code then has to pass it at its own call site. It already holds the value. The same class captures it at authorize time intothis._scopesand logsCaptured scopes from authorization URL, and it is also persisted asscopeon the stored tokens, which is the copy that survives a session restart. Captured, stored, and then not sent on refresh.Claude Code bundles v1.x, and #2720 targets v2
The bundled
refreshAuthorizationandexecuteTokenRequestmatchsrc/client/auth.tsat tag1.31.0parameter for parameter, including argument order. They do not match currentmain, which adds adpopparameter. v1.31.0 mentions dpop 0 times,main43, the bundled copy 0.#2720 says it plainly already: "v1.x (
src/client/auth.ts) has the same two-parameter request body; happy to open the backport if wanted."Merging #2720 as it stands will not fix any Claude Code user, because Claude Code is not on v2.
I have opened the v1.x backport as #2937. It mirrors #2720's design deliberately so the two lines stay consistent: optional
scopeonrefreshAuthorizationset only when non-empty, granted scope preserved on the result when the response omits it, andauth()sending exactlytokens.scope. It is +116/-2 across three files including a changeset,tsgo --noEmitis clean, eslint and prettier are clean, and the full suite passes at 1857 tests over 56 files. The two behavioral tests were written failing-first and do fail with the source change reverted.If #2720's design shifts in review I will rebase #2937 to match rather than let the lines diverge, and I am happy for it to wait behind #2720 if you would rather take them in order.
On the spec question
#2720 already frames this correctly and I want to reinforce it, since it is the argument most likely to get this closed by mistake. Omitting
scopeon a refresh grant is legal under RFC 6749 §6. The fix does not send a widened scope, it sends the scope already granted, which §6 permits and which is what the server would assume anyway. Nothing about this asks the SDK to violate the spec. It asks it to be explicit where Entra cannot infer.alexchow94 commentedon Oct 6, 2026
Another confirmation, from a second Entra tenant, plus a client that isn't listed yet: Cursor.
Setup: one app registration serves as both the OAuth client and the resource (public client, PKCE,
requestedAccessTokenVersion: 2). Its Application ID URIs are the MCP server URLs, and the PRMresourceis the server URL. Initial sign-in works in Cursor. About 75 minutes later, every refresh fails with
AADSTS90009, and the server stays in an error state until the user signs in again.Cursor uses the v1.x code path that #2937 fixes. In Cursor 3.23's bundled MCP client:
executeTokenRequestin 1.31.0.refreshAuthorizationcall site: the SDK's ownauth()path. There is no separateproactive refresh like the one described above for Claude Code.
addClientAuthentication, so nothing addsscopeto the request.So the v1.x backport (#2937) should be all Cursor needs, once it's bundled. Cursor already records the
granted scope from the token response.
Raw refresh grants against a fresh PKCE refresh token, varying only
resourceand wherescopegoes:resourcescopeAADSTS90009AADSTS90009AADSTS90009AADSTS90009AADSTS90009Two findings I haven't seen in this thread:
resourcefails too, so the "GUID based App Identifier" hint in theerror doesn't help on the v2 endpoint.
scopepassed in the token endpoint's query string. A server therefore can't workaround this by advertising a
token_endpointwith?scope=…in its RFC 8414 metadata; the fix hasto be in the client.
Happy to test #2937 against our tenant if that helps.
felixweinberger commentedon Oct 23, 2026
#3085 adds an opt-in: implement
scopeForRefreshon the provider to send ascopeon refresh requests. The default request does not change. It will be in the next release. Closing.Generated by Claude Code
lukeyeager commentedon Oct 23, 2026
Ugh. I had hoped you were being too conservative with your choice not to change the default behavior in #3085 but then I found this issue - google/adk-python#5328. So I guess it'll be up to your clients (e.g. opencode and pi) to decide how they want to address this discrepancy between Azure and Salesforce. My work isn't done. Thanks for adding the flag here, though!