feat: use scopes_supported from resource metadata by default (fixes #580) (#757)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Anthony Giniers 2026-03-02 12:42:39 +01:00 • committed by GitHub
commit c9b58d1986
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 3 deletions

View file

@ -501,6 +501,13 @@ async function authInternal(
const resource: URL | undefined = 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;
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
@ -534,6 +541,7 @@ async function authInternal(
const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
scope: resolvedScope,
fetchFn
});
@ -594,7 +602,7 @@ async function authInternal(
clientInformation,
state,
redirectUrl: provider.redirectUrl,
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
scope: resolvedScope,
resource
});
@ -1416,16 +1424,22 @@ export async function fetchToken(
/**
* Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591.
*
* If `scope` is provided, it overrides `clientMetadata.scope` in the registration
* request body. This allows callers to apply the Scope Selection Strategy (SEP-835)
* consistently across both DCR and the subsequent authorization request.
*/
export async function registerClient(
authorizationServerUrl: string | URL,
{
metadata,
clientMetadata,
scope,
fetchFn
}: {
metadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
scope?: string;
fetchFn?: FetchLike;
}
): Promise<OAuthClientInformationFull> {
@ -1446,7 +1460,10 @@ export async function registerClient(
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(clientMetadata)
body: JSON.stringify({
...clientMetadata,
...(scope !== undefined ? { scope } : {})
})
});
if (!response.ok) {

View file

@ -17,7 +17,7 @@ import {
} from '../../src/client/auth.js';
import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js';
import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js';
import { AuthorizationServerMetadata, OAuthTokens } from '../../src/shared/auth.js';
import { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '../../src/shared/auth.js';
import { expect, vi, type Mock } from 'vitest';
// Mock pkce-challenge
@ -1873,6 +1873,43 @@ describe('OAuth Authorization', () => {
);
});
it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => {
const clientMetadataWithScope: OAuthClientMetadata = {
...validClientMetadata,
scope: 'should-be-overridden'
};
const expectedClientInfo = {
...validClientInfo,
scope: 'openid profile'
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => expectedClientInfo
});
const clientInfo = await registerClient('https://auth.example.com', {
clientMetadata: clientMetadataWithScope,
scope: 'openid profile'
});
expect(clientInfo).toEqual(expectedClientInfo);
expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: 'https://auth.example.com/register'
}),
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' })
})
);
});
it('validates client information response schema', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@ -2746,6 +2783,12 @@ describe('OAuth Authorization', () => {
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
// Verify the same scope was also used in the DCR request body
const registerCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/register'));
expect(registerCall).toBeDefined();
const registerBody = JSON.parse(registerCall![1].body);
expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin');
});
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {