From 61db19537b3b7cf5ac54990b28467a0ab9859331 Mon Sep 17 00:00:00 2001
From: Matt <77928207+mattzcarey@users.noreply.github.com>
Date: Wed, 19 Nov 2025 15:45:54 +0000
Subject: [PATCH 1/2] chore: remove unused @types/eslint__js dependency (#1128)
---
package-lock.json | 42 ++++++++++++++++++------------------------
package.json | 3 +--
2 files changed, 19 insertions(+), 26 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 47005612bf..56512fd8d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,11 +25,10 @@
},
"devDependencies": {
"@cfworker/json-schema": "^4.1.1",
- "@eslint/js": "^9.8.0",
+ "@eslint/js": "^9.39.1",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/cross-spawn": "^6.0.6",
- "@types/eslint__js": "^8.42.3",
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
@@ -600,12 +599,16 @@
"license": "MIT"
},
"node_modules/@eslint/js": {
- "version": "9.13.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz",
- "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==",
+ "version": "9.39.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
+ "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
@@ -1309,25 +1312,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/eslint": {
- "version": "9.6.1",
- "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
- "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
- "dev": true,
- "dependencies": {
- "@types/estree": "*",
- "@types/json-schema": "*"
- }
- },
- "node_modules/@types/eslint__js": {
- "version": "8.42.3",
- "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz",
- "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==",
- "dev": true,
- "dependencies": {
- "@types/eslint": "*"
- }
- },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2639,6 +2623,16 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/@eslint/js": {
+ "version": "9.13.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz",
+ "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/eslint/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
diff --git a/package.json b/package.json
index aea1e0bf25..0ea194384f 100644
--- a/package.json
+++ b/package.json
@@ -102,11 +102,10 @@
},
"devDependencies": {
"@cfworker/json-schema": "^4.1.1",
- "@eslint/js": "^9.8.0",
+ "@eslint/js": "^9.39.1",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/cross-spawn": "^6.0.6",
- "@types/eslint__js": "^8.42.3",
"@types/eventsource": "^1.1.15",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
From 7fca1f2b151d1ecca3ff627e5229cd007119b63b Mon Sep 17 00:00:00 2001
From: Matt <77928207+mattzcarey@users.noreply.github.com>
Date: Wed, 19 Nov 2025 16:44:53 +0000
Subject: [PATCH 2/2] feat: url based client metadata registration (SEP 991)
(#1127)
---
src/client/auth.test.ts | 307 +++++++++++++++++-
src/client/auth.ts | 58 +++-
src/examples/README.md | 2 +-
src/examples/client/simpleOAuthClient.ts | 32 +-
.../client/simpleOAuthClientProvider.ts | 3 +-
src/shared/auth.ts | 6 +-
6 files changed, 383 insertions(+), 25 deletions(-)
diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts
index d7dd21f7ad..aec5b7ff6f 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 882359f449..105d3cad96 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 3a8e3a2110..0dc6867ff7 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 2cb458d3b1..21dcae0127 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 d33aba161a..3f1932c3e5 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 819b330861..1274fcd612 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();