@ai-sdk/mcp 1.0.89 → 1.0.90

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.90",
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,
@@ -149,16 +151,20 @@ function isOAuthLoopbackHost(hostname: string): boolean {
149
151
  }
150
152
 
151
153
  /**
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).
154
+ * Guards metadata-derived OAuth URLs before they are requested. Loopback is
155
+ * allowed only when the caller has established that it belongs to a locally
156
+ * configured OAuth server; every other target uses the shared URL guard.
155
157
  *
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.
158
+ * Credential POSTs use `fetchWithValidatedEndpoint`, which enforces
159
+ * `redirect: 'error'` so the authorization code, PKCE verifier, and client
160
+ * secret are never replayed to a redirect target.
159
161
  */
160
- function assertSafeOAuthEndpoint(endpointUrl: URL): void {
162
+ function assertSafeOAuthEndpoint(
163
+ endpointUrl: URL,
164
+ { allowLoopback = false }: { allowLoopback?: boolean } = {},
165
+ ): void {
161
166
  if (
167
+ allowLoopback &&
162
168
  (endpointUrl.protocol === 'http:' || endpointUrl.protocol === 'https:') &&
163
169
  isOAuthLoopbackHost(endpointUrl.hostname)
164
170
  ) {
@@ -175,6 +181,13 @@ function assertSafeOAuthEndpoint(endpointUrl: URL): void {
175
181
  }
176
182
  }
177
183
 
184
+ function getTrustedLoopbackOrigin(
185
+ authorizationServerUrl: string | URL,
186
+ ): string | undefined {
187
+ const url = new URL(authorizationServerUrl);
188
+ return isOAuthLoopbackHost(url.hostname) ? url.origin : undefined;
189
+ }
190
+
178
191
  function createAuthorizationServerInformation(
179
192
  authorizationServerUrl: string | URL,
180
193
  metadata?: AuthorizationServerMetadata,
@@ -408,13 +421,31 @@ async function fetchWithCorsRetry(
408
421
  url: URL,
409
422
  headers?: Record<string, string>,
410
423
  fetchFn: FetchFunction = fetch,
424
+ trustedOrigin?: string,
411
425
  ): Promise<Response | undefined> {
412
426
  try {
413
- return await fetchFn(url, { headers });
427
+ return await fetchWithValidatedRedirects({
428
+ url: url.href,
429
+ fetch: async (input, init) =>
430
+ fetchWithValidatedEndpoint({
431
+ url: input as string | URL,
432
+ init: {
433
+ ...init,
434
+ headers: new Headers(init?.headers).has('MCP-Protocol-Version')
435
+ ? headers
436
+ : undefined,
437
+ },
438
+ fetch: fetchFn,
439
+ trustedOrigin,
440
+ redirect: 'manual',
441
+ }),
442
+ headers,
443
+ trustedOrigin,
444
+ });
414
445
  } catch (error) {
415
446
  if (error instanceof TypeError) {
416
447
  if (headers) {
417
- return fetchWithCorsRetry(url, undefined, fetchFn);
448
+ return fetchWithCorsRetry(url, undefined, fetchFn, trustedOrigin);
418
449
  } else {
419
450
  return undefined;
420
451
  }
@@ -430,11 +461,12 @@ async function tryMetadataDiscovery(
430
461
  url: URL,
431
462
  protocolVersion: string,
432
463
  fetchFn: FetchFunction = fetch,
464
+ trustedOrigin?: string,
433
465
  ): Promise<Response | undefined> {
434
466
  const headers = {
435
467
  'MCP-Protocol-Version': protocolVersion,
436
468
  };
437
- return await fetchWithCorsRetry(url, headers, fetchFn);
469
+ return await fetchWithCorsRetry(url, headers, fetchFn, trustedOrigin);
438
470
  }
439
471
 
440
472
  /**
@@ -461,6 +493,7 @@ async function discoverMetadataWithFallback(
461
493
  protocolVersion?: string;
462
494
  metadataUrl?: string | URL;
463
495
  metadataServerUrl?: string | URL;
496
+ trustedOrigin?: string;
464
497
  },
465
498
  ): Promise<Response | undefined> {
466
499
  const issuer = new URL(serverUrl);
@@ -475,11 +508,21 @@ async function discoverMetadataWithFallback(
475
508
  url.search = issuer.search;
476
509
  }
477
510
 
478
- let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
511
+ let response = await tryMetadataDiscovery(
512
+ url,
513
+ protocolVersion,
514
+ fetchFn,
515
+ opts?.trustedOrigin,
516
+ );
479
517
 
480
518
  if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
481
519
  const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
482
- response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
520
+ response = await tryMetadataDiscovery(
521
+ rootUrl,
522
+ protocolVersion,
523
+ fetchFn,
524
+ opts?.trustedOrigin,
525
+ );
483
526
  }
484
527
 
485
528
  return response;
@@ -497,6 +540,9 @@ export async function discoverOAuthProtectedResourceMetadata(
497
540
  {
498
541
  protocolVersion: opts?.protocolVersion,
499
542
  metadataUrl: opts?.resourceMetadataUrl,
543
+ // The configured MCP server is trusted as a request target. Redirects
544
+ // crossing its origin are still validated before they are followed.
545
+ trustedOrigin: new URL(serverUrl).origin,
500
546
  },
501
547
  );
502
548
 
@@ -609,9 +655,11 @@ export async function discoverAuthorizationServerMetadata(
609
655
  {
610
656
  fetchFn = fetch,
611
657
  protocolVersion = LATEST_PROTOCOL_VERSION,
658
+ trustedOrigin,
612
659
  }: {
613
660
  fetchFn?: FetchFunction;
614
661
  protocolVersion?: string;
662
+ trustedOrigin?: string;
615
663
  } = {},
616
664
  ): Promise<AuthorizationServerMetadata | undefined> {
617
665
  const headers = { 'MCP-Protocol-Version': protocolVersion };
@@ -619,7 +667,12 @@ export async function discoverAuthorizationServerMetadata(
619
667
  const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
620
668
 
621
669
  for (const { url: endpointUrl, type, expectedIssuer } of urlsToTry) {
622
- const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn);
670
+ const response = await fetchWithCorsRetry(
671
+ endpointUrl,
672
+ headers,
673
+ fetchFn,
674
+ trustedOrigin,
675
+ );
623
676
 
624
677
  if (!response) {
625
678
  /**
@@ -929,7 +982,10 @@ export async function exchangeAuthorization(
929
982
  const tokenUrl = metadata?.token_endpoint
930
983
  ? new URL(metadata.token_endpoint)
931
984
  : new URL('/token', authorizationServerUrl);
932
- assertSafeOAuthEndpoint(tokenUrl);
985
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
986
+ assertSafeOAuthEndpoint(tokenUrl, {
987
+ allowLoopback: tokenUrl.origin === trustedOrigin,
988
+ });
933
989
 
934
990
  if (
935
991
  metadata?.grant_types_supported &&
@@ -973,11 +1029,15 @@ export async function exchangeAuthorization(
973
1029
  params.set('resource', resourceUrlStripSlash(resource));
974
1030
  }
975
1031
 
976
- const response = await (fetchFn ?? fetch)(tokenUrl, {
977
- method: 'POST',
978
- headers,
979
- body: params,
980
- redirect: 'error',
1032
+ const response = await fetchWithValidatedEndpoint({
1033
+ url: tokenUrl,
1034
+ init: {
1035
+ method: 'POST',
1036
+ headers,
1037
+ body: params,
1038
+ },
1039
+ fetch: fetchFn,
1040
+ trustedOrigin,
981
1041
  });
982
1042
 
983
1043
  if (!response.ok) {
@@ -1034,7 +1094,10 @@ export async function refreshAuthorization(
1034
1094
  } else {
1035
1095
  tokenUrl = new URL('/token', authorizationServerUrl);
1036
1096
  }
1037
- assertSafeOAuthEndpoint(tokenUrl);
1097
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1098
+ assertSafeOAuthEndpoint(tokenUrl, {
1099
+ allowLoopback: tokenUrl.origin === trustedOrigin,
1100
+ });
1038
1101
 
1039
1102
  const headers = new Headers({
1040
1103
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1067,11 +1130,15 @@ export async function refreshAuthorization(
1067
1130
  params.set('resource', resourceUrlStripSlash(resource));
1068
1131
  }
1069
1132
 
1070
- const response = await (fetchFn ?? fetch)(tokenUrl, {
1071
- method: 'POST',
1072
- headers,
1073
- body: params,
1074
- redirect: 'error',
1133
+ const response = await fetchWithValidatedEndpoint({
1134
+ url: tokenUrl,
1135
+ init: {
1136
+ method: 'POST',
1137
+ headers,
1138
+ body: params,
1139
+ },
1140
+ fetch: fetchFn,
1141
+ trustedOrigin,
1075
1142
  });
1076
1143
  if (!response.ok) {
1077
1144
  throw await parseErrorResponse(response);
@@ -1111,15 +1178,22 @@ export async function registerClient(
1111
1178
  } else {
1112
1179
  registrationUrl = new URL('/register', authorizationServerUrl);
1113
1180
  }
1114
- assertSafeOAuthEndpoint(registrationUrl);
1181
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1182
+ assertSafeOAuthEndpoint(registrationUrl, {
1183
+ allowLoopback: registrationUrl.origin === trustedOrigin,
1184
+ });
1115
1185
 
1116
- const response = await (fetchFn ?? fetch)(registrationUrl, {
1117
- method: 'POST',
1118
- headers: {
1119
- 'Content-Type': 'application/json',
1186
+ const response = await fetchWithValidatedEndpoint({
1187
+ url: registrationUrl,
1188
+ init: {
1189
+ method: 'POST',
1190
+ headers: {
1191
+ 'Content-Type': 'application/json',
1192
+ },
1193
+ body: JSON.stringify(clientMetadata),
1120
1194
  },
1121
- body: JSON.stringify(clientMetadata),
1122
- redirect: 'error',
1195
+ fetch: fetchFn,
1196
+ trustedOrigin,
1123
1197
  });
1124
1198
 
1125
1199
  if (!response.ok) {
@@ -1262,6 +1336,24 @@ async function authInternal(
1262
1336
  serverUrl;
1263
1337
  }
1264
1338
 
1339
+ const parsedServerUrl = new URL(serverUrl);
1340
+ const parsedAuthorizationServerUrl = new URL(authorizationServerUrl);
1341
+ const serverOrigin = parsedServerUrl.origin;
1342
+ const authorizationServerOrigin = parsedAuthorizationServerUrl.origin;
1343
+ const trustedAuthorizationServerOrigin =
1344
+ authorizationServerOrigin === serverOrigin ||
1345
+ (isOAuthLoopbackHost(parsedServerUrl.hostname) &&
1346
+ isOAuthLoopbackHost(parsedAuthorizationServerUrl.hostname))
1347
+ ? authorizationServerOrigin
1348
+ : undefined;
1349
+
1350
+ // An authorization server selected by response metadata is untrusted until
1351
+ // its target has passed the SSRF guard. A same-origin server is already the
1352
+ // developer-configured MCP request target.
1353
+ if (!trustedAuthorizationServerOrigin) {
1354
+ assertSafeOAuthEndpoint(new URL(authorizationServerUrl));
1355
+ }
1356
+
1265
1357
  /** Validate and select the resource value sent to the AS */
1266
1358
  const resource: URL | undefined = await selectResourceURL(
1267
1359
  serverUrl,
@@ -1280,6 +1372,7 @@ async function authInternal(
1280
1372
  authorizationServerUrl,
1281
1373
  {
1282
1374
  fetchFn,
1375
+ trustedOrigin: trustedAuthorizationServerOrigin,
1283
1376
  },
1284
1377
  );
1285
1378
  const currentAuthorizationServerInformation =