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
6 changes: 6 additions & 0 deletions .changeset/oauth-resource-fallback-no-prm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

Always send the OAuth `resource` parameter, falling back to the canonical server URI when Protected Resource Metadata (RFC 9728) is absent. Previously `selectResourceURL` returned `undefined` whenever PRM discovery failed, omitting `resource` from `/authorize` and `/token`
requests. The MCP authorization spec requires clients to send this parameter "regardless of whether authorization servers support it".
8 changes: 5 additions & 3 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ async function authInternal(
fetchFn
);
} catch {
// RFC 9728 not available — selectResourceURL will handle undefined
// RFC 9728 not available — selectResourceURL falls back to the canonical server URI
}
}

Expand Down Expand Up @@ -641,9 +641,11 @@ export async function selectResourceURL(
return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource);
}

// Only include resource parameter when Protected Resource Metadata is present
// Fall back to the canonical server URI when Protected Resource Metadata is absent.
// The MCP spec requires clients to send the `resource` parameter "regardless of whether
// authorization servers support it", using the canonical server URI (RFC 8707).
if (!resourceMetadata) {
return undefined;
return defaultResource;
}

// Validate that the metadata's resource is compatible with our request
Expand Down
23 changes: 12 additions & 11 deletions test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2562,7 +2562,7 @@ describe('OAuth Authorization', () => {
expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/');
});

it('excludes resource parameter when Protected Resource Metadata is not present', async () => {
it('falls back to the canonical server URI when Protected Resource Metadata is not present', async () => {
// Mock metadata discovery where protected resource metadata is not available (404)
// but authorization server metadata is available
mockFetch.mockImplementation(url => {
Expand Down Expand Up @@ -2600,14 +2600,14 @@ describe('OAuth Authorization', () => {
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);

// Call auth - should not include resource parameter
// Call auth - should still include resource parameter (canonical server URI)
const result = await auth(mockProvider, {
serverUrl: 'https://api.example.com/mcp-server'
});

expect(result).toBe('REDIRECT');

// Verify the authorization URL does NOT include the resource parameter
// Verify the authorization URL includes the resource parameter
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
expect.objectContaining({
searchParams: expect.any(URLSearchParams)
Expand All @@ -2616,11 +2616,12 @@ describe('OAuth Authorization', () => {

const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
// Resource parameter should not be present when PRM is not available
expect(authUrl.searchParams.has('resource')).toBe(false);
// Resource parameter must fall back to the canonical server URI when PRM is not available.
// The MCP spec requires clients to send `resource` regardless of authorization server support.
expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server');
});

it('excludes resource parameter in token exchange when Protected Resource Metadata is not present', async () => {
it('falls back to the canonical server URI in token exchange when Protected Resource Metadata is not present', async () => {
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
mockFetch.mockImplementation(url => {
const urlString = url.toString();
Expand Down Expand Up @@ -2679,12 +2680,12 @@ describe('OAuth Authorization', () => {
expect(tokenCall).toBeDefined();

const body = tokenCall![1].body as URLSearchParams;
// Resource parameter should not be present when PRM is not available
expect(body.has('resource')).toBe(false);
// Resource parameter must fall back to the canonical server URI when PRM is not available
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
expect(body.get('code')).toBe('auth-code-123');
});

it('excludes resource parameter in token refresh when Protected Resource Metadata is not present', async () => {
it('falls back to the canonical server URI in token refresh when Protected Resource Metadata is not present', async () => {
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
mockFetch.mockImplementation(url => {
const urlString = url.toString();
Expand Down Expand Up @@ -2744,8 +2745,8 @@ describe('OAuth Authorization', () => {
expect(tokenCall).toBeDefined();

const body = tokenCall![1].body as URLSearchParams;
// Resource parameter should not be present when PRM is not available
expect(body.has('resource')).toBe(false);
// Resource parameter must fall back to the canonical server URI when PRM is not available
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('refresh123');
});
Expand Down
Loading