Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,38 @@ export async function auth(
}
}

/**
* Selects scopes per the MCP spec and augment for refresh token support.
*/
export function determineScope(options: {
requestedScope?: string;
resourceMetadata?: OAuthProtectedResourceMetadata;
authServerMetadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
}): string | undefined {
const { requestedScope, resourceMetadata, authServerMetadata, clientMetadata } = options;

// Scope selection priority (MCP spec):
// 1. WWW-Authenticate header scope
// 2. PRM scopes_supported
// 3. clientMetadata.scope (SDK fallback)
// 4. Omit scope parameter
let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;

// SEP-2207: Append offline_access when the AS advertises it
// and the client supports the refresh_token grant.
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: OAuthClientProvider,
{
Expand Down Expand Up @@ -509,13 +541,21 @@ async function authInternal(

const state = provider.state ? await provider.state() : undefined;

// Determine scope per MCP Scope Selection Strategy + SEP-2207
const effectiveScope = determineScope({
requestedScope: scope,
resourceMetadata,
authServerMetadata: metadata,
clientMetadata: provider.clientMetadata
});

// Start new authorization flow
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
state,
redirectUrl: provider.redirectUrl,
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
scope: effectiveScope,
resource
});

Expand Down
224 changes: 224 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { OAuthClientProvider } from '../../src/client/auth.js';
import {
auth,
buildDiscoveryUrls,
determineScope,
discoverAuthorizationServerMetadata,
discoverOAuthMetadata,
discoverOAuthProtectedResourceMetadata,
Expand Down Expand Up @@ -3230,4 +3231,227 @@ describe('OAuth Authorization', () => {
});
});
});

describe('determineScope', () => {
const baseClientMetadata = {
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
};

describe('MCP Scope Selection Strategy', () => {
it('returns explicit requestedScope as-is (priority 1)', () => {
const result = determineScope({
requestedScope: 'files:read',
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
clientMetadata: {
...baseClientMetadata,
scope: 'fallback:scope'
}
});

expect(result).toBe('files:read');
});

it('uses PRM scopes_supported when no explicit scope (priority 2)', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
},
clientMetadata: {
...baseClientMetadata,
scope: 'fallback:scope'
}
});

expect(result).toBe('mcp:read mcp:write mcp:admin');
});

it('falls back to clientMetadata.scope when no PRM scopes (priority 3)', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/'
},
clientMetadata: {
...baseClientMetadata,
scope: 'client:default'
}
});

expect(result).toBe('client:default');
});

it('returns undefined when no scope source available (priority 4)', () => {
const result = determineScope({
clientMetadata: baseClientMetadata
});

expect(result).toBeUndefined();
});

it('returns undefined when PRM has no scopes_supported and clientMetadata has no scope', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/'
},
clientMetadata: baseClientMetadata
});

expect(result).toBeUndefined();
});
});

describe('SEP-2207: offline_access scope augmentation', () => {
const asMetadataWithOfflineAccess = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code'] as string[],
scopes_supported: ['openid', 'profile', 'offline_access']
};

const asMetadataWithoutOfflineAccess = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code'] as string[],
scopes_supported: ['openid', 'profile']
};

const clientMetadataWithRefreshToken = {
...baseClientMetadata,
grant_types: ['authorization_code', 'refresh_token']
};

it('augments explicit scope with offline_access', () => {
const result = determineScope({
requestedScope: 'mcp:read mcp:write',
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: clientMetadataWithRefreshToken
});

expect(result).toBe('mcp:read mcp:write offline_access');
});

it('adds offline_access when AS supports it and client grant_types includes refresh_token', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: clientMetadataWithRefreshToken
});

expect(result).toBe('mcp:read mcp:write offline_access');
});

it('adds offline_access when using clientMetadata.scope fallback', () => {
const result = determineScope({
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: {
...clientMetadataWithRefreshToken,
scope: 'mcp:tools'
}
});

expect(result).toBe('mcp:tools offline_access');
});

it('does NOT augment when no other scopes are present', () => {
const result = determineScope({
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: clientMetadataWithRefreshToken
});

expect(result).toBeUndefined();
});

it('does NOT augment when AS metadata lacks offline_access', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
authServerMetadata: asMetadataWithoutOfflineAccess,
clientMetadata: clientMetadataWithRefreshToken
});

expect(result).toBe('mcp:read mcp:write');
});

it('does NOT augment when AS metadata is undefined', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
clientMetadata: clientMetadataWithRefreshToken
});

expect(result).toBe('mcp:read mcp:write');
});

it('does NOT augment when offline_access already in clientMetadata.scope', () => {
const result = determineScope({
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: {
...clientMetadataWithRefreshToken,
scope: 'mcp:tools offline_access'
}
});

expect(result).toBe('mcp:tools offline_access');
});

it('does NOT augment when non-compliant PRM already includes offline_access', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'offline_access', 'mcp:write']
},
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: clientMetadataWithRefreshToken
});

expect(result).toBe('mcp:read offline_access mcp:write');
});

it('does NOT augment when grant_types omits refresh_token', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: {
...baseClientMetadata,
grant_types: ['authorization_code']
}
});

expect(result).toBe('mcp:read mcp:write');
});

it('does NOT augment when grant_types is undefined (respects OAuth defaults)', () => {
const result = determineScope({
resourceMetadata: {
resource: 'https://api.example.com/',
scopes_supported: ['mcp:read', 'mcp:write']
},
authServerMetadata: asMetadataWithOfflineAccess,
clientMetadata: baseClientMetadata
});

expect(result).toBe('mcp:read mcp:write');
});
});
});
});
Loading