From ea6da25b8373f7d08c5f4d313fd5f85d2f0bd5da Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 5 Jun 2026 10:44:31 +0100 Subject: [PATCH] fix(auth): send resource parameter even when PRM is absent Fall back to the canonical server URI in selectResourceURL when Protected Resource Metadata (RFC 9728) discovery fails, instead of returning undefined and omitting the resource parameter from authorization and token requests. The MCP authorization spec requires clients to send the resource parameter regardless of whether the authorization server supports it. --- .changeset/oauth-resource-fallback-no-prm.md | 6 +++++ src/client/auth.ts | 8 ++++--- test/client/auth.test.ts | 23 ++++++++++---------- 3 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 .changeset/oauth-resource-fallback-no-prm.md diff --git a/.changeset/oauth-resource-fallback-no-prm.md b/.changeset/oauth-resource-fallback-no-prm.md new file mode 100644 index 0000000000..f462e60fc9 --- /dev/null +++ b/.changeset/oauth-resource-fallback-no-prm.md @@ -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". diff --git a/src/client/auth.ts b/src/client/auth.ts index 85398340b0..a5cef5e886 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -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 } } @@ -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 diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 6b70fbe942..e59a55240f 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -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 => { @@ -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) @@ -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(); @@ -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(); @@ -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'); });