@ai-sdk/mcp 2.0.47 → 2.0.49

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": "2.0.47",
3
+ "version": "2.0.49",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -31,8 +31,8 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@ai-sdk/provider": "4.0.12",
35
- "@ai-sdk/provider-utils": "5.0.38",
34
+ "@ai-sdk/provider": "4.0.14",
35
+ "@ai-sdk/provider-utils": "5.0.40",
36
36
  "cross-spawn": "^7.0.6",
37
37
  "pkce-challenge": "^5.0.1"
38
38
  },
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,
@@ -140,16 +142,20 @@ function isOAuthLoopbackHost(hostname: string): boolean {
140
142
  }
141
143
 
142
144
  /**
143
- * Guards metadata-derived token/registration URLs before credentials are sent.
144
- * Loopback is allowed for local OAuth; every other target uses the shared
145
- * download URL guard (http(s) only, no private/link-local IPs).
145
+ * Guards metadata-derived OAuth URLs before they are requested. Loopback is
146
+ * allowed only when the caller has established that it belongs to a locally
147
+ * configured OAuth server; every other target uses the shared URL guard.
146
148
  *
147
- * Credential POSTs use `redirect: 'error'` instead of
148
- * `fetchWithValidatedRedirects`, which is GET-only and would follow hops with
149
- * the authorization code, PKCE verifier, and client secret still attached.
149
+ * Credential POSTs use `fetchWithValidatedEndpoint`, which enforces
150
+ * `redirect: 'error'` so the authorization code, PKCE verifier, and client
151
+ * secret are never replayed to a redirect target.
150
152
  */
151
- function assertSafeOAuthEndpoint(endpointUrl: URL): void {
153
+ function assertSafeOAuthEndpoint(
154
+ endpointUrl: URL,
155
+ { allowLoopback = false }: { allowLoopback?: boolean } = {},
156
+ ): void {
152
157
  if (
158
+ allowLoopback &&
153
159
  (endpointUrl.protocol === 'http:' || endpointUrl.protocol === 'https:') &&
154
160
  isOAuthLoopbackHost(endpointUrl.hostname)
155
161
  ) {
@@ -166,6 +172,13 @@ function assertSafeOAuthEndpoint(endpointUrl: URL): void {
166
172
  }
167
173
  }
168
174
 
175
+ function getTrustedLoopbackOrigin(
176
+ authorizationServerUrl: string | URL,
177
+ ): string | undefined {
178
+ const url = new URL(authorizationServerUrl);
179
+ return isOAuthLoopbackHost(url.hostname) ? url.origin : undefined;
180
+ }
181
+
169
182
  function validateAuthorizationResponseIssuer({
170
183
  callbackIssuer,
171
184
  expectedIssuer,
@@ -423,13 +436,31 @@ async function fetchWithCorsRetry(
423
436
  url: URL,
424
437
  headers?: Record<string, string>,
425
438
  fetchFn: FetchFunction = fetch,
439
+ trustedOrigin?: string,
426
440
  ): Promise<Response | undefined> {
427
441
  try {
428
- return await fetchFn(url, { headers });
442
+ return await fetchWithValidatedRedirects({
443
+ url: url.href,
444
+ fetch: async (input, init) =>
445
+ fetchWithValidatedEndpoint({
446
+ url: input as string | URL,
447
+ init: {
448
+ ...init,
449
+ headers: new Headers(init?.headers).has('MCP-Protocol-Version')
450
+ ? headers
451
+ : undefined,
452
+ },
453
+ fetch: fetchFn,
454
+ trustedOrigin,
455
+ redirect: 'manual',
456
+ }),
457
+ headers,
458
+ trustedOrigin,
459
+ });
429
460
  } catch (error) {
430
461
  if (error instanceof TypeError) {
431
462
  if (headers) {
432
- return fetchWithCorsRetry(url, undefined, fetchFn);
463
+ return fetchWithCorsRetry(url, undefined, fetchFn, trustedOrigin);
433
464
  } else {
434
465
  return undefined;
435
466
  }
@@ -445,11 +476,12 @@ async function tryMetadataDiscovery(
445
476
  url: URL,
446
477
  protocolVersion: string,
447
478
  fetchFn: FetchFunction = fetch,
479
+ trustedOrigin?: string,
448
480
  ): Promise<Response | undefined> {
449
481
  const headers = {
450
482
  'MCP-Protocol-Version': protocolVersion,
451
483
  };
452
- return await fetchWithCorsRetry(url, headers, fetchFn);
484
+ return await fetchWithCorsRetry(url, headers, fetchFn, trustedOrigin);
453
485
  }
454
486
 
455
487
  /**
@@ -476,6 +508,7 @@ async function discoverMetadataWithFallback(
476
508
  protocolVersion?: string;
477
509
  metadataUrl?: string | URL;
478
510
  metadataServerUrl?: string | URL;
511
+ trustedOrigin?: string;
479
512
  },
480
513
  ): Promise<Response | undefined> {
481
514
  const issuer = new URL(serverUrl);
@@ -490,11 +523,21 @@ async function discoverMetadataWithFallback(
490
523
  url.search = issuer.search;
491
524
  }
492
525
 
493
- let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
526
+ let response = await tryMetadataDiscovery(
527
+ url,
528
+ protocolVersion,
529
+ fetchFn,
530
+ opts?.trustedOrigin,
531
+ );
494
532
 
495
533
  if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
496
534
  const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
497
- response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn);
535
+ response = await tryMetadataDiscovery(
536
+ rootUrl,
537
+ protocolVersion,
538
+ fetchFn,
539
+ opts?.trustedOrigin,
540
+ );
498
541
  }
499
542
 
500
543
  return response;
@@ -512,6 +555,9 @@ export async function discoverOAuthProtectedResourceMetadata(
512
555
  {
513
556
  protocolVersion: opts?.protocolVersion,
514
557
  metadataUrl: opts?.resourceMetadataUrl,
558
+ // The configured MCP server is trusted as a request target. Redirects
559
+ // crossing its origin are still validated before they are followed.
560
+ trustedOrigin: new URL(serverUrl).origin,
515
561
  },
516
562
  );
517
563
 
@@ -624,9 +670,11 @@ export async function discoverAuthorizationServerMetadata(
624
670
  {
625
671
  fetchFn = fetch,
626
672
  protocolVersion = LATEST_PROTOCOL_VERSION,
673
+ trustedOrigin,
627
674
  }: {
628
675
  fetchFn?: FetchFunction;
629
676
  protocolVersion?: string;
677
+ trustedOrigin?: string;
630
678
  } = {},
631
679
  ): Promise<AuthorizationServerMetadata | undefined> {
632
680
  const headers = { 'MCP-Protocol-Version': protocolVersion };
@@ -634,7 +682,12 @@ export async function discoverAuthorizationServerMetadata(
634
682
  const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
635
683
 
636
684
  for (const { url: endpointUrl, type, expectedIssuer } of urlsToTry) {
637
- const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn);
685
+ const response = await fetchWithCorsRetry(
686
+ endpointUrl,
687
+ headers,
688
+ fetchFn,
689
+ trustedOrigin,
690
+ );
638
691
 
639
692
  if (!response) {
640
693
  /**
@@ -944,7 +997,10 @@ export async function exchangeAuthorization(
944
997
  const tokenUrl = metadata?.token_endpoint
945
998
  ? new URL(metadata.token_endpoint)
946
999
  : new URL('/token', authorizationServerUrl);
947
- assertSafeOAuthEndpoint(tokenUrl);
1000
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1001
+ assertSafeOAuthEndpoint(tokenUrl, {
1002
+ allowLoopback: tokenUrl.origin === trustedOrigin,
1003
+ });
948
1004
 
949
1005
  if (
950
1006
  metadata?.grant_types_supported &&
@@ -988,11 +1044,15 @@ export async function exchangeAuthorization(
988
1044
  params.set('resource', resourceUrlStripSlash(resource));
989
1045
  }
990
1046
 
991
- const response = await (fetchFn ?? fetch)(tokenUrl, {
992
- method: 'POST',
993
- headers,
994
- body: params,
995
- redirect: 'error',
1047
+ const response = await fetchWithValidatedEndpoint({
1048
+ url: tokenUrl,
1049
+ init: {
1050
+ method: 'POST',
1051
+ headers,
1052
+ body: params,
1053
+ },
1054
+ fetch: fetchFn,
1055
+ trustedOrigin,
996
1056
  });
997
1057
 
998
1058
  if (!response.ok) {
@@ -1049,7 +1109,10 @@ export async function refreshAuthorization(
1049
1109
  } else {
1050
1110
  tokenUrl = new URL('/token', authorizationServerUrl);
1051
1111
  }
1052
- assertSafeOAuthEndpoint(tokenUrl);
1112
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1113
+ assertSafeOAuthEndpoint(tokenUrl, {
1114
+ allowLoopback: tokenUrl.origin === trustedOrigin,
1115
+ });
1053
1116
 
1054
1117
  const headers = new Headers({
1055
1118
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -1082,11 +1145,15 @@ export async function refreshAuthorization(
1082
1145
  params.set('resource', resourceUrlStripSlash(resource));
1083
1146
  }
1084
1147
 
1085
- const response = await (fetchFn ?? fetch)(tokenUrl, {
1086
- method: 'POST',
1087
- headers,
1088
- body: params,
1089
- redirect: 'error',
1148
+ const response = await fetchWithValidatedEndpoint({
1149
+ url: tokenUrl,
1150
+ init: {
1151
+ method: 'POST',
1152
+ headers,
1153
+ body: params,
1154
+ },
1155
+ fetch: fetchFn,
1156
+ trustedOrigin,
1090
1157
  });
1091
1158
  if (!response.ok) {
1092
1159
  throw await parseErrorResponse(response);
@@ -1126,21 +1193,28 @@ export async function registerClient(
1126
1193
  } else {
1127
1194
  registrationUrl = new URL('/register', authorizationServerUrl);
1128
1195
  }
1129
- assertSafeOAuthEndpoint(registrationUrl);
1196
+ const trustedOrigin = getTrustedLoopbackOrigin(authorizationServerUrl);
1197
+ assertSafeOAuthEndpoint(registrationUrl, {
1198
+ allowLoopback: registrationUrl.origin === trustedOrigin,
1199
+ });
1130
1200
 
1131
1201
  const applicationType =
1132
1202
  clientMetadata.application_type ??
1133
1203
  inferOAuthApplicationType(clientMetadata.redirect_uris);
1134
- const response = await (fetchFn ?? fetch)(registrationUrl, {
1135
- method: 'POST',
1136
- headers: {
1137
- 'Content-Type': 'application/json',
1204
+ const response = await fetchWithValidatedEndpoint({
1205
+ url: registrationUrl,
1206
+ init: {
1207
+ method: 'POST',
1208
+ headers: {
1209
+ 'Content-Type': 'application/json',
1210
+ },
1211
+ body: JSON.stringify({
1212
+ ...clientMetadata,
1213
+ application_type: applicationType,
1214
+ }),
1138
1215
  },
1139
- body: JSON.stringify({
1140
- ...clientMetadata,
1141
- application_type: applicationType,
1142
- }),
1143
- redirect: 'error',
1216
+ fetch: fetchFn,
1217
+ trustedOrigin,
1144
1218
  });
1145
1219
 
1146
1220
  if (!response.ok) {
@@ -1273,6 +1347,24 @@ async function authInternal(
1273
1347
  authorizationServerUrl = serverUrl;
1274
1348
  }
1275
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
+
1276
1368
  /** Validate and select the resource value sent to the AS */
1277
1369
  const resource: URL | undefined = await selectResourceURL(
1278
1370
  serverUrl,
@@ -1291,6 +1383,7 @@ async function authInternal(
1291
1383
  authorizationServerUrl,
1292
1384
  {
1293
1385
  fetchFn,
1386
+ trustedOrigin: trustedAuthorizationServerOrigin,
1294
1387
  },
1295
1388
  );
1296
1389
  const currentAuthorizationServerInformation =