Skip to content

Commit 8019972

Browse files
[auth] Fix march spec fallback for metadata discovery (modelcontextprotocol#1108)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
1 parent 29cb080 commit 8019972

File tree

2 files changed

+75
-3
lines changed

2 files changed

+75
-3
lines changed

src/client/auth.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1585,10 +1585,81 @@ describe('OAuth Authorization', () => {
15851585
// First call should be to protected resource metadata
15861586
expect(mockFetch.mock.calls[0][0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
15871587

1588-
// Second call should be to oauth metadata
1588+
// Second call should be to oauth metadata at the root path
15891589
expect(mockFetch.mock.calls[1][0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
15901590
});
15911591

1592+
it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => {
1593+
// Setup: First call to protected resource metadata fails (404)
1594+
// When no authorization_servers are found in protected resource metadata,
1595+
// the auth server URL should be set to the base URL with "/" path
1596+
let callCount = 0;
1597+
mockFetch.mockImplementation(url => {
1598+
callCount++;
1599+
1600+
const urlString = url.toString();
1601+
1602+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
1603+
// Protected resource metadata discovery attempts (both path-aware and root) fail with 404
1604+
return Promise.resolve({
1605+
ok: false,
1606+
status: 404
1607+
});
1608+
} else if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') {
1609+
// Should fetch from base URL with root path, not the full serverUrl path
1610+
return Promise.resolve({
1611+
ok: true,
1612+
status: 200,
1613+
json: async () => ({
1614+
issuer: 'https://resource.example.com/',
1615+
authorization_endpoint: 'https://resource.example.com/authorize',
1616+
token_endpoint: 'https://resource.example.com/token',
1617+
registration_endpoint: 'https://resource.example.com/register',
1618+
response_types_supported: ['code'],
1619+
code_challenge_methods_supported: ['S256']
1620+
})
1621+
});
1622+
} else if (urlString.includes('/register')) {
1623+
// Client registration succeeds
1624+
return Promise.resolve({
1625+
ok: true,
1626+
status: 200,
1627+
json: async () => ({
1628+
client_id: 'test-client-id',
1629+
client_secret: 'test-client-secret',
1630+
client_id_issued_at: 1612137600,
1631+
client_secret_expires_at: 1612224000,
1632+
redirect_uris: ['http://localhost:3000/callback'],
1633+
client_name: 'Test Client'
1634+
})
1635+
});
1636+
}
1637+
1638+
return Promise.reject(new Error(`Unexpected fetch call #${callCount}: ${urlString}`));
1639+
});
1640+
1641+
// Mock provider methods
1642+
(mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined);
1643+
(mockProvider.tokens as jest.Mock).mockResolvedValue(undefined);
1644+
mockProvider.saveClientInformation = jest.fn();
1645+
1646+
// Call the auth function with a server URL that has a path
1647+
const result = await auth(mockProvider, {
1648+
serverUrl: 'https://resource.example.com/path/to/server'
1649+
});
1650+
1651+
// Verify the result
1652+
expect(result).toBe('REDIRECT');
1653+
1654+
// Verify that the oauth-authorization-server call uses the base URL
1655+
// This proves the fix: using new URL("/", serverUrl) instead of serverUrl
1656+
const authServerCall = mockFetch.mock.calls.find(call =>
1657+
call[0].toString().includes('/.well-known/oauth-authorization-server')
1658+
);
1659+
expect(authServerCall).toBeDefined();
1660+
expect(authServerCall[0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
1661+
});
1662+
15921663
it('passes resource parameter through authorization flow', async () => {
15931664
// Mock successful metadata discovery - need to include protected resource metadata
15941665
mockFetch.mockImplementation(url => {

src/client/auth.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ async function authInternal(
348348
): Promise<AuthResult> {
349349
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
350350
let authorizationServerUrl: string | URL | undefined;
351+
351352
try {
352353
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
353354
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
@@ -359,10 +360,10 @@ async function authInternal(
359360

360361
/**
361362
* If we don't get a valid authorization server metadata from protected resource metadata,
362-
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server.
363+
* fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server.
363364
*/
364365
if (!authorizationServerUrl) {
365-
authorizationServerUrl = serverUrl;
366+
authorizationServerUrl = new URL('/', serverUrl);
366367
}
367368

368369
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);

0 commit comments

Comments
 (0)