diff --git a/.changeset/protected-resource-trailing-slashes.md b/.changeset/protected-resource-trailing-slashes.md new file mode 100644 index 0000000000..a0b85f19a5 --- /dev/null +++ b/.changeset/protected-resource-trailing-slashes.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Preserve trailing slashes on non-root resource paths when constructing OAuth Protected Resource Metadata discovery URLs. A resource at `/mcp/` now discovers metadata at `/.well-known/oauth-protected-resource/mcp/` instead of `/.well-known/oauth-protected-resource/mcp`. Servers that relied on the stripped path should serve or redirect the preserved path, or advertise an explicit `resource_metadata` URL. Root discovery and authorization-server discovery are unchanged. diff --git a/.changeset/server-protected-resource-trailing-slashes.md b/.changeset/server-protected-resource-trailing-slashes.md new file mode 100644 index 0000000000..3f133a6007 --- /dev/null +++ b/.changeset/server-protected-resource-trailing-slashes.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Preserve trailing slashes on non-root resource paths in `getOAuthProtectedResourceMetadataUrl`, matching client discovery. The `oauthMetadataResponse` handler serves the generated URL and retains its existing tolerance for a single trailing slash. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a95aca0a03..9be2b8eaff 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1707,6 +1707,11 @@ function buildWellKnownPath( pathname: string = '', options: { prependPathname?: boolean } = {} ): string { + if (wellKnownPrefix === 'oauth-protected-resource' && !options.prependPathname) { + const resourcePath = pathname === '/' ? '' : pathname; + return `/.well-known/${wellKnownPrefix}${resourcePath}`; + } + // Strip trailing slash from pathname to avoid double slashes if (pathname.endsWith('/')) { pathname = pathname.slice(0, -1); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a87cbd7d92..e7f3deb605 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -367,6 +367,26 @@ describe('OAuth Authorization', () => { expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); }); + it.each([ + ['/mcp/', '/.well-known/oauth-protected-resource/mcp/'], + ['/mcp//', '/.well-known/oauth-protected-resource/mcp//'], + ['/mcp/?tenant=1', '/.well-known/oauth-protected-resource/mcp/?tenant=1'] + ])('preserves trailing slashes in path-aware discovery for %s', async (resourcePath, metadataPath) => { + const resource = `https://resource.example.com${resourcePath}`; + const expectedMetadata = { ...validMetadata, resource }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => expectedMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata(resource); + + expect(metadata).toEqual(expectedMetadata); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]![0].toString()).toBe(`https://resource.example.com${metadataPath}`); + }); + it('preserves query parameters in path-aware discovery', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -541,6 +561,23 @@ describe('OAuth Authorization', () => { }); }); + it('falls back to root discovery when a trailing-slash path returns 404', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/mcp/'); + + expect(metadata).toEqual(validMetadata); + expect(mockFetch.mock.calls.map(([url]) => url.toString())).toEqual([ + 'https://resource.example.com/.well-known/oauth-protected-resource/mcp/', + 'https://resource.example.com/.well-known/oauth-protected-resource' + ]); + }); + it('does not fallback when resourceMetadataUrl is provided', async () => { // Call with explicit URL returns 404 mockFetch.mockResolvedValueOnce({ @@ -633,6 +670,22 @@ describe('OAuth Authorization', () => { }); }); + it('removes the trailing slash from authorization-server discovery paths', async () => { + const issuer = 'https://auth.example.com/tenant/'; + const expectedMetadata = { ...validMetadata, issuer }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => expectedMetadata + }); + + const metadata = await discoverOAuthMetadata(issuer); + + expect(metadata).toEqual(expectedMetadata); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]![0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant'); + }); + it('falls back to root discovery when path-aware discovery returns 404', async () => { // First call (path-aware) returns 404 mockFetch.mockResolvedValueOnce({ diff --git a/packages/server/src/server/middleware/oauthMetadata.ts b/packages/server/src/server/middleware/oauthMetadata.ts index 64296d05ee..58d5bdebd9 100644 --- a/packages/server/src/server/middleware/oauthMetadata.ts +++ b/packages/server/src/server/middleware/oauthMetadata.ts @@ -97,9 +97,7 @@ export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { /** The RFC 9728 path-aware well-known path for a resource URL. */ function protectedResourceMetadataPath(resourceServerUrl: URL): string { - // Normalized like the request path in `oauthMetadataResponse`: a resource - // URL with a trailing slash must not make its own PRM route unreachable. - const rsPath = stripTrailingSlash(resourceServerUrl.pathname); + const rsPath = resourceServerUrl.pathname; return `/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`; } @@ -172,7 +170,7 @@ export function oauthMetadataResponse(request: Request, options: AuthMetadataOpt // never on the host's own traffic. // Tolerate a single trailing slash, as path-mounted routers do. const requestPath = stripTrailingSlash(new URL(request.url).pathname); - if (requestPath === protectedResourceMetadataPath(options.resourceServerUrl)) { + if (requestPath === stripTrailingSlash(protectedResourceMetadataPath(options.resourceServerUrl))) { return metadataDocumentResponse(request, buildOAuthProtectedResourceMetadata(options)); } if (requestPath === '/.well-known/oauth-authorization-server') { diff --git a/packages/server/test/server/oauthMetadata.test.ts b/packages/server/test/server/oauthMetadata.test.ts index 19bb362d17..01ec86b2d8 100644 --- a/packages/server/test/server/oauthMetadata.test.ts +++ b/packages/server/test/server/oauthMetadata.test.ts @@ -88,6 +88,19 @@ describe('getOAuthProtectedResourceMetadataUrl', () => { 'https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme' ); }); + + it.each(['/mcp/', '/mcp//', '/mcp/?tenant=acme'])( + 'preserves trailing slashes for %s and serves the generated URL', + async resourcePath => { + const resourceServerUrl = new URL(`https://api.example.com${resourcePath}`); + const metadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl); + expect(metadataUrl).toBe(`https://api.example.com/.well-known/oauth-protected-resource${resourcePath}`); + + const response = oauthMetadataResponse(new Request(metadataUrl), { ...options, resourceServerUrl }); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ resource: resourceServerUrl.href }); + } + ); }); describe('oauthMetadataResponse', () => {