@zackbart/connecta 0.7.0 → 0.7.3

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -1
  3. package/dist/connectors/remote-mcp.d.ts +24 -1
  4. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  5. package/dist/connectors/remote-mcp.js +208 -88
  6. package/dist/connectors/remote-mcp.js.map +1 -1
  7. package/dist/credential-health.d.ts +8 -5
  8. package/dist/credential-health.d.ts.map +1 -1
  9. package/dist/credential-health.js +20 -13
  10. package/dist/credential-health.js.map +1 -1
  11. package/dist/execute.d.ts.map +1 -1
  12. package/dist/execute.js +10 -8
  13. package/dist/execute.js.map +1 -1
  14. package/dist/executors/quickjs.d.ts.map +1 -1
  15. package/dist/executors/quickjs.js +57 -5
  16. package/dist/executors/quickjs.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/meta-tools.d.ts +25 -0
  21. package/dist/meta-tools.d.ts.map +1 -1
  22. package/dist/meta-tools.js +162 -20
  23. package/dist/meta-tools.js.map +1 -1
  24. package/dist/registry.d.ts +18 -22
  25. package/dist/registry.d.ts.map +1 -1
  26. package/dist/registry.js +33 -21
  27. package/dist/registry.js.map +1 -1
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +18 -7
  30. package/dist/server.js.map +1 -1
  31. package/dist/timeout.d.ts +9 -4
  32. package/dist/timeout.d.ts.map +1 -1
  33. package/dist/timeout.js +34 -4
  34. package/dist/timeout.js.map +1 -1
  35. package/dist/toolkits.d.ts +8 -0
  36. package/dist/toolkits.d.ts.map +1 -1
  37. package/dist/toolkits.js +3 -0
  38. package/dist/toolkits.js.map +1 -1
  39. package/dist/types.d.ts +2 -2
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/ui.d.ts +12 -1
  42. package/dist/ui.d.ts.map +1 -1
  43. package/dist/ui.js +187 -6
  44. package/dist/ui.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
  48. package/src/connectors/remote-mcp.ts +269 -93
  49. package/src/credential-health.ts +20 -18
  50. package/src/execute.ts +18 -7
  51. package/src/executors/quickjs.ts +65 -5
  52. package/src/index.ts +7 -2
  53. package/src/meta-tools.ts +226 -43
  54. package/src/registry.ts +48 -20
  55. package/src/server.ts +20 -9
  56. package/src/timeout.ts +41 -4
  57. package/src/toolkits.ts +11 -0
  58. package/src/types.ts +2 -2
  59. package/src/ui.ts +212 -11
  60. package/src/version.ts +1 -1
package/src/registry.ts CHANGED
@@ -6,7 +6,11 @@ import type {
6
6
  Logger,
7
7
  ToolDef,
8
8
  } from "./types.js";
9
- import type { CredentialVault } from "./credentials.js";
9
+ import {
10
+ storedCredentialShape,
11
+ type CredentialVault,
12
+ } from "./credentials.js";
13
+ import { ConnectorCallError } from "./errors.js";
10
14
  import {
11
15
  CredentialHealthChecker,
12
16
  type CredentialCheckOptions,
@@ -154,6 +158,11 @@ function msg(err: unknown): string {
154
158
  return err instanceof Error ? err.message : String(err);
155
159
  }
156
160
 
161
+ type ConnectorOperationOptions = Pick<
162
+ ConnectorContext,
163
+ "signal" | "timeoutMs"
164
+ >;
165
+
157
166
  /**
158
167
  * The registry surface a per-connection MCP server consumes: every meta-tool
159
168
  * (`src/meta-tools.ts`) and the `execute_code` sandbox bridge (`src/execute.ts`)
@@ -178,18 +187,20 @@ export interface RegistryView {
178
187
  id: string,
179
188
  baseUrl: string,
180
189
  requestScope?: object,
190
+ callOptions?: ConnectorOperationOptions,
181
191
  ): Promise<ToolDef[]>;
182
192
  refreshTools(
183
193
  id: string,
184
194
  baseUrl: string,
185
195
  requestScope?: object,
196
+ callOptions?: ConnectorOperationOptions,
186
197
  ): Promise<ToolDef[]>;
187
198
  peekTools(id: string): ToolDef[] | undefined;
188
199
  contextFor(
189
200
  id: string,
190
201
  baseUrl: string,
191
202
  requestScope?: object,
192
- callOptions?: { signal?: AbortSignal; timeoutMs?: number },
203
+ callOptions?: ConnectorOperationOptions,
193
204
  ): ConnectorContext;
194
205
  resultsStorage(): KVStorage;
195
206
  recordSuccess(id: string, latencyMs: number): void;
@@ -209,6 +220,7 @@ export interface RegistryView {
209
220
  id: string,
210
221
  baseUrl: string,
211
222
  requestScope?: object,
223
+ callOptions?: ConnectorOperationOptions,
212
224
  ): Promise<ConnectorStatus>;
213
225
  invalidateStored(id: string): Promise<void>;
214
226
  }
@@ -352,21 +364,31 @@ export class Registry implements RegistryView {
352
364
  id: string,
353
365
  baseUrl: string,
354
366
  requestScope: object = {},
355
- callOptions: { signal?: AbortSignal; timeoutMs?: number } = {},
367
+ callOptions: ConnectorOperationOptions = {},
356
368
  ): ConnectorContext {
369
+ const credentialConfig = this.connectors.get(id)?.credential;
370
+ let credentialAccess: ConnectorContext["credential"];
371
+ if (this.opts.credentialVault && credentialConfig) {
372
+ const vault = this.opts.credentialVault;
373
+ const readValues = async () => {
374
+ const values = await vault.getAll(id);
375
+ const shape = storedCredentialShape(credentialConfig, values);
376
+ if (shape.state === "mismatch") {
377
+ throw new ConnectorCallError("auth_required", shape.message);
378
+ }
379
+ return values;
380
+ };
381
+ credentialAccess = {
382
+ get: async (field = "value") =>
383
+ (await readValues())?.[field] ?? null,
384
+ getAll: readValues,
385
+ };
386
+ }
357
387
  return {
358
388
  storage: namespaced(this.opts.storage, `conn:${id}:`),
359
389
  logger: this.opts.logger,
360
390
  baseUrl,
361
- ...(this.opts.credentialVault && this.connectors.get(id)?.credential
362
- ? {
363
- credential: {
364
- get: (field?: string) =>
365
- this.opts.credentialVault!.get(id, field),
366
- getAll: () => this.opts.credentialVault!.getAll(id),
367
- },
368
- }
369
- : {}),
391
+ ...(credentialAccess ? { credential: credentialAccess } : {}),
370
392
  requestScope,
371
393
  ...callOptions,
372
394
  };
@@ -441,13 +463,14 @@ export class Registry implements RegistryView {
441
463
  id: string,
442
464
  baseUrl: string,
443
465
  requestScope?: object,
466
+ callOptions: ConnectorOperationOptions = {},
444
467
  ): Promise<ToolDef[]> {
445
468
  const connector = this.connectors.get(id);
446
469
  if (!connector) throw new Error(`Unknown connector "${id}"`);
447
470
  const tools = connector.staticTools
448
471
  ? connector.staticTools
449
472
  : await connector.listTools(
450
- this.contextFor(id, baseUrl, requestScope),
473
+ this.contextFor(id, baseUrl, requestScope, callOptions),
451
474
  );
452
475
  const now = Date.now();
453
476
  const previous = this.cache.get(id);
@@ -479,6 +502,7 @@ export class Registry implements RegistryView {
479
502
  id: string,
480
503
  baseUrl: string,
481
504
  requestScope?: object,
505
+ callOptions: ConnectorOperationOptions = {},
482
506
  ): Promise<ToolDef[]> {
483
507
  const connector = this.connectors.get(id);
484
508
  if (!connector) throw new Error(`Unknown connector "${id}"`);
@@ -512,7 +536,7 @@ export class Registry implements RegistryView {
512
536
  }
513
537
 
514
538
  try {
515
- return await this.refreshTools(id, baseUrl, requestScope);
539
+ return await this.refreshTools(id, baseUrl, requestScope, callOptions);
516
540
  } catch (err) {
517
541
  if (stale) {
518
542
  this.opts.logger.warn(
@@ -626,10 +650,11 @@ export class Registry implements RegistryView {
626
650
  id: string,
627
651
  baseUrl: string,
628
652
  requestScope: object = {},
653
+ callOptions: ConnectorOperationOptions = {},
629
654
  ): Promise<ConnectorStatus> {
630
655
  const connector = this.connectors.get(id);
631
656
  if (!connector) return { state: "error", message: "Unknown connector" };
632
- const ctx = this.contextFor(id, baseUrl, requestScope);
657
+ const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
633
658
  if (connector.status) {
634
659
  try {
635
660
  return await connector.status(ctx);
@@ -638,7 +663,7 @@ export class Registry implements RegistryView {
638
663
  }
639
664
  }
640
665
  try {
641
- await this.getTools(id, baseUrl, requestScope);
666
+ await this.getTools(id, baseUrl, requestScope, callOptions);
642
667
  return { state: "ok" };
643
668
  } catch (err) {
644
669
  return { state: "error", message: msg(err) };
@@ -782,11 +807,12 @@ export class ScopedRegistry implements RegistryView {
782
807
  id: string,
783
808
  baseUrl: string,
784
809
  requestScope?: object,
810
+ callOptions: ConnectorOperationOptions = {},
785
811
  ): Promise<ToolDef[]> {
786
812
  if (!this.visible(id)) throw this.unknownConnector(id);
787
813
  return this.inScopeTools(
788
814
  id,
789
- await this.base.getTools(id, baseUrl, requestScope),
815
+ await this.base.getTools(id, baseUrl, requestScope, callOptions),
790
816
  );
791
817
  }
792
818
 
@@ -794,11 +820,12 @@ export class ScopedRegistry implements RegistryView {
794
820
  id: string,
795
821
  baseUrl: string,
796
822
  requestScope?: object,
823
+ callOptions: ConnectorOperationOptions = {},
797
824
  ): Promise<ToolDef[]> {
798
825
  if (!this.visible(id)) throw this.unknownConnector(id);
799
826
  return this.inScopeTools(
800
827
  id,
801
- await this.base.refreshTools(id, baseUrl, requestScope),
828
+ await this.base.refreshTools(id, baseUrl, requestScope, callOptions),
802
829
  );
803
830
  }
804
831
 
@@ -812,7 +839,7 @@ export class ScopedRegistry implements RegistryView {
812
839
  id: string,
813
840
  baseUrl: string,
814
841
  requestScope: object = {},
815
- callOptions: { signal?: AbortSignal; timeoutMs?: number } = {},
842
+ callOptions: ConnectorOperationOptions = {},
816
843
  ): ConnectorContext {
817
844
  // Unreachable through the meta-tools (they resolve first), so a throw here
818
845
  // is a loud backstop rather than a silent grant of connector storage and
@@ -897,12 +924,13 @@ export class ScopedRegistry implements RegistryView {
897
924
  id: string,
898
925
  baseUrl: string,
899
926
  requestScope: object = {},
927
+ callOptions: { signal?: AbortSignal; timeoutMs?: number } = {},
900
928
  ): Promise<ConnectorStatus> {
901
929
  // Same shape the unscoped registry returns for an unregistered id.
902
930
  if (!this.visible(id)) {
903
931
  return { state: "error", message: "Unknown connector" };
904
932
  }
905
- return this.base.statusFor(id, baseUrl, requestScope);
933
+ return this.base.statusFor(id, baseUrl, requestScope, callOptions);
906
934
  }
907
935
 
908
936
  async invalidateStored(id: string): Promise<void> {
package/src/server.ts CHANGED
@@ -222,8 +222,8 @@ async function authorize(
222
222
  | {
223
223
  ok: true;
224
224
  actor: ActivityActor;
225
- providerKind?: string;
226
- userId?: string;
225
+ /** True only when the admitting provider can also authorize UI mutation. */
226
+ uiAdminEligible?: boolean;
227
227
  /** The admitting identity's toolkit binding (docs/toolkits.md). */
228
228
  toolkitBinding?: ToolkitBinding;
229
229
  }
@@ -261,8 +261,9 @@ async function authorize(
261
261
  kind: provider.kind,
262
262
  ...(subjectId ? { id: subjectId } : {}),
263
263
  },
264
- providerKind: provider.kind,
265
- ...(result.userId ? { userId: result.userId } : {}),
264
+ ...(result.userId && provider.uiAuth?.kind === "clerk"
265
+ ? { uiAdminEligible: true }
266
+ : {}),
266
267
  ...(binding.binding ? { toolkitBinding: binding.binding } : {}),
267
268
  };
268
269
  }
@@ -1088,7 +1089,15 @@ export function createFetchHandler(
1088
1089
  // Canonicalize the legacy bookmark while upgrading it so an old /ui URL
1089
1090
  // reaches the new Connections entry point in one permanent redirect.
1090
1091
  const targetPath = path === "/ui" ? "/" : url.pathname;
1091
- const target = new URL(`${targetPath}${url.search}`, publicUrl);
1092
+ // Assign the path and query onto the configured URL instead of resolving
1093
+ // attacker-controlled text against it. A pathname beginning with `//`
1094
+ // (including a backslash form normalized by URL parsing) is an authority
1095
+ // when passed to `new URL(value, base)` and would otherwise replace the
1096
+ // deployment host.
1097
+ const target = new URL(publicUrl);
1098
+ target.pathname = targetPath;
1099
+ target.search = url.search;
1100
+ target.hash = "";
1092
1101
  return withSecurityHeaders(
1093
1102
  new Response(null, {
1094
1103
  status: 308,
@@ -1189,7 +1198,7 @@ export function createFetchHandler(
1189
1198
 
1190
1199
  const operatorPage = operatorPageForPath(path);
1191
1200
  if (operatorPage) {
1192
- if (request.method !== "GET") {
1201
+ if (request.method !== "GET" && request.method !== "HEAD") {
1193
1202
  return privateJson({ error: "method not allowed" }, { status: 405 });
1194
1203
  }
1195
1204
  // Open shell — carries no operator data; everything comes from the
@@ -1204,7 +1213,9 @@ export function createFetchHandler(
1204
1213
  // — only script execution, the XSS sink, is gated.
1205
1214
  const nonce = uiScriptNonce();
1206
1215
  return new Response(
1207
- renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
1216
+ request.method === "HEAD"
1217
+ ? null
1218
+ : renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage),
1208
1219
  {
1209
1220
  status: 200,
1210
1221
  headers: {
@@ -1227,8 +1238,7 @@ export function createFetchHandler(
1227
1238
  // After the restriction check, not before: an identity that may not
1228
1239
  // read this surface should not get to trigger background work from it.
1229
1240
  sweepCredentials();
1230
- const eligibleClerkOperator =
1231
- authz.providerKind === "clerk" && Boolean(authz.userId);
1241
+ const eligibleClerkOperator = authz.uiAdminEligible === true;
1232
1242
  const credentialManagement = credentialManagementCapability({
1233
1243
  eligibleClerkOperator,
1234
1244
  hasCredentialSlots: registry
@@ -1245,6 +1255,7 @@ export function createFetchHandler(
1245
1255
  eligibleClerkOperator ? opts.credentialVault : undefined,
1246
1256
  Boolean(opts.activity?.list),
1247
1257
  credentialManagement,
1258
+ opts.toolkits,
1248
1259
  );
1249
1260
  return privateJson(data);
1250
1261
  }
package/src/timeout.ts CHANGED
@@ -21,10 +21,9 @@ export function normalizeTimeoutMs(
21
21
 
22
22
  /**
23
23
  * Reject `promise` after `ms` if it has not settled, so one hung downstream
24
- * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait
25
- * the registry probe methods take no AbortSignal, so the underlying fetch is
26
- * NOT cancelled and keeps running in the background. Real cancellation
27
- * (AbortSignal plumbed through the registry) is a deferred follow-up.
24
+ * cannot stall a whole fan-out. This form bounds only the caller-facing wait;
25
+ * use `withAbortableTimeout` when the operation accepts an AbortSignal and the
26
+ * underlying work must stop too.
28
27
  */
29
28
  export function withTimeout<T>(
30
29
  promise: Promise<T>,
@@ -47,3 +46,41 @@ export function withTimeout<T>(
47
46
  );
48
47
  });
49
48
  }
49
+
50
+ /**
51
+ * Give one operation a caller-facing deadline and the matching cancellation
52
+ * signal. The timeout rejects with the stable, labelled error while aborting
53
+ * any in-flight work that honors the signal.
54
+ */
55
+ export function withAbortableTimeout<T>(
56
+ operation: (signal: AbortSignal) => Promise<T>,
57
+ ms: number,
58
+ label: string,
59
+ ): Promise<T> {
60
+ const controller = new AbortController();
61
+ return new Promise<T>((resolve, reject) => {
62
+ const timeoutError = new Error(`${label} timed out after ${ms}ms`);
63
+ const timer = setTimeout(() => {
64
+ controller.abort(timeoutError);
65
+ reject(timeoutError);
66
+ }, ms);
67
+ let promise: Promise<T>;
68
+ try {
69
+ promise = operation(controller.signal);
70
+ } catch (err) {
71
+ clearTimeout(timer);
72
+ reject(err);
73
+ return;
74
+ }
75
+ promise.then(
76
+ (value) => {
77
+ clearTimeout(timer);
78
+ resolve(value);
79
+ },
80
+ (err) => {
81
+ clearTimeout(timer);
82
+ reject(err);
83
+ },
84
+ );
85
+ });
86
+ }
package/src/toolkits.ts CHANGED
@@ -41,6 +41,14 @@ export type ToolkitConfig = Record<string, ToolkitDefinition>;
41
41
  export interface Toolkit {
42
42
  readonly name: string;
43
43
  readonly description?: string;
44
+ /**
45
+ * The validated config this scope came from. Kept as immutable data so
46
+ * deployment-wide operator surfaces can explain the running configuration
47
+ * without inventing a second source of truth.
48
+ */
49
+ readonly connectors: readonly string[];
50
+ readonly includeTools: readonly string[];
51
+ readonly excludeTools: readonly string[];
44
52
  /** True when `connectorId` is inside this toolkit's scope. */
45
53
  hasConnector(connectorId: string): boolean;
46
54
  /** True when `<connectorId>.<toolName>` is inside this toolkit's scope. */
@@ -178,6 +186,9 @@ function resolveToolkit(
178
186
  return {
179
187
  name,
180
188
  ...(definition.description ? { description: definition.description } : {}),
189
+ connectors: Object.freeze([...connectorIds]),
190
+ includeTools: Object.freeze([...(definition.includeTools ?? [])]),
191
+ excludeTools: Object.freeze([...(definition.excludeTools ?? [])]),
181
192
  hasConnector: (connectorId) => connectorIds.has(connectorId),
182
193
  hasTool: (connectorId, toolName) => {
183
194
  if (!connectorIds.has(connectorId)) return false;
package/src/types.ts CHANGED
@@ -113,9 +113,9 @@ export interface ConnectorContext {
113
113
  * when omitted.
114
114
  */
115
115
  requestScope?: object;
116
- /** Best-effort cancellation signal for this individual tool call. */
116
+ /** Best-effort cancellation signal for this connector operation. */
117
117
  signal?: AbortSignal;
118
- /** Requested tool-call deadline in milliseconds. */
118
+ /** Requested connector-operation deadline in milliseconds. */
119
119
  timeoutMs?: number;
120
120
  }
121
121