diff --git a/package-lock.json b/package-lock.json index 8759a701e..c413fc1ff 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", @@ -2484,6 +2485,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/bowser": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.0.tgz", + "integrity": "sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==", + "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 8be8f1002..aa95ca671 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "ajv": "^6.12.6", + "bowser": "^2.12.0", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -100,4 +101,4 @@ "resolutions": { "strip-ansi": "6.0.1" } -} \ No newline at end of file +} diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f28163d14..7fbf612fb 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,7 @@ describe("OAuth Authorization", () => { }); // Should fail with the second error - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com", userAgentProvider)) .rejects.toThrow("Second failure"); // Verify both calls were made @@ -154,7 +157,7 @@ describe("OAuth Authorization", () => { status: 404, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com", userAgentProvider)) .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); }); @@ -164,7 +167,7 @@ describe("OAuth Authorization", () => { status: 500, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com", userAgentProvider)) .rejects.toThrow("HTTP 500"); }); @@ -178,7 +181,7 @@ describe("OAuth Authorization", () => { }), }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com", userAgentProvider)) .rejects.toThrow(); }); @@ -189,7 +192,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); @@ -204,7 +207,7 @@ 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 +229,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 +239,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, }); }); @@ -260,7 +265,7 @@ describe("OAuth Authorization", () => { status: 404, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name")) + 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; @@ -274,7 +279,7 @@ describe("OAuth Authorization", () => { status: 500, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name", userAgentProvider)) .rejects.toThrow(); const calls = mockFetch.mock.calls; @@ -288,7 +293,7 @@ describe("OAuth Authorization", () => { status: 404, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/", userAgentProvider)) .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); const calls = mockFetch.mock.calls; @@ -305,7 +310,7 @@ describe("OAuth Authorization", () => { status: 404, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com")) + await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com", userAgentProvider)) .rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata."); const calls = mockFetch.mock.calls; @@ -332,7 +337,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; @@ -342,7 +347,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, }); }); @@ -353,7 +359,7 @@ describe("OAuth Authorization", () => { status: 404, }); - await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", { + await expect(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."); @@ -378,6 +384,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverOAuthProtectedResourceMetadata( "https://resource.example.com", + userAgentProvider, undefined, customFetch ); @@ -389,7 +396,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, }); }); }); @@ -411,14 +419,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, }); }); @@ -429,14 +438,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, }); }); @@ -454,7 +464,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; @@ -464,14 +474,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, }); }); @@ -488,7 +500,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; @@ -502,7 +514,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; @@ -519,7 +531,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; @@ -546,7 +558,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; @@ -556,7 +568,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, }); }); @@ -583,7 +596,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 @@ -611,7 +624,7 @@ describe("OAuth Authorization", () => { }); // Should fail with the second error - await expect(discoverOAuthMetadata("https://auth.example.com")) + await expect(discoverOAuthMetadata("https://auth.example.com", userAgentProvider)) .rejects.toThrow("Second failure"); // Verify both calls were made @@ -627,7 +640,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,7 +650,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(); }); @@ -645,7 +658,7 @@ describe("OAuth Authorization", () => { mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); await expect( - discoverOAuthMetadata("https://auth.example.com") + discoverOAuthMetadata("https://auth.example.com", userAgentProvider) ).rejects.toThrow("HTTP 500"); }); @@ -661,7 +674,7 @@ describe("OAuth Authorization", () => { ); await expect( - discoverOAuthMetadata("https://auth.example.com") + discoverOAuthMetadata("https://auth.example.com", userAgentProvider) ).rejects.toThrow(); }); @@ -683,6 +696,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverOAuthMetadata( "https://auth.example.com", + userAgentProvider, {}, customFetch ); @@ -694,7 +708,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, }); }); }); @@ -784,7 +799,8 @@ describe("OAuth Authorization", () => { }); const metadata = await discoverAuthorizationServerMetadata( - "https://auth.example.com/tenant1" + "https://auth.example.com/tenant1", + userAgentProvider, ); expect(metadata).toEqual(validOAuthMetadata); @@ -817,7 +833,8 @@ describe("OAuth Authorization", () => { await expect( discoverAuthorizationServerMetadata( - "https://auth.example.com" + "https://auth.example.com", + userAgentProvider, ) ).rejects.toThrow("does not support S256 code challenge method required by MCP specification"); }); @@ -834,7 +851,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); @@ -847,7 +864,7 @@ describe("OAuth Authorization", () => { }); await expect( - discoverAuthorizationServerMetadata("https://mcp.example.com") + discoverAuthorizationServerMetadata("https://mcp.example.com", userAgentProvider) ).rejects.toThrow("HTTP 500"); }); @@ -863,7 +880,8 @@ describe("OAuth Authorization", () => { }); const metadata = await discoverAuthorizationServerMetadata( - "https://auth.example.com" + "https://auth.example.com", + userAgentProvider, ); expect(metadata).toEqual(validOAuthMetadata); @@ -886,6 +904,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverAuthorizationServerMetadata( "https://auth.example.com", + userAgentProvider, { fetchFn: customFetch } ); @@ -903,6 +922,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverAuthorizationServerMetadata( "https://auth.example.com", + userAgentProvider, { protocolVersion: "2025-01-01" } ); @@ -910,7 +930,8 @@ describe("OAuth Authorization", () => { const calls = mockFetch.mock.calls; const [, options] = calls[0]; expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2025-01-01" + "MCP-Protocol-Version": "2025-01-01", + "User-Agent": TEST_UA, }); }); @@ -918,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(); @@ -1115,6 +1136,7 @@ describe("OAuth Authorization", () => { codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", resource: new URL("https://api.example.com/mcp-server"), + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -1126,6 +1148,7 @@ describe("OAuth Authorization", () => { method: "POST", headers: new Headers({ "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": TEST_UA, }), }) ); @@ -1159,6 +1182,7 @@ describe("OAuth Authorization", () => { params.set("example_metadata", metadata.authorization_endpoint); params.set("example_param", "example_value"); }, + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -1202,6 +1226,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", + userAgentProvider, }) ).rejects.toThrow(); }); @@ -1220,6 +1245,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", + userAgentProvider, }) ).rejects.toThrow("Token exchange failed"); }); @@ -1238,6 +1264,7 @@ describe("OAuth Authorization", () => { redirectUri: "http://localhost:3000/callback", resource: new URL("https://api.example.com/mcp-server"), fetchFn: customFetch, + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -1301,6 +1328,7 @@ describe("OAuth Authorization", () => { clientInformation: validClientInfo, refreshToken: "refresh123", resource: new URL("https://api.example.com/mcp-server"), + userAgentProvider, }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -1312,6 +1340,7 @@ describe("OAuth Authorization", () => { method: "POST", headers: new Headers({ "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": TEST_UA, }), }) ); @@ -1341,6 +1370,7 @@ describe("OAuth Authorization", () => { params.set("example_metadata", metadata?.authorization_endpoint ?? '?'); params.set("example_param", "example_value"); }, + userAgentProvider, }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -1377,6 +1407,7 @@ describe("OAuth Authorization", () => { const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken, + userAgentProvider, }); expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); @@ -1396,6 +1427,7 @@ describe("OAuth Authorization", () => { refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", + userAgentProvider, }) ).rejects.toThrow(); }); @@ -1412,6 +1444,7 @@ describe("OAuth Authorization", () => { refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", + userAgentProvider, }) ).rejects.toThrow("Token refresh failed"); }); @@ -1440,6 +1473,7 @@ describe("OAuth Authorization", () => { const clientInfo = await registerClient("https://auth.example.com", { clientMetadata: validClientMetadata, + userAgentProvider, }); expect(clientInfo).toEqual(validClientInfo); @@ -1451,6 +1485,7 @@ describe("OAuth Authorization", () => { method: "POST", headers: { "Content-Type": "application/json", + "User-Agent": TEST_UA, }, body: JSON.stringify(validClientMetadata), }) @@ -1470,6 +1505,7 @@ describe("OAuth Authorization", () => { await expect( registerClient("https://auth.example.com", { clientMetadata: validClientMetadata, + userAgentProvider, }) ).rejects.toThrow(); }); @@ -1486,6 +1522,7 @@ describe("OAuth Authorization", () => { registerClient("https://auth.example.com", { metadata, clientMetadata: validClientMetadata, + userAgentProvider, }) ).rejects.toThrow(/does not support dynamic client registration/); }); @@ -1501,6 +1538,7 @@ describe("OAuth Authorization", () => { await expect( registerClient("https://auth.example.com", { clientMetadata: validClientMetadata, + userAgentProvider, }) ).rejects.toThrow("Dynamic client registration failed"); }); @@ -1583,6 +1621,7 @@ describe("OAuth Authorization", () => { // Call the auth function const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", + userAgentProvider, }); // Verify the result @@ -1643,6 +1682,7 @@ describe("OAuth Authorization", () => { // Call auth without authorization code (should trigger redirect) const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", + userAgentProvider, }); expect(result).toBe("REDIRECT"); @@ -1713,6 +1753,7 @@ describe("OAuth Authorization", () => { const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", authorizationCode: "auth-code-123", + userAgentProvider, }); expect(result).toBe("AUTHORIZED"); @@ -1783,6 +1824,7 @@ describe("OAuth Authorization", () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", + userAgentProvider, }); expect(result).toBe("AUTHORIZED"); @@ -1849,6 +1891,7 @@ 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", + userAgentProvider, }); expect(result).toBe("REDIRECT"); @@ -1904,6 +1947,7 @@ 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", + userAgentProvider, }); expect(result).toBe("REDIRECT"); @@ -1962,6 +2006,7 @@ describe("OAuth Authorization", () => { // Call auth - should not include resource parameter const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", + userAgentProvider, }); expect(result).toBe("REDIRECT"); @@ -2029,6 +2074,7 @@ describe("OAuth Authorization", () => { const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", authorizationCode: "auth-code-123", + userAgentProvider, }); expect(result).toBe("AUTHORIZED"); @@ -2096,6 +2142,7 @@ describe("OAuth Authorization", () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", + userAgentProvider, }); expect(result).toBe("AUTHORIZED"); @@ -2157,6 +2204,7 @@ describe("OAuth Authorization", () => { // Call auth with serverUrl that has a path const result = await auth(mockProvider, { serverUrl: "https://my.resource.com/path/name", + userAgentProvider, }); expect(result).toBe("REDIRECT"); @@ -2220,6 +2268,7 @@ describe("OAuth Authorization", () => { const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", fetchFn: customFetch, + userAgentProvider, }); expect(result).toBe("REDIRECT"); @@ -2286,6 +2335,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", redirectUri: "http://localhost:3000/callback", codeVerifier: "verifier123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -2314,6 +2364,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", redirectUri: "http://localhost:3000/callback", codeVerifier: "verifier123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -2340,6 +2391,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", redirectUri: "http://localhost:3000/callback", codeVerifier: "verifier123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -2375,6 +2427,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", redirectUri: "http://localhost:3000/callback", codeVerifier: "verifier123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -2400,6 +2453,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", redirectUri: "http://localhost:3000/callback", codeVerifier: "verifier123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -2454,6 +2508,7 @@ describe("OAuth Authorization", () => { metadata: metadataWithBasicOnly, clientInformation: validClientInfo, refreshToken: "refresh123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); @@ -2481,6 +2536,7 @@ describe("OAuth Authorization", () => { metadata: metadataWithPostOnly, clientInformation: validClientInfo, refreshToken: "refresh123", + userAgentProvider, }); expect(tokens).toEqual(validTokens); diff --git a/src/client/auth.ts b/src/client/auth.ts index fcc320f17..15e1f801c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -22,6 +22,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. @@ -286,6 +287,7 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; }): Promise { try { return await authInternal(provider, options); @@ -311,19 +313,21 @@ async function authInternal( scope, resourceMetadataUrl, 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]; } @@ -341,7 +345,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, }); @@ -360,6 +364,7 @@ async function authInternal( metadata, clientMetadata: provider.clientMetadata, fetchFn, + userAgentProvider, }); await provider.saveClientInformation(fullInformation); @@ -377,7 +382,8 @@ async function authInternal( redirectUri: provider.redirectUrl, resource, addClientAuthentication: provider.addClientAuthentication, - fetchFn: fetchFn, + fetchFn, + userAgentProvider, }); await provider.saveTokens(tokens); @@ -397,6 +403,7 @@ async function authInternal( resource, addClientAuthentication: provider.addClientAuthentication, fetchFn, + userAgentProvider, }); await provider.saveTokens(newTokens); @@ -486,6 +493,7 @@ 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 { @@ -493,6 +501,7 @@ export async function discoverOAuthProtectedResourceMetadata( serverUrl, 'oauth-protected-resource', fetchFn, + userAgentProvider, { protocolVersion: opts?.protocolVersion, metadataUrl: opts?.resourceMetadataUrl, @@ -559,10 +568,12 @@ function buildWellKnownPath( 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); } @@ -581,6 +592,7 @@ async function discoverMetadataWithFallback( serverUrl: string | URL, wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', fetchFn: FetchLike, + userAgentProvider: UserAgentProvider, opts?: { protocolVersion?: string; metadataUrl?: string | URL, metadataServerUrl?: string | URL }, ): Promise { const issuer = new URL(serverUrl); @@ -596,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; @@ -617,6 +629,7 @@ async function discoverMetadataWithFallback( */ export async function discoverOAuthMetadata( issuer: string | URL, + userAgentProvider: UserAgentProvider, { authorizationServerUrl, protocolVersion, @@ -641,6 +654,7 @@ export async function discoverOAuthMetadata( authorizationServerUrl, 'oauth-authorization-server', fetchFn, + userAgentProvider, { protocolVersion, metadataServerUrl: authorizationServerUrl, @@ -742,6 +756,7 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, + userAgentProvider: UserAgentProvider, { fetchFn = fetch, protocolVersion = LATEST_PROTOCOL_VERSION, @@ -750,7 +765,7 @@ export async function discoverAuthorizationServerMetadata( protocolVersion?: string; } = {} ): Promise { - const headers = { 'MCP-Protocol-Version': protocolVersion }; + const headers = { 'MCP-Protocol-Version': protocolVersion, 'User-Agent': await userAgentProvider() }; // Get the list of URLs to try const urlsToTry = buildDiscoveryUrls(authorizationServerUrl); @@ -900,6 +915,7 @@ export async function exchangeAuthorization( resource, addClientAuthentication, fetchFn, + userAgentProvider, }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; @@ -909,6 +925,7 @@ export async function exchangeAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; }, ): Promise { const grantType = "authorization_code"; @@ -930,6 +947,7 @@ export async function exchangeAuthorization( const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", + "User-Agent": await userAgentProvider(), }); const params = new URLSearchParams({ grant_type: grantType, @@ -986,6 +1004,7 @@ export async function refreshAuthorization( resource, addClientAuthentication, fetchFn, + userAgentProvider, }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; @@ -993,6 +1012,7 @@ export async function refreshAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; } ): Promise { const grantType = "refresh_token"; @@ -1016,6 +1036,7 @@ export async function refreshAuthorization( // Exchange refresh token const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": await userAgentProvider(), }); const params = new URLSearchParams({ grant_type: grantType, @@ -1057,10 +1078,12 @@ export async function registerClient( metadata, clientMetadata, fetchFn, + userAgentProvider, }: { metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; fetchFn?: FetchLike; + userAgentProvider: UserAgentProvider; }, ): Promise { let registrationUrl: URL; @@ -1079,6 +1102,7 @@ export async function registerClient( method: "POST", headers: { "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 265aa70d6..e5229709a 100644 --- a/src/client/middleware.test.ts +++ b/src/client/middleware.test.ts @@ -161,6 +161,7 @@ describe("withOAuth", () => { serverUrl: "https://api.example.com", resourceMetadataUrl: mockResourceUrl, fetchFn: mockFetch, + userAgentProvider: expect.any(Function), }); // Verify the retry used the new token @@ -209,6 +210,7 @@ describe("withOAuth", () => { serverUrl: "https://api.example.com", // Should be extracted from request URL resourceMetadataUrl: mockResourceUrl, fetchFn: mockFetch, + userAgentProvider: expect.any(Function), }); // Verify the retry used the new token @@ -396,6 +398,7 @@ describe("withOAuth", () => { serverUrl: "https://api.example.com", // Should extract origin from URL object resourceMetadataUrl: undefined, fetchFn: mockFetch, + userAgentProvider: expect.any(Function), }); }); }); @@ -995,6 +998,7 @@ describe("Integration Tests", () => { "https://auth.example.com/.well-known/oauth-protected-resource", ), fetchFn: mockFetch, + userAgentProvider: expect.any(Function), }); }); }); diff --git a/src/client/middleware.ts b/src/client/middleware.ts index 3d0661584..301f06a5d 100644 --- a/src/client/middleware.ts +++ b/src/client/middleware.ts @@ -5,6 +5,7 @@ import { UnauthorizedError, } from "./auth.js"; import { FetchLike } from "../shared/transport.js"; +import { createUserAgentProvider } from "../shared/userAgent.js"; /** * Middleware function that wraps and enhances fetch functionality. @@ -41,6 +42,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); @@ -70,6 +72,7 @@ export const withOAuth = serverUrl, resourceMetadataUrl, fetchFn: next, + userAgentProvider, }); if (result === "REDIRECT") { diff --git a/src/client/sse.ts b/src/client/sse.ts index e1c86ccdb..9482cb0a3 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; @@ -84,6 +86,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,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch, userAgentProvider: this._userAgentProvider }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -118,6 +121,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 } ); @@ -218,7 +223,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch, userAgentProvider: this._userAgentProvider }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -252,7 +257,7 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, 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 12714ea44..98a51bd49 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"; @@ -131,6 +132,7 @@ export class StreamableHTTPClientTransport implements Transport { private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; + private _userAgentProvider: UserAgentProvider; onclose?: () => void; onerror?: (error: Error) => void; @@ -147,6 +149,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 { @@ -156,7 +159,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch, userAgentProvider: this._userAgentProvider }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -185,6 +188,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({ @@ -392,7 +397,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch, userAgentProvider: this._userAgentProvider }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -440,7 +445,7 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, fetchFn: this._fetch, userAgentProvider: this._userAgentProvider }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 4e98d0dc0..2247bad3a 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -212,9 +212,9 @@ describe("Proxy OAuth Server Provider", () => { "https://auth.example.com/token", expect.objectContaining({ method: "POST", - headers: { + headers: expect.objectContaining({ "Content-Type": "application/x-www-form-urlencoded", - }, + }), body: expect.stringContaining("grant_type=refresh_token") }) ); @@ -233,9 +233,9 @@ describe("Proxy OAuth Server Provider", () => { 'https://auth.example.com/token', expect.objectContaining({ method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: expect.objectContaining({ + "Content-Type": "application/x-www-form-urlencoded", + }), body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) }) ); @@ -263,9 +263,9 @@ describe("Proxy OAuth Server Provider", () => { "https://auth.example.com/register", expect.objectContaining({ method: "POST", - headers: { + headers: expect.objectContaining({ "Content-Type": "application/json", - }, + }), body: JSON.stringify(newClient), }) ); @@ -302,9 +302,9 @@ describe("Proxy OAuth Server Provider", () => { "https://auth.example.com/revoke", expect.objectContaining({ method: "POST", - headers: { + headers: expect.objectContaining({ "Content-Type": "application/x-www-form-urlencoded", - }, + }), body: expect.stringContaining("token=token-to-revoke"), }) ); diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index c66a8707c..d793ffaa2 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -11,6 +11,7 @@ import { AuthInfo } from "../types.js"; import { AuthorizationParams, OAuthServerProvider } from "../provider.js"; import { ServerError } from "../errors.js"; import { FetchLike } from "../../../shared/transport.js"; +import { createUserAgentProvider, UserAgentProvider } from "../../../shared/userAgent.js"; export type ProxyEndpoints = { authorizationUrl: string; @@ -49,6 +50,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { protected readonly _verifyAccessToken: (token: string) => Promise; protected readonly _getClient: (clientId: string) => Promise; protected readonly _fetch?: FetchLike; + protected readonly _userAgentProvider: UserAgentProvider; skipLocalPkceValidation = true; @@ -62,6 +64,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { this._verifyAccessToken = options.verifyAccessToken; this._getClient = options.getClient; this._fetch = options.fetch; + this._userAgentProvider = createUserAgentProvider(); if (options.endpoints?.revocationUrl) { this.revokeToken = async ( client: OAuthClientInformationFull, @@ -87,6 +90,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": await this._userAgentProvider(), }, body: params.toString(), }); @@ -108,6 +112,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { method: "POST", headers: { "Content-Type": "application/json", + "User-Agent": await this._userAgentProvider(), }, body: JSON.stringify(client), }); @@ -231,6 +236,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": await this._userAgentProvider(), }, body: params.toString(), }); diff --git a/src/shared/userAgent.test.ts b/src/shared/userAgent.test.ts new file mode 100644 index 000000000..7e5c5cc1b --- /dev/null +++ b/src/shared/userAgent.test.ts @@ -0,0 +1,42 @@ +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..bb91a1193 --- /dev/null +++ b/src/shared/userAgent.ts @@ -0,0 +1,58 @@ +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 + ? // @ts-expect-error bowser's type definition is inconsistent with how this is actually imported + 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; +}