@mastra/connect 1.1.0-alpha.3 → 1.1.0-alpha.5

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/dist/index.cjs CHANGED
@@ -4,6 +4,7 @@ let zod = require("zod");
4
4
  let _mastra_core_observability = require("@mastra/core/observability");
5
5
  let _mastra_core_tools = require("@mastra/core/tools");
6
6
  let crypto = require("crypto");
7
+ let _mastra_core_request_context = require("@mastra/core/request-context");
7
8
  //#region src/errors.ts
8
9
  const MAX_DETAIL_LENGTH = 2e3;
9
10
  var MastraConnectError = class extends Error {
@@ -236,6 +237,20 @@ function stripTrailingSlashes(url) {
236
237
  while (end > 0 && url[end - 1] === "/") end--;
237
238
  return url.slice(0, end);
238
239
  }
240
+ /** Header asserting the end-user resource key on platform data-path calls. */
241
+ const RESOURCE_ID_HEADER = "x-mastra-resource-id";
242
+ /**
243
+ * Validates an end-user resource key against the platform contract: an opaque
244
+ * string of 1–256 characters after trimming. Returns the trimmed key.
245
+ * Mirrors the platform's `resourceIdSchema` so an invalid key fails here —
246
+ * before any platform call — instead of as an opaque 400.
247
+ */
248
+ function validateResourceId(resourceId) {
249
+ if (typeof resourceId !== "string") throw new MastraConnectError("invalid_options", `Invalid resourceId: expected a string, got ${typeof resourceId}.`);
250
+ const trimmed = resourceId.trim();
251
+ if (trimmed.length < 1 || trimmed.length > 256) throw new MastraConnectError("invalid_options", `Invalid resourceId: expected 1-256 characters after trimming, got ${trimmed.length}.`);
252
+ return trimmed;
253
+ }
239
254
  function resolveClient(options) {
240
255
  const accessToken = options?.accessToken?.trim() || process.env.MASTRA_PLATFORM_ACCESS_TOKEN?.trim() || process.env.MASTRA_PLATFORM_SECRET_KEY?.trim();
241
256
  if (!accessToken) throw new MastraConnectError("missing_access_token", "Missing Mastra platform access token: set MASTRA_PLATFORM_ACCESS_TOKEN (or MASTRA_PLATFORM_SECRET_KEY), or pass client.accessToken.");
@@ -248,11 +263,14 @@ function resolveClient(options) {
248
263
  if (orgId) headers["x-organization-id"] = orgId;
249
264
  const projectId = options?.projectId?.trim() || process.env.MASTRA_PROJECT_ID?.trim();
250
265
  if (projectId) headers["x-mastra-project-id"] = projectId;
266
+ const resourceId = options?.resourceId !== void 0 ? validateResourceId(options.resourceId) : void 0;
267
+ if (resourceId !== void 0) headers[RESOURCE_ID_HEADER] = resourceId;
251
268
  return {
252
269
  baseUrl,
253
270
  headers,
254
271
  fetch: options?.fetch ?? globalThis.fetch,
255
- accessToken
272
+ accessToken,
273
+ resourceId
256
274
  };
257
275
  }
258
276
  function redact(message, accessToken) {
@@ -344,12 +362,31 @@ const connectionSchema = zod.z.object({
344
362
  connectedByUserId: zod.z.string().nullish(),
345
363
  connectedAt: zod.z.string().nullish(),
346
364
  createdAt: zod.z.string().nullish(),
347
- accountLabel: zod.z.string().nullish()
365
+ accountLabel: zod.z.string().nullish(),
366
+ /** End-user resource key the connection is scoped to; null/absent = global. */
367
+ resourceId: zod.z.string().nullish()
348
368
  });
349
369
  const connectionListSchema = zod.z.object({ connections: zod.z.array(connectionSchema) });
370
+ /**
371
+ * One credential-form field from the platform catalog, as the hosted connect
372
+ * surface needs it. Lenient subset of the platform's `VendorAuthField`.
373
+ */
374
+ const catalogAuthFieldSchema = zod.z.object({
375
+ name: zod.z.string(),
376
+ label: zod.z.string().nullish(),
377
+ description: zod.z.string().nullish(),
378
+ placeholder: zod.z.string().nullish(),
379
+ secret: zod.z.boolean().nullish(),
380
+ required: zod.z.boolean().nullish(),
381
+ order: zod.z.number().nullish()
382
+ });
350
383
  const integrationCatalogEntrySchema = zod.z.object({
351
384
  id: zod.z.string(),
352
- capabilities: zod.z.object({ mcp: zod.z.boolean().optional() })
385
+ capabilities: zod.z.object({ mcp: zod.z.boolean().optional() }),
386
+ displayName: zod.z.string().nullish(),
387
+ logoUrl: zod.z.string().nullish(),
388
+ authType: zod.z.string().nullish(),
389
+ authFields: zod.z.array(catalogAuthFieldSchema).nullish()
353
390
  });
354
391
  const integrationCatalogResponseSchema = zod.z.object({ integrations: zod.z.array(integrationCatalogEntrySchema) });
355
392
  /**
@@ -386,7 +423,11 @@ const connectionContextSchema = zod.z.object({
386
423
  metadata: zod.z.record(zod.z.string(), zod.z.unknown()).nullable()
387
424
  });
388
425
  async function listProjectConnections(client, projectId) {
389
- const response = await platformFetch(client, `/v2/projects/${encodeURIComponent(projectId)}/connections`);
426
+ const query = client.resourceId !== void 0 ? `?${new URLSearchParams({
427
+ resourceId: client.resourceId,
428
+ includeGlobal: "true"
429
+ })}` : "";
430
+ const response = await platformFetch(client, `/v2/projects/${encodeURIComponent(projectId)}/connections${query}`);
390
431
  if (!response.ok) await throwPlatformError(response, `listing connections for project ${projectId}`);
391
432
  const parsed = connectionListSchema.safeParse(await parsePlatformJson(response, `listing connections for project ${projectId}`));
392
433
  if (!parsed.success) throw new MastraConnectError("platform_error", `Platform returned an unexpected connection list shape for project ${projectId}.`);
@@ -540,6 +581,34 @@ async function proxyRequestWithResponse(client, connectionId, options) {
540
581
  };
541
582
  }
542
583
  }
584
+ const connectSessionSchema = zod.z.object({
585
+ connectionId: zod.z.string(),
586
+ integrationId: zod.z.string(),
587
+ connectUrl: zod.z.string(),
588
+ sessionToken: zod.z.string(),
589
+ expiresAt: zod.z.string()
590
+ });
591
+ /**
592
+ * Creates a project-scoped connect session. A resource-scoped client passes
593
+ * its resource key in the body, so the resulting connection is owned by that
594
+ * end user; an unscoped client creates a global connection.
595
+ */
596
+ async function createProjectConnectSession(client, projectId, integrationId) {
597
+ const response = await platformFetch(client, `/v2/projects/${encodeURIComponent(projectId)}/integrations/${encodeURIComponent(integrationId)}/connect-sessions`, {
598
+ method: "POST",
599
+ headers: { "content-type": "application/json" },
600
+ body: JSON.stringify(client.resourceId !== void 0 ? { resourceId: client.resourceId } : {})
601
+ });
602
+ if (!response.ok) await throwPlatformError(response, `creating a connect session for ${integrationId}`);
603
+ const parsed = connectSessionSchema.safeParse(await parsePlatformJson(response, `creating a connect session for ${integrationId}`));
604
+ if (!parsed.success) throw new MastraConnectError("platform_error", `Platform returned an unexpected connect-session shape for ${integrationId}.`);
605
+ return parsed.data;
606
+ }
607
+ /** Disconnects a connection. The platform tears down the vendor side asynchronously. */
608
+ async function disconnectConnection(client, connectionId) {
609
+ const response = await platformFetch(client, `/v2/connections/${encodeURIComponent(connectionId)}`, { method: "DELETE" });
610
+ if (!response.ok) await throwPlatformError(response, `disconnecting connection ${connectionId}`);
611
+ }
543
612
  //#endregion
544
613
  //#region src/toolset.ts
545
614
  /**
@@ -668,7 +737,8 @@ function toNamedConnections(candidates) {
668
737
  named.push({
669
738
  id: connection.id,
670
739
  name,
671
- accountLabel: connection.accountLabel ?? null
740
+ accountLabel: connection.accountLabel ?? null,
741
+ resourceId: connection.resourceId ?? null
672
742
  });
673
743
  }
674
744
  return named;
@@ -786,8 +856,9 @@ function listConnectionsToolKey(integrationId) {
786
856
  * of the active connections. Deterministic, no I/O — reads a captured snapshot.
787
857
  */
788
858
  function buildListConnectionsTool(integrationId, connections) {
859
+ const listToolKey = listConnectionsToolKey(integrationId);
789
860
  return (0, _mastra_core_tools.createTool)({
790
- id: listConnectionsToolKey(integrationId),
861
+ id: listToolKey,
791
862
  description: `List the available connections for the ${integrationId} provider. Use the returned 'name' values as connection_name on other ${integrationId} tools.`,
792
863
  inputSchema: zod.z.object({}),
793
864
  outputSchema: zod.z.object({ connections: zod.z.array(zod.z.object({
@@ -853,6 +924,7 @@ function buildProxyMultiConnectionTools(input) {
853
924
  */
854
925
  async function buildMcpMultiConnectionTools(input) {
855
926
  const { registration, connections, allowTools, disallowTools, requireApproval, client, mcpClients, resolverId } = input;
927
+ const globalClient = input.globalClient ?? client;
856
928
  const requireApprovalFor = Array.isArray(requireApproval) ? compileToolMatcher(requireApproval) : void 0;
857
929
  const listToolKey = listConnectionsToolKey(registration.integrationId);
858
930
  const innerAllowTools = allowTools?.filter((name) => name !== listToolKey);
@@ -869,7 +941,7 @@ async function buildMcpMultiConnectionTools(input) {
869
941
  const cacheKey = `${registration.integrationId}::${connection.id}`;
870
942
  let entry = mcpClients.get(cacheKey);
871
943
  if (!entry) {
872
- const transport = platformMcpTransport(client, connection.id);
944
+ const transport = platformMcpTransport(connection.resourceId == null ? globalClient : client, connection.id);
873
945
  entry = {
874
946
  integrationId: registration.integrationId,
875
947
  connectionId: connection.id,
@@ -15444,7 +15516,7 @@ function findFreeSlotsTool(proxy) {
15444
15516
  if (totalDuration >= minDurationMs) freeSlots.push({
15445
15517
  start: input.timeMin,
15446
15518
  end: input.timeMax,
15447
- durationMinutes: Math.floor(totalDuration / (60 * 1e3))
15519
+ durationMinutes: Math.floor(totalDuration / 6e4)
15448
15520
  });
15449
15521
  } else {
15450
15522
  const firstBusyPeriod = mergedBusyPeriods[0];
@@ -15455,7 +15527,7 @@ function findFreeSlotsTool(proxy) {
15455
15527
  if (gapDuration >= minDurationMs) freeSlots.push({
15456
15528
  start: input.timeMin,
15457
15529
  end: firstBusyPeriod.start,
15458
- durationMinutes: Math.floor(gapDuration / (60 * 1e3))
15530
+ durationMinutes: Math.floor(gapDuration / 6e4)
15459
15531
  });
15460
15532
  }
15461
15533
  }
@@ -15470,7 +15542,7 @@ function findFreeSlotsTool(proxy) {
15470
15542
  if (gapDuration >= minDurationMs) freeSlots.push({
15471
15543
  start: currentPeriod.end,
15472
15544
  end: nextPeriod.start,
15473
- durationMinutes: Math.floor(gapDuration / (60 * 1e3))
15545
+ durationMinutes: Math.floor(gapDuration / 6e4)
15474
15546
  });
15475
15547
  }
15476
15548
  }
@@ -15482,7 +15554,7 @@ function findFreeSlotsTool(proxy) {
15482
15554
  if (gapDuration >= minDurationMs) freeSlots.push({
15483
15555
  start: lastBusyPeriod.end,
15484
15556
  end: input.timeMax,
15485
- durationMinutes: Math.floor(gapDuration / (60 * 1e3))
15557
+ durationMinutes: Math.floor(gapDuration / 6e4)
15486
15558
  });
15487
15559
  }
15488
15560
  }
@@ -45982,11 +46054,12 @@ function cleanBlock(block) {
45982
46054
  ].includes(key)) continue;
45983
46055
  if (value === null) continue;
45984
46056
  if (Array.isArray(value)) cleanedContent[key] = value;
45985
- else if (isRecord$1(value)) if (value["object"] === "block") {
45986
- const nestedBlock = cleanBlock(value);
45987
- if (nestedBlock !== null) cleanedContent[key] = nestedBlock;
46057
+ else if (isRecord$1(value)) {
46058
+ if (value["object"] === "block") {
46059
+ const nestedBlock = cleanBlock(value);
46060
+ if (nestedBlock !== null) cleanedContent[key] = nestedBlock;
46061
+ } else cleanedContent[key] = value;
45988
46062
  } else cleanedContent[key] = value;
45989
- else cleanedContent[key] = value;
45990
46063
  }
45991
46064
  cleaned[blockType] = cleanedContent;
45992
46065
  }
@@ -68646,9 +68719,11 @@ function updatePriceTool(proxy) {
68646
68719
  ...input.tax_behavior !== void 0 && { tax_behavior: input.tax_behavior }
68647
68720
  };
68648
68721
  const params = new URLSearchParams();
68649
- for (const [key, value] of Object.entries(updateData)) if (value !== void 0 && value !== null) if (typeof value === "object" && !Array.isArray(value)) {
68650
- for (const [nestedKey, nestedValue] of Object.entries(value)) if (nestedValue !== void 0 && nestedValue !== null) params.append(`${key}[${nestedKey}]`, String(nestedValue));
68651
- } else params.append(key, String(value));
68722
+ for (const [key, value] of Object.entries(updateData)) if (value !== void 0 && value !== null) {
68723
+ if (typeof value === "object" && !Array.isArray(value)) {
68724
+ for (const [nestedKey, nestedValue] of Object.entries(value)) if (nestedValue !== void 0 && nestedValue !== null) params.append(`${key}[${nestedKey}]`, String(nestedValue));
68725
+ } else params.append(key, String(value));
68726
+ }
68652
68727
  const proxyConfig = {
68653
68728
  endpoint: `/v1/prices/${encodeURIComponent(input.id)}`,
68654
68729
  data: params.toString(),
@@ -75231,8 +75306,266 @@ function resolveProviderConnection(request, candidates) {
75231
75306
  const DEFAULT_TTL_MS$1 = 3e4;
75232
75307
  /** Minimum wait after a failed platform fetch before another background revalidation. */
75233
75308
  const FAILURE_COOLDOWN_MS$1 = 3e4;
75309
+ /** Maximum resource-scoped snapshots kept per shared state; least recently used evicts first. */
75310
+ const MAX_RESOURCE_SNAPSHOTS = 50;
75311
+ /**
75312
+ * Grace period after a resource snapshot is evicted before its MCP clients
75313
+ * become eligible for disconnect, so an agent call that resolved tools just
75314
+ * before eviction does not lose its transport mid-tool-call.
75315
+ */
75316
+ const EVICTION_LINGER_MS = 6e4;
75317
+ const GLOBAL_SCOPE_KEY = "global";
75234
75318
  let nextResolverId = 0;
75235
75319
  /**
75320
+ * Maximum shared states kept in the registry. Each distinct option set (for
75321
+ * example an inline `fetch` closure or a rotated access token) mints one
75322
+ * state holding snapshots and open MCP transports; without a bound those
75323
+ * accumulate for the life of the process. Least recently acquired evicts
75324
+ * first and is disconnected; a resolver still holding an evicted state keeps
75325
+ * working — it just refetches and reopens transports on its next resolution.
75326
+ */
75327
+ const MAX_SHARED_STATES = 32;
75328
+ const sharedStates = /* @__PURE__ */ new Map();
75329
+ const functionIds = /* @__PURE__ */ new WeakMap();
75330
+ let nextFunctionId = 0;
75331
+ /** Stable per-process id for a function identity (custom fetch), for use in the registry key. */
75332
+ function identityId(fn) {
75333
+ if (!fn) return void 0;
75334
+ let id = functionIds.get(fn);
75335
+ if (id === void 0) {
75336
+ id = ++nextFunctionId;
75337
+ functionIds.set(fn, id);
75338
+ }
75339
+ return id;
75340
+ }
75341
+ /** Deterministic JSON with sorted object keys, so option objects written in any order share a key. */
75342
+ function stableStringify(value) {
75343
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
75344
+ if (value && typeof value === "object") return `{${Object.entries(value).filter(([, entryValue]) => entryValue !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
75345
+ return JSON.stringify(value) ?? "undefined";
75346
+ }
75347
+ function sharedStateKey(projectId, ttlMs, options) {
75348
+ return stableStringify({
75349
+ projectId,
75350
+ ttlMs,
75351
+ providers: options.providers,
75352
+ allowTools: options.allowTools,
75353
+ disallowTools: options.disallowTools,
75354
+ requireApproval: options.requireApproval,
75355
+ client: {
75356
+ baseUrl: options.client?.baseUrl,
75357
+ orgId: options.client?.orgId,
75358
+ accessToken: options.client?.accessToken,
75359
+ fetch: identityId(options.client?.fetch)
75360
+ }
75361
+ });
75362
+ }
75363
+ function acquireSharedState(projectId, ttlMs, options, normalizedProviders) {
75364
+ const key = sharedStateKey(projectId, ttlMs, options);
75365
+ let state = sharedStates.get(key);
75366
+ if (!state) {
75367
+ state = {
75368
+ resolverId: ++nextResolverId,
75369
+ projectId,
75370
+ ttlMs,
75371
+ options,
75372
+ normalizedProviders,
75373
+ mcpClients: /* @__PURE__ */ new Map(),
75374
+ scopes: /* @__PURE__ */ new Map(),
75375
+ evictionHolds: [],
75376
+ lastAcquiredAt: Date.now()
75377
+ };
75378
+ sharedStates.set(key, state);
75379
+ evictSharedStates(key);
75380
+ }
75381
+ state.lastAcquiredAt = Date.now();
75382
+ return state;
75383
+ }
75384
+ /** Evicts and disconnects the least recently acquired states beyond the registry bound. */
75385
+ function evictSharedStates(justAcquiredKey) {
75386
+ let excess = sharedStates.size - MAX_SHARED_STATES;
75387
+ if (excess <= 0) return;
75388
+ const candidates = Array.from(sharedStates.entries()).filter(([key]) => key !== justAcquiredKey).sort((a, b) => a[1].lastAcquiredAt - b[1].lastAcquiredAt);
75389
+ for (const [key, state] of candidates) {
75390
+ if (excess <= 0) break;
75391
+ sharedStates.delete(key);
75392
+ excess -= 1;
75393
+ setTimeout(() => void disconnectState(state).catch(() => {}), EVICTION_LINGER_MS).unref?.();
75394
+ }
75395
+ }
75396
+ function scopeKey(resourceId) {
75397
+ return resourceId === void 0 ? GLOBAL_SCOPE_KEY : `resource:${resourceId}`;
75398
+ }
75399
+ /** Gets or creates the scope for a resource key, touching its LRU clock. */
75400
+ function acquireScope(state, resourceId) {
75401
+ const key = scopeKey(resourceId);
75402
+ let scope = state.scopes.get(key);
75403
+ if (!scope) {
75404
+ scope = {
75405
+ resourceId,
75406
+ client: resolveClient({
75407
+ ...state.options.client,
75408
+ projectId: state.projectId,
75409
+ ...resourceId === void 0 ? {} : { resourceId }
75410
+ }),
75411
+ lastUsedAt: Date.now()
75412
+ };
75413
+ state.scopes.set(key, scope);
75414
+ if (resourceId !== void 0) evictResourceScopes(state);
75415
+ }
75416
+ scope.lastUsedAt = Date.now();
75417
+ return scope;
75418
+ }
75419
+ /**
75420
+ * Bounds the per-resource snapshot map. Evicted scopes' MCP keys get a
75421
+ * linger hold so transports stay alive for calls resolved just before
75422
+ * eviction; a timer re-runs the prune once the hold expires. Scopes with a
75423
+ * fetch in flight are skipped this round.
75424
+ */
75425
+ function evictResourceScopes(state) {
75426
+ const resourceScopes = Array.from(state.scopes.entries()).filter(([key]) => key !== GLOBAL_SCOPE_KEY);
75427
+ let excess = resourceScopes.length - MAX_RESOURCE_SNAPSHOTS;
75428
+ if (excess <= 0) return;
75429
+ resourceScopes.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
75430
+ let scheduledPrune = false;
75431
+ for (const [key, scope] of resourceScopes) {
75432
+ if (excess <= 0) break;
75433
+ if (scope.inflight) continue;
75434
+ state.scopes.delete(key);
75435
+ excess -= 1;
75436
+ if (scope.cache && scope.cache.mcpKeys.size > 0) {
75437
+ state.evictionHolds.push({
75438
+ mcpKeys: scope.cache.mcpKeys,
75439
+ expiresAt: Date.now() + EVICTION_LINGER_MS
75440
+ });
75441
+ if (!scheduledPrune) {
75442
+ scheduledPrune = true;
75443
+ setTimeout(() => void pruneStaleMcpClients(state), 60010).unref?.();
75444
+ }
75445
+ }
75446
+ }
75447
+ }
75448
+ /**
75449
+ * Disconnects MCP clients no longer referenced by any scope's current
75450
+ * snapshot or by an unexpired eviction hold. Runs after every snapshot
75451
+ * refresh (a connection removed on the platform drops its transport, as
75452
+ * before) and when eviction holds expire.
75453
+ */
75454
+ async function pruneStaleMcpClients(state) {
75455
+ if (state.closing) return;
75456
+ const now = Date.now();
75457
+ state.evictionHolds = state.evictionHolds.filter((hold) => hold.expiresAt > now);
75458
+ const active = /* @__PURE__ */ new Set();
75459
+ for (const scope of state.scopes.values()) for (const key of scope.cache?.mcpKeys ?? []) active.add(key);
75460
+ for (const hold of state.evictionHolds) for (const key of hold.mcpKeys) active.add(key);
75461
+ const stale = Array.from(state.mcpClients.entries()).filter(([key]) => !active.has(key));
75462
+ for (const [key] of stale) state.mcpClients.delete(key);
75463
+ await Promise.allSettled(stale.map(([, entry]) => entry.client.disconnect()));
75464
+ }
75465
+ /**
75466
+ * Loads the connection list and the integration catalog for one scope. A
75467
+ * catalog failure only matters when an active connection needs catalog-backed
75468
+ * MCP discovery; otherwise checked-in HTTP providers resolve with an empty
75469
+ * catalog.
75470
+ */
75471
+ async function loadSnapshotInputs(state, client) {
75472
+ const [connectionsResult, catalogResult] = await Promise.allSettled([listProjectConnections(client, state.projectId), listIntegrations(client)]);
75473
+ if (connectionsResult.status === "rejected") throw connectionsResult.reason;
75474
+ const connections = connectionsResult.value;
75475
+ if (catalogResult.status === "fulfilled") return {
75476
+ connections,
75477
+ catalog: catalogResult.value,
75478
+ catalogAvailable: true
75479
+ };
75480
+ const { overrides, only } = state.normalizedProviders;
75481
+ const checkedIn = new Set(PROVIDERS.map((registration) => registration.integrationId));
75482
+ if (connections.some((connection) => connection.status === "active" && !checkedIn.has(connection.integrationId) && !overrides[connection.integrationId]?.disabled && (only === void 0 || only.has(connection.integrationId)))) throw catalogResult.reason;
75483
+ const reason = catalogResult.reason;
75484
+ console.warn(`[@mastra/connect] Platform catalog unavailable (${reason instanceof Error ? reason.message : String(reason)}); resolving checked-in providers only.`);
75485
+ return {
75486
+ connections,
75487
+ catalog: [],
75488
+ catalogAvailable: false
75489
+ };
75490
+ }
75491
+ /**
75492
+ * Fetches a fresh snapshot for one scope, deduplicating concurrent calls per
75493
+ * scope. Rejects on failure. A refresh requested while the state is closing
75494
+ * starts after the close completes (against a freshly acquired scope, since
75495
+ * closing clears the scope map).
75496
+ */
75497
+ function refreshScope(state, scope) {
75498
+ if (state.closing) return state.closing.then(() => refreshScope(state, acquireScope(state, scope.resourceId)));
75499
+ if (!scope.inflight) scope.inflight = (async () => {
75500
+ try {
75501
+ const { connections, catalog, catalogAvailable } = await loadSnapshotInputs(state, scope.client);
75502
+ const requests = buildRequests(state.normalizedProviders, catalog, catalogAvailable);
75503
+ const clientOptions = {
75504
+ ...state.options.client,
75505
+ projectId: state.projectId,
75506
+ ...scope.resourceId === void 0 ? {} : { resourceId: scope.resourceId }
75507
+ };
75508
+ const { record, mcpKeys } = await mapTools(connections, requests, state.options, clientOptions, scope.client, state.mcpClients, state.resolverId);
75509
+ scope.cache = {
75510
+ snapshot: record,
75511
+ fetchedAt: Date.now(),
75512
+ mcpKeys
75513
+ };
75514
+ scope.lastFailureAt = void 0;
75515
+ await pruneStaleMcpClients(state);
75516
+ return record;
75517
+ } catch (error) {
75518
+ scope.lastFailureAt = Date.now();
75519
+ throw error;
75520
+ } finally {
75521
+ scope.inflight = void 0;
75522
+ }
75523
+ })();
75524
+ return scope.inflight;
75525
+ }
75526
+ /** Serves a scope's snapshot: fresh from cache, stale with background revalidation, or a blocking first fetch. */
75527
+ async function resolveScope(state, resourceId) {
75528
+ const scope = acquireScope(state, resourceId);
75529
+ if (scope.cache && Date.now() - scope.cache.fetchedAt < state.ttlMs) return scope.cache.snapshot;
75530
+ if (scope.cache) {
75531
+ const staleSnapshot = scope.cache.snapshot;
75532
+ if (!(scope.lastFailureAt !== void 0 && Date.now() - scope.lastFailureAt < FAILURE_COOLDOWN_MS$1) && !scope.inflight && !state.closing) {
75533
+ const staleFetchedAt = scope.cache.fetchedAt;
75534
+ refreshScope(state, scope).catch((error) => {
75535
+ console.warn(`[@mastra/connect] Keeping cached tools (fetched ${Date.now() - staleFetchedAt}ms ago); platform refresh failed: ${error instanceof Error ? error.message : String(error)}`);
75536
+ });
75537
+ }
75538
+ return staleSnapshot;
75539
+ }
75540
+ return refreshScope(state, scope);
75541
+ }
75542
+ /**
75543
+ * Closes every MCP transport and clears every cached snapshot in the shared
75544
+ * state. Any refresh in progress settles first so it cannot repopulate the
75545
+ * cache or register an MCP client after cleanup; refreshes requested
75546
+ * meanwhile wait for the close and start afterwards. The state stays usable:
75547
+ * the next resolution fetches fresh.
75548
+ */
75549
+ function disconnectState(state) {
75550
+ state.closing ??= (async () => {
75551
+ try {
75552
+ let pending = Array.from(state.scopes.values(), (scope) => scope.inflight).filter(Boolean);
75553
+ while (pending.length > 0) {
75554
+ await Promise.allSettled(pending);
75555
+ pending = Array.from(state.scopes.values(), (scope) => scope.inflight).filter(Boolean);
75556
+ }
75557
+ state.scopes.clear();
75558
+ state.evictionHolds = [];
75559
+ const clients = Array.from(state.mcpClients.values(), (entry) => entry.client);
75560
+ state.mcpClients.clear();
75561
+ await Promise.allSettled(clients.map((mcp) => mcp.disconnect()));
75562
+ } finally {
75563
+ state.closing = void 0;
75564
+ }
75565
+ })();
75566
+ return state.closing;
75567
+ }
75568
+ /**
75236
75569
  * Returns a live toolset resolver over the project's Platform connections.
75237
75570
  * HTTP providers are loaded from the shipped `PROVIDERS` registry. MCP providers
75238
75571
  * are discovered from the Platform integration catalog. Tools from every supported
@@ -75261,13 +75594,14 @@ function tools(options = {}) {
75261
75594
  if (!projectId) throw new MastraConnectError("missing_project_id", "Missing project id: set MASTRA_PROJECT_ID or pass projectId.");
75262
75595
  if (options.ttlMs !== void 0 && (!Number.isFinite(options.ttlMs) || options.ttlMs < 0)) throw new MastraConnectError("invalid_options", `Invalid ttlMs (${options.ttlMs}): expected a finite number of milliseconds >= 0.`);
75263
75596
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS$1;
75264
- const clientOptions = {
75597
+ if (options.client?.resourceId !== void 0) throw new MastraConnectError("invalid_options", "Set resourceId on the tools() options (tools({ resourceId })), not on client.resourceId.");
75598
+ const resourceIdOption = options.resourceId;
75599
+ if (resourceIdOption !== void 0 && typeof resourceIdOption !== "string" && typeof resourceIdOption !== "function") throw new MastraConnectError("invalid_options", `Invalid resourceId: expected a string or a function, got ${typeof resourceIdOption}.`);
75600
+ const staticResourceId = typeof resourceIdOption === "string" ? validateResourceId(resourceIdOption) : void 0;
75601
+ resolveClient({
75265
75602
  ...options.client,
75266
75603
  projectId
75267
- };
75268
- const client = resolveClient(clientOptions);
75269
- const resolverId = ++nextResolverId;
75270
- const mcpClients = /* @__PURE__ */ new Map();
75604
+ });
75271
75605
  const normalizedProviders = normalizeProviderOverrides(options.providers);
75272
75606
  const providerOverrides = normalizedProviders.overrides;
75273
75607
  validateProviderIds(providerOverrides);
@@ -75276,90 +75610,45 @@ function tools(options = {}) {
75276
75610
  rejectRemovedAutoApproveTools(providerOverrides);
75277
75611
  if (options.allowTools !== void 0 && options.disallowTools !== void 0) throw new MastraConnectError("invalid_options", "Top-level allowTools and disallowTools are mutually exclusive; set at most one.");
75278
75612
  if (options.requireApproval !== void 0 && typeof options.requireApproval !== "boolean" && !(Array.isArray(options.requireApproval) && options.requireApproval.every((name) => typeof name === "string"))) throw new MastraConnectError("invalid_options", "Top-level requireApproval must be a boolean or an array of tool keys.");
75279
- let cache;
75280
- let inflight;
75281
- let closing;
75282
- let lastFailureAt;
75283
- /**
75284
- * Loads the connection list and the integration catalog. A catalog failure
75285
- * only matters when an active connection needs catalog-backed MCP discovery;
75286
- * otherwise checked-in HTTP providers resolve with an empty catalog.
75287
- */
75288
- const loadSnapshotInputs = async () => {
75289
- const [connectionsResult, catalogResult] = await Promise.allSettled([listProjectConnections(client, projectId), listIntegrations(client)]);
75290
- if (connectionsResult.status === "rejected") throw connectionsResult.reason;
75291
- const connections = connectionsResult.value;
75292
- if (catalogResult.status === "fulfilled") return {
75293
- connections,
75294
- catalog: catalogResult.value,
75295
- catalogAvailable: true
75296
- };
75297
- const checkedIn = new Set(PROVIDERS.map((registration) => registration.integrationId));
75298
- if (connections.some((connection) => connection.status === "active" && !checkedIn.has(connection.integrationId) && !providerOverrides[connection.integrationId]?.disabled && (normalizedProviders.only === void 0 || normalizedProviders.only.has(connection.integrationId)))) throw catalogResult.reason;
75299
- const reason = catalogResult.reason;
75300
- console.warn(`[@mastra/connect] Platform catalog unavailable (${reason instanceof Error ? reason.message : String(reason)}); resolving checked-in providers only.`);
75301
- return {
75302
- connections,
75303
- catalog: [],
75304
- catalogAvailable: false
75305
- };
75613
+ const state = acquireSharedState(projectId, ttlMs, options, normalizedProviders);
75614
+ const validateDynamicResourceId = (raw) => raw === void 0 || raw === null ? void 0 : validateResourceId(raw);
75615
+ const resolve = (ctx) => {
75616
+ if (typeof resourceIdOption === "function") return Promise.resolve(resourceIdOption(ctx)).then((raw) => resolveScope(state, validateDynamicResourceId(raw)));
75617
+ return resolveScope(state, staticResourceId);
75306
75618
  };
75307
- /**
75308
- * Fetches a fresh snapshot, deduplicating concurrent calls. Rejects on
75309
- * failure. A refresh requested while `disconnect()` runs starts after it.
75310
- */
75311
- const refresh = () => {
75312
- if (closing) return closing.then(refresh);
75313
- if (!inflight) inflight = (async () => {
75314
- try {
75315
- const { connections, catalog, catalogAvailable } = await loadSnapshotInputs();
75316
- const snapshot = await mapTools(connections, buildRequests(normalizedProviders, catalog, catalogAvailable), options, clientOptions, client, mcpClients, resolverId);
75317
- cache = {
75318
- snapshot,
75319
- fetchedAt: Date.now()
75320
- };
75321
- lastFailureAt = void 0;
75322
- return snapshot;
75323
- } catch (error) {
75324
- lastFailureAt = Date.now();
75325
- throw error;
75326
- } finally {
75327
- inflight = void 0;
75328
- }
75329
- })();
75330
- return inflight;
75331
- };
75332
- const resolve = async () => {
75333
- if (cache && Date.now() - cache.fetchedAt < ttlMs) return cache.snapshot;
75334
- if (cache) {
75335
- const staleSnapshot = cache.snapshot;
75336
- if (!(lastFailureAt !== void 0 && Date.now() - lastFailureAt < FAILURE_COOLDOWN_MS$1) && !inflight && !closing) {
75337
- const staleFetchedAt = cache.fetchedAt;
75338
- refresh().catch((error) => {
75339
- console.warn(`[@mastra/connect] Keeping cached tools (fetched ${Date.now() - staleFetchedAt}ms ago); platform refresh failed: ${error instanceof Error ? error.message : String(error)}`);
75340
- });
75341
- }
75342
- return staleSnapshot;
75343
- }
75344
- return refresh();
75345
- };
75346
- const resolver = Object.assign(resolve, {
75347
- refresh,
75348
- disconnect: () => {
75349
- closing ??= (async () => {
75350
- try {
75351
- while (inflight) await inflight.catch(() => void 0);
75352
- cache = void 0;
75353
- const clients = Array.from(mcpClients.values(), (entry) => entry.client);
75354
- mcpClients.clear();
75355
- await Promise.allSettled(clients.map((mcp) => mcp.disconnect()));
75356
- } finally {
75357
- closing = void 0;
75358
- }
75359
- })();
75360
- return closing;
75619
+ const resolver = finalizeResolver(Object.assign(resolve, promiseSurface(resolve), {
75620
+ refresh: () => {
75621
+ if (typeof resourceIdOption === "function") return Promise.resolve(resourceIdOption(void 0)).then((raw) => {
75622
+ const resourceId = validateDynamicResourceId(raw);
75623
+ return refreshScope(state, acquireScope(state, resourceId));
75624
+ });
75625
+ return refreshScope(state, acquireScope(state, staticResourceId));
75361
75626
  },
75627
+ disconnect: () => disconnectState(state),
75362
75628
  with: (extra) => withExtraTools(resolver, extra)
75629
+ }));
75630
+ return resolver;
75631
+ }
75632
+ /**
75633
+ * The Promise surface shared by every resolver shape. Each method resolves
75634
+ * fresh, so `await resolver` always reflects the current snapshot.
75635
+ */
75636
+ function promiseSurface(resolve) {
75637
+ return {
75638
+ then: (onFulfilled, onRejected) => resolve().then(onFulfilled, onRejected),
75639
+ catch: (onRejected) => resolve().catch(onRejected),
75640
+ finally: (onFinally) => resolve().finally(onFinally)
75641
+ };
75642
+ }
75643
+ /**
75644
+ * Adds the `Symbol.toStringTag` the Promise interface requires. Done with
75645
+ * `defineProperty` because async functions carry a non-writable inherited
75646
+ * tag ('AsyncFunction') that a plain assignment would throw on.
75647
+ */
75648
+ function finalizeResolver(resolver) {
75649
+ Object.defineProperty(resolver, Symbol.toStringTag, {
75650
+ value: "ToolsResolver",
75651
+ configurable: true
75363
75652
  });
75364
75653
  return resolver;
75365
75654
  }
@@ -75368,8 +75657,9 @@ function tools(options = {}) {
75368
75657
  * (and MCP clients) for at once. Callbacks are expected to produce a small,
75369
75658
  * closed set of option shapes (one per role, say); the cap exists so a
75370
75659
  * callback that accidentally derives options from unbounded per-request data
75371
- * cannot leak an MCP client per request. Eviction is least-recently-used and
75372
- * disconnects the evicted variant's transports.
75660
+ * cannot retain an unbounded number of resolver wrappers. Eviction is
75661
+ * least-recently-used; the separately bounded shared-state registry owns
75662
+ * transport cleanup so resource-only variants cannot disconnect each other.
75373
75663
  */
75374
75664
  const MAX_OPTION_VARIANTS = 32;
75375
75665
  /**
@@ -75384,15 +75674,6 @@ const MAX_OPTION_VARIANTS = 32;
75384
75674
  function dynamicTools(getOptions) {
75385
75675
  const variants = /* @__PURE__ */ new Map();
75386
75676
  let closing;
75387
- const disconnectEvicted = (entry) => {
75388
- entry.resolver.disconnect().catch((error) => {
75389
- console.warn(`[@mastra/connect] Failed to disconnect evicted tools variant: ${error instanceof Error ? error.message : String(error)}`);
75390
- });
75391
- };
75392
- const releaseVariant = (entry) => {
75393
- entry.inUse -= 1;
75394
- if (entry.evicted && entry.inUse === 0) disconnectEvicted(entry);
75395
- };
75396
75677
  const acquireVariant = async (ctx) => {
75397
75678
  if (closing) await closing;
75398
75679
  const options = await getOptions(ctx);
@@ -75403,40 +75684,23 @@ function dynamicTools(getOptions) {
75403
75684
  if (existing) {
75404
75685
  variants.delete(key);
75405
75686
  variants.set(key, existing);
75406
- existing.inUse += 1;
75407
75687
  return existing;
75408
75688
  }
75409
- const entry = {
75410
- resolver: tools(options),
75411
- inUse: 1,
75412
- evicted: false
75413
- };
75689
+ const entry = { resolver: tools(options) };
75414
75690
  variants.set(key, entry);
75415
75691
  if (variants.size > MAX_OPTION_VARIANTS) {
75416
- const [oldestKey, oldest] = variants.entries().next().value;
75692
+ const [oldestKey] = variants.entries().next().value;
75417
75693
  variants.delete(oldestKey);
75418
75694
  console.warn(`[@mastra/connect] tools() options callback produced more than ${MAX_OPTION_VARIANTS} distinct options values; evicting the least recently used variant. Derive options from a small, closed set of inputs (e.g. roles), not per-request data.`);
75419
- oldest.evicted = true;
75420
- if (oldest.inUse === 0) disconnectEvicted(oldest);
75421
75695
  }
75422
75696
  return entry;
75423
75697
  };
75424
75698
  const resolve = async (ctx) => {
75425
- const entry = await acquireVariant(ctx);
75426
- try {
75427
- return await entry.resolver(ctx);
75428
- } finally {
75429
- releaseVariant(entry);
75430
- }
75699
+ return (await acquireVariant(ctx)).resolver(ctx);
75431
75700
  };
75432
- const resolver = Object.assign(resolve, {
75701
+ const resolver = finalizeResolver(Object.assign(resolve, promiseSurface(resolve), {
75433
75702
  refresh: async () => {
75434
- const entry = await acquireVariant(void 0);
75435
- try {
75436
- return await entry.resolver.refresh();
75437
- } finally {
75438
- releaseVariant(entry);
75439
- }
75703
+ return (await acquireVariant(void 0)).resolver.refresh();
75440
75704
  },
75441
75705
  disconnect: () => {
75442
75706
  closing ??= (async () => {
@@ -75451,7 +75715,7 @@ function dynamicTools(getOptions) {
75451
75715
  return closing;
75452
75716
  },
75453
75717
  with: (extra) => withExtraTools(resolver, extra)
75454
- });
75718
+ }));
75455
75719
  return resolver;
75456
75720
  }
75457
75721
  /** Identity registry for non-serializable option values (e.g. a custom `client.fetch`). */
@@ -75501,7 +75765,7 @@ function withExtraTools(base, extra) {
75501
75765
  ...extraTools
75502
75766
  };
75503
75767
  };
75504
- const resolver = Object.assign(resolve, {
75768
+ const resolver = finalizeResolver(Object.assign(resolve, promiseSurface(resolve), {
75505
75769
  refresh: async () => {
75506
75770
  const [baseTools, extraTools] = await Promise.all([base.refresh(), resolveExtra()]);
75507
75771
  return {
@@ -75511,7 +75775,7 @@ function withExtraTools(base, extra) {
75511
75775
  },
75512
75776
  disconnect: () => base.disconnect(),
75513
75777
  with: (more) => withExtraTools(resolver, more)
75514
- });
75778
+ }));
75515
75779
  return resolver;
75516
75780
  }
75517
75781
  /**
@@ -75617,9 +75881,19 @@ function buildRequests(providers, catalog, catalogAvailable) {
75617
75881
  }
75618
75882
  return requests;
75619
75883
  }
75620
- /** Maps one platform connection list snapshot to a flat tool record without allowing ambiguous tool ownership. */
75884
+ /**
75885
+ * Maps one platform connection list snapshot to a flat tool record without
75886
+ * allowing ambiguous tool ownership. Returns the `${integrationId}::${connectionId}`
75887
+ * keys the snapshot's MCP tools depend on; the caller prunes clients no scope
75888
+ * references anymore.
75889
+ */
75621
75890
  async function mapTools(connections, requests, options, clientOptions, client, mcpClients, resolverId) {
75622
75891
  const byIntegrationId = groupByIntegrationId(connections);
75892
+ const globalClient = clientOptions.resourceId === void 0 ? client : resolveClient({
75893
+ ...clientOptions,
75894
+ resourceId: void 0
75895
+ });
75896
+ const clientFor = (connectionId) => connections.find((connection) => connection.id === connectionId)?.resourceId == null ? globalClient : client;
75623
75897
  const activeMcpKeys = /* @__PURE__ */ new Set();
75624
75898
  const result = {};
75625
75899
  const toolOwners = /* @__PURE__ */ new Map();
@@ -75636,27 +75910,28 @@ async function mapTools(connections, requests, options, clientOptions, client, m
75636
75910
  connectionId: request.options.connectionId
75637
75911
  }, candidates);
75638
75912
  if (resolution.kind === "skip") continue;
75639
- if (resolution.kind === "single") if (request.registration.transport === "mcp") {
75640
- activeMcpKeys.add(`${integrationId}::${resolution.connectionId}`);
75641
- providerTools = await discoverMcpTools({
75642
- registration: request.registration,
75643
- connectionId: resolution.connectionId,
75644
- allowTools: request.options.allowTools,
75645
- disallowTools: request.options.disallowTools,
75646
- requireApproval: request.options.requireApproval,
75647
- client,
75648
- mcpClients,
75649
- resolverId
75650
- });
75651
- } else {
75652
- providerTools = request.registration.createTools({
75653
- connectionId: resolution.connectionId,
75654
- client: clientOptions,
75655
- ...providerFilterOptions(request.options)
75656
- });
75657
- applyProxyRequireApproval(providerTools, integrationId, request.options.requireApproval);
75658
- }
75659
- else if (request.registration.transport === "mcp") {
75913
+ if (resolution.kind === "single") {
75914
+ if (request.registration.transport === "mcp") {
75915
+ activeMcpKeys.add(`${integrationId}::${resolution.connectionId}`);
75916
+ providerTools = await discoverMcpTools({
75917
+ registration: request.registration,
75918
+ connectionId: resolution.connectionId,
75919
+ allowTools: request.options.allowTools,
75920
+ disallowTools: request.options.disallowTools,
75921
+ requireApproval: request.options.requireApproval,
75922
+ client: clientFor(resolution.connectionId),
75923
+ mcpClients,
75924
+ resolverId
75925
+ });
75926
+ } else {
75927
+ providerTools = request.registration.createTools({
75928
+ connectionId: resolution.connectionId,
75929
+ client: clientOptions,
75930
+ ...providerFilterOptions(request.options)
75931
+ });
75932
+ applyProxyRequireApproval(providerTools, integrationId, request.options.requireApproval);
75933
+ }
75934
+ } else if (request.registration.transport === "mcp") {
75660
75935
  for (const connection of resolution.connections) activeMcpKeys.add(`${integrationId}::${connection.id}`);
75661
75936
  providerTools = await buildMcpMultiConnectionTools({
75662
75937
  registration: request.registration,
@@ -75665,6 +75940,7 @@ async function mapTools(connections, requests, options, clientOptions, client, m
75665
75940
  disallowTools: request.options.disallowTools,
75666
75941
  requireApproval: request.options.requireApproval,
75667
75942
  client,
75943
+ globalClient,
75668
75944
  mcpClients,
75669
75945
  resolverId
75670
75946
  });
@@ -75703,10 +75979,10 @@ async function mapTools(connections, requests, options, clientOptions, client, m
75703
75979
  ];
75704
75980
  for (const entry of defaultEntries) if (!matchedDefaultEntries.has(entry)) console.warn(`[@mastra/connect] Top-level entry '${entry}' matched no tools on any resolved provider.`);
75705
75981
  }
75706
- const staleClients = Array.from(mcpClients.entries()).filter(([key]) => !activeMcpKeys.has(key));
75707
- for (const [key] of staleClients) mcpClients.delete(key);
75708
- await Promise.allSettled(staleClients.map(([, entry]) => entry.client.disconnect()));
75709
- return result;
75982
+ return {
75983
+ record: result,
75984
+ mcpKeys: activeMcpKeys
75985
+ };
75710
75986
  }
75711
75987
  /**
75712
75988
  * Applies the top-level default filter to a provider that set no filter of
@@ -76093,9 +76369,501 @@ function selectChannelConnection(integrationId, pinnedId, candidates) {
76093
76369
  /**
76094
76370
  * Fetches the raw credential for a connection, for building custom
76095
76371
  * interactions (own SDKs, MCP servers). Never log the result.
76372
+ *
76373
+ * Pass `resourceId` when the connection is scoped to an end user: the call
76374
+ * then asserts `x-mastra-resource-id`, which the platform requires for
76375
+ * resource-scoped connections (mismatch or omission → not found).
76096
76376
  */
76097
76377
  async function credential(connectionId, options) {
76098
- return getCredential(resolveClient(options?.client), connectionId);
76378
+ return getCredential(resolveClient(options?.resourceId !== void 0 ? {
76379
+ ...options?.client,
76380
+ resourceId: options.resourceId
76381
+ } : options?.client), connectionId);
76382
+ }
76383
+ //#endregion
76384
+ //#region src/user-connect-page.ts
76385
+ function escapeHtml(value) {
76386
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
76387
+ }
76388
+ /**
76389
+ * Serializes data for embedding inside an inline `<script>`: escapes `<` so a
76390
+ * value containing `<\/script>` cannot break out of the tag, and the JS line
76391
+ * separators that are invalid inside string literals.
76392
+ */
76393
+ function toScriptJson(value) {
76394
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
76395
+ }
76396
+ /** Only https URLs are rendered as a logo; anything else is dropped. */
76397
+ function safeLogoUrl(logoUrl) {
76398
+ if (!logoUrl) return null;
76399
+ try {
76400
+ const parsed = new URL(logoUrl);
76401
+ return parsed.protocol === "https:" ? parsed.href : null;
76402
+ } catch {
76403
+ return null;
76404
+ }
76405
+ }
76406
+ function renderConnectPage(params) {
76407
+ const displayName = params.provider.displayName?.trim() || params.provider.id;
76408
+ const logoUrl = safeLogoUrl(params.provider.logoUrl);
76409
+ const data = toScriptJson({
76410
+ basePath: params.basePath,
76411
+ providerId: params.provider.id,
76412
+ displayName,
76413
+ returnTo: params.returnTo
76414
+ });
76415
+ return `<!doctype html>
76416
+ <html lang="en">
76417
+ <head>
76418
+ <meta charset="utf-8" />
76419
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
76420
+ <meta name="robots" content="noindex" />
76421
+ <title>Connect ${escapeHtml(displayName)}</title>
76422
+ <style>
76423
+ :root { color-scheme: light dark; }
76424
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; margin: 0; align-items: center; justify-content: center; background: Canvas; color: CanvasText; }
76425
+ main { text-align: center; padding: 2rem; max-width: 26rem; }
76426
+ img.logo { width: 56px; height: 56px; border-radius: 12px; object-fit: contain; }
76427
+ h1 { font-size: 1.25rem; margin: 1rem 0 0.5rem; }
76428
+ p { color: GrayText; font-size: 0.9rem; line-height: 1.4; }
76429
+ button { font: inherit; padding: 0.6rem 1.6rem; border-radius: 8px; border: 1px solid GrayText; background: ButtonFace; color: ButtonText; cursor: pointer; }
76430
+ button:disabled { opacity: 0.6; cursor: default; }
76431
+ #status { min-height: 1.5rem; margin-top: 1rem; }
76432
+ #status.error { color: #c0392b; }
76433
+ a { color: inherit; }
76434
+ </style>
76435
+ </head>
76436
+ <body>
76437
+ <main>
76438
+ ${logoUrl ? `<img class="logo" src="${escapeHtml(logoUrl)}" alt="" />` : ""}
76439
+ <h1>Connect ${escapeHtml(displayName)}</h1>
76440
+ <p>You'll be sent to ${escapeHtml(displayName)} to authorize access. This window stays open until the connection is ready.</p>
76441
+ <button id="connect" type="button">Connect ${escapeHtml(displayName)}</button>
76442
+ <p id="status" role="status"></p>
76443
+ </main>
76444
+ <script>
76445
+ (function () {
76446
+ var data = ${data};
76447
+ var button = document.getElementById('connect');
76448
+ var status = document.getElementById('status');
76449
+ var POLL_INTERVAL_MS = 2000;
76450
+ var MAX_POLLS = 150; // 5 minutes
76451
+
76452
+ function setStatus(text, isError) {
76453
+ status.textContent = text;
76454
+ status.className = isError ? 'error' : '';
76455
+ }
76456
+
76457
+ function finish(connectionId) {
76458
+ if (data.returnTo) {
76459
+ var sep = data.returnTo.indexOf('?') === -1 ? '?' : '&';
76460
+ window.location.assign(
76461
+ data.returnTo + sep + 'connectionId=' + encodeURIComponent(connectionId) + '&status=active',
76462
+ );
76463
+ return;
76464
+ }
76465
+ setStatus('Connected. You can close this window.', false);
76466
+ }
76467
+
76468
+ function poll(connectionId, popup, attempt) {
76469
+ if (attempt >= MAX_POLLS) {
76470
+ button.disabled = false;
76471
+ setStatus('Timed out waiting for the connection. Try again.', true);
76472
+ return;
76473
+ }
76474
+ fetch(data.basePath + '/connections/' + encodeURIComponent(connectionId) + '/status', {
76475
+ credentials: 'same-origin',
76476
+ headers: { accept: 'application/json' },
76477
+ })
76478
+ .then(function (res) {
76479
+ // A pending connection may briefly be invisible; keep polling on 404.
76480
+ if (res.status === 404) return { status: 'pending' };
76481
+ if (!res.ok) throw new Error('status check failed');
76482
+ return res.json();
76483
+ })
76484
+ .then(function (body) {
76485
+ if (body.status === 'active') {
76486
+ if (popup && !popup.closed) popup.close();
76487
+ finish(connectionId);
76488
+ return;
76489
+ }
76490
+ if (body.status === 'error') {
76491
+ button.disabled = false;
76492
+ setStatus('The connection failed. Try again.', true);
76493
+ return;
76494
+ }
76495
+ setTimeout(function () {
76496
+ poll(connectionId, popup, attempt + 1);
76497
+ }, POLL_INTERVAL_MS);
76498
+ })
76499
+ .catch(function () {
76500
+ setTimeout(function () {
76501
+ poll(connectionId, popup, attempt + 1);
76502
+ }, POLL_INTERVAL_MS);
76503
+ });
76504
+ }
76505
+
76506
+ button.addEventListener('click', function () {
76507
+ button.disabled = true;
76508
+ setStatus('Starting…', false);
76509
+ // Open the popup synchronously inside the click gesture so popup blockers
76510
+ // allow it; it is navigated once the session exists.
76511
+ var popup = window.open('about:blank', 'mastra-connect', 'width=600,height=700');
76512
+ fetch(data.basePath + '/' + encodeURIComponent(data.providerId) + '/sessions', {
76513
+ method: 'POST',
76514
+ credentials: 'same-origin',
76515
+ headers: { accept: 'application/json' },
76516
+ })
76517
+ .then(function (res) {
76518
+ if (!res.ok) throw new Error('session creation failed');
76519
+ return res.json();
76520
+ })
76521
+ .then(function (session) {
76522
+ if (popup && !popup.closed) {
76523
+ popup.location = session.connectUrl;
76524
+ setStatus('Finish authorizing in the popup…', false);
76525
+ } else {
76526
+ // Popup blocked: offer a manual link instead of auto-navigating away.
76527
+ status.textContent = '';
76528
+ var link = document.createElement('a');
76529
+ link.href = session.connectUrl;
76530
+ link.target = '_blank';
76531
+ link.rel = 'noopener';
76532
+ link.textContent = 'Popup blocked — click here to authorize';
76533
+ status.appendChild(link);
76534
+ }
76535
+ poll(session.connectionId, popup, 0);
76536
+ })
76537
+ .catch(function () {
76538
+ if (popup && !popup.closed) popup.close();
76539
+ button.disabled = false;
76540
+ setStatus('Could not start the connection. Try again.', true);
76541
+ });
76542
+ });
76543
+ })();
76544
+ <\/script>
76545
+ </body>
76546
+ </html>`;
76547
+ }
76548
+ //#endregion
76549
+ //#region src/user-connect-routes.ts
76550
+ /** Route paths that can never be used as a provider id (they collide with static segments). */
76551
+ const RESERVED_PROVIDER_IDS = /* @__PURE__ */ new Set([
76552
+ "providers",
76553
+ "connections",
76554
+ "sessions"
76555
+ ]);
76556
+ const NO_STORE = "no-store, private";
76557
+ function errorResponse(c, error) {
76558
+ c.header("cache-control", NO_STORE);
76559
+ return c.json({ error: {
76560
+ code: error.code,
76561
+ message: error.message
76562
+ } }, error.status);
76563
+ }
76564
+ /**
76565
+ * Maps a platform-call failure to a safe end-user response. Platform detail
76566
+ * strings never reach the end user — this is an unauthenticated-adjacent
76567
+ * surface, and the details describe the developer's platform account.
76568
+ */
76569
+ function toRouteError(error) {
76570
+ if (error instanceof MastraConnectError) {
76571
+ if (error.code === "connection_not_found") return {
76572
+ status: 404,
76573
+ code: "connection_not_found",
76574
+ message: "Connection not found."
76575
+ };
76576
+ if (error.code === "missing_access_token" || error.code === "missing_project_id" || error.code === "invalid_options") return {
76577
+ status: 500,
76578
+ code: "misconfigured",
76579
+ message: "Connect routes are not configured correctly."
76580
+ };
76581
+ return {
76582
+ status: 502,
76583
+ code: "platform_error",
76584
+ message: "Platform request failed."
76585
+ };
76586
+ }
76587
+ return {
76588
+ status: 500,
76589
+ code: "internal_error",
76590
+ message: "Unexpected error."
76591
+ };
76592
+ }
76593
+ function logRouteError(c, error) {
76594
+ try {
76595
+ const logger = c.get?.("mastra")?.getLogger?.();
76596
+ if (logger?.error) {
76597
+ logger.error(`[userConnectRoutes] ${error instanceof Error ? error.message : String(error)}`);
76598
+ return;
76599
+ }
76600
+ } catch {}
76601
+ console.error("[userConnectRoutes]", error instanceof Error ? error.message : error);
76602
+ }
76603
+ const UNAUTHORIZED = {
76604
+ status: 401,
76605
+ code: "unauthorized",
76606
+ message: "No end-user identity for this request. Configure server.auth with mapUserToResourceId, or pass a resourceId callback to userConnectRoutes()."
76607
+ };
76608
+ /** Fixed base used to prove a relative `returnTo` cannot escape the origin. */
76609
+ const RETURN_TO_BASE = "https://relative.invalid";
76610
+ /**
76611
+ * Validates a `returnTo` redirect target. Relative paths (`/done`) are always
76612
+ * allowed; protocol-relative (`//host`), backslash tricks, and non-http(s)
76613
+ * schemes are rejected; absolute URLs require their origin to be allowlisted.
76614
+ */
76615
+ function validateReturnTo(raw, allowedOrigins) {
76616
+ if (raw === void 0 || raw === "") return {
76617
+ ok: true,
76618
+ value: null
76619
+ };
76620
+ if (raw.startsWith("/")) {
76621
+ if (raw.startsWith("//") || raw.startsWith("/\\")) return { ok: false };
76622
+ let resolved;
76623
+ try {
76624
+ resolved = new URL(raw, RETURN_TO_BASE);
76625
+ } catch {
76626
+ return { ok: false };
76627
+ }
76628
+ if (resolved.origin !== RETURN_TO_BASE) return { ok: false };
76629
+ if (resolved.pathname.startsWith("//")) return { ok: false };
76630
+ return {
76631
+ ok: true,
76632
+ value: `${resolved.pathname}${resolved.search}${resolved.hash}`
76633
+ };
76634
+ }
76635
+ let parsed;
76636
+ try {
76637
+ parsed = new URL(raw);
76638
+ } catch {
76639
+ return { ok: false };
76640
+ }
76641
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return { ok: false };
76642
+ if (parsed.username || parsed.password) return { ok: false };
76643
+ if (!allowedOrigins.includes(parsed.origin)) return { ok: false };
76644
+ return {
76645
+ ok: true,
76646
+ value: parsed.href
76647
+ };
76648
+ }
76649
+ function connectionScope(connection) {
76650
+ return connection.resourceId == null ? "global" : "user";
76651
+ }
76652
+ /**
76653
+ * Builds the mountable end-user connect surface as `ApiRoute[]` for
76654
+ * `server.apiRoutes`. The routes do no authentication of their own — the
76655
+ * server's auth pipeline authenticates the request and
76656
+ * `server.auth.mapUserToResourceId` establishes the end-user resource key the
76657
+ * routes are gated on. Every platform call happens server-side with the
76658
+ * configured platform token; the browser only ever talks to these routes.
76659
+ *
76660
+ * ```ts
76661
+ * server: {
76662
+ * auth: myAuth,
76663
+ * apiRoutes: [...userConnectRoutes({ providers: ['linear', 'slack'] })],
76664
+ * }
76665
+ * ```
76666
+ */
76667
+ function userConnectRoutes(options) {
76668
+ if (!Array.isArray(options.providers) || options.providers.length === 0) throw new MastraConnectError("invalid_options", "userConnectRoutes requires a non-empty providers allowlist: the end-user surface never defaults open.");
76669
+ const providers = options.providers.map((provider) => {
76670
+ const id = typeof provider === "string" ? provider.trim().toLowerCase() : "";
76671
+ if (!id) throw new MastraConnectError("invalid_options", "Invalid provider id in userConnectRoutes providers allowlist.");
76672
+ if (RESERVED_PROVIDER_IDS.has(id) || id.includes("/")) throw new MastraConnectError("invalid_options", `Invalid provider id '${id}': reserved route segment.`);
76673
+ return id;
76674
+ });
76675
+ const allowlist = new Set(providers);
76676
+ const basePath = options.basePath ?? "/connect";
76677
+ if (!basePath.startsWith("/") || basePath.endsWith("/") || basePath.includes(":") || basePath.includes("*")) throw new MastraConnectError("invalid_options", `Invalid basePath '${basePath}': expected an absolute path without a trailing slash or route parameters.`);
76678
+ const projectId = options.projectId?.trim() || process.env.MASTRA_PROJECT_ID?.trim();
76679
+ if (!projectId) throw new MastraConnectError("missing_project_id", "Missing project id: set MASTRA_PROJECT_ID or pass projectId.");
76680
+ if (options.client?.resourceId !== void 0) throw new MastraConnectError("invalid_options", "userConnectRoutes derives the resource key per request; do not set client.resourceId.");
76681
+ if (options.resourceId !== void 0 && typeof options.resourceId !== "function") throw new MastraConnectError("invalid_options", "Invalid resourceId: userConnectRoutes expects a callback deriving the key per request.");
76682
+ const allowedOrigins = options.returnTo?.allowedOrigins ?? [];
76683
+ /** Resolves the caller's resource key or null when the request carries no usable identity. */
76684
+ const identify = async (c) => {
76685
+ const requestContext = c.get?.("requestContext");
76686
+ let raw;
76687
+ if (options.resourceId) try {
76688
+ raw = await options.resourceId({
76689
+ requestContext,
76690
+ user: requestContext?.get("user")
76691
+ });
76692
+ } catch (error) {
76693
+ logRouteError(c, error);
76694
+ return null;
76695
+ }
76696
+ else raw = requestContext?.get(_mastra_core_request_context.MASTRA_RESOURCE_ID_KEY);
76697
+ if (typeof raw !== "string" || raw.trim() === "") return null;
76698
+ try {
76699
+ return validateResourceId(raw);
76700
+ } catch (error) {
76701
+ logRouteError(c, error);
76702
+ return null;
76703
+ }
76704
+ };
76705
+ /**
76706
+ * Wraps a handler with the shared gate: resolve the caller identity (401
76707
+ * when absent), resolve a resource-scoped platform client, set no-store,
76708
+ * and map failures to safe responses.
76709
+ */
76710
+ const gated = (handler) => async (c) => {
76711
+ c.header("cache-control", NO_STORE);
76712
+ const resourceId = await identify(c);
76713
+ if (resourceId === null) return errorResponse(c, UNAUTHORIZED);
76714
+ let client;
76715
+ try {
76716
+ client = resolveClient({
76717
+ ...options.client,
76718
+ resourceId
76719
+ });
76720
+ } catch (error) {
76721
+ logRouteError(c, error);
76722
+ return errorResponse(c, toRouteError(error));
76723
+ }
76724
+ try {
76725
+ return await handler(c, client, resourceId);
76726
+ } catch (error) {
76727
+ logRouteError(c, error);
76728
+ return errorResponse(c, toRouteError(error));
76729
+ }
76730
+ };
76731
+ /** Lists the caller's visible connections (global + own), filtered to the allowlist. */
76732
+ const listVisibleConnections = async (client) => {
76733
+ return (await listProjectConnections(client, projectId)).filter((connection) => allowlist.has(connection.integrationId.toLowerCase()));
76734
+ };
76735
+ const providerParam = (c) => {
76736
+ const provider = String(c.req.param("provider") ?? "").trim().toLowerCase();
76737
+ return allowlist.has(provider) ? provider : null;
76738
+ };
76739
+ const notFound = (c, code, message) => errorResponse(c, {
76740
+ status: 404,
76741
+ code,
76742
+ message
76743
+ });
76744
+ return [
76745
+ {
76746
+ path: `${basePath}/providers`,
76747
+ method: "GET",
76748
+ requiresAuth: true,
76749
+ handler: gated(async (c, client) => {
76750
+ const catalog = await listIntegrations(client);
76751
+ const byId = new Map(catalog.map((entry) => [entry.id.toLowerCase(), entry]));
76752
+ const visible = providers.flatMap((id) => {
76753
+ const entry = byId.get(id);
76754
+ if (!entry) return [];
76755
+ return [{
76756
+ id: entry.id,
76757
+ displayName: entry.displayName ?? null,
76758
+ logoUrl: entry.logoUrl ?? null,
76759
+ authType: entry.authType ?? null,
76760
+ authFields: entry.authFields ?? null
76761
+ }];
76762
+ });
76763
+ return c.json({ providers: visible });
76764
+ })
76765
+ },
76766
+ {
76767
+ path: `${basePath}/connections`,
76768
+ method: "GET",
76769
+ requiresAuth: true,
76770
+ handler: gated(async (c, client) => {
76771
+ const connections = await listVisibleConnections(client);
76772
+ return c.json({ connections: connections.map((connection) => ({
76773
+ id: connection.id,
76774
+ integrationId: connection.integrationId,
76775
+ status: connection.status,
76776
+ accountLabel: connection.accountLabel ?? null,
76777
+ scope: connectionScope(connection)
76778
+ })) });
76779
+ })
76780
+ },
76781
+ {
76782
+ path: `${basePath}/connections/:id/status`,
76783
+ method: "GET",
76784
+ requiresAuth: true,
76785
+ handler: gated(async (c, client) => {
76786
+ const id = String(c.req.param("id") ?? "");
76787
+ const connection = (await listVisibleConnections(client)).find((candidate) => candidate.id === id);
76788
+ if (!connection) return notFound(c, "connection_not_found", "Connection not found.");
76789
+ return c.json({
76790
+ id: connection.id,
76791
+ status: connection.status,
76792
+ scope: connectionScope(connection)
76793
+ });
76794
+ })
76795
+ },
76796
+ {
76797
+ path: `${basePath}/connections/:id`,
76798
+ method: "DELETE",
76799
+ requiresAuth: true,
76800
+ handler: gated(async (c, client) => {
76801
+ const id = String(c.req.param("id") ?? "");
76802
+ const connection = (await listVisibleConnections(client)).find((candidate) => candidate.id === id);
76803
+ if (!connection) return notFound(c, "connection_not_found", "Connection not found.");
76804
+ if (connectionScope(connection) === "global") return errorResponse(c, {
76805
+ status: 403,
76806
+ code: "forbidden",
76807
+ message: "Global connections cannot be disconnected from the end-user surface."
76808
+ });
76809
+ await disconnectConnection(client, connection.id);
76810
+ return c.body(null, 204);
76811
+ })
76812
+ },
76813
+ {
76814
+ path: `${basePath}/:provider/sessions`,
76815
+ method: "POST",
76816
+ requiresAuth: true,
76817
+ handler: gated(async (c, client) => {
76818
+ const provider = providerParam(c);
76819
+ if (!provider) return notFound(c, "provider_not_found", "Provider not available.");
76820
+ const session = await createProjectConnectSession(client, projectId, provider);
76821
+ return c.json({
76822
+ connectionId: session.connectionId,
76823
+ integrationId: session.integrationId,
76824
+ connectUrl: session.connectUrl,
76825
+ sessionToken: session.sessionToken,
76826
+ expiresAt: session.expiresAt
76827
+ }, 201);
76828
+ })
76829
+ },
76830
+ {
76831
+ path: `${basePath}/:provider`,
76832
+ method: "GET",
76833
+ requiresAuth: true,
76834
+ handler: gated(async (c, client) => {
76835
+ const provider = providerParam(c);
76836
+ if (!provider) return notFound(c, "provider_not_found", "Provider not available.");
76837
+ const returnTo = validateReturnTo(c.req.query("returnTo") ?? void 0, allowedOrigins);
76838
+ if (!returnTo.ok) return errorResponse(c, {
76839
+ status: 400,
76840
+ code: "invalid_return_to",
76841
+ message: "Invalid returnTo: use a relative path, or allowlist the origin via returnTo.allowedOrigins."
76842
+ });
76843
+ const entry = (await listIntegrations(client)).find((candidate) => candidate.id.toLowerCase() === provider);
76844
+ if (!entry) return notFound(c, "provider_not_found", "Provider not available.");
76845
+ c.header("content-security-policy", [
76846
+ "default-src 'none'",
76847
+ "script-src 'unsafe-inline'",
76848
+ "style-src 'unsafe-inline'",
76849
+ "img-src https:",
76850
+ "connect-src 'self'",
76851
+ "base-uri 'none'",
76852
+ "form-action 'none'",
76853
+ "frame-ancestors 'none'"
76854
+ ].join("; "));
76855
+ return c.html(renderConnectPage({
76856
+ basePath,
76857
+ provider: {
76858
+ id: entry.id,
76859
+ displayName: entry.displayName,
76860
+ logoUrl: entry.logoUrl
76861
+ },
76862
+ returnTo: returnTo.value
76863
+ }));
76864
+ })
76865
+ }
76866
+ ];
76099
76867
  }
76100
76868
  //#endregion
76101
76869
  exports.CHANNELS = CHANNELS;
@@ -76104,5 +76872,6 @@ exports.PROVIDERS = PROVIDERS;
76104
76872
  exports.channels = channels;
76105
76873
  exports.credential = credential;
76106
76874
  exports.tools = tools;
76875
+ exports.userConnectRoutes = userConnectRoutes;
76107
76876
 
76108
76877
  //# sourceMappingURL=index.cjs.map