From fd93114ec07489fb8f0b2d21858f602ee3699cb8 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 12 Aug 2025 16:01:40 -0700 Subject: [PATCH 1/2] feat: use informative user agent in HTTP requests --- package-lock.json | 7 + package.json | 1 + src/client/auth.test.ts | 237 ++++++++++++++++++++++------------ src/client/auth.ts | 63 ++++++--- src/client/middleware.test.ts | 12 +- src/client/middleware.ts | 5 +- src/client/sse.ts | 14 +- src/client/streamableHttp.ts | 14 +- src/shared/userAgent.test.ts | 36 ++++++ src/shared/userAgent.ts | 53 ++++++++ 10 files changed, 328 insertions(+), 114 deletions(-) create mode 100644 src/shared/userAgent.test.ts create mode 100644 src/shared/userAgent.ts diff --git a/package-lock.json b/package-lock.json index dc3752f98..e487f9230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "ajv": "^6.12.6", + "bowser": "^2.12.0", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -2486,6 +2487,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/package.json b/package.json index 418209efc..68c264274 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ }, "dependencies": { "ajv": "^6.12.6", + "bowser": "^2.12.0", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f2dadbb15..4012c2013 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -19,6 +19,9 @@ import { AuthorizationServerMetadata } from '../shared/auth.js'; const mockFetch = jest.fn(); global.fetch = mockFetch; +const TEST_UA = 'test/1.0'; +const userAgentProvider = () => Promise.resolve(TEST_UA); + describe('OAuth Authorization', () => { beforeEach(() => { mockFetch.mockReset(); @@ -82,7 +85,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); @@ -113,7 +116,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 @@ -141,7 +144,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); @@ -153,7 +158,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.' ); }); @@ -164,7 +169,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 () => { @@ -177,7 +184,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 () => { @@ -187,7 +194,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); @@ -202,7 +209,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); @@ -226,7 +236,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; @@ -236,14 +246,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 }); } ); @@ -261,9 +273,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); @@ -276,7 +288,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 @@ -289,7 +303,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.' ); @@ -307,7 +321,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.' ); @@ -335,7 +349,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; @@ -345,7 +359,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 }); }); @@ -357,7 +372,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.'); @@ -381,7 +396,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); @@ -390,7 +410,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 }); }); }); @@ -412,14 +433,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 }); }); @@ -430,14 +452,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 }); }); @@ -455,7 +478,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; @@ -465,14 +488,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 }); }); @@ -489,7 +514,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; @@ -503,7 +528,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; @@ -520,7 +545,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; @@ -547,7 +572,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; @@ -557,7 +582,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 }); }); @@ -584,7 +610,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 @@ -612,7 +638,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); @@ -627,7 +653,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(); }); @@ -637,14 +663,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 () => { @@ -658,7 +684,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 () => { @@ -677,7 +703,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); @@ -686,7 +712,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 }); }); }); @@ -775,7 +802,7 @@ describe('OAuth Authorization', () => { json: async () => validOAuthMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1', userAgentProvider); expect(metadata).toEqual(validOAuthMetadata); @@ -798,7 +825,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); }); @@ -809,7 +836,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 () => { @@ -823,7 +850,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; @@ -843,7 +870,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); @@ -857,14 +886,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 }); }); @@ -872,7 +904,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(); @@ -1073,7 +1105,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); @@ -1084,7 +1117,8 @@ describe('OAuth Authorization', () => { expect.objectContaining({ method: 'POST', headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': TEST_UA }) }) ); @@ -1122,7 +1156,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); @@ -1165,7 +1200,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', codeVerifier: 'verifier123', - redirectUri: 'http://localhost:3000/callback' + redirectUri: 'http://localhost:3000/callback', + userAgentProvider }) ).rejects.toThrow(); }); @@ -1178,7 +1214,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'); }); @@ -1196,7 +1233,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); @@ -1259,7 +1297,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); @@ -1270,7 +1309,8 @@ describe('OAuth Authorization', () => { expect.objectContaining({ method: 'POST', headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': TEST_UA }) }) ); @@ -1304,7 +1344,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); @@ -1340,7 +1381,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 }); @@ -1359,7 +1401,8 @@ describe('OAuth Authorization', () => { await expect( refreshAuthorization('https://auth.example.com', { clientInformation: validClientInfo, - refreshToken: 'refresh123' + refreshToken: 'refresh123', + userAgentProvider }) ).rejects.toThrow(); }); @@ -1370,7 +1413,8 @@ describe('OAuth Authorization', () => { await expect( refreshAuthorization('https://auth.example.com', { clientInformation: validClientInfo, - refreshToken: 'refresh123' + refreshToken: 'refresh123', + userAgentProvider }) ).rejects.toThrow('Token refresh failed'); }); @@ -1398,7 +1442,8 @@ describe('OAuth Authorization', () => { }); const clientInfo = await registerClient('https://auth.example.com', { - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }); expect(clientInfo).toEqual(validClientInfo); @@ -1409,7 +1454,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) }) @@ -1428,7 +1474,8 @@ describe('OAuth Authorization', () => { await expect( registerClient('https://auth.example.com', { - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }) ).rejects.toThrow(); }); @@ -1444,7 +1491,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/); }); @@ -1456,7 +1504,8 @@ describe('OAuth Authorization', () => { await expect( registerClient('https://auth.example.com', { - clientMetadata: validClientMetadata + clientMetadata: validClientMetadata, + userAgentProvider }) ).rejects.toThrow('Dynamic client registration failed'); }); @@ -1540,7 +1589,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 @@ -1596,7 +1646,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'); @@ -1666,7 +1717,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'); @@ -1734,7 +1786,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'); @@ -1798,7 +1851,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'); @@ -1853,7 +1907,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'); @@ -1911,7 +1966,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'); @@ -1978,7 +2034,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'); @@ -2043,7 +2100,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'); @@ -2102,7 +2160,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'); @@ -2167,7 +2226,8 @@ describe('OAuth Authorization', () => { const result = await auth(mockProvider, { serverUrl: 'https://resource.example.com', - fetchFn: customFetch + fetchFn: customFetch, + userAgentProvider }); expect(result).toBe('REDIRECT'); @@ -2233,7 +2293,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2261,7 +2322,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2287,7 +2349,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2322,7 +2385,8 @@ describe('OAuth Authorization', () => { clientInformation: clientInfoWithoutSecret, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2347,7 +2411,8 @@ describe('OAuth Authorization', () => { clientInformation: validClientInfo, authorizationCode: 'code123', redirectUri: 'http://localhost:3000/callback', - codeVerifier: 'verifier123' + codeVerifier: 'verifier123', + userAgentProvider }); expect(tokens).toEqual(validTokens); @@ -2401,7 +2466,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); @@ -2428,7 +2494,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); diff --git a/src/client/auth.ts b/src/client/auth.ts index 1e90f34ba..e1aec28ff 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -27,6 +27,7 @@ import { UnauthorizedClientError } from '../server/auth/errors.js'; import { FetchLike } from '../shared/transport.js'; +import { UserAgentProvider } from '../shared/userAgent.js'; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -296,6 +297,7 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { try { @@ -322,19 +324,21 @@ 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]; } @@ -352,7 +356,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 }); @@ -370,6 +374,7 @@ async function authInternal( const fullInformation = await registerClient(authorizationServerUrl, { metadata, clientMetadata: provider.clientMetadata, + userAgentProvider, fetchFn }); @@ -388,7 +393,8 @@ async function authInternal( redirectUri: provider.redirectUrl, resource, addClientAuthentication: provider.addClientAuthentication, - fetchFn: fetchFn + fetchFn: fetchFn, + userAgentProvider }); await provider.saveTokens(tokens); @@ -407,7 +413,8 @@ async function authInternal( refreshToken: tokens.refresh_token, resource, addClientAuthentication: provider.addClientAuthentication, - fetchFn + fetchFn, + userAgentProvider }); await provider.saveTokens(newTokens); @@ -500,10 +507,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 }); @@ -557,9 +565,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); } @@ -577,6 +591,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 { @@ -593,12 +608,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; @@ -614,6 +629,7 @@ async function discoverMetadataWithFallback( */ export async function discoverOAuthMetadata( issuer: string | URL, + userAgentProvider: UserAgentProvider, { authorizationServerUrl, protocolVersion @@ -634,7 +650,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 }); @@ -730,6 +746,7 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, + userAgentProvider: UserAgentProvider, { fetchFn = fetch, protocolVersion = LATEST_PROTOCOL_VERSION @@ -740,7 +757,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 @@ -873,7 +891,8 @@ export async function exchangeAuthorization( redirectUri, resource, addClientAuthentication, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; @@ -883,6 +902,7 @@ export async function exchangeAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { const grantType = 'authorization_code'; @@ -896,7 +916,8 @@ export async function exchangeAuthorization( // Exchange code for tokens const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' + Accept: 'application/json', + 'User-Agent': await userAgentProvider() }); const params = new URLSearchParams({ grant_type: grantType, @@ -952,7 +973,8 @@ export async function refreshAuthorization( refreshToken, resource, addClientAuthentication, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; @@ -960,6 +982,7 @@ export async function refreshAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { const grantType = 'refresh_token'; @@ -977,7 +1000,8 @@ export async function refreshAuthorization( // Exchange refresh token const headers = new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': await userAgentProvider() }); const params = new URLSearchParams({ grant_type: grantType, @@ -1018,11 +1042,13 @@ export async function registerClient( { metadata, clientMetadata, - fetchFn + fetchFn, + userAgentProvider }: { metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { let registrationUrl: URL; @@ -1040,7 +1066,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/src/client/middleware.test.ts b/src/client/middleware.test.ts index c0420514b..0aee5fbee 100644 --- a/src/client/middleware.test.ts +++ b/src/client/middleware.test.ts @@ -142,7 +142,8 @@ describe('withOAuth', () => { expect(mockAuth).toHaveBeenCalledWith(mockProvider, { serverUrl: 'https://api.example.com', resourceMetadataUrl: mockResourceUrl, - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); // Verify the retry used the new token @@ -186,7 +187,8 @@ describe('withOAuth', () => { expect(mockAuth).toHaveBeenCalledWith(mockProvider, { serverUrl: 'https://api.example.com', // Should be extracted from request URL resourceMetadataUrl: mockResourceUrl, - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); // Verify the retry used the new token @@ -357,7 +359,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) }); }); }); @@ -896,7 +899,8 @@ describe('Integration Tests', () => { expect(mockAuth).toHaveBeenCalledWith(mockProvider, { serverUrl: 'https://mcp-server.example.com', resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'), - fetchFn: mockFetch + fetchFn: mockFetch, + userAgentProvider: expect.any(Function) }); }); }); diff --git a/src/client/middleware.ts b/src/client/middleware.ts index a7cbc6c69..6b2ec1cba 100644 --- a/src/client/middleware.ts +++ b/src/client/middleware.ts @@ -1,5 +1,6 @@ import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { FetchLike } from '../shared/transport.js'; +import { createUserAgentProvider } from '../shared/userAgent.js'; /** * Middleware function that wraps and enhances fetch functionality. @@ -36,6 +37,7 @@ export type Middleware = (next: FetchLike) => FetchLike; export const withOAuth = (provider: OAuthClientProvider, baseUrl?: string | URL): Middleware => next => { + const userAgentProvider = createUserAgentProvider(); return async (input, init) => { const makeRequest = async (): Promise => { const headers = new Headers(init?.headers); @@ -62,7 +64,8 @@ export const withOAuth = const result = await auth(provider, { serverUrl, resourceMetadataUrl, - fetchFn: next + fetchFn: next, + userAgentProvider }); if (result === 'REDIRECT') { diff --git a/src/client/sse.ts b/src/client/sse.ts index aa4942444..557b2073b 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,6 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource' import { Transport, FetchLike } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import { createUserAgentProvider, UserAgentProvider } from '../shared/userAgent.js'; export class SseError extends Error { constructor( @@ -69,6 +70,7 @@ export class SSEClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _fetch?: FetchLike; private _protocolVersion?: string; + private _userAgentProvider: UserAgentProvider; onclose?: () => void; onerror?: (error: Error) => void; @@ -81,6 +83,7 @@ export class SSEClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; + this._userAgentProvider = createUserAgentProvider(); } private async _authThenStart(): Promise { @@ -93,7 +96,8 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - fetchFn: this._fetch + fetchFn: this._fetch, + userAgentProvider: this._userAgentProvider }); } catch (error) { this.onerror?.(error as Error); @@ -119,6 +123,8 @@ export class SSEClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } + headers['user-agent'] = await this._userAgentProvider(); + return new Headers({ ...headers, ...this._requestInit?.headers }); } @@ -213,7 +219,8 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - fetchFn: this._fetch + fetchFn: this._fetch, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -250,7 +257,8 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - fetchFn: this._fetch + fetchFn: this._fetch, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 12cb94864..294502178 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,3 +1,4 @@ +import { createUserAgentProvider, UserAgentProvider } from '../shared/userAgent.js'; import { Transport, FetchLike } from '../shared/transport.js'; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; @@ -132,6 +133,7 @@ export class StreamableHTTPClientTransport implements Transport { private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401 + private _userAgentProvider: UserAgentProvider; onclose?: () => void; onerror?: (error: Error) => void; @@ -145,6 +147,7 @@ export class StreamableHTTPClientTransport implements Transport { this._fetch = opts?.fetch; this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + this._userAgentProvider = createUserAgentProvider(); } private async _authThenStart(): Promise { @@ -157,7 +160,8 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - fetchFn: this._fetch + fetchFn: this._fetch, + userAgentProvider: this._userAgentProvider }); } catch (error) { this.onerror?.(error as Error); @@ -187,6 +191,8 @@ export class StreamableHTTPClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } + headers['user-agent'] = await this._userAgentProvider(); + const extraHeaders = this._normalizeHeaders(this._requestInit?.headers); return new Headers({ @@ -381,7 +387,8 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - fetchFn: this._fetch + fetchFn: this._fetch, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -442,7 +449,8 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - fetchFn: this._fetch + fetchFn: this._fetch, + userAgentProvider: this._userAgentProvider }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); diff --git a/src/shared/userAgent.test.ts b/src/shared/userAgent.test.ts new file mode 100644 index 000000000..4f59064ba --- /dev/null +++ b/src/shared/userAgent.test.ts @@ -0,0 +1,36 @@ +import { createUserAgentProvider } from './userAgent.js'; +import packageJson from '../../package.json'; +import { platform, release } from 'node:os'; +import { versions } from 'node:process'; + +describe('createUserAgent', () => { + describe('browser', () => { + let windowOriginal: Window & typeof globalThis; + + beforeEach(() => { + windowOriginal = globalThis.window; + globalThis.window = {} as Window & typeof globalThis; + 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'; + } + } as Navigator; + }); + + 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/src/shared/userAgent.ts b/src/shared/userAgent.ts new file mode 100644 index 000000000..cd5afc592 --- /dev/null +++ b/src/shared/userAgent.ts @@ -0,0 +1,53 @@ +import * as Bowser from 'bowser'; +import packageJson from '../../package.json'; + +export type UserAgentProvider = () => Promise; + +const UA_LANG = 'lang/js'; + +function isBrowser() { + return typeof window !== 'undefined'; +} + +function uaProduct() { + 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() { + const ua = window.navigator?.userAgent ? Bowser.parse(window.navigator.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; +} From eabf70683ae418e3282f92a58ef8b79820d8c1db Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 24 Oct 2025 13:49:43 -0700 Subject: [PATCH 2/2] feat: support overriding UA provider --- src/client/middleware.ts | 9 +++++---- src/client/sse.ts | 7 ++++++- src/client/streamableHttp.ts | 7 ++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/client/middleware.ts b/src/client/middleware.ts index 6b2ec1cba..fb4173b50 100644 --- a/src/client/middleware.ts +++ b/src/client/middleware.ts @@ -1,6 +1,6 @@ import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { FetchLike } from '../shared/transport.js'; -import { createUserAgentProvider } from '../shared/userAgent.js'; +import { createUserAgentProvider, UserAgentProvider } from '../shared/userAgent.js'; /** * Middleware function that wraps and enhances fetch functionality. @@ -32,12 +32,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 userAgentProvider = createUserAgentProvider(); + const uaProvider = userAgentProvider ?? createUserAgentProvider(); return async (input, init) => { const makeRequest = async (): Promise => { const headers = new Headers(init?.headers); @@ -65,7 +66,7 @@ export const withOAuth = serverUrl, resourceMetadataUrl, fetchFn: next, - userAgentProvider + userAgentProvider: uaProvider }); if (result === 'REDIRECT') { diff --git a/src/client/sse.ts b/src/client/sse.ts index 557b2073b..f144668ae 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -53,6 +53,11 @@ export type SSEClientTransportOptions = { * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; + + /** + * User agent provider for the connection. + */ + userAgentProvider?: UserAgentProvider; }; /** @@ -83,7 +88,7 @@ export class SSEClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; - this._userAgentProvider = createUserAgentProvider(); + this._userAgentProvider = opts?.userAgentProvider ?? createUserAgentProvider(); } private async _authThenStart(): Promise { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 294502178..36663a911 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -115,6 +115,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; }; /** @@ -147,7 +152,7 @@ export class StreamableHTTPClientTransport implements Transport { this._fetch = opts?.fetch; this._sessionId = opts?.sessionId; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; - this._userAgentProvider = createUserAgentProvider(); + this._userAgentProvider = opts?.userAgentProvider ?? createUserAgentProvider(); } private async _authThenStart(): Promise {