diff --git a/package-lock.json b/package-lock.json index 64bc6f21d..219e121c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "bowser": "^2.12.0", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -1889,6 +1890,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 93048e4b3..55ca14092 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -7,10 +7,12 @@ import type { OAuthClientMetadata, OAuthMetadata, OAuthProtectedResourceMetadata, - OAuthTokens + OAuthTokens, + UserAgentProvider } from '@modelcontextprotocol/core'; import { checkResourceAllowed, + createUserAgentProvider, InvalidClientError, InvalidClientMetadataError, InvalidGrantError, @@ -361,18 +363,23 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + userAgentProvider?: UserAgentProvider; } ): Promise { + const optionsWithDefaults = { + ...options, + userAgentProvider: options.userAgentProvider ?? createUserAgentProvider() + }; try { - return await authInternal(provider, options); + return await authInternal(provider, optionsWithDefaults); } catch (error) { // Handle recoverable error types by invalidating credentials and retrying if (error instanceof InvalidClientError || error instanceof UnauthorizedClientError) { await provider.invalidateCredentials?.('all'); - return await authInternal(provider, options); + return await authInternal(provider, optionsWithDefaults); } else if (error instanceof InvalidGrantError) { await provider.invalidateCredentials?.('tokens'); - return await authInternal(provider, options); + return await authInternal(provider, optionsWithDefaults); } // Throw otherwise @@ -387,20 +394,22 @@ async function authInternal( authorizationCode, scope, resourceMetadataUrl, - fetchFn + fetchFn, + userAgentProvider }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL | undefined; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, userAgentProvider, { resourceMetadataUrl }, fetchFn); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } @@ -418,7 +427,7 @@ async function authInternal( const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { + const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, userAgentProvider, { fetchFn }); @@ -455,6 +464,7 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, + userAgentProvider, fetchFn }); @@ -472,7 +482,8 @@ async function authInternal( metadata, resource, authorizationCode, - fetchFn + fetchFn, + userAgentProvider }); await provider.saveTokens(tokens); @@ -491,7 +502,8 @@ async function authInternal( refreshToken: tokens.refresh_token, resource, addClientAuthentication: provider.addClientAuthentication, - fetchFn + fetchFn, + userAgentProvider }); await provider.saveTokens(newTokens); @@ -661,10 +673,11 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { */ export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, + userAgentProvider: UserAgentProvider, opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL }, fetchFn: FetchLike = fetch ): Promise { - const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, { + const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', userAgentProvider, fetchFn, { protocolVersion: opts?.protocolVersion, metadataUrl: opts?.resourceMetadataUrl }); @@ -720,9 +733,15 @@ function buildWellKnownPath( /** * Tries to discover OAuth metadata at a specific URL */ -async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: FetchLike = fetch): Promise { +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, + userAgentProvider: UserAgentProvider, + fetchFn: FetchLike = fetch +): Promise { const headers = { - 'MCP-Protocol-Version': protocolVersion + 'MCP-Protocol-Version': protocolVersion, + 'User-Agent': await userAgentProvider() }; return await fetchWithCorsRetry(url, headers, fetchFn); } @@ -740,6 +759,7 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string) async function discoverMetadataWithFallback( serverUrl: string | URL, wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', + userAgentProvider: UserAgentProvider, fetchFn: FetchLike, opts?: { protocolVersion?: string; metadataUrl?: string | URL; metadataServerUrl?: string | URL } ): Promise { @@ -756,12 +776,12 @@ async function discoverMetadataWithFallback( url.search = issuer.search; } - let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); + let response = await tryMetadataDiscovery(url, protocolVersion, userAgentProvider, fetchFn); // If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); - response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); + response = await tryMetadataDiscovery(rootUrl, protocolVersion, userAgentProvider, fetchFn); } return response; @@ -777,6 +797,7 @@ async function discoverMetadataWithFallback( */ export async function discoverOAuthMetadata( issuer: string | URL, + userAgentProvider: UserAgentProvider, { authorizationServerUrl, protocolVersion @@ -797,7 +818,7 @@ export async function discoverOAuthMetadata( } protocolVersion ??= LATEST_PROTOCOL_VERSION; - const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, { + const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', userAgentProvider, fetchFn, { protocolVersion, metadataServerUrl: authorizationServerUrl }); @@ -889,6 +910,7 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, + userAgentProvider: UserAgentProvider, { fetchFn = fetch, protocolVersion = LATEST_PROTOCOL_VERSION @@ -899,7 +921,8 @@ export async function discoverAuthorizationServerMetadata( ): Promise { const headers = { 'MCP-Protocol-Version': protocolVersion, - Accept: 'application/json' + Accept: 'application/json', + 'User-Agent': await userAgentProvider() }; // Get the list of URLs to try @@ -1047,7 +1070,8 @@ async function executeTokenRequest( clientInformation, addClientAuthentication, resource, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; tokenRequestParams: URLSearchParams; @@ -1055,6 +1079,7 @@ async function executeTokenRequest( addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; resource?: URL; fetchFn?: FetchLike; + userAgentProvider?: UserAgentProvider; } ): Promise { const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl); @@ -1064,6 +1089,10 @@ async function executeTokenRequest( Accept: 'application/json' }); + if (userAgentProvider) { + headers.set('User-Agent', await userAgentProvider()); + } + if (resource) { tokenRequestParams.set('resource', resource.href); } @@ -1111,7 +1140,8 @@ export async function exchangeAuthorization( redirectUri, resource, addClientAuthentication, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; @@ -1121,6 +1151,7 @@ export async function exchangeAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; + userAgentProvider?: UserAgentProvider; } ): Promise { const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri); @@ -1131,7 +1162,8 @@ export async function exchangeAuthorization( clientInformation, addClientAuthentication, resource, - fetchFn + fetchFn, + userAgentProvider: userAgentProvider ?? createUserAgentProvider() }); } @@ -1155,7 +1187,8 @@ export async function refreshAuthorization( refreshToken, resource, addClientAuthentication, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; @@ -1163,6 +1196,7 @@ export async function refreshAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; + userAgentProvider?: UserAgentProvider; } ): Promise { const tokenRequestParams = new URLSearchParams({ @@ -1176,7 +1210,8 @@ export async function refreshAuthorization( clientInformation, addClientAuthentication, resource, - fetchFn + fetchFn, + userAgentProvider: userAgentProvider ?? createUserAgentProvider() }); // Preserve original refresh token if server didn't return a new one @@ -1216,13 +1251,15 @@ export async function fetchToken( metadata, resource, authorizationCode, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; resource?: URL; /** Authorization code for the default authorization_code grant flow */ authorizationCode?: string; fetchFn?: FetchLike; + userAgentProvider?: UserAgentProvider; } = {} ): Promise { const scope = provider.clientMetadata.scope; @@ -1253,7 +1290,8 @@ export async function fetchToken( clientInformation: clientInformation ?? undefined, addClientAuthentication: provider.addClientAuthentication, resource, - fetchFn + fetchFn, + userAgentProvider }); } @@ -1265,11 +1303,13 @@ export async function registerClient( { metadata, clientMetadata, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { let registrationUrl: URL; @@ -1287,7 +1327,8 @@ export async function registerClient( const response = await (fetchFn ?? fetch)(registrationUrl, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'User-Agent': await userAgentProvider() }, body: JSON.stringify(clientMetadata) }); diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index 331a920e2..bc74f3aeb 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,4 +1,5 @@ -import type { FetchLike } from '@modelcontextprotocol/core'; +import type { FetchLike, UserAgentProvider } from '@modelcontextprotocol/core'; +import { createUserAgentProvider } from '@modelcontextprotocol/core'; import type { OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; @@ -33,11 +34,13 @@ export type Middleware = (next: FetchLike) => FetchLike; * * @param provider - OAuth client provider for authentication * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @param userAgentProvider - User agent provider for the connection. * @returns A fetch middleware function */ export const withOAuth = - (provider: OAuthClientProvider, baseUrl?: string | URL): Middleware => + (provider: OAuthClientProvider, baseUrl?: string | URL, userAgentProvider?: UserAgentProvider): Middleware => next => { + const uaProvider = userAgentProvider ?? createUserAgentProvider(); return async (input, init) => { const makeRequest = async (): Promise => { const headers = new Headers(init?.headers); @@ -65,7 +68,8 @@ export const withOAuth = serverUrl, resourceMetadataUrl, scope, - fetchFn: next + fetchFn: next, + userAgentProvider: uaProvider }); if (result === 'REDIRECT') { diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index bff74986e..a8b9e73ed 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,5 +1,5 @@ -import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; -import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders } from '@modelcontextprotocol/core'; +import type { FetchLike, JSONRPCMessage, Transport, UserAgentProvider } from '@modelcontextprotocol/core'; +import { createFetchWithInit, createUserAgentProvider, JSONRPCMessageSchema, normalizeHeaders } from '@modelcontextprotocol/core'; import { type ErrorEvent, EventSource, type EventSourceInit } from 'eventsource'; import type { AuthResult, OAuthClientProvider } from './auth.js'; @@ -54,6 +54,11 @@ export type SSEClientTransportOptions = { * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; + + /** + * User agent provider for the connection. + */ + userAgentProvider?: UserAgentProvider; }; /** @@ -74,6 +79,7 @@ export class SSEClientTransport implements Transport { private _fetch?: FetchLike; private _fetchWithInit: FetchLike; private _protocolVersion?: string; + private _userAgentProvider: UserAgentProvider; onclose?: () => void; onerror?: (error: Error) => void; @@ -88,6 +94,7 @@ export class SSEClientTransport implements Transport { this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._userAgentProvider = opts?.userAgentProvider ?? createUserAgentProvider(); } private async _authThenStart(): Promise { @@ -101,7 +108,8 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); } catch (error) { this.onerror?.(error as Error); @@ -127,6 +135,8 @@ export class SSEClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } + headers['user-agent'] = await this._userAgentProvider(); + const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ @@ -229,7 +239,8 @@ export class SSEClientTransport implements Transport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -271,7 +282,8 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 91709a9a6..0817235bd 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,8 +1,9 @@ import type { ReadableWritablePair } from 'node:stream/web'; -import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import type { FetchLike, JSONRPCMessage, Transport, UserAgentProvider } from '@modelcontextprotocol/core'; import { createFetchWithInit, + createUserAgentProvider, isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, @@ -125,6 +126,11 @@ export type StreamableHTTPClientTransportOptions = { * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. */ sessionId?: string; + + /** + * User agent provider for the connection. + */ + userAgentProvider?: UserAgentProvider; }; /** @@ -148,6 +154,7 @@ export class StreamableHTTPClientTransport implements Transport { private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field private _reconnectionTimeout?: ReturnType; + private _userAgentProvider: UserAgentProvider; onclose?: () => void; onerror?: (error: Error) => void; @@ -163,6 +170,7 @@ export class StreamableHTTPClientTransport implements Transport { this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + this._userAgentProvider = opts?.userAgentProvider ?? createUserAgentProvider(); } private async _authThenStart(): Promise { @@ -176,7 +184,8 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); } catch (error) { this.onerror?.(error as Error); @@ -206,6 +215,8 @@ export class StreamableHTTPClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } + headers['user-agent'] = await this._userAgentProvider(); + const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ @@ -443,7 +454,8 @@ export class StreamableHTTPClientTransport implements Transport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -511,7 +523,8 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); @@ -548,7 +561,8 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index cb01d37d5..9bdfda89a 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -31,6 +31,9 @@ vi.mock('pkce-challenge', () => ({ const mockFetch = vi.fn(); global.fetch = mockFetch; +const TEST_UA = 'test/1.0'; +const userAgentProvider = () => Promise.resolve(TEST_UA); + describe('OAuth Authorization', () => { beforeEach(() => { mockFetch.mockReset(); @@ -121,7 +124,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com'); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); @@ -152,7 +155,7 @@ describe('OAuth Authorization', () => { }); // Should succeed with the second call - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com'); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider); expect(metadata).toEqual(validMetadata); // Verify both calls were made @@ -180,7 +183,9 @@ describe('OAuth Authorization', () => { }); // Should fail with the second error - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('Second failure'); + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider)).rejects.toThrow( + 'Second failure' + ); // Verify both calls were made expect(mockFetch).toHaveBeenCalledTimes(2); @@ -192,7 +197,7 @@ describe('OAuth Authorization', () => { status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow( + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider)).rejects.toThrow( 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' ); }); @@ -203,7 +208,9 @@ describe('OAuth Authorization', () => { status: 500 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('HTTP 500'); + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider)).rejects.toThrow( + 'HTTP 500' + ); }); it('validates metadata schema', async () => { @@ -216,7 +223,7 @@ describe('OAuth Authorization', () => { }) }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(); + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider)).rejects.toThrow(); }); it('returns metadata when discovery succeeds with path', async () => { @@ -226,7 +233,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name'); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); @@ -241,7 +248,10 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path?param=value'); + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path?param=value', + userAgentProvider + ); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); @@ -265,7 +275,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name'); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; @@ -275,14 +285,16 @@ describe('OAuth Authorization', () => { const [firstUrl, firstOptions] = calls[0]!; expect(firstUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); expect(firstOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); // Second call should be root fallback const [secondUrl, secondOptions] = calls[1]!; expect(secondUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); expect(secondOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); } ); @@ -300,9 +312,9 @@ describe('OAuth Authorization', () => { status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow( - 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' - ); + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name', userAgentProvider) + ).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.'); const calls = mockFetch.mock.calls; expect(calls.length).toBe(2); @@ -315,7 +327,9 @@ describe('OAuth Authorization', () => { status: 500 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow(); + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name', userAgentProvider) + ).rejects.toThrow(); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback @@ -328,7 +342,7 @@ describe('OAuth Authorization', () => { status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/')).rejects.toThrow( + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/', userAgentProvider)).rejects.toThrow( 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' ); @@ -346,7 +360,7 @@ describe('OAuth Authorization', () => { status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow( + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider)).rejects.toThrow( 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' ); @@ -374,7 +388,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/deep/path'); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/deep/path', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; @@ -384,7 +398,8 @@ describe('OAuth Authorization', () => { const [lastUrl, lastOptions] = calls[2]!; expect(lastUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); expect(lastOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); @@ -396,7 +411,7 @@ describe('OAuth Authorization', () => { }); await expect( - discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', { + discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', userAgentProvider, { resourceMetadataUrl: 'https://custom.example.com/metadata' }) ).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.'); @@ -420,7 +435,12 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, customFetch); + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com', + userAgentProvider, + undefined, + customFetch + ); expect(metadata).toEqual(validMetadata); expect(customFetch).toHaveBeenCalledTimes(1); @@ -429,7 +449,8 @@ describe('OAuth Authorization', () => { const [url, options] = customFetch.mock.calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); expect(options.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); }); @@ -451,14 +472,15 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthMetadata('https://auth.example.com'); + const metadata = await discoverOAuthMetadata('https://auth.example.com', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); const [url, options] = calls[0]!; expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); expect(options.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); @@ -469,14 +491,15 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); const [url, options] = calls[0]!; expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); expect(options.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); @@ -494,7 +517,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; @@ -504,14 +527,16 @@ describe('OAuth Authorization', () => { const [firstUrl, firstOptions] = calls[0]!; expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); expect(firstOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); // Second call should be root fallback const [secondUrl, secondOptions] = calls[1]!; expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); expect(secondOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); @@ -528,7 +553,7 @@ describe('OAuth Authorization', () => { status: 404 }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name', userAgentProvider); expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; @@ -542,7 +567,7 @@ describe('OAuth Authorization', () => { status: 404 }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/'); + const metadata = await discoverOAuthMetadata('https://auth.example.com/', userAgentProvider); expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; @@ -559,7 +584,7 @@ describe('OAuth Authorization', () => { status: 404 }); - const metadata = await discoverOAuthMetadata('https://auth.example.com'); + const metadata = await discoverOAuthMetadata('https://auth.example.com', userAgentProvider); expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; @@ -586,7 +611,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthMetadata('https://auth.example.com/deep/path'); + const metadata = await discoverOAuthMetadata('https://auth.example.com/deep/path', userAgentProvider); expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; @@ -596,7 +621,8 @@ describe('OAuth Authorization', () => { const [lastUrl, lastOptions] = calls[2]!; expect(lastUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); expect(lastOptions.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); @@ -623,7 +649,7 @@ describe('OAuth Authorization', () => { }); // Should succeed with the second call - const metadata = await discoverOAuthMetadata('https://auth.example.com'); + const metadata = await discoverOAuthMetadata('https://auth.example.com', userAgentProvider); expect(metadata).toEqual(validMetadata); // Verify both calls were made @@ -651,7 +677,7 @@ describe('OAuth Authorization', () => { }); // Should fail with the second error - await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('Second failure'); + await expect(discoverOAuthMetadata('https://auth.example.com', userAgentProvider)).rejects.toThrow('Second failure'); // Verify both calls were made expect(mockFetch).toHaveBeenCalledTimes(2); @@ -666,7 +692,7 @@ describe('OAuth Authorization', () => { }); // This should return undefined (the desired behavior after the fix) - const metadata = await discoverOAuthMetadata('https://auth.example.com/path'); + const metadata = await discoverOAuthMetadata('https://auth.example.com/path', userAgentProvider); expect(metadata).toBeUndefined(); }); @@ -676,14 +702,14 @@ describe('OAuth Authorization', () => { status: 404 }); - const metadata = await discoverOAuthMetadata('https://auth.example.com'); + const metadata = await discoverOAuthMetadata('https://auth.example.com', userAgentProvider); expect(metadata).toBeUndefined(); }); it('throws on non-404 errors', async () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); - await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500'); + await expect(discoverOAuthMetadata('https://auth.example.com', userAgentProvider)).rejects.toThrow('HTTP 500'); }); it('validates metadata schema', async () => { @@ -697,7 +723,7 @@ describe('OAuth Authorization', () => { ) ); - await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow(); + await expect(discoverOAuthMetadata('https://auth.example.com', userAgentProvider)).rejects.toThrow(); }); it('supports overriding the fetch function used for requests', async () => { @@ -716,7 +742,7 @@ describe('OAuth Authorization', () => { json: async () => validMetadata }); - const metadata = await discoverOAuthMetadata('https://auth.example.com', {}, customFetch); + const metadata = await discoverOAuthMetadata('https://auth.example.com', userAgentProvider, {}, customFetch); expect(metadata).toEqual(validMetadata); expect(customFetch).toHaveBeenCalledTimes(1); @@ -725,7 +751,8 @@ describe('OAuth Authorization', () => { const [url, options] = customFetch.mock.calls[0]!; expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); expect(options.headers).toEqual({ - 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + 'User-Agent': TEST_UA }); }); }); @@ -810,7 +837,7 @@ describe('OAuth Authorization', () => { json: async () => validOpenIdMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1', userAgentProvider); expect(metadata).toEqual(validOpenIdMetadata); @@ -833,7 +860,7 @@ describe('OAuth Authorization', () => { json: async () => validOpenIdMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com'); + const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com', userAgentProvider); expect(metadata).toEqual(validOpenIdMetadata); }); @@ -844,7 +871,7 @@ describe('OAuth Authorization', () => { status: 500 }); - await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500'); + await expect(discoverAuthorizationServerMetadata('https://mcp.example.com', userAgentProvider)).rejects.toThrow('HTTP 500'); }); it('handles CORS errors with retry', async () => { @@ -858,7 +885,7 @@ describe('OAuth Authorization', () => { json: async () => validOAuthMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', userAgentProvider); expect(metadata).toEqual(validOAuthMetadata); const calls = mockFetch.mock.calls; @@ -878,7 +905,9 @@ describe('OAuth Authorization', () => { json: async () => validOAuthMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { fetchFn: customFetch }); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', userAgentProvider, { + fetchFn: customFetch + }); expect(metadata).toEqual(validOAuthMetadata); expect(customFetch).toHaveBeenCalledTimes(1); @@ -892,14 +921,17 @@ describe('OAuth Authorization', () => { json: async () => validOAuthMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { protocolVersion: '2025-01-01' }); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', userAgentProvider, { + protocolVersion: '2025-01-01' + }); expect(metadata).toEqual(validOAuthMetadata); const calls = mockFetch.mock.calls; const [, options] = calls[0]!; expect(options.headers).toEqual({ 'MCP-Protocol-Version': '2025-01-01', - Accept: 'application/json' + Accept: 'application/json', + 'User-Agent': TEST_UA }); }); @@ -907,7 +939,7 @@ describe('OAuth Authorization', () => { // All fetch attempts fail with CORS errors (TypeError) mockFetch.mockImplementation(() => Promise.reject(new TypeError('CORS error'))); - const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1', userAgentProvider); expect(metadata).toBeUndefined(); @@ -1127,7 +1159,8 @@ describe('OAuth Authorization', () => { authorizationCode: 'code123', codeVerifier: 'verifier123', redirectUri: 'http://localhost:3000/callback', - resource: new URL('https://api.example.com/mcp-server') + resource: new URL('https://api.example.com/mcp-server'), + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -1216,7 +1249,8 @@ describe('OAuth Authorization', () => { params.set('example_url', typeof url === 'string' ? url : url.toString()); params.set('example_metadata', metadata?.authorization_endpoint ?? ''); params.set('example_param', 'example_value'); - } + }, + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -1259,7 +1293,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', codeVerifier: 'verifier123', - redirectUri: 'http://localhost:3000/callback' + redirectUri: 'http://localhost:3000/callback', + userAgentProvider }) ).rejects.toThrow(); }); @@ -1272,7 +1307,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', codeVerifier: 'verifier123', - redirectUri: 'http://localhost:3000/callback' + redirectUri: 'http://localhost:3000/callback', + userAgentProvider }) ).rejects.toThrow('Token exchange failed'); }); @@ -1290,7 +1326,8 @@ describe('OAuth Authorization', () => { codeVerifier: 'verifier123', redirectUri: 'http://localhost:3000/callback', resource: new URL('https://api.example.com/mcp-server'), - fetchFn: customFetch + fetchFn: customFetch, + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -1353,7 +1390,8 @@ describe('OAuth Authorization', () => { const tokens = await refreshAuthorization('https://auth.example.com', { clientInformation: validClientInfo, refreshToken: 'refresh123', - resource: new URL('https://api.example.com/mcp-server') + resource: new URL('https://api.example.com/mcp-server'), + userAgentProvider }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -1397,7 +1435,8 @@ describe('OAuth Authorization', () => { params.set('example_url', typeof url === 'string' ? url : url.toString()); params.set('example_metadata', metadata?.authorization_endpoint ?? '?'); params.set('example_param', 'example_value'); - } + }, + userAgentProvider }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -1433,7 +1472,8 @@ describe('OAuth Authorization', () => { const refreshToken = 'refresh123'; const tokens = await refreshAuthorization('https://auth.example.com', { clientInformation: validClientInfo, - refreshToken + refreshToken, + userAgentProvider }); expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); @@ -1452,7 +1492,8 @@ describe('OAuth Authorization', () => { await expect( refreshAuthorization('https://auth.example.com', { clientInformation: validClientInfo, - refreshToken: 'refresh123' + refreshToken: 'refresh123', + userAgentProvider }) ).rejects.toThrow(); }); @@ -1463,7 +1504,8 @@ describe('OAuth Authorization', () => { await expect( refreshAuthorization('https://auth.example.com', { clientInformation: validClientInfo, - refreshToken: 'refresh123' + refreshToken: 'refresh123', + userAgentProvider }) ).rejects.toThrow('Token refresh failed'); }); @@ -1491,7 +1533,8 @@ describe('OAuth Authorization', () => { }); const clientInfo = await registerClient('https://auth.example.com', { - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }); expect(clientInfo).toEqual(validClientInfo); @@ -1502,7 +1545,8 @@ describe('OAuth Authorization', () => { expect.objectContaining({ method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'User-Agent': TEST_UA }, body: JSON.stringify(validClientMetadata) }) @@ -1521,7 +1565,8 @@ describe('OAuth Authorization', () => { await expect( registerClient('https://auth.example.com', { - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }) ).rejects.toThrow(); }); @@ -1537,7 +1582,8 @@ describe('OAuth Authorization', () => { await expect( registerClient('https://auth.example.com', { metadata, - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }) ).rejects.toThrow(/does not support dynamic client registration/); }); @@ -1549,7 +1595,8 @@ describe('OAuth Authorization', () => { await expect( registerClient('https://auth.example.com', { - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }) ).rejects.toThrow('Dynamic client registration failed'); }); @@ -1730,7 +1777,8 @@ describe('OAuth Authorization', () => { // Call the auth function const result = await auth(mockProvider, { - serverUrl: 'https://resource.example.com' + serverUrl: 'https://resource.example.com', + userAgentProvider }); // Verify the result @@ -1802,7 +1850,8 @@ describe('OAuth Authorization', () => { // Call the auth function with a server URL that has a path const result = await auth(mockProvider, { - serverUrl: 'https://resource.example.com/path/to/server' + serverUrl: 'https://resource.example.com/path/to/server', + userAgentProvider }); // Verify the result @@ -1857,7 +1906,8 @@ describe('OAuth Authorization', () => { // Call auth without authorization code (should trigger redirect) const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/mcp-server' + serverUrl: 'https://api.example.com/mcp-server', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -1927,7 +1977,8 @@ describe('OAuth Authorization', () => { // Call auth with authorization code const result = await auth(mockProvider, { serverUrl: 'https://api.example.com/mcp-server', - authorizationCode: 'auth-code-123' + authorizationCode: 'auth-code-123', + userAgentProvider }); expect(result).toBe('AUTHORIZED'); @@ -1995,7 +2046,8 @@ describe('OAuth Authorization', () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/mcp-server' + serverUrl: 'https://api.example.com/mcp-server', + userAgentProvider }); expect(result).toBe('AUTHORIZED'); @@ -2059,7 +2111,8 @@ describe('OAuth Authorization', () => { // Call auth - should succeed despite resource mismatch because custom validation overrides default const result = await auth(providerWithCustomValidation, { - serverUrl: 'https://api.example.com/mcp-server' + serverUrl: 'https://api.example.com/mcp-server', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2114,7 +2167,8 @@ describe('OAuth Authorization', () => { // Call auth with a URL that has the resource as prefix const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/mcp-server/endpoint' + serverUrl: 'https://api.example.com/mcp-server/endpoint', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2172,7 +2226,8 @@ describe('OAuth Authorization', () => { // Call auth - should not include resource parameter const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/mcp-server' + serverUrl: 'https://api.example.com/mcp-server', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2239,7 +2294,8 @@ describe('OAuth Authorization', () => { // Call auth with authorization code const result = await auth(mockProvider, { serverUrl: 'https://api.example.com/mcp-server', - authorizationCode: 'auth-code-123' + authorizationCode: 'auth-code-123', + userAgentProvider }); expect(result).toBe('AUTHORIZED'); @@ -2304,7 +2360,8 @@ describe('OAuth Authorization', () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/mcp-server' + serverUrl: 'https://api.example.com/mcp-server', + userAgentProvider }); expect(result).toBe('AUTHORIZED'); @@ -2373,7 +2430,8 @@ describe('OAuth Authorization', () => { // Call auth without scope parameter const result = await auth(mockProvider, { - serverUrl: 'https://api.example.com/' + serverUrl: 'https://api.example.com/', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2438,7 +2496,8 @@ describe('OAuth Authorization', () => { // Call auth with explicit scope parameter const result = await auth(mockProvider, { serverUrl: 'https://api.example.com/', - scope: 'mcp:read' + scope: 'mcp:read', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2492,7 +2551,8 @@ describe('OAuth Authorization', () => { // Call auth with serverUrl that has a path const result = await auth(mockProvider, { - serverUrl: 'https://my.resource.com/path/name' + serverUrl: 'https://my.resource.com/path/name', + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2557,7 +2617,8 @@ describe('OAuth Authorization', () => { const result = await auth(mockProvider, { serverUrl: 'https://resource.example.com', - fetchFn: customFetch + fetchFn: customFetch, + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2623,7 +2684,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2651,7 +2713,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2677,7 +2740,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2712,7 +2776,8 @@ describe('OAuth Authorization', () => { clientInformation: clientInfoWithoutSecret, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2737,7 +2802,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2791,7 +2857,8 @@ describe('OAuth Authorization', () => { const tokens = await refreshAuthorization('https://auth.example.com', { metadata: metadataWithBasicOnly, clientInformation: validClientInfo, - refreshToken: 'refresh123' + refreshToken: 'refresh123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2818,7 +2885,8 @@ describe('OAuth Authorization', () => { const tokens = await refreshAuthorization('https://auth.example.com', { metadata: metadataWithPostOnly, clientInformation: validClientInfo, - refreshToken: 'refresh123' + refreshToken: 'refresh123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2855,14 +2923,13 @@ describe('OAuth Authorization', () => { } }); - await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch); + await discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider, undefined, wrappedFetch); expect(customFetch).toHaveBeenCalledTimes(1); const [url, options] = customFetch.mock.calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); expect(options.headers).toMatchObject({ - 'user-agent': 'MyApp/1.0', 'x-custom-header': 'test-value', 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION }); @@ -2891,7 +2958,7 @@ describe('OAuth Authorization', () => { } }); - await discoverAuthorizationServerMetadata('https://auth.example.com', { + await discoverAuthorizationServerMetadata('https://auth.example.com', userAgentProvider, { fetchFn: wrappedFetch }); @@ -2901,7 +2968,6 @@ describe('OAuth Authorization', () => { // Auth-specific Accept header should override base Accept header expect(options.headers).toMatchObject({ Accept: 'application/json', // Auth-specific value wins - 'user-agent': 'MyApp/1.0', // Base value preserved 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION }); }); @@ -2928,7 +2994,7 @@ describe('OAuth Authorization', () => { } }); - await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch); + await discoverOAuthProtectedResourceMetadata('https://resource.example.com', userAgentProvider, undefined, wrappedFetch); expect(customFetch).toHaveBeenCalledTimes(1); const [, options] = customFetch.mock.calls[0]!; @@ -2937,9 +3003,6 @@ describe('OAuth Authorization', () => { expect(options.credentials).toBe('include'); expect(options.mode).toBe('cors'); expect(options.cache).toBe('no-cache'); - expect(options.headers).toMatchObject({ - 'user-agent': 'MyApp/1.0' - }); }); }); @@ -3033,7 +3096,8 @@ describe('OAuth Authorization', () => { }); await auth(mockProvider, { - serverUrl: 'https://server.example.com' + serverUrl: 'https://server.example.com', + userAgentProvider }); // Should save URL-based client info @@ -3077,7 +3141,8 @@ describe('OAuth Authorization', () => { }); await auth(mockProvider, { - serverUrl: 'https://server.example.com' + serverUrl: 'https://server.example.com', + userAgentProvider }); // Should save DCR client info @@ -3118,7 +3183,8 @@ describe('OAuth Authorization', () => { await expect( auth(providerWithInvalidUri, { - serverUrl: 'https://server.example.com' + serverUrl: 'https://server.example.com', + userAgentProvider }) ).rejects.toThrow(InvalidClientMetadataError); }); @@ -3153,7 +3219,8 @@ describe('OAuth Authorization', () => { await expect( auth(providerWithRootPathname, { - serverUrl: 'https://server.example.com' + serverUrl: 'https://server.example.com', + userAgentProvider }) ).rejects.toThrow(InvalidClientMetadataError); }); @@ -3188,7 +3255,8 @@ describe('OAuth Authorization', () => { await expect( auth(providerWithInvalidUrl, { - serverUrl: 'https://server.example.com' + serverUrl: 'https://server.example.com', + userAgentProvider }) ).rejects.toThrow(InvalidClientMetadataError); }); @@ -3233,7 +3301,8 @@ describe('OAuth Authorization', () => { }); await auth(providerWithoutUri, { - serverUrl: 'https://server.example.com' + serverUrl: 'https://server.example.com', + userAgentProvider }); // Should fall back to DCR diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index 451715423..09db60862 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -148,7 +148,8 @@ describe('withOAuth', () => { serverUrl: 'https://api.example.com', resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl, scope: mockWWWAuthenticateParams.scope, - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); // Verify the retry used the new token @@ -196,7 +197,8 @@ describe('withOAuth', () => { serverUrl: 'https://api.example.com', // Should be extracted from request URL resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl, scope: mockWWWAuthenticateParams.scope, - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); // Verify the retry used the new token @@ -367,7 +369,8 @@ describe('withOAuth', () => { expect(mockAuth).toHaveBeenCalledWith(mockProvider, { serverUrl: 'https://api.example.com', // Should extract origin from URL object resourceMetadataUrl: undefined, - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); }); }); @@ -910,7 +913,8 @@ describe('Integration Tests', () => { serverUrl: 'https://mcp-server.example.com', resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'), scope: 'read', - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index a7364d4dd..1502f5efd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,6 +35,7 @@ "dependencies": { "ajv": "catalog:runtimeShared", "ajv-formats": "catalog:runtimeShared", + "bowser": "catalog:runtimeShared", "json-schema-typed": "catalog:runtimeShared", "zod": "catalog:runtimeShared", "zod-to-json-schema": "catalog:runtimeShared" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f4eaeabfc..9cc5f02c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; +export * from './shared/userAgent.js'; export * from './types/types.js'; export * from './util/inMemory.js'; export * from './util/zod-compat.js'; diff --git a/packages/core/src/shared/userAgent.test.ts b/packages/core/src/shared/userAgent.test.ts new file mode 100644 index 000000000..f0e3158c4 --- /dev/null +++ b/packages/core/src/shared/userAgent.test.ts @@ -0,0 +1,47 @@ +import { platform, release } from 'node:os'; +import { versions } from 'node:process'; + +import packageJson from '../../package.json' with { type: 'json' }; +import { createUserAgentProvider } from './userAgent.js'; + +// Type for mocking window in tests +type MockWindow = { navigator?: { userAgent?: string } }; + +// Augment globalThis for test mocking +declare global { + // eslint-disable-next-line no-var + var window: MockWindow | undefined; +} + +describe('createUserAgent', () => { + describe('browser', () => { + let windowOriginal: MockWindow | undefined; + + beforeEach(() => { + windowOriginal = globalThis.window; + globalThis.window = { + navigator: { + get userAgent() { + return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; + } + } + }; + }); + + afterEach(async () => { + globalThis.window = windowOriginal; + }); + + it('should generate user agent in a browser environment', async () => { + const ua = await createUserAgentProvider()(); + expect(ua).toBe(`mcp-sdk-ts/${packageJson.version} os/macOS#10.15.7 lang/js`); + }); + }); + + describe('Node', () => { + it('should generate user agent in a Node environment', async () => { + const ua = await createUserAgentProvider()(); + expect(ua).toBe(`mcp-sdk-ts/${packageJson.version} os/${platform()}#${release()} lang/js md/nodejs#${versions.node}`); + }); + }); +}); diff --git a/packages/core/src/shared/userAgent.ts b/packages/core/src/shared/userAgent.ts new file mode 100644 index 000000000..04335dcaa --- /dev/null +++ b/packages/core/src/shared/userAgent.ts @@ -0,0 +1,59 @@ +import Bowser from 'bowser'; + +import packageJson from '../../package.json' with { type: 'json' }; + +export type UserAgentProvider = () => Promise; + +const UA_LANG = 'lang/js'; + +// Declare window for browser environment detection +declare const window: { navigator?: { userAgent?: string } } | undefined; + +function isBrowser(): boolean { + return typeof window !== 'undefined'; +} + +function uaProduct(): string { + return `mcp-sdk-ts/${packageJson.version}`; +} + +function uaOS(os: string | undefined, version: string | undefined) { + const osSegment = `os/${os ?? 'unknown'}`; + if (version) { + return `${osSegment}#${version}`; + } else { + return osSegment; + } +} + +function uaNode(version: string | undefined) { + const nodeSegment = 'md/nodejs'; + if (version) { + return `${nodeSegment}#${version}`; + } else { + return nodeSegment; + } +} + +function browserUserAgent(): string { + // window is guaranteed to exist when this function is called (checked by isBrowser()) + const userAgent = window?.navigator?.userAgent; + const ua = userAgent ? Bowser.parse(userAgent) : undefined; + return `${uaProduct()} ${uaOS(ua?.os.name, ua?.os.version)} ${UA_LANG}`; +} + +async function nodeUserAgent() { + const { platform, release } = await import('node:os'); + const { versions } = await import('node:process'); + return `${uaProduct()} ${uaOS(platform(), release())} ${UA_LANG} ${uaNode(versions.node)}`; +} + +export function createUserAgentProvider(): UserAgentProvider { + if (isBrowser()) { + const browserUA = browserUserAgent(); + return () => Promise.resolve(browserUA); + } + + const nodeUA = nodeUserAgent(); + return () => nodeUA; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dbf8253..d6888f9d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ catalogs: ajv-formats: specifier: ^3.0.1 version: 3.0.1 + bowser: + specifier: ^2.12.0 + version: 2.13.1 json-schema-typed: specifier: ^8.0.2 version: 8.0.2 @@ -482,6 +485,9 @@ importers: ajv-formats: specifier: catalog:runtimeShared version: 3.0.1(ajv@8.17.1) + bowser: + specifier: catalog:runtimeShared + version: 2.13.1 json-schema-typed: specifier: catalog:runtimeShared version: 8.0.2 @@ -1713,6 +1719,9 @@ packages: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -4338,6 +4347,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 12bae8326..44cb6bac7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalogs: runtimeShared: ajv: ^8.17.1 ajv-formats: ^3.0.1 + bowser: ^2.12.0 json-schema-typed: ^8.0.2 pkce-challenge: ^5.0.0 zod: ^3.25 || ^4.0