@mastra/connect 1.1.0-alpha.2 → 1.1.0-alpha.4

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