Skip to content

Commit 36c416e

Browse files
authored
fix(mcp): request refresh token scope (anomalyco#34125)
1 parent e1e0304 commit 36c416e

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

packages/opencode/test/mcp/oauth-provider.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect, describe } from "bun:test"
2+
import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js"
23
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
34
import type { McpAuth } from "../../src/mcp/auth"
45

@@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => {
5960
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
6061
})
6162
})
63+
64+
describe("MCP OAuth scope selection", () => {
65+
test("adds offline_access when the authorization server and client support refresh tokens", () => {
66+
expect(
67+
determineScope({
68+
resourceMetadata: {
69+
resource: "https://mcp.example.com/mcp",
70+
scopes_supported: ["resource.read"],
71+
},
72+
authServerMetadata: {
73+
issuer: "https://auth.example.com",
74+
authorization_endpoint: "https://auth.example.com/authorize",
75+
token_endpoint: "https://auth.example.com/token",
76+
response_types_supported: ["code"],
77+
scopes_supported: ["resource.read", "offline_access"],
78+
},
79+
clientMetadata: makeProvider({}).clientMetadata,
80+
}),
81+
).toBe("resource.read offline_access")
82+
})
83+
84+
test("does not add unsupported authorization server scopes", () => {
85+
expect(
86+
determineScope({
87+
resourceMetadata: {
88+
resource: "https://mcp.example.com/mcp",
89+
scopes_supported: ["resource.read"],
90+
},
91+
authServerMetadata: {
92+
issuer: "https://auth.example.com",
93+
authorization_endpoint: "https://auth.example.com/authorize",
94+
token_endpoint: "https://auth.example.com/token",
95+
response_types_supported: ["code"],
96+
scopes_supported: ["resource.read"],
97+
},
98+
clientMetadata: makeProvider({}).clientMetadata,
99+
}),
100+
).toBe("resource.read")
101+
})
102+
})

patches/@modelcontextprotocol%2Fsdk@1.29.0.patch

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,158 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb7
221221
this._cleanupTimeout(messageId);
222222
reject(error);
223223
});
224+
diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts
225+
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
226+
--- a/dist/cjs/client/auth.d.ts
227+
+++ b/dist/cjs/client/auth.d.ts
228+
@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
229+
* @returns A Promise that resolves to an OAuthError instance
230+
*/
231+
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
232+
+/**
233+
+ * Selects scopes per the MCP spec and augments them for refresh token support.
234+
+ */
235+
+export declare function determineScope(options: {
236+
+ requestedScope?: string;
237+
+ resourceMetadata?: OAuthProtectedResourceMetadata;
238+
+ authServerMetadata?: AuthorizationServerMetadata;
239+
+ clientMetadata: OAuthClientMetadata;
240+
+}): string | undefined;
241+
/**
242+
* Orchestrates the full auth flow with a server.
243+
*
244+
diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js
245+
index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644
246+
--- a/dist/cjs/client/auth.js
247+
+++ b/dist/cjs/client/auth.js
248+
@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0;
249+
exports.selectClientAuthMethod = selectClientAuthMethod;
250+
exports.parseErrorResponse = parseErrorResponse;
251+
exports.auth = auth;
252+
+exports.determineScope = determineScope;
253+
exports.isHttpsUrl = isHttpsUrl;
254+
exports.selectResourceURL = selectResourceURL;
255+
exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams;
256+
@@ -186,6 +187,19 @@ async function auth(provider, options) {
257+
throw error;
258+
}
259+
}
260+
+/**
261+
+ * Selects scopes per the MCP spec and augments them for refresh token support.
262+
+ */
263+
+function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
264+
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
265+
+ if (effectiveScope &&
266+
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
267+
+ !effectiveScope.split(' ').includes('offline_access') &&
268+
+ clientMetadata.grant_types?.includes('refresh_token')) {
269+
+ effectiveScope = `${effectiveScope} offline_access`;
270+
+ }
271+
+ return effectiveScope;
272+
+}
273+
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
274+
// Check if the provider has cached discovery state to skip discovery
275+
const cachedState = await provider.discoveryState?.();
276+
@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
277+
});
278+
}
279+
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
280+
- // Apply scope selection strategy (SEP-835):
281+
- // 1. WWW-Authenticate scope (passed via `scope` param)
282+
- // 2. PRM scopes_supported
283+
- // 3. Client metadata scope (user-configured fallback)
284+
- // The resolved scope is used consistently for both DCR and the authorization request.
285+
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
286+
+ const resolvedScope = determineScope({
287+
+ requestedScope: scope,
288+
+ resourceMetadata,
289+
+ authServerMetadata: metadata,
290+
+ clientMetadata: provider.clientMetadata
291+
+ });
292+
// Handle client registration if needed
293+
let clientInformation = await Promise.resolve(provider.clientInformation());
294+
if (!clientInformation) {
295+
@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
296+
if (scope) {
297+
authorizationUrl.searchParams.set('scope', scope);
298+
}
299+
- if (scope?.includes('offline_access')) {
300+
+ if (scope?.split(' ').includes('offline_access')) {
301+
// if the request includes the OIDC-only "offline_access" scope,
302+
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
303+
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
304+
diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts
305+
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
306+
--- a/dist/esm/client/auth.d.ts
307+
+++ b/dist/esm/client/auth.d.ts
308+
@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
309+
* @returns A Promise that resolves to an OAuthError instance
310+
*/
311+
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
312+
+/**
313+
+ * Selects scopes per the MCP spec and augments them for refresh token support.
314+
+ */
315+
+export declare function determineScope(options: {
316+
+ requestedScope?: string;
317+
+ resourceMetadata?: OAuthProtectedResourceMetadata;
318+
+ authServerMetadata?: AuthorizationServerMetadata;
319+
+ clientMetadata: OAuthClientMetadata;
320+
+}): string | undefined;
321+
/**
322+
* Orchestrates the full auth flow with a server.
323+
*
324+
diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js
325+
index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644
326+
--- a/dist/esm/client/auth.js
327+
+++ b/dist/esm/client/auth.js
328+
@@ -161,6 +161,19 @@ export async function auth(provider, options) {
329+
throw error;
330+
}
331+
}
332+
+/**
333+
+ * Selects scopes per the MCP spec and augments them for refresh token support.
334+
+ */
335+
+export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
336+
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
337+
+ if (effectiveScope &&
338+
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
339+
+ !effectiveScope.split(' ').includes('offline_access') &&
340+
+ clientMetadata.grant_types?.includes('refresh_token')) {
341+
+ effectiveScope = `${effectiveScope} offline_access`;
342+
+ }
343+
+ return effectiveScope;
344+
+}
345+
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
346+
// Check if the provider has cached discovery state to skip discovery
347+
const cachedState = await provider.discoveryState?.();
348+
@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
349+
});
350+
}
351+
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
352+
- // Apply scope selection strategy (SEP-835):
353+
- // 1. WWW-Authenticate scope (passed via `scope` param)
354+
- // 2. PRM scopes_supported
355+
- // 3. Client metadata scope (user-configured fallback)
356+
- // The resolved scope is used consistently for both DCR and the authorization request.
357+
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
358+
+ const resolvedScope = determineScope({
359+
+ requestedScope: scope,
360+
+ resourceMetadata,
361+
+ authServerMetadata: metadata,
362+
+ clientMetadata: provider.clientMetadata
363+
+ });
364+
// Handle client registration if needed
365+
let clientInformation = await Promise.resolve(provider.clientInformation());
366+
if (!clientInformation) {
367+
@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli
368+
if (scope) {
369+
authorizationUrl.searchParams.set('scope', scope);
370+
}
371+
- if (scope?.includes('offline_access')) {
372+
+ if (scope?.split(' ').includes('offline_access')) {
373+
// if the request includes the OIDC-only "offline_access" scope,
374+
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
375+
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
224376
diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js
225377
index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644
226378
--- a/dist/esm/client/index.js

0 commit comments

Comments
 (0)