Skip to content

Commit 4258f34

Browse files
address review: normalize the expected PRM path like the request path; point the resourceMetadataUrl JSDoc at this package
A resourceServerUrl with a trailing slash derived an expected well-known path that kept the slash while incoming request paths had theirs stripped, so the PRM route matched neither spelling. Both sides now share one normalization.
1 parent f1a0e13 commit 4258f34

3 files changed

Lines changed: 18 additions & 5 deletions

File tree

packages/server/src/server/middleware/bearerAuth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ export interface BearerAuthOptions {
4848
* `WWW-Authenticate` header on 401/403 responses, per
4949
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
5050
*
51-
* Typically built with `getOAuthProtectedResourceMetadataUrl` from
52-
* `@modelcontextprotocol/express`.
51+
* Typically built with `getOAuthProtectedResourceMetadataUrl`, exported
52+
* from this package.
5353
*/
5454
resourceMetadataUrl?: string;
5555
}

packages/server/src/server/middleware/oauthMetadata.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,16 @@ export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
9494

9595
/** The RFC 9728 path-aware well-known path for a resource URL. */
9696
function protectedResourceMetadataPath(resourceServerUrl: URL): string {
97-
const rsPath = resourceServerUrl.pathname;
97+
// Normalized like the request path in `oauthMetadataResponse`: a resource
98+
// URL with a trailing slash must not make its own PRM route unreachable.
99+
const rsPath = stripTrailingSlash(resourceServerUrl.pathname);
98100
return `/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`;
99101
}
100102

103+
function stripTrailingSlash(path: string): string {
104+
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
105+
}
106+
101107
const ALLOWED_METHODS = 'GET, HEAD, OPTIONS';
102108

103109
function metadataDocumentResponse(request: Request, metadata: OAuthMetadata | OAuthProtectedResourceMetadata): Response {
@@ -161,9 +167,8 @@ export function oauthMetadataResponse(request: Request, options: AuthMetadataOpt
161167
// even when the options are misconfigured — a bad issuer surfaces on the
162168
// discovery routes (or at startup via buildOAuthProtectedResourceMetadata),
163169
// never on the host's own traffic.
164-
const rawPath = new URL(request.url).pathname;
165170
// Tolerate a single trailing slash, as path-mounted routers do.
166-
const requestPath = rawPath.length > 1 && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
171+
const requestPath = stripTrailingSlash(new URL(request.url).pathname);
167172
if (requestPath === protectedResourceMetadataPath(options.resourceServerUrl)) {
168173
return metadataDocumentResponse(request, buildOAuthProtectedResourceMetadata(options));
169174
}

packages/server/test/server/oauthMetadata.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ describe('review-hardened contracts', () => {
150150
).toThrow('Issuer URL must be HTTPS');
151151
});
152152

153+
it('serves the PRM when the resource URL itself has a trailing slash', () => {
154+
const slashOptions = { ...options, resourceServerUrl: new URL('https://api.example.com/mcp/') };
155+
for (const path of ['/.well-known/oauth-protected-resource/mcp', '/.well-known/oauth-protected-resource/mcp/']) {
156+
const response = oauthMetadataResponse(new Request(`https://api.example.com${path}`), slashOptions);
157+
expect(response?.status).toBe(200);
158+
}
159+
});
160+
153161
it('tolerates a single trailing slash like path-mounted routers', () => {
154162
const response = oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-protected-resource/mcp/'), options);
155163
expect(response?.status).toBe(200);

0 commit comments

Comments
 (0)