Skip to content

Commit 9604648

Browse files
badjerclaude
andauthored
Support multi-tenant PRM (PRM URLs not at the domain root) (#130)
* Support multi-tenant PRM (PRM URLs not at the domain root) * Fix tests for multi-tenant PRM URL format - Fix double-slash bug in PRM URL construction by stripping trailing slashes - Update mockResourceServer to use RFC 9728 compliant PRM URL format - Update test assertions to match new PRM URL path structure - Fix lint error (prefer-const) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9e8b2cc commit 9604648

8 files changed

Lines changed: 53 additions & 272 deletions

File tree

package-lock.json

Lines changed: 7 additions & 214 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/atxp-client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@atxp/common": "0.10.2",
3737
"@modelcontextprotocol/sdk": "^1.15.0",
3838
"bignumber.js": "^9.3.0",
39-
"oauth4webapi": "^3.5.0"
39+
"oauth4webapi": "^3.8.3"
4040
},
4141
"peerDependencies": {
4242
"expo-crypto": ">=14.0.0",

packages/atxp-client/src/clientTestHelpers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { FetchMock } from 'fetch-mock';
22
import { DEFAULT_AUTHORIZATION_SERVER } from '@atxp/common';
33

44
export function mockResourceServer(mock: FetchMock, baseUrl: string = 'https://example.com', resourcePath: string = '/mcp', authServerUrl: string = DEFAULT_AUTHORIZATION_SERVER) {
5+
// RFC 9728: PRM URL is at {resourceUrl}/.well-known/oauth-protected-resource
6+
const prmUrl = `${baseUrl}${resourcePath}/.well-known/oauth-protected-resource`;
57
mock.route({
6-
name: `${baseUrl}/.well-known/oauth-protected-resource${resourcePath}`,
7-
url: `${baseUrl}/.well-known/oauth-protected-resource${resourcePath}`,
8+
name: prmUrl,
9+
url: prmUrl,
810
response: {
911
body: {
1012
resource: baseUrl + resourcePath,

packages/atxp-client/src/oauth.test.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,9 @@ describe('oauthClient', () => {
249249
expect(res.issuer).toBe(DEFAULT_AUTHORIZATION_SERVER);
250250
expect(res.authorization_endpoint).toBe(`${DEFAULT_AUTHORIZATION_SERVER}/authorize`);
251251
expect(res.registration_endpoint).toBe(`${DEFAULT_AUTHORIZATION_SERVER}/register`);
252-
253-
const prmCall = f.callHistory.lastCall('https://example.com/.well-known/oauth-protected-resource/mcp');
252+
253+
// RFC 9728: PRM URL is at {resourceUrl}/.well-known/oauth-protected-resource
254+
const prmCall = f.callHistory.lastCall('https://example.com/mcp/.well-known/oauth-protected-resource');
254255
expect(prmCall).toBeDefined();
255256
});
256257

@@ -261,19 +262,22 @@ describe('oauthClient', () => {
261262

262263
const client = oauthClient(f.fetchHandler);
263264
await client.getAuthorizationServer('https://example.com/mcp?test=1');
264-
265-
const prmCall = f.callHistory.lastCall('https://example.com/.well-known/oauth-protected-resource/mcp?test=1');
265+
266+
// RFC 9728: PRM URL is at {resourceUrl}/.well-known/oauth-protected-resource
267+
const prmCall = f.callHistory.lastCall('https://example.com/mcp?test=1/.well-known/oauth-protected-resource');
266268
expect(prmCall).toBeDefined();
267269
});
268270

269271
it('should try to request AS metadata from resource server if PRM doc cannot be found (non-strict mode)', async () => {
270272
// This is in violation of the MCP spec (the PRM endpoint is supposed to exist), but some older
271273
// servers serve OAuth metadata from the MCP server instead of PRM data, so we fallback to support them
272274
const f = fetchMock.createInstance().getOnce('https://example.com/mcp', 401);
275+
// RFC 9728: PRM URL is at {resourceUrl}/.well-known/oauth-protected-resource
276+
const prmUrl = 'https://example.com/mcp/.well-known/oauth-protected-resource';
273277
mockResourceServer(f, 'https://example.com', '/mcp')
274278
// Note: fetch-mock also supplies .removeRoute, but .modifyRoute has the nice property of
275279
// throwing if the route isn't already mocked, so we know we haven't screwed up the test
276-
.modifyRoute('https://example.com/.well-known/oauth-protected-resource/mcp', {response: {status: 404}})
280+
.modifyRoute(prmUrl, {response: {status: 404}})
277281
// Emulate the resource server serving AS metadata
278282
.get('https://example.com/.well-known/oauth-authorization-server', {
279283
issuer: DEFAULT_AUTHORIZATION_SERVER,
@@ -283,9 +287,9 @@ describe('oauthClient', () => {
283287
mockAuthorizationServer(f, DEFAULT_AUTHORIZATION_SERVER);
284288

285289
const client = oauthClient(f.fetchHandler, new MemoryOAuthDb(), true, false); // strict = false
286-
290+
287291
await client.getAuthorizationServer('https://example.com/mcp');
288-
const prmCall = f.callHistory.lastCall('https://example.com/.well-known/oauth-protected-resource/mcp');
292+
const prmCall = f.callHistory.lastCall(prmUrl);
289293
expect(prmCall).toBeDefined();
290294
expect(prmCall?.response?.status).toBe(404);
291295
// Yes, example.com - again, this test is checking an old pattern where the resource server is
@@ -296,10 +300,12 @@ describe('oauthClient', () => {
296300

297301
it('should throw if there is no way to find AS endpoints from resource server', async () => {
298302
const f = fetchMock.createInstance().get('https://example.com/mcp', 401);
303+
// RFC 9728: PRM URL is at {resourceUrl}/.well-known/oauth-protected-resource
304+
const prmUrl = 'https://example.com/mcp/.well-known/oauth-protected-resource';
299305
mockResourceServer(f, 'https://example.com', '/mcp')
300306
// Note: fetch-mock also supplies .removeRoute, but .modifyRoute has the nice property of
301307
// throwing if the route isn't already mocked, so we know we haven't screwed up the test
302-
.modifyRoute('https://example.com/.well-known/oauth-protected-resource/mcp', {response: {status: 404}})
308+
.modifyRoute(prmUrl, {response: {status: 404}})
303309

304310
const client = oauthClient(f.fetchHandler);
305311
await expect(client.getAuthorizationServer('https://example.com/mcp')).rejects.toThrow('unexpected HTTP status code');

packages/atxp-common/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"dependencies": {
4848
"bignumber.js": "^9.3.0",
4949
"jose": "^6.0.11",
50-
"oauth4webapi": "^3.5.0",
50+
"oauth4webapi": "^3.8.3",
5151
"tweetnacl": "^1.0.3",
5252
"tweetnacl-util": "^0.15.1"
5353
},

0 commit comments

Comments
 (0)