Skip to content

Commit 1a78b01

Browse files
pcarletonantogyn
andauthored
feat: use scopes_supported from resource metadata by default (forward-port #757) (#1614)
Co-authored-by: Anthony Giniers <antogyn@gmail.com>
1 parent 69a0626 commit 1a78b01

File tree

3 files changed

+73
-3
lines changed

3 files changed

+73
-3
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@modelcontextprotocol/client': minor
3+
---
4+
5+
Apply resolved scope consistently to both DCR and the authorization URL (SEP-835)
6+
7+
When `scopes_supported` is present in the protected resource metadata (`/.well-known/oauth-protected-resource`), the SDK already uses it as the default scope for the authorization URL. This change applies the same resolved scope to the dynamic client registration request body, ensuring both use a consistent value.
8+
9+
- `registerClient()` now accepts an optional `scope` parameter that overrides `clientMetadata.scope` in the registration body.
10+
- `auth()` now computes the resolved scope once (WWW-Authenticate → PRM `scopes_supported``clientMetadata.scope`) and passes it to both DCR and the authorization request.

packages/client/src/client/auth.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,13 @@ async function authInternal(
503503

504504
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
505505

506+
// Apply scope selection strategy (SEP-835):
507+
// 1. WWW-Authenticate scope (passed via `scope` param)
508+
// 2. PRM scopes_supported
509+
// 3. Client metadata scope (user-configured fallback)
510+
// The resolved scope is used consistently for both DCR and the authorization request.
511+
const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
512+
506513
// Handle client registration if needed
507514
let clientInformation = await Promise.resolve(provider.clientInformation());
508515
if (!clientInformation) {
@@ -537,6 +544,7 @@ async function authInternal(
537544
const fullInformation = await registerClient(authorizationServerUrl, {
538545
metadata,
539546
clientMetadata: provider.clientMetadata,
547+
scope: resolvedScope,
540548
fetchFn
541549
});
542550

@@ -597,7 +605,7 @@ async function authInternal(
597605
clientInformation,
598606
state,
599607
redirectUrl: provider.redirectUrl,
600-
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
608+
scope: resolvedScope,
601609
resource
602610
});
603611

@@ -1437,16 +1445,22 @@ export async function fetchToken(
14371445
/**
14381446
* Performs OAuth 2.0 Dynamic Client Registration according to
14391447
* {@link https://datatracker.ietf.org/doc/html/rfc7591 | RFC 7591}.
1448+
*
1449+
* If `scope` is provided, it overrides `clientMetadata.scope` in the registration
1450+
* request body. This allows callers to apply the Scope Selection Strategy (SEP-835)
1451+
* consistently across both DCR and the subsequent authorization request.
14401452
*/
14411453
export async function registerClient(
14421454
authorizationServerUrl: string | URL,
14431455
{
14441456
metadata,
14451457
clientMetadata,
1458+
scope,
14461459
fetchFn
14471460
}: {
14481461
metadata?: AuthorizationServerMetadata;
14491462
clientMetadata: OAuthClientMetadata;
1463+
scope?: string;
14501464
fetchFn?: FetchLike;
14511465
}
14521466
): Promise<OAuthClientInformationFull> {
@@ -1467,7 +1481,10 @@ export async function registerClient(
14671481
headers: {
14681482
'Content-Type': 'application/json'
14691483
},
1470-
body: JSON.stringify(clientMetadata)
1484+
body: JSON.stringify({
1485+
...clientMetadata,
1486+
...(scope === undefined ? {} : { scope })
1487+
})
14711488
});
14721489

14731490
if (!response.ok) {

packages/client/test/client/auth.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AuthorizationServerMetadata, OAuthTokens } from '@modelcontextprotocol/core';
1+
import type { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
22
import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core';
33
import type { Mock } from 'vitest';
44
import { expect, vi } from 'vitest';
@@ -1885,6 +1885,43 @@ describe('OAuth Authorization', () => {
18851885
);
18861886
});
18871887

1888+
it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => {
1889+
const clientMetadataWithScope: OAuthClientMetadata = {
1890+
...validClientMetadata,
1891+
scope: 'should-be-overridden'
1892+
};
1893+
1894+
const expectedClientInfo = {
1895+
...validClientInfo,
1896+
scope: 'openid profile'
1897+
};
1898+
1899+
mockFetch.mockResolvedValueOnce({
1900+
ok: true,
1901+
status: 200,
1902+
json: async () => expectedClientInfo
1903+
});
1904+
1905+
const clientInfo = await registerClient('https://auth.example.com', {
1906+
clientMetadata: clientMetadataWithScope,
1907+
scope: 'openid profile'
1908+
});
1909+
1910+
expect(clientInfo).toEqual(expectedClientInfo);
1911+
expect(mockFetch).toHaveBeenCalledWith(
1912+
expect.objectContaining({
1913+
href: 'https://auth.example.com/register'
1914+
}),
1915+
expect.objectContaining({
1916+
method: 'POST',
1917+
headers: {
1918+
'Content-Type': 'application/json'
1919+
},
1920+
body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' })
1921+
})
1922+
);
1923+
});
1924+
18881925
it('validates client information response schema', async () => {
18891926
mockFetch.mockResolvedValueOnce({
18901927
ok: true,
@@ -2761,6 +2798,12 @@ describe('OAuth Authorization', () => {
27612798
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!;
27622799
const authUrl: URL = redirectCall[0];
27632800
expect(authUrl?.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
2801+
2802+
// Verify the same scope was also used in the DCR request body
2803+
const registerCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/register'));
2804+
expect(registerCall).toBeDefined();
2805+
const registerBody = JSON.parse(registerCall![1].body as string);
2806+
expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin');
27642807
});
27652808

27662809
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {

0 commit comments

Comments
 (0)