From 738ef454c25c6697cc3bc98adef68b8b9098bd8c Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 30 Mar 2026 18:18:29 +0200 Subject: [PATCH 1/3] fix(auth): add validateClientMetadataUrl utility for fail-fast validation Export validateClientMetadataUrl() so OAuthClientProvider implementations can validate clientMetadataUrl in their constructors. Demonstrate usage in InMemoryOAuthClientProvider example. Closes #1159 --- .../fix-validate-client-metadata-url.md | 9 +++ .../client/src/simpleOAuthClientProvider.ts | 4 + packages/client/src/client/auth.ts | 22 ++++++ packages/client/src/index.ts | 3 +- packages/client/test/client/auth.test.ts | 77 ++++++++++++++++++- 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-validate-client-metadata-url.md diff --git a/.changeset/fix-validate-client-metadata-url.md b/.changeset/fix-validate-client-metadata-url.md new file mode 100644 index 000000000..78ab638e5 --- /dev/null +++ b/.changeset/fix-validate-client-metadata-url.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Add `validateClientMetadataUrl()` utility for early validation of `clientMetadataUrl` + +Exports a `validateClientMetadataUrl()` function that `OAuthClientProvider` implementations +can call in their constructors to fail fast on invalid URL-based client IDs, instead of +discovering the error deep in the auth flow. diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/client/src/simpleOAuthClientProvider.ts index 96655c9f6..1ef08279f 100644 --- a/examples/client/src/simpleOAuthClientProvider.ts +++ b/examples/client/src/simpleOAuthClientProvider.ts @@ -1,4 +1,5 @@ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client'; +import { validateClientMetadataUrl } from '@modelcontextprotocol/client'; /** * In-memory OAuth client provider for demonstration purposes @@ -15,6 +16,9 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider { onRedirect?: (url: URL) => void, public readonly clientMetadataUrl?: string ) { + // Validate clientMetadataUrl at construction time (fail-fast) + validateClientMetadataUrl(clientMetadataUrl); + this._onRedirect = onRedirect || (url => { diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 1a021be18..4ed52f331 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -771,6 +771,28 @@ async function authInternal( return 'REDIRECT'; } +/** + * Validates that the given `clientMetadataUrl` is a valid HTTPS URL with a non-root pathname. + * + * No-op when `url` is `undefined` or empty (providers that do not use URL-based client IDs + * are unaffected). When the value is defined but invalid, throws an {@linkcode OAuthError} + * with code {@linkcode OAuthErrorCode.InvalidClientMetadata}. + * + * {@linkcode OAuthClientProvider} implementations that accept a `clientMetadataUrl` should + * call this in their constructors for early validation. + * + * @param url - The `clientMetadataUrl` value to validate (from `OAuthClientProvider.clientMetadataUrl`) + * @throws {OAuthError} When `url` is defined but is not a valid HTTPS URL with a non-root pathname + */ +export function validateClientMetadataUrl(url: string | undefined): void { + if (url && !isHttpsUrl(url)) { + throw new OAuthError( + OAuthErrorCode.InvalidClientMetadata, + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${url}` + ); + } +} + /** * SEP-991: URL-based Client IDs * Validate that the `client_id` is a valid URL with `https` scheme diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d1af95103..ee1cd678d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -34,7 +34,8 @@ export { selectClientAuthMethod, selectResourceURL, startAuthorization, - UnauthorizedError + UnauthorizedError, + validateClientMetadataUrl } from './client/auth.js'; export type { AssertionCallback, diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 8178df906..433f66da4 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -17,7 +17,8 @@ import { refreshAuthorization, registerClient, selectClientAuthMethod, - startAuthorization + startAuthorization, + validateClientMetadataUrl } from '../../src/client/auth.js'; import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js'; @@ -3734,4 +3735,78 @@ describe('OAuth Authorization', () => { }); }); }); + + describe('validateClientMetadataUrl', () => { + it('passes for valid HTTPS URL with path', () => { + expect(() => validateClientMetadataUrl('https://client.example.com/.well-known/oauth-client')).not.toThrow(); + }); + + it('passes for valid HTTPS URL with multi-segment path', () => { + expect(() => validateClientMetadataUrl('https://example.com/clients/metadata.json')).not.toThrow(); + }); + + it('throws OAuthError for HTTP URL', () => { + expect(() => validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client')).toThrow(OAuthError); + try { + validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('http://client.example.com/.well-known/oauth-client'); + } + }); + + it('throws OAuthError for non-URL string', () => { + expect(() => validateClientMetadataUrl('not-a-url')).toThrow(OAuthError); + try { + validateClientMetadataUrl('not-a-url'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('not-a-url'); + } + }); + + it('passes silently for empty string', () => { + expect(() => validateClientMetadataUrl('')).not.toThrow(); + }); + + it('throws OAuthError for root-path HTTPS URL with trailing slash', () => { + expect(() => validateClientMetadataUrl('https://client.example.com/')).toThrow(OAuthError); + try { + validateClientMetadataUrl('https://client.example.com/'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('https://client.example.com/'); + } + }); + + it('throws OAuthError for root-path HTTPS URL without trailing slash', () => { + expect(() => validateClientMetadataUrl('https://client.example.com')).toThrow(OAuthError); + try { + validateClientMetadataUrl('https://client.example.com'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('https://client.example.com'); + } + }); + + it('passes silently for undefined', () => { + expect(() => validateClientMetadataUrl(undefined)).not.toThrow(); + }); + + it('error message matches expected format', () => { + expect(() => validateClientMetadataUrl('http://example.com/path')).toThrow(OAuthError); + try { + validateClientMetadataUrl('http://example.com/path'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).message).toBe( + 'clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: http://example.com/path' + ); + } + }); + }); }); From 276b439171bf6670d358fa8a95735b4434fa260c Mon Sep 17 00:00:00 2001 From: rechedev Date: Wed, 1 Apr 2026 18:13:14 +0200 Subject: [PATCH 2/3] style(client): add blank line between test describe blocks --- packages/client/test/client/auth.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index caed7a651..04d7f4a3f 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3907,6 +3907,7 @@ describe('OAuth Authorization', () => { } }); }); + describe('determineScope', () => { const baseClientMetadata = { redirect_uris: ['http://localhost:3000/callback'], From 5e48ff0d974def1f8e23e000617994a73443aa9d Mon Sep 17 00:00:00 2001 From: rechedev Date: Thu, 2 Apr 2026 18:03:44 +0200 Subject: [PATCH 3/3] fix(changeset): classify client export as minor --- .changeset/fix-validate-client-metadata-url.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-validate-client-metadata-url.md b/.changeset/fix-validate-client-metadata-url.md index 78ab638e5..a460fca4c 100644 --- a/.changeset/fix-validate-client-metadata-url.md +++ b/.changeset/fix-validate-client-metadata-url.md @@ -1,5 +1,5 @@ --- -'@modelcontextprotocol/client': patch +'@modelcontextprotocol/client': minor --- Add `validateClientMetadataUrl()` utility for early validation of `clientMetadataUrl`