Describe the bug
Remote (type: http) MCP servers that authenticate via Microsoft Entra OAuth repeatedly force an interactive "Sign in" — roughly once per access-token lifetime (~60–75 min). The silent refresh path never succeeds.
Root cause is a scope bug in the refresh request. On the refresh_token grant, the client replays the granted scope string that Entra echoes back in the token response. That granted string contains the resource-specific scopes plus the resource's .default scope. Entra rejects that combination:
AADSTS70011: invalid_scope — "The scope <resource>/<Scope1> ... <resource>/.default is not valid.
.default scope can't be combined with resource-specific scopes."
The native OAuth layer maps this failure to browser_required, which the SDK surfaces as MCPOAuthBrowserRequiredError → interactive re-auth. The refresh token itself is valid; only the malformed scope parameter fails.
Affected version
1.0.79-9 (bundled SDK; buildMetadata.gitCommit 65f2c0f). Windows x64. The failing logic is in the SDK/native OAuth layer, so it is expected to be platform-independent.
Steps to reproduce the behavior
- Configure one or more
type: http remote MCP servers whose OAuth authorization server is Microsoft Entra and whose tokens use resource-specific scopes (e.g. an agent365 / M365 Agents MCP server: https://agent365.svc.cloud.microsoft/agents/tenants/{TENANT_ID}/servers/mcp_*).
- Sign in interactively — succeeds; the server works.
- Wait for the ~70-minute access token to expire, then make another MCP call.
- The server returns
401; the client attempts a silent refresh and immediately logs:
[ERROR] MCP server <name> still needs interactive re-auth:
MCPOAuthBrowserRequiredError: Browser-based OAuth required for
https://agent365.svc.cloud.microsoft/agents/tenants/{TENANT_ID}/servers/<server>
- You must click Sign in again for every such server, repeatedly.
Isolated reproduction against the Entra token endpoint, using the stored refresh token from ~/.copilot/mcp-oauth-config/<hash>.tokens.json and varying only the scope parameter of a grant_type=refresh_token POST:
scope sent on refresh |
Result |
Stored/granted scope = <resource>/<Scope1> … <ScopeN> <resource>/.default (what the client replays) |
HTTP 400 · AADSTS70011 invalid_scope |
<resource>/.default only |
HTTP 200 — new access token + rotated refresh token |
<resource>/<single resource scope> only (no .default) |
HTTP 200 |
So the refresh token is valid and refresh works — only the .default + resource-specific-scopes combination is rejected.
The persisted scope field in the token file contains the .default-contaminated list, which is exactly what gets replayed on refresh. This also explains why an interactive sign-in only fixes it until the next refresh: the fresh interactive grant re-stores the same contaminated scope.
Expected behavior
Silent refresh should succeed without interactive re-auth. On the refresh_token grant the client should send a valid scope — e.g. request <resource>/.default (optionally with openid profile offline_access), or strip .default when resource-specific scopes are present — instead of echoing back the granted scope string that mixes .default with resource-specific scopes.
Additional context
- Symptom cadence: re-auth is required every time the access token expires; all remote MCP servers sharing the same Entra broker/client id drop to "needs auth" together on each expiry.
- Call path:
401 from the resource → silent refresh (reads creds from OS keyring service copilot-mcp-oauth, POSTs to login.microsoftonline.com) → Entra AADSTS70011 → native MCP_OAUTH_ERROR[browser_required] → SDK MCPOAuthBrowserRequiredError.
- Token store:
~/.copilot/mcp-oauth-config/*.tokens.json, each holding accessToken, scope, expiresAt, refreshToken; the scope field holds the .default + resource-scope mix that is replayed.
- OS: Windows x64.
- Sensitive values (tenant id, access/refresh tokens, Entra trace/correlation ids, machine paths, token-file hashes) have been redacted. Entra trace/correlation IDs from the reproduced 400s are available on request.
Describe the bug
Remote (
type: http) MCP servers that authenticate via Microsoft Entra OAuth repeatedly force an interactive "Sign in" — roughly once per access-token lifetime (~60–75 min). The silent refresh path never succeeds.Root cause is a scope bug in the refresh request. On the
refresh_tokengrant, the client replays the granted scope string that Entra echoes back in the token response. That granted string contains the resource-specific scopes plus the resource's.defaultscope. Entra rejects that combination:The native OAuth layer maps this failure to
browser_required, which the SDK surfaces asMCPOAuthBrowserRequiredError→ interactive re-auth. The refresh token itself is valid; only the malformedscopeparameter fails.Affected version
1.0.79-9(bundled SDK;buildMetadata.gitCommit65f2c0f). Windows x64. The failing logic is in the SDK/native OAuth layer, so it is expected to be platform-independent.Steps to reproduce the behavior
type: httpremote MCP servers whose OAuth authorization server is Microsoft Entra and whose tokens use resource-specific scopes (e.g. an agent365 / M365 Agents MCP server:https://agent365.svc.cloud.microsoft/agents/tenants/{TENANT_ID}/servers/mcp_*).401; the client attempts a silent refresh and immediately logs:Isolated reproduction against the Entra token endpoint, using the stored refresh token from
~/.copilot/mcp-oauth-config/<hash>.tokens.jsonand varying only thescopeparameter of agrant_type=refresh_tokenPOST:scopesent on refresh<resource>/<Scope1> … <ScopeN> <resource>/.default(what the client replays)<resource>/.defaultonly<resource>/<single resource scope>only (no.default)So the refresh token is valid and refresh works — only the
.default+ resource-specific-scopes combination is rejected.The persisted
scopefield in the token file contains the.default-contaminated list, which is exactly what gets replayed on refresh. This also explains why an interactive sign-in only fixes it until the next refresh: the fresh interactive grant re-stores the same contaminated scope.Expected behavior
Silent refresh should succeed without interactive re-auth. On the
refresh_tokengrant the client should send a valid scope — e.g. request<resource>/.default(optionally withopenid profile offline_access), or strip.defaultwhen resource-specific scopes are present — instead of echoing back the granted scope string that mixes.defaultwith resource-specific scopes.Additional context
401from the resource → silent refresh (reads creds from OS keyring servicecopilot-mcp-oauth, POSTs tologin.microsoftonline.com) → EntraAADSTS70011→ nativeMCP_OAUTH_ERROR[browser_required]→ SDKMCPOAuthBrowserRequiredError.~/.copilot/mcp-oauth-config/*.tokens.json, each holdingaccessToken,scope,expiresAt,refreshToken; thescopefield holds the.default+ resource-scope mix that is replayed.