Skip to content

Commit 54e8ded

Browse files
fix(server): preserve resource metadata queries
Keep RFC 9728 query components when deriving metadata URLs and omit the fallback for abstract RFC 8707 resource identifiers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 6813ca8 commit 54e8ded

5 files changed

Lines changed: 29 additions & 8 deletions

File tree

.changeset/scope-challenge-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived
1818
from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now
1919
stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return
2020
(new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the
21-
well-known location for the token's RFC 8707 `resource` identifier; the
21+
well-known location for an HTTP(S) RFC 8707 `resource` identifier; the
2222
parameter is omitted when neither is available.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ export function buildOAuthProtectedResourceMetadata(options: AuthMetadataOptions
8989
* ```
9090
*/
9191
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
92-
return new URL(protectedResourceMetadataPath(serverUrl), serverUrl).href;
92+
const metadataUrl = new URL(serverUrl);
93+
metadataUrl.pathname = protectedResourceMetadataPath(serverUrl);
94+
metadataUrl.hash = '';
95+
return metadataUrl.href;
9396
}
9497

9598
/** The RFC 9728 path-aware well-known path for a resource URL. */

packages/server/src/server/scopeChallenge.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,17 @@ export async function findScopeChallenge(
9292
* challenge, derived from the verified {@link AuthInfo}: the URL the
9393
* authentication gate stamped (`authInfo.resourceMetadataUrl`, set by the
9494
* bearer-auth helpers from their `resourceMetadataUrl` option), falling back
95-
* to the well-known location for the token's RFC 8707 `resource` identifier,
96-
* or `undefined` when neither is available (the `resource_metadata` parameter
97-
* is then omitted, matching the bearer-auth challenges).
95+
* to the well-known location for an HTTP(S) RFC 8707 `resource` identifier, or
96+
* `undefined` when neither is available (the `resource_metadata` parameter is
97+
* then omitted, matching the bearer-auth challenges).
9898
*
9999
* @internal
100100
*/
101101
export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined): string | undefined {
102102
if (authInfo?.resourceMetadataUrl !== undefined) {
103103
return authInfo.resourceMetadataUrl;
104104
}
105-
if (authInfo?.resource !== undefined) {
105+
if (authInfo?.resource?.protocol === 'https:' || authInfo?.resource?.protocol === 'http:') {
106106
return getOAuthProtectedResourceMetadataUrl(authInfo.resource);
107107
}
108108
return undefined;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ describe('getOAuthProtectedResourceMetadataUrl', () => {
8282
'https://api.example.com/.well-known/oauth-protected-resource'
8383
);
8484
});
85+
86+
it('preserves the resource identifier query', () => {
87+
expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp?tenant=acme'))).toBe(
88+
'https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme'
89+
);
90+
});
8591
});
8692

8793
describe('oauthMetadataResponse', () => {

packages/server/test/server/scopeChallenge.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,25 @@ describe('legacy Streamable HTTP scope preflight', () => {
246246
const harness = await createLegacyHarness(requireScopes('repo:write'));
247247
const sessionId = await initializeLegacy(harness.transport);
248248
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
249-
authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp') }
249+
authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp?tenant=acme') }
250250
});
251251

252252
expect(response.status).toBe(403);
253253
expect(response.headers.get('WWW-Authenticate')).toContain(
254-
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"'
254+
'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme"'
255255
);
256256
await harness.transport.close();
257257
});
258+
259+
it('omits resource_metadata when an abstract RFC 8707 resource identifier cannot locate an RFC 9728 document', async () => {
260+
const harness = await createLegacyHarness(requireScopes('repo:write'));
261+
const sessionId = await initializeLegacy(harness.transport);
262+
const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), {
263+
authInfo: { ...auth(['repo:read']), resource: new URL('urn:example:mcp') }
264+
});
265+
266+
expect(response.status).toBe(403);
267+
expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata');
268+
await harness.transport.close();
269+
});
258270
});

0 commit comments

Comments
 (0)