Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/protected-resource-trailing-slashes.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/server-protected-resource-trailing-slashes.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
6 changes: 2 additions & 4 deletions packages/server/src/server/middleware/oauthMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down Expand Up @@ -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') {
Expand Down
13 changes: 13 additions & 0 deletions packages/server/test/server/oauthMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading