Parcourir la source

fix(mcp): request refresh token scope (#34125)

Aiden Cline il y a 2 mois
Parent
commit
36c416e143

+ 41 - 0
packages/opencode/test/mcp/oauth-provider.test.ts

@@ -1,4 +1,5 @@
 import { test, expect, describe } from "bun:test"
+import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js"
 import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
 import type { McpAuth } from "../../src/mcp/auth"
 
@@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => {
     expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
   })
 })
+
+describe("MCP OAuth scope selection", () => {
+  test("adds offline_access when the authorization server and client support refresh tokens", () => {
+    expect(
+      determineScope({
+        resourceMetadata: {
+          resource: "https://mcp.example.com/mcp",
+          scopes_supported: ["resource.read"],
+        },
+        authServerMetadata: {
+          issuer: "https://auth.example.com",
+          authorization_endpoint: "https://auth.example.com/authorize",
+          token_endpoint: "https://auth.example.com/token",
+          response_types_supported: ["code"],
+          scopes_supported: ["resource.read", "offline_access"],
+        },
+        clientMetadata: makeProvider({}).clientMetadata,
+      }),
+    ).toBe("resource.read offline_access")
+  })
+
+  test("does not add unsupported authorization server scopes", () => {
+    expect(
+      determineScope({
+        resourceMetadata: {
+          resource: "https://mcp.example.com/mcp",
+          scopes_supported: ["resource.read"],
+        },
+        authServerMetadata: {
+          issuer: "https://auth.example.com",
+          authorization_endpoint: "https://auth.example.com/authorize",
+          token_endpoint: "https://auth.example.com/token",
+          response_types_supported: ["code"],
+          scopes_supported: ["resource.read"],
+        },
+        clientMetadata: makeProvider({}).clientMetadata,
+      }),
+    ).toBe("resource.read")
+  })
+})

+ 152 - 0
patches/@modelcontextprotocol%2Fsdk@1.29.0.patch

@@ -221,6 +221,158 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb7
                      this._cleanupTimeout(messageId);
                      reject(error);
                  });
+diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts
+index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
+--- a/dist/cjs/client/auth.d.ts
++++ b/dist/cjs/client/auth.d.ts
+@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
+  * @returns A Promise that resolves to an OAuthError instance
+  */
+ export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
++/**
++ * Selects scopes per the MCP spec and augments them for refresh token support.
++ */
++export declare function determineScope(options: {
++    requestedScope?: string;
++    resourceMetadata?: OAuthProtectedResourceMetadata;
++    authServerMetadata?: AuthorizationServerMetadata;
++    clientMetadata: OAuthClientMetadata;
++}): string | undefined;
+ /**
+  * Orchestrates the full auth flow with a server.
+  *
+diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js
+index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644
+--- a/dist/cjs/client/auth.js
++++ b/dist/cjs/client/auth.js
+@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0;
+ exports.selectClientAuthMethod = selectClientAuthMethod;
+ exports.parseErrorResponse = parseErrorResponse;
+ exports.auth = auth;
++exports.determineScope = determineScope;
+ exports.isHttpsUrl = isHttpsUrl;
+ exports.selectResourceURL = selectResourceURL;
+ exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams;
+@@ -186,6 +187,19 @@ async function auth(provider, options) {
+         throw error;
+     }
+ }
++/**
++ * Selects scopes per the MCP spec and augments them for refresh token support.
++ */
++function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
++    let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
++    if (effectiveScope &&
++        authServerMetadata?.scopes_supported?.includes('offline_access') &&
++        !effectiveScope.split(' ').includes('offline_access') &&
++        clientMetadata.grant_types?.includes('refresh_token')) {
++        effectiveScope = `${effectiveScope} offline_access`;
++    }
++    return effectiveScope;
++}
+ async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
+     // Check if the provider has cached discovery state to skip discovery
+     const cachedState = await provider.discoveryState?.();
+@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
+         });
+     }
+     const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
+-    // Apply scope selection strategy (SEP-835):
+-    // 1. WWW-Authenticate scope (passed via `scope` param)
+-    // 2. PRM scopes_supported
+-    // 3. Client metadata scope (user-configured fallback)
+-    // The resolved scope is used consistently for both DCR and the authorization request.
+-    const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
++    const resolvedScope = determineScope({
++        requestedScope: scope,
++        resourceMetadata,
++        authServerMetadata: metadata,
++        clientMetadata: provider.clientMetadata
++    });
+     // Handle client registration if needed
+     let clientInformation = await Promise.resolve(provider.clientInformation());
+     if (!clientInformation) {
+@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
+     if (scope) {
+         authorizationUrl.searchParams.set('scope', scope);
+     }
+-    if (scope?.includes('offline_access')) {
++    if (scope?.split(' ').includes('offline_access')) {
+         // if the request includes the OIDC-only "offline_access" scope,
+         // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
+         // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
+diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts
+index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
+--- a/dist/esm/client/auth.d.ts
++++ b/dist/esm/client/auth.d.ts
+@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
+  * @returns A Promise that resolves to an OAuthError instance
+  */
+ export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
++/**
++ * Selects scopes per the MCP spec and augments them for refresh token support.
++ */
++export declare function determineScope(options: {
++    requestedScope?: string;
++    resourceMetadata?: OAuthProtectedResourceMetadata;
++    authServerMetadata?: AuthorizationServerMetadata;
++    clientMetadata: OAuthClientMetadata;
++}): string | undefined;
+ /**
+  * Orchestrates the full auth flow with a server.
+  *
+diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js
+index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644
+--- a/dist/esm/client/auth.js
++++ b/dist/esm/client/auth.js
+@@ -161,6 +161,19 @@ export async function auth(provider, options) {
+         throw error;
+     }
+ }
++/**
++ * Selects scopes per the MCP spec and augments them for refresh token support.
++ */
++export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
++    let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
++    if (effectiveScope &&
++        authServerMetadata?.scopes_supported?.includes('offline_access') &&
++        !effectiveScope.split(' ').includes('offline_access') &&
++        clientMetadata.grant_types?.includes('refresh_token')) {
++        effectiveScope = `${effectiveScope} offline_access`;
++    }
++    return effectiveScope;
++}
+ async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
+     // Check if the provider has cached discovery state to skip discovery
+     const cachedState = await provider.discoveryState?.();
+@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
+         });
+     }
+     const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
+-    // Apply scope selection strategy (SEP-835):
+-    // 1. WWW-Authenticate scope (passed via `scope` param)
+-    // 2. PRM scopes_supported
+-    // 3. Client metadata scope (user-configured fallback)
+-    // The resolved scope is used consistently for both DCR and the authorization request.
+-    const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
++    const resolvedScope = determineScope({
++        requestedScope: scope,
++        resourceMetadata,
++        authServerMetadata: metadata,
++        clientMetadata: provider.clientMetadata
++    });
+     // Handle client registration if needed
+     let clientInformation = await Promise.resolve(provider.clientInformation());
+     if (!clientInformation) {
+@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli
+     if (scope) {
+         authorizationUrl.searchParams.set('scope', scope);
+     }
+-    if (scope?.includes('offline_access')) {
++    if (scope?.split(' ').includes('offline_access')) {
+         // if the request includes the OIDC-only "offline_access" scope,
+         // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
+         // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
 diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js
 index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644
 --- a/dist/esm/client/index.js