Co-authored-by: Anthony Giniers <antogyn@gmail.com>
This commit is contained in:
parent
69a062693f
commit
1a78b0138f
3 changed files with 73 additions and 3 deletions
10
.changeset/use-scopes-supported-in-dcr.md
Normal file
10
.changeset/use-scopes-supported-in-dcr.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': minor
|
||||
---
|
||||
|
||||
Apply resolved scope consistently to both DCR and the authorization URL (SEP-835)
|
||||
|
||||
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.
|
||||
|
||||
- `registerClient()` now accepts an optional `scope` parameter that overrides `clientMetadata.scope` in the registration body.
|
||||
- `auth()` now computes the resolved scope once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes it to both DCR and the authorization request.
|
||||
|
|
@ -503,6 +503,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) {
|
||||
|
|
@ -537,6 +544,7 @@ async function authInternal(
|
|||
const fullInformation = await registerClient(authorizationServerUrl, {
|
||||
metadata,
|
||||
clientMetadata: provider.clientMetadata,
|
||||
scope: resolvedScope,
|
||||
fetchFn
|
||||
});
|
||||
|
||||
|
|
@ -597,7 +605,7 @@ async function authInternal(
|
|||
clientInformation,
|
||||
state,
|
||||
redirectUrl: provider.redirectUrl,
|
||||
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
|
||||
scope: resolvedScope,
|
||||
resource
|
||||
});
|
||||
|
||||
|
|
@ -1437,16 +1445,22 @@ export async function fetchToken(
|
|||
/**
|
||||
* Performs OAuth 2.0 Dynamic Client Registration according to
|
||||
* {@link https://datatracker.ietf.org/doc/html/rfc7591 | 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> {
|
||||
|
|
@ -1467,7 +1481,10 @@ export async function registerClient(
|
|||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(clientMetadata)
|
||||
body: JSON.stringify({
|
||||
...clientMetadata,
|
||||
...(scope === undefined ? {} : { scope })
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AuthorizationServerMetadata, OAuthTokens } from '@modelcontextprotocol/core';
|
||||
import type { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core';
|
||||
import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core';
|
||||
import type { Mock } from 'vitest';
|
||||
import { expect, vi } from 'vitest';
|
||||
|
|
@ -1885,6 +1885,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,
|
||||
|
|
@ -2761,6 +2798,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 as string);
|
||||
expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin');
|
||||
});
|
||||
|
||||
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue