@ai-sdk/mcp 1.0.89 → 1.0.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/mcp",
3
- "version": "1.0.89",
3
+ "version": "1.0.91",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "cross-spawn": "^7.0.6",
36
36
  "pkce-challenge": "^5.0.0",
37
37
  "@ai-sdk/provider": "3.0.18",
38
- "@ai-sdk/provider-utils": "4.0.56"
38
+ "@ai-sdk/provider-utils": "4.0.57"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/cross-spawn": "^6.0.6",
package/src/tool/oauth.ts CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  } from '../util/oauth-util';
29
29
  import { LATEST_PROTOCOL_VERSION } from './types';
30
30
  import {
31
+ fetchWithValidatedEndpoint,
32
+ fetchWithValidatedRedirects,
31
33
  parseJSON,
32
34
  validateDownloadUrl,
33
35
  type FetchFunction,
@@ -78,9 +80,15 @@ export interface OAuthClientProvider {
78
80
  * If implemented, provides a way for the client to invalidate (e.g. delete) the specified
79
81
  * credentials, in the case where the server has indicated that they are no longer valid.
80
82
  * This avoids requiring the user to intervene manually.
83
+ *
84
+ * For token invalidation, context identifies the token generation being invalidated
85
+ * when available. Providers sharing storage must atomically compare and delete
86
+ * that generation so a concurrent refresh's tokens are preserved. Omitting or
87
+ * ignoring context retains unconditional invalidation.
81
88
  */
82
89
  invalidateCredentials?(
83
90
  scope: 'all' | 'client' | 'tokens' | 'verifier',
91
+ context?: { tokens: OAuthTokens },
84
92
  ): void | Promise<void>;
85
93
  get redirectUrl(): string | URL;
86
94
  get clientMetadata(): OAuthClientMetadata;
@@ -149,16 +157,20 @@ function isOAuthLoopbackHost(hostname: string): boolean {
149
157
  }
150
158
 
151
159
  /**
152
- * Guards metadata-derived token/registration URLs before credentials are sent.
153
- * Loopback is allowed for local OAuth; every other target uses the shared
154
- * download URL guard (http(s) only, no private/link-local IPs).
160
+ * Guards metadata-derived OAuth URLs before they are requested. Loopback is
161
+ * allowed only when the caller has established that it belongs to a locally
162
+ * configured OAuth server; every other target uses the shared URL guard.
155
163
  *
156
- * Credential POSTs use `redirect: 'error'` instead of
157
- * `fetchWithValidatedRedirects`, which is GET-only and would follow hops with
158
- * the authorization code, PKCE verifier, and client secret still attached.
164
+ * Credential POSTs use `fetchWithValidatedEndpoint`, which enforces
165
+ * `redirect: 'error'` so the authorization code, PKCE verifier, and client
166
+ * secret are never replayed to a redirect target.
159
167
  */
160
- function assertSafeOAuthEndpoint(endpointUrl: URL): void {
168
+ function assertSafeOAuthEndpoint(
169
+ endpointUrl: URL,
170
+ { allowLoopback = false }: { allowLoopback?: boolean } = {},
171
+ ): void {
161
172
  if (
173
+ allowLoopback &&
162
174
  (endpointUrl.protocol === 'http:' || endpointUrl.protocol === 'https:') &&
163
175
  isOAuthLoopbackHost(endpointUrl.hostname)
164
176
  ) {
@@ -175,6 +187,13 @@ function assertSafeOAuthEndpoint(endpointUrl: URL): void {
175
187
  }
176
188
  }
177
189
 
190
+ function getTrustedLoopbackOrigin(
191
+ authorizationServerUrl: string | URL,
192
+ ): string | undefined {
193
+ const url = new URL(authorizationServerUrl);
194
+ return isOAuthLoopbackHost(url.hostname) ? url.origin : undefined;
195
+ }
196
+
178
197
  function createAuthorizationServerInformation(
179
198
  authorizationServerUrl: string | URL,
180
199
  metadata?: AuthorizationServerMetadata,
@@ -408,13 +427,31 @@ async function fetchWithCorsRetry(
408
427
  url: URL,
409
428
  headers?: Record<string, string>,
410
429
  fetchFn: FetchFunction = fetch,
430
+ trustedOrigin?: string,
411
431
  ): Promise<Response | undefined> {
412
432
  try {
413
- return await fetchFn(url, { headers });
433
+ return await fetchWithValidatedRedirects({
434
+ url: url.href,
435
+ fetch: async (input, init) =>
436
+ fetchWithValidatedEndpoint({
437
+ url: input as string | URL,
438
+ init: {
439
+ ...init,
440
+ headers: new Headers(init?.headers).has('MCP-Protocol-Version')
441
+ ? headers
442
+ : undefined,
443
+ },
444
+ fetch: fetchFn,
445
+ trustedOrigin,
446
+ redirect: 'manual',
447
+ }),
448
+ headers,
449
+ trustedOrigin,
450
+ });
414
451
  } catch (error) {
415
452
  if (error instanceof TypeError) {
416
453
  if (headers) {
417
- return fetchWithCorsRetry(url, undefined, fetchFn);
454
+ return fetchWithCorsRetry(url, undefined, fetchFn, trustedOrigin);
418
455
  } else {
419
456
  return undefined;
420
457
  }
@@ -430,11 +467,12 @@ async function tryMetadataDiscovery(
430
467
  url: URL,
431
468
  protocolVersion: string,
432
469
  fetchFn: FetchFunction = fetch,
470
+ trustedOrigin?: string,
433
471
  ): Promise<Response | undefined> {
434
472
  const headers = {
435
473
  'MCP-Protocol-Version': protocolVersion,
436
474
  };
437
- return await fetchWithCorsRetry(url, headers, fetchFn);
475
+ return await fetchWithCorsRetry(url, headers, fetchFn, trustedOrigin);
438
476
  }
439
477
 
440
478
  /**
@@ -461,6 +499,7 @@ async function discoverMetadataWithFallback(
461
499
  protocolVersion?: string;
462
500
  metadataUrl?: string | URL;
463
501
  metadataServerUrl?: string | URL;
502
+ trustedOrigin?: string;
464
503
  },
465
504
  ): Promise<Response | undefined> {
466
505
  const issuer = new URL(serverUrl);
@@ -475,11 +514,21 @@ async function discoverMetadataWithFallback(
475
514
  url.search = issuer.search;
476
515
  }
477
516
 
478
- let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
517
+ let response = await tryMetadataDiscovery(
518
+ url,
519
+ protocolVersion,
520
+ fetchFn,
521
+ opts?.trustedOrigin,
522
+ );
479
523
 
480
524
  if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
481
525
  const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
482
- response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
526
+ response = await tryMetadataDiscovery(
527
+ rootUrl,
528
+ protocolVersion,
529
+ fetchFn,
530
+ opts?.trustedOrigin,
531
+ );
483
532
  }
484
533
 
485
534
  return response;
@@ -497,6 +546,9 @@ export async function discoverOAuthProtectedResourceMetadata(
497
546
  {
498
547
  protocolVersion: opts?.protocolVersion,
499
548
  metadataUrl: opts?.resourceMetadataUrl,
549
+ // The configured MCP server is trusted as a request target. Redirects
550
+ // crossing its origin are still validated before they are followed.
551
+ trustedOrigin: new URL(serverUrl).origin,
500
552
  },
501
553
  );
502
554
 
@@ -609,9 +661,11 @@ export async function discoverAuthorizationServerMetadata(
609
661
  {
610
662
  fetchFn = fetch,
611
663
  protocolVersion = LATEST_PROTOCOL_VERSION,
664
+ trustedOrigin,
612
665
  }: {
613
666
  fetchFn?: FetchFunction;
614
667
  protocolVersion?: string;
668
+ trustedOrigin?: string;
615
669
  } = {},
616
670
  ): Promise<AuthorizationServerMetadata | undefined> {
617
671
  const headers = { 'MCP-Protocol-Version': protocolVersion };
@@ -619,7 +673,12 @@ export async function discoverAuthorizationServerMetadata(
619
673
  const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
620
674
 
621
675
  for (const { url: endpointUrl, type, expectedIssuer } of urlsToTry) {
622
- const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn);
676
+ const response = await fetchWithCorsRetry(
677
+ endpointUrl,
678
+ headers,
679
+ fetchFn,
680
+ trustedOrigin,
681
+ );
623
682
 
624
683
  if (!response) {
625
684
  /**
@@ -929,7 +988,10 @@ export async function exchangeAuthorization(
929
988
  const tokenUrl = metadata?.token_endpoint
930
989
  ? new URL(metadata.token_endpoint)
931
990
  : new URL('/token', authorizationServerUrl);
932
- assertSafeOAuthEndpoint(tokenUrl);
991
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
992
+ assertSafeOAuthEndpoint(tokenUrl, {
993
+ allowLoopback: tokenUrl.origin === trustedOrigin,
994
+ });
933
995
 
934
996
  if (
935
997
  metadata?.grant_types_supported &&
@@ -973,11 +1035,15 @@ export async function exchangeAuthorization(
973
1035
  params.set('resource', resourceUrlStripSlash(resource));
974
1036
  }
975
1037
 
976
- const response = await (fetchFn ?? fetch)(tokenUrl, {
977
- method: 'POST',
978
- headers,
979
- body: params,
980
- redirect: 'error',
1038
+ const response = await fetchWithValidatedEndpoint({
1039
+ url: tokenUrl,
1040
+ init: {
1041
+ method: 'POST',
1042
+ headers,
1043
+ body: params,
1044
+ },
1045
+ fetch: fetchFn,
1046
+ trustedOrigin,
981
1047
  });
982
1048
 
983
1049
  if (!response.ok) {
@@ -1034,7 +1100,10 @@ export async function refreshAuthorization(
1034
1100
  } else {
1035
1101
  tokenUrl = new URL('/token', authorizationServerUrl);
1036
1102
  }
1037
- assertSafeOAuthEndpoint(tokenUrl);
1103
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1104
+ assertSafeOAuthEndpoint(tokenUrl, {
1105
+ allowLoopback: tokenUrl.origin === trustedOrigin,
1106
+ });
1038
1107
 
1039
1108
  const headers = new Headers({
1040
1109
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1067,11 +1136,15 @@ export async function refreshAuthorization(
1067
1136
  params.set('resource', resourceUrlStripSlash(resource));
1068
1137
  }
1069
1138
 
1070
- const response = await (fetchFn ?? fetch)(tokenUrl, {
1071
- method: 'POST',
1072
- headers,
1073
- body: params,
1074
- redirect: 'error',
1139
+ const response = await fetchWithValidatedEndpoint({
1140
+ url: tokenUrl,
1141
+ init: {
1142
+ method: 'POST',
1143
+ headers,
1144
+ body: params,
1145
+ },
1146
+ fetch: fetchFn,
1147
+ trustedOrigin,
1075
1148
  });
1076
1149
  if (!response.ok) {
1077
1150
  throw await parseErrorResponse(response);
@@ -1111,15 +1184,22 @@ export async function registerClient(
1111
1184
  } else {
1112
1185
  registrationUrl = new URL('/register', authorizationServerUrl);
1113
1186
  }
1114
- assertSafeOAuthEndpoint(registrationUrl);
1187
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1188
+ assertSafeOAuthEndpoint(registrationUrl, {
1189
+ allowLoopback: registrationUrl.origin === trustedOrigin,
1190
+ });
1115
1191
 
1116
- const response = await (fetchFn ?? fetch)(registrationUrl, {
1117
- method: 'POST',
1118
- headers: {
1119
- 'Content-Type': 'application/json',
1192
+ const response = await fetchWithValidatedEndpoint({
1193
+ url: registrationUrl,
1194
+ init: {
1195
+ method: 'POST',
1196
+ headers: {
1197
+ 'Content-Type': 'application/json',
1198
+ },
1199
+ body: JSON.stringify(clientMetadata),
1120
1200
  },
1121
- body: JSON.stringify(clientMetadata),
1122
- redirect: 'error',
1201
+ fetch: fetchFn,
1202
+ trustedOrigin,
1123
1203
  });
1124
1204
 
1125
1205
  if (!response.ok) {
@@ -1140,8 +1220,9 @@ export async function auth(
1140
1220
  fetchFn?: FetchFunction;
1141
1221
  },
1142
1222
  ): Promise<AuthResult> {
1223
+ const refreshAttempt: { tokens?: OAuthTokens } = {};
1143
1224
  try {
1144
- return await authInternal(provider, options);
1225
+ return await authInternal(provider, options, refreshAttempt);
1145
1226
  } catch (error) {
1146
1227
  if (
1147
1228
  error instanceof InvalidClientError ||
@@ -1156,8 +1237,11 @@ export async function auth(
1156
1237
 
1157
1238
  await provider.invalidateCredentials?.('all');
1158
1239
  return await authInternal(provider, options);
1159
- } else if (error instanceof InvalidGrantError) {
1160
- await provider.invalidateCredentials?.('tokens');
1240
+ } else if (error instanceof InvalidGrantError && refreshAttempt.tokens) {
1241
+ // Only invalidate the tokens used by a failed refresh.
1242
+ await provider.invalidateCredentials?.('tokens', {
1243
+ tokens: refreshAttempt.tokens,
1244
+ });
1161
1245
  return await authInternal(provider, options);
1162
1246
  }
1163
1247
 
@@ -1213,6 +1297,7 @@ async function authInternal(
1213
1297
  resourceMetadataUrl?: URL;
1214
1298
  fetchFn?: FetchFunction;
1215
1299
  },
1300
+ refreshAttempt?: { tokens?: OAuthTokens },
1216
1301
  ): Promise<AuthResult> {
1217
1302
  let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
1218
1303
  let authorizationServerUrl: string | URL | undefined;
@@ -1262,6 +1347,24 @@ async function authInternal(
1262
1347
  serverUrl;
1263
1348
  }
1264
1349
 
1350
+ const parsedServerUrl = new URL(serverUrl);
1351
+ const parsedAuthorizationServerUrl = new URL(authorizationServerUrl);
1352
+ const serverOrigin = parsedServerUrl.origin;
1353
+ const authorizationServerOrigin = parsedAuthorizationServerUrl.origin;
1354
+ const trustedAuthorizationServerOrigin =
1355
+ authorizationServerOrigin === serverOrigin ||
1356
+ (isOAuthLoopbackHost(parsedServerUrl.hostname) &&
1357
+ isOAuthLoopbackHost(parsedAuthorizationServerUrl.hostname))
1358
+ ? authorizationServerOrigin
1359
+ : undefined;
1360
+
1361
+ // An authorization server selected by response metadata is untrusted until
1362
+ // its target has passed the SSRF guard. A same-origin server is already the
1363
+ // developer-configured MCP request target.
1364
+ if (!trustedAuthorizationServerOrigin) {
1365
+ assertSafeOAuthEndpoint(new URL(authorizationServerUrl));
1366
+ }
1367
+
1265
1368
  /** Validate and select the resource value sent to the AS */
1266
1369
  const resource: URL | undefined = await selectResourceURL(
1267
1370
  serverUrl,
@@ -1280,6 +1383,7 @@ async function authInternal(
1280
1383
  authorizationServerUrl,
1281
1384
  {
1282
1385
  fetchFn,
1386
+ trustedOrigin: trustedAuthorizationServerOrigin,
1283
1387
  },
1284
1388
  );
1285
1389
  const currentAuthorizationServerInformation =
@@ -1390,11 +1494,16 @@ async function authInternal(
1390
1494
  currentAuthorizationServerInformation,
1391
1495
  });
1392
1496
  } else {
1393
- await provider.invalidateCredentials?.('tokens');
1497
+ await provider.invalidateCredentials?.('tokens', {
1498
+ tokens: { ...tokens },
1499
+ });
1394
1500
  }
1395
1501
 
1396
1502
  try {
1397
1503
  if (storedAuthorizationServerInformation) {
1504
+ if (refreshAttempt) {
1505
+ refreshAttempt.tokens = { ...tokens };
1506
+ }
1398
1507
  // Attempt to refresh the token
1399
1508
  const newTokens = await refreshAuthorization(authorizationServerUrl, {
1400
1509
  metadata,