Skip to content

Commit fd93114

Browse files
committed
feat: use informative user agent in HTTP requests
1 parent 7098bff commit fd93114

File tree

10 files changed

+328
-114
lines changed

10 files changed

+328
-114
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
},
6565
"dependencies": {
6666
"ajv": "^6.12.6",
67+
"bowser": "^2.12.0",
6768
"content-type": "^1.0.5",
6869
"cors": "^2.8.5",
6970
"cross-spawn": "^7.0.5",

src/client/auth.test.ts

Lines changed: 152 additions & 85 deletions
Large diffs are not rendered by default.

src/client/auth.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
UnauthorizedClientError
2828
} from '../server/auth/errors.js';
2929
import { FetchLike } from '../shared/transport.js';
30+
import { UserAgentProvider } from '../shared/userAgent.js';
3031

3132
/**
3233
* Implements an end-to-end OAuth client to be used with one MCP server.
@@ -296,6 +297,7 @@ export async function auth(
296297
scope?: string;
297298
resourceMetadataUrl?: URL;
298299
fetchFn?: FetchLike;
300+
userAgentProvider: UserAgentProvider;
299301
}
300302
): Promise<AuthResult> {
301303
try {
@@ -322,19 +324,21 @@ async function authInternal(
322324
authorizationCode,
323325
scope,
324326
resourceMetadataUrl,
325-
fetchFn
327+
fetchFn,
328+
userAgentProvider
326329
}: {
327330
serverUrl: string | URL;
328331
authorizationCode?: string;
329332
scope?: string;
330333
resourceMetadataUrl?: URL;
331334
fetchFn?: FetchLike;
335+
userAgentProvider: UserAgentProvider;
332336
}
333337
): Promise<AuthResult> {
334338
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
335339
let authorizationServerUrl: string | URL | undefined;
336340
try {
337-
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn);
341+
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, userAgentProvider, { resourceMetadataUrl }, fetchFn);
338342
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
339343
authorizationServerUrl = resourceMetadata.authorization_servers[0];
340344
}
@@ -352,7 +356,7 @@ async function authInternal(
352356

353357
const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);
354358

355-
const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, {
359+
const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, userAgentProvider, {
356360
fetchFn
357361
});
358362

@@ -370,6 +374,7 @@ async function authInternal(
370374
const fullInformation = await registerClient(authorizationServerUrl, {
371375
metadata,
372376
clientMetadata: provider.clientMetadata,
377+
userAgentProvider,
373378
fetchFn
374379
});
375380

@@ -388,7 +393,8 @@ async function authInternal(
388393
redirectUri: provider.redirectUrl,
389394
resource,
390395
addClientAuthentication: provider.addClientAuthentication,
391-
fetchFn: fetchFn
396+
fetchFn: fetchFn,
397+
userAgentProvider
392398
});
393399

394400
await provider.saveTokens(tokens);
@@ -407,7 +413,8 @@ async function authInternal(
407413
refreshToken: tokens.refresh_token,
408414
resource,
409415
addClientAuthentication: provider.addClientAuthentication,
410-
fetchFn
416+
fetchFn,
417+
userAgentProvider
411418
});
412419

413420
await provider.saveTokens(newTokens);
@@ -500,10 +507,11 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined {
500507
*/
501508
export async function discoverOAuthProtectedResourceMetadata(
502509
serverUrl: string | URL,
510+
userAgentProvider: UserAgentProvider,
503511
opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL },
504512
fetchFn: FetchLike = fetch
505513
): Promise<OAuthProtectedResourceMetadata> {
506-
const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, {
514+
const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', userAgentProvider, fetchFn, {
507515
protocolVersion: opts?.protocolVersion,
508516
metadataUrl: opts?.resourceMetadataUrl
509517
});
@@ -557,9 +565,15 @@ function buildWellKnownPath(
557565
/**
558566
* Tries to discover OAuth metadata at a specific URL
559567
*/
560-
async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: FetchLike = fetch): Promise<Response | undefined> {
568+
async function tryMetadataDiscovery(
569+
url: URL,
570+
protocolVersion: string,
571+
userAgentProvider: UserAgentProvider,
572+
fetchFn: FetchLike = fetch
573+
): Promise<Response | undefined> {
561574
const headers = {
562-
'MCP-Protocol-Version': protocolVersion
575+
'MCP-Protocol-Version': protocolVersion,
576+
'User-Agent': await userAgentProvider()
563577
};
564578
return await fetchWithCorsRetry(url, headers, fetchFn);
565579
}
@@ -577,6 +591,7 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string)
577591
async function discoverMetadataWithFallback(
578592
serverUrl: string | URL,
579593
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
594+
userAgentProvider: UserAgentProvider,
580595
fetchFn: FetchLike,
581596
opts?: { protocolVersion?: string; metadataUrl?: string | URL; metadataServerUrl?: string | URL }
582597
): Promise<Response | undefined> {
@@ -593,12 +608,12 @@ async function discoverMetadataWithFallback(
593608
url.search = issuer.search;
594609
}
595610

596-
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
611+
let response = await tryMetadataDiscovery(url, protocolVersion, userAgentProvider, fetchFn);
597612

598613
// If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery
599614
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
600615
const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
601-
response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
616+
response = await tryMetadataDiscovery(rootUrl, protocolVersion, userAgentProvider, fetchFn);
602617
}
603618

604619
return response;
@@ -614,6 +629,7 @@ async function discoverMetadataWithFallback(
614629
*/
615630
export async function discoverOAuthMetadata(
616631
issuer: string | URL,
632+
userAgentProvider: UserAgentProvider,
617633
{
618634
authorizationServerUrl,
619635
protocolVersion
@@ -634,7 +650,7 @@ export async function discoverOAuthMetadata(
634650
}
635651
protocolVersion ??= LATEST_PROTOCOL_VERSION;
636652

637-
const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, {
653+
const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', userAgentProvider, fetchFn, {
638654
protocolVersion,
639655
metadataServerUrl: authorizationServerUrl
640656
});
@@ -730,6 +746,7 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url:
730746
*/
731747
export async function discoverAuthorizationServerMetadata(
732748
authorizationServerUrl: string | URL,
749+
userAgentProvider: UserAgentProvider,
733750
{
734751
fetchFn = fetch,
735752
protocolVersion = LATEST_PROTOCOL_VERSION
@@ -740,7 +757,8 @@ export async function discoverAuthorizationServerMetadata(
740757
): Promise<AuthorizationServerMetadata | undefined> {
741758
const headers = {
742759
'MCP-Protocol-Version': protocolVersion,
743-
Accept: 'application/json'
760+
Accept: 'application/json',
761+
'User-Agent': await userAgentProvider()
744762
};
745763

746764
// Get the list of URLs to try
@@ -873,7 +891,8 @@ export async function exchangeAuthorization(
873891
redirectUri,
874892
resource,
875893
addClientAuthentication,
876-
fetchFn
894+
fetchFn,
895+
userAgentProvider
877896
}: {
878897
metadata?: AuthorizationServerMetadata;
879898
clientInformation: OAuthClientInformation;
@@ -883,6 +902,7 @@ export async function exchangeAuthorization(
883902
resource?: URL;
884903
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
885904
fetchFn?: FetchLike;
905+
userAgentProvider: UserAgentProvider;
886906
}
887907
): Promise<OAuthTokens> {
888908
const grantType = 'authorization_code';
@@ -896,7 +916,8 @@ export async function exchangeAuthorization(
896916
// Exchange code for tokens
897917
const headers = new Headers({
898918
'Content-Type': 'application/x-www-form-urlencoded',
899-
Accept: 'application/json'
919+
Accept: 'application/json',
920+
'User-Agent': await userAgentProvider()
900921
});
901922
const params = new URLSearchParams({
902923
grant_type: grantType,
@@ -952,14 +973,16 @@ export async function refreshAuthorization(
952973
refreshToken,
953974
resource,
954975
addClientAuthentication,
955-
fetchFn
976+
fetchFn,
977+
userAgentProvider
956978
}: {
957979
metadata?: AuthorizationServerMetadata;
958980
clientInformation: OAuthClientInformation;
959981
refreshToken: string;
960982
resource?: URL;
961983
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
962984
fetchFn?: FetchLike;
985+
userAgentProvider: UserAgentProvider;
963986
}
964987
): Promise<OAuthTokens> {
965988
const grantType = 'refresh_token';
@@ -977,7 +1000,8 @@ export async function refreshAuthorization(
9771000

9781001
// Exchange refresh token
9791002
const headers = new Headers({
980-
'Content-Type': 'application/x-www-form-urlencoded'
1003+
'Content-Type': 'application/x-www-form-urlencoded',
1004+
'User-Agent': await userAgentProvider()
9811005
});
9821006
const params = new URLSearchParams({
9831007
grant_type: grantType,
@@ -1018,11 +1042,13 @@ export async function registerClient(
10181042
{
10191043
metadata,
10201044
clientMetadata,
1021-
fetchFn
1045+
fetchFn,
1046+
userAgentProvider
10221047
}: {
10231048
metadata?: AuthorizationServerMetadata;
10241049
clientMetadata: OAuthClientMetadata;
10251050
fetchFn?: FetchLike;
1051+
userAgentProvider: UserAgentProvider;
10261052
}
10271053
): Promise<OAuthClientInformationFull> {
10281054
let registrationUrl: URL;
@@ -1040,7 +1066,8 @@ export async function registerClient(
10401066
const response = await (fetchFn ?? fetch)(registrationUrl, {
10411067
method: 'POST',
10421068
headers: {
1043-
'Content-Type': 'application/json'
1069+
'Content-Type': 'application/json',
1070+
'User-Agent': await userAgentProvider()
10441071
},
10451072
body: JSON.stringify(clientMetadata)
10461073
});

src/client/middleware.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ describe('withOAuth', () => {
142142
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
143143
serverUrl: 'https://api.example.com',
144144
resourceMetadataUrl: mockResourceUrl,
145-
fetchFn: mockFetch
145+
fetchFn: mockFetch,
146+
userAgentProvider: expect.any(Function)
146147
});
147148

148149
// Verify the retry used the new token
@@ -186,7 +187,8 @@ describe('withOAuth', () => {
186187
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
187188
serverUrl: 'https://api.example.com', // Should be extracted from request URL
188189
resourceMetadataUrl: mockResourceUrl,
189-
fetchFn: mockFetch
190+
fetchFn: mockFetch,
191+
userAgentProvider: expect.any(Function)
190192
});
191193

192194
// Verify the retry used the new token
@@ -357,7 +359,8 @@ describe('withOAuth', () => {
357359
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
358360
serverUrl: 'https://api.example.com', // Should extract origin from URL object
359361
resourceMetadataUrl: undefined,
360-
fetchFn: mockFetch
362+
fetchFn: mockFetch,
363+
userAgentProvider: expect.any(Function)
361364
});
362365
});
363366
});
@@ -896,7 +899,8 @@ describe('Integration Tests', () => {
896899
expect(mockAuth).toHaveBeenCalledWith(mockProvider, {
897900
serverUrl: 'https://mcp-server.example.com',
898901
resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'),
899-
fetchFn: mockFetch
902+
fetchFn: mockFetch,
903+
userAgentProvider: expect.any(Function)
900904
});
901905
});
902906
});

src/client/middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js';
22
import { FetchLike } from '../shared/transport.js';
3+
import { createUserAgentProvider } from '../shared/userAgent.js';
34

45
/**
56
* Middleware function that wraps and enhances fetch functionality.
@@ -36,6 +37,7 @@ export type Middleware = (next: FetchLike) => FetchLike;
3637
export const withOAuth =
3738
(provider: OAuthClientProvider, baseUrl?: string | URL): Middleware =>
3839
next => {
40+
const userAgentProvider = createUserAgentProvider();
3941
return async (input, init) => {
4042
const makeRequest = async (): Promise<Response> => {
4143
const headers = new Headers(init?.headers);
@@ -62,7 +64,8 @@ export const withOAuth =
6264
const result = await auth(provider, {
6365
serverUrl,
6466
resourceMetadataUrl,
65-
fetchFn: next
67+
fetchFn: next,
68+
userAgentProvider
6669
});
6770

6871
if (result === 'REDIRECT') {

0 commit comments

Comments
 (0)