diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts
index d7dd21f7a..aec5b7ff6 100644
--- a/src/client/auth.test.ts
+++ b/src/client/auth.test.ts
@@ -11,9 +11,10 @@ import {
extractWWWAuthenticateParams,
auth,
type OAuthClientProvider,
- selectClientAuthMethod
+ selectClientAuthMethod,
+ isHttpsUrl
} from './auth.js';
-import { ServerError } from '../server/auth/errors.js';
+import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js';
import { AuthorizationServerMetadata } from '../shared/auth.js';
import { expect, vi, type Mock } from 'vitest';
@@ -2796,4 +2797,306 @@ describe('OAuth Authorization', () => {
});
});
});
+
+ describe('isHttpsUrl', () => {
+ it('returns true for valid HTTPS URL with path', () => {
+ expect(isHttpsUrl('https://example.com/client-metadata.json')).toBe(true);
+ });
+
+ it('returns true for HTTPS URL with query params', () => {
+ expect(isHttpsUrl('https://example.com/metadata?version=1')).toBe(true);
+ });
+
+ it('returns false for HTTPS URL without path', () => {
+ expect(isHttpsUrl('https://example.com')).toBe(false);
+ expect(isHttpsUrl('https://example.com/')).toBe(false);
+ });
+
+ it('returns false for HTTP URL', () => {
+ expect(isHttpsUrl('http://example.com/metadata')).toBe(false);
+ });
+
+ it('returns false for non-URL strings', () => {
+ expect(isHttpsUrl('not a url')).toBe(false);
+ });
+
+ it('returns false for undefined', () => {
+ expect(isHttpsUrl(undefined)).toBe(false);
+ });
+
+ it('returns false for empty string', () => {
+ expect(isHttpsUrl('')).toBe(false);
+ });
+
+ it('returns false for javascript: scheme', () => {
+ expect(isHttpsUrl('javascript:alert(1)')).toBe(false);
+ });
+
+ it('returns false for data: scheme', () => {
+ expect(isHttpsUrl('data:text/html,')).toBe(false);
+ });
+ });
+
+ describe('SEP-991: URL-based Client ID fallback logic', () => {
+ const validClientMetadata = {
+ redirect_uris: ['http://localhost:3000/callback'],
+ client_name: 'Test Client',
+ client_uri: 'https://example.com/client-metadata.json'
+ };
+
+ const mockProvider: OAuthClientProvider = {
+ get redirectUrl() {
+ return 'http://localhost:3000/callback';
+ },
+ clientMetadataUrl: 'https://example.com/client-metadata.json',
+ get clientMetadata() {
+ return validClientMetadata;
+ },
+ clientInformation: vi.fn().mockResolvedValue(undefined),
+ saveClientInformation: vi.fn().mockResolvedValue(undefined),
+ tokens: vi.fn().mockResolvedValue(undefined),
+ saveTokens: vi.fn().mockResolvedValue(undefined),
+ redirectToAuthorization: vi.fn().mockResolvedValue(undefined),
+ saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
+ codeVerifier: vi.fn().mockResolvedValue('verifier123')
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('uses URL-based client ID when server supports it', async () => {
+ // Mock protected resource metadata discovery (404 to skip)
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({})
+ });
+
+ // Mock authorization server metadata discovery to return support for URL-based client IDs
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ issuer: 'https://server.example.com',
+ authorization_endpoint: 'https://server.example.com/authorize',
+ token_endpoint: 'https://server.example.com/token',
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256'],
+ client_id_metadata_document_supported: true // SEP-991 support
+ })
+ });
+
+ await auth(mockProvider, {
+ serverUrl: 'https://server.example.com'
+ });
+
+ // Should save URL-based client info
+ expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({
+ client_id: 'https://example.com/client-metadata.json'
+ });
+ });
+
+ it('falls back to DCR when server does not support URL-based client IDs', async () => {
+ // Mock protected resource metadata discovery (404 to skip)
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({})
+ });
+
+ // Mock authorization server metadata discovery without SEP-991 support
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ issuer: 'https://server.example.com',
+ authorization_endpoint: 'https://server.example.com/authorize',
+ token_endpoint: 'https://server.example.com/token',
+ registration_endpoint: 'https://server.example.com/register',
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256']
+ // No client_id_metadata_document_supported
+ })
+ });
+
+ // Mock DCR response
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 201,
+ json: async () => ({
+ client_id: 'generated-uuid',
+ client_secret: 'generated-secret',
+ redirect_uris: ['http://localhost:3000/callback']
+ })
+ });
+
+ await auth(mockProvider, {
+ serverUrl: 'https://server.example.com'
+ });
+
+ // Should save DCR client info
+ expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({
+ client_id: 'generated-uuid',
+ client_secret: 'generated-secret',
+ redirect_uris: ['http://localhost:3000/callback']
+ });
+ });
+
+ it('throws an error when clientMetadataUrl is not an HTTPS URL', async () => {
+ const providerWithInvalidUri = {
+ ...mockProvider,
+ clientMetadataUrl: 'http://example.com/metadata'
+ };
+
+ // Mock protected resource metadata discovery (404 to skip)
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({})
+ });
+
+ // Mock authorization server metadata discovery with SEP-991 support
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ issuer: 'https://server.example.com',
+ authorization_endpoint: 'https://server.example.com/authorize',
+ token_endpoint: 'https://server.example.com/token',
+ registration_endpoint: 'https://server.example.com/register',
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256'],
+ client_id_metadata_document_supported: true
+ })
+ });
+
+ await expect(
+ auth(providerWithInvalidUri, {
+ serverUrl: 'https://server.example.com'
+ })
+ ).rejects.toThrow(InvalidClientMetadataError);
+ });
+
+ it('throws an error when clientMetadataUrl has root pathname', async () => {
+ const providerWithRootPathname = {
+ ...mockProvider,
+ clientMetadataUrl: 'https://example.com/'
+ };
+
+ // Mock protected resource metadata discovery (404 to skip)
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({})
+ });
+
+ // Mock authorization server metadata discovery with SEP-991 support
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ issuer: 'https://server.example.com',
+ authorization_endpoint: 'https://server.example.com/authorize',
+ token_endpoint: 'https://server.example.com/token',
+ registration_endpoint: 'https://server.example.com/register',
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256'],
+ client_id_metadata_document_supported: true
+ })
+ });
+
+ await expect(
+ auth(providerWithRootPathname, {
+ serverUrl: 'https://server.example.com'
+ })
+ ).rejects.toThrow(InvalidClientMetadataError);
+ });
+
+ it('throws an error when clientMetadataUrl is not a valid URL', async () => {
+ const providerWithInvalidUrl = {
+ ...mockProvider,
+ clientMetadataUrl: 'not-a-valid-url'
+ };
+
+ // Mock protected resource metadata discovery (404 to skip)
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({})
+ });
+
+ // Mock authorization server metadata discovery with SEP-991 support
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ issuer: 'https://server.example.com',
+ authorization_endpoint: 'https://server.example.com/authorize',
+ token_endpoint: 'https://server.example.com/token',
+ registration_endpoint: 'https://server.example.com/register',
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256'],
+ client_id_metadata_document_supported: true
+ })
+ });
+
+ await expect(
+ auth(providerWithInvalidUrl, {
+ serverUrl: 'https://server.example.com'
+ })
+ ).rejects.toThrow(InvalidClientMetadataError);
+ });
+
+ it('falls back to DCR when client_uri is missing', async () => {
+ const providerWithoutUri = {
+ ...mockProvider,
+ clientMetadataUrl: undefined
+ };
+
+ // Mock protected resource metadata discovery (404 to skip)
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({})
+ });
+
+ // Mock authorization server metadata discovery with SEP-991 support
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ issuer: 'https://server.example.com',
+ authorization_endpoint: 'https://server.example.com/authorize',
+ token_endpoint: 'https://server.example.com/token',
+ registration_endpoint: 'https://server.example.com/register',
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256'],
+ client_id_metadata_document_supported: true
+ })
+ });
+
+ // Mock DCR response
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 201,
+ json: async () => ({
+ client_id: 'generated-uuid',
+ client_secret: 'generated-secret',
+ redirect_uris: ['http://localhost:3000/callback']
+ })
+ });
+
+ await auth(providerWithoutUri, {
+ serverUrl: 'https://server.example.com'
+ });
+
+ // Should fall back to DCR
+ expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({
+ client_id: 'generated-uuid',
+ client_secret: 'generated-secret',
+ redirect_uris: ['http://localhost:3000/callback']
+ });
+ });
+ });
});
diff --git a/src/client/auth.ts b/src/client/auth.ts
index 882359f44..105d3cad9 100644
--- a/src/client/auth.ts
+++ b/src/client/auth.ts
@@ -21,6 +21,7 @@ import {
import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js';
import {
InvalidClientError,
+ InvalidClientMetadataError,
InvalidGrantError,
OAUTH_ERRORS,
OAuthError,
@@ -42,6 +43,11 @@ export interface OAuthClientProvider {
*/
get redirectUrl(): string | URL;
+ /**
+ * External URL the server should use to fetch client metadata document
+ */
+ clientMetadataUrl?: string;
+
/**
* Metadata about this OAuth client.
*/
@@ -379,18 +385,38 @@ async function authInternal(
throw new Error('Existing OAuth client information is required when exchanging an authorization code');
}
- if (!provider.saveClientInformation) {
- throw new Error('OAuth client information must be saveable for dynamic registration');
+ const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true;
+ const clientMetadataUrl = provider.clientMetadataUrl;
+
+ if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) {
+ throw new InvalidClientMetadataError(
+ `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`
+ );
}
- const fullInformation = await registerClient(authorizationServerUrl, {
- metadata,
- clientMetadata: provider.clientMetadata,
- fetchFn
- });
+ const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl;
+
+ if (shouldUseUrlBasedClientId) {
+ // SEP-991: URL-based Client IDs
+ clientInformation = {
+ client_id: clientMetadataUrl
+ };
+ await provider.saveClientInformation?.(clientInformation);
+ } else {
+ // Fallback to dynamic registration
+ if (!provider.saveClientInformation) {
+ throw new Error('OAuth client information must be saveable for dynamic registration');
+ }
+
+ const fullInformation = await registerClient(authorizationServerUrl, {
+ metadata,
+ clientMetadata: provider.clientMetadata,
+ fetchFn
+ });
- await provider.saveClientInformation(fullInformation);
- clientInformation = fullInformation;
+ await provider.saveClientInformation(fullInformation);
+ clientInformation = fullInformation;
+ }
}
// Exchange authorization code for tokens
@@ -456,6 +482,20 @@ async function authInternal(
return 'REDIRECT';
}
+/**
+ * SEP-991: URL-based Client IDs
+ * Validate that the client_id is a valid URL with https scheme
+ */
+export function isHttpsUrl(value?: string): boolean {
+ if (!value) return false;
+ try {
+ const url = new URL(value);
+ return url.protocol === 'https:' && url.pathname !== '/';
+ } catch {
+ return false;
+ }
+}
+
export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
diff --git a/src/examples/README.md b/src/examples/README.md
index 3a8e3a211..0dc6867ff 100644
--- a/src/examples/README.md
+++ b/src/examples/README.md
@@ -39,7 +39,7 @@ npx tsx src/examples/client/simpleStreamableHttp.ts
Example client with OAuth:
```bash
-npx tsx src/examples/client/simpleOAuthClient.js
+npx tsx src/examples/client/simpleOAuthClient.ts
```
### Backwards Compatible Client
diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts
index 2cb458d3b..21dcae012 100644
--- a/src/examples/client/simpleOAuthClient.ts
+++ b/src/examples/client/simpleOAuthClient.ts
@@ -27,7 +27,10 @@ class InteractiveOAuthClient {
output: process.stdout
});
- constructor(private serverUrl: string) {}
+ constructor(
+ private serverUrl: string,
+ private clientMetadataUrl?: string
+ ) {}
/**
* Prompts user for input via readline
@@ -155,16 +158,20 @@ class InteractiveOAuthClient {
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
- token_endpoint_auth_method: 'client_secret_post',
- scope: 'mcp:tools'
+ token_endpoint_auth_method: 'client_secret_post'
};
console.log('🔐 Creating OAuth provider...');
- const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => {
- console.log(`📌 OAuth redirect handler called - opening browser`);
- console.log(`Opening browser to: ${redirectUrl.toString()}`);
- this.openBrowser(redirectUrl.toString());
- });
+ const oauthProvider = new InMemoryOAuthClientProvider(
+ CALLBACK_URL,
+ clientMetadata,
+ (redirectUrl: URL) => {
+ console.log(`📌 OAuth redirect handler called - opening browser`);
+ console.log(`Opening browser to: ${redirectUrl.toString()}`);
+ this.openBrowser(redirectUrl.toString());
+ },
+ this.clientMetadataUrl
+ );
console.log('🔐 OAuth provider created');
console.log('👤 Creating MCP client...');
@@ -327,13 +334,18 @@ class InteractiveOAuthClient {
* Main entry point
*/
async function main(): Promise {
- const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL;
+ const args = process.argv.slice(2);
+ const serverUrl = args[0] || DEFAULT_SERVER_URL;
+ const clientMetadataUrl = args[1];
console.log('🚀 Simple MCP OAuth Client');
console.log(`Connecting to: ${serverUrl}`);
+ if (clientMetadataUrl) {
+ console.log(`Client Metadata URL: ${clientMetadataUrl}`);
+ }
console.log();
- const client = new InteractiveOAuthClient(serverUrl);
+ const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl);
// Handle graceful shutdown
process.on('SIGINT', () => {
diff --git a/src/examples/client/simpleOAuthClientProvider.ts b/src/examples/client/simpleOAuthClientProvider.ts
index d33aba161..3f1932c3e 100644
--- a/src/examples/client/simpleOAuthClientProvider.ts
+++ b/src/examples/client/simpleOAuthClientProvider.ts
@@ -13,7 +13,8 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
constructor(
private readonly _redirectUrl: string | URL,
private readonly _clientMetadata: OAuthClientMetadata,
- onRedirect?: (url: URL) => void
+ onRedirect?: (url: URL) => void,
+ public readonly clientMetadataUrl?: string
) {
this._onRedirect =
onRedirect ||
diff --git a/src/shared/auth.ts b/src/shared/auth.ts
index 819b33086..1274fcd61 100644
--- a/src/shared/auth.ts
+++ b/src/shared/auth.ts
@@ -69,7 +69,8 @@ export const OAuthMetadataSchema = z
introspection_endpoint: z.string().optional(),
introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(),
introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
- code_challenge_methods_supported: z.array(z.string()).optional()
+ code_challenge_methods_supported: z.array(z.string()).optional(),
+ client_id_metadata_document_supported: z.boolean().optional()
})
.passthrough();
@@ -113,7 +114,8 @@ export const OpenIdProviderMetadataSchema = z
request_uri_parameter_supported: z.boolean().optional(),
require_request_uri_registration: z.boolean().optional(),
op_policy_uri: SafeUrlSchema.optional(),
- op_tos_uri: SafeUrlSchema.optional()
+ op_tos_uri: SafeUrlSchema.optional(),
+ client_id_metadata_document_supported: z.boolean().optional()
})
.passthrough();