@zackbart/connecta 0.2.1 → 0.4.0

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 (65) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +62 -0
  4. package/dist/catalog.js.map +1 -1
  5. package/dist/connectors/api.d.ts +14 -0
  6. package/dist/connectors/api.d.ts.map +1 -1
  7. package/dist/connectors/api.js +18 -1
  8. package/dist/connectors/api.js.map +1 -1
  9. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  10. package/dist/connectors/remote-mcp.js +22 -8
  11. package/dist/connectors/remote-mcp.js.map +1 -1
  12. package/dist/credentials.d.ts.map +1 -1
  13. package/dist/credentials.js +4 -1
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/errors.d.ts +53 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +89 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/execute.d.ts.map +1 -1
  20. package/dist/execute.js +10 -6
  21. package/dist/execute.js.map +1 -1
  22. package/dist/index.d.ts +20 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +6 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/json-schema.d.ts +3 -0
  27. package/dist/json-schema.d.ts.map +1 -0
  28. package/dist/json-schema.js +6 -0
  29. package/dist/json-schema.js.map +1 -0
  30. package/dist/meta-tools.d.ts +31 -2
  31. package/dist/meta-tools.d.ts.map +1 -1
  32. package/dist/meta-tools.js +75 -31
  33. package/dist/meta-tools.js.map +1 -1
  34. package/dist/node.d.ts +1 -0
  35. package/dist/node.d.ts.map +1 -1
  36. package/dist/node.js.map +1 -1
  37. package/dist/server.d.ts +2 -0
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +5 -1
  40. package/dist/server.js.map +1 -1
  41. package/dist/storage/file.d.ts +6 -2
  42. package/dist/storage/file.d.ts.map +1 -1
  43. package/dist/storage/file.js +3 -2
  44. package/dist/storage/file.js.map +1 -1
  45. package/dist/validate.d.ts +39 -0
  46. package/dist/validate.d.ts.map +1 -0
  47. package/dist/validate.js +66 -0
  48. package/dist/validate.js.map +1 -0
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +8 -1
  52. package/src/catalog.ts +61 -0
  53. package/src/connectors/api.ts +25 -1
  54. package/src/connectors/remote-mcp.ts +32 -13
  55. package/src/credentials.ts +4 -1
  56. package/src/errors.ts +126 -0
  57. package/src/execute.ts +13 -6
  58. package/src/index.ts +24 -0
  59. package/src/json-schema.ts +11 -0
  60. package/src/meta-tools.ts +102 -46
  61. package/src/node.ts +1 -0
  62. package/src/server.ts +7 -1
  63. package/src/storage/file.ts +12 -3
  64. package/src/validate.ts +96 -0
  65. package/src/version.ts +1 -1
package/src/meta-tools.ts CHANGED
@@ -7,6 +7,12 @@ import {
7
7
  type ActivityRequestContext,
8
8
  } from "./activity.js";
9
9
  import { unwrapMcpResult } from "./mcp-result.js";
10
+ import {
11
+ classifyCallError,
12
+ ConnectorCallError,
13
+ messageLooksRetryable,
14
+ type CallErrorDetails,
15
+ } from "./errors.js";
10
16
  import type { Registry } from "./registry.js";
11
17
  import { AVAILABLE_SKILLS } from "./skills.js";
12
18
  import type { KVStorage, ToolDef } from "./types.js";
@@ -45,21 +51,54 @@ const DEFAULT_SEARCH_LIMIT = 25;
45
51
  const enc = new TextEncoder();
46
52
  const dec = new TextDecoder();
47
53
 
48
- interface ErrorDetails {
49
- code: string;
50
- message: string;
51
- retryable: boolean;
54
+ type ErrorDetails = CallErrorDetails;
55
+
56
+ /**
57
+ * The longest the engine will park a synchronous inbound request in *waiting
58
+ * alone*. The engine already treats ~15 s as the outer bound of one reasonable
59
+ * connector call (EXECUTE_HOST_CALL_TIMEOUT_MS), so sleeping for minutes trades
60
+ * a fast, informative failure for a hung one. A connector-reported window this
61
+ * long isn't truncated — it's declined (see `retryBackoffMs`) and reported
62
+ * verbatim as `error.retryAfterMs`, so the agent, which can afford to wait,
63
+ * decides when to re-issue.
64
+ */
65
+ export const MAX_RETRY_BACKOFF_MS = 10_000;
66
+
67
+ /** A finite, positive integer number of milliseconds, or undefined. */
68
+ function normalizeTimeoutMs(value: number | undefined): number | undefined {
69
+ if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
70
+ return undefined;
71
+ }
72
+ return Math.max(1, Math.trunc(value));
73
+ }
74
+
75
+ /**
76
+ * How long to wait before the next attempt, or `undefined` for "don't retry".
77
+ *
78
+ * A connector that read a `Retry-After` header knows the window exactly, so it
79
+ * is honoured **exactly or not at all**: truncating an exponential *guess* is
80
+ * harmless, but truncating a *known* window means deliberately retrying inside
81
+ * a rate limit — the harm this channel exists to prevent. A window longer than
82
+ * `MAX_RETRY_BACKOFF_MS` therefore declines the retry rather than shortening
83
+ * it. (`retryAfterMs` is normalized non-negative, so `0` means "retry now".)
84
+ * Connectors that report no window keep the historical exponential guess.
85
+ *
86
+ * Waits are per attempt, matching the per-attempt `timeoutMs` race in
87
+ * `runCall`. Exported for direct testing.
88
+ */
89
+ export function retryBackoffMs(
90
+ attempt: number,
91
+ retryAfterMs: number | undefined,
92
+ ): number | undefined {
93
+ if (retryAfterMs === undefined) {
94
+ return Math.min(250 * 2 ** (attempt - 1), 1_000);
95
+ }
96
+ return retryAfterMs <= MAX_RETRY_BACKOFF_MS ? retryAfterMs : undefined;
52
97
  }
53
98
 
99
+ /** Details for failures that never reached a connector (no thrown value). */
54
100
  function errorDetails(code: string, message: string): ErrorDetails {
55
- return {
56
- code,
57
- message,
58
- retryable:
59
- /timeout|timed out|econnreset|econnrefused|temporar|rate.?limit|429|502|503|504|refcountedcanceler|different request/i.test(
60
- message,
61
- ),
62
- };
101
+ return { code, message, retryable: messageLooksRetryable(message) };
63
102
  }
64
103
 
65
104
  /** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
@@ -264,18 +303,22 @@ export interface SkillArgs {
264
303
  /**
265
304
  * The nine meta-tool handlers over a registry. Exported for direct testing;
266
305
  * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
267
- * overrides the registry's default result-size cap. (execute_code, the optional
268
- * tenth tool, is registered separately by registerExecuteTool.)
306
+ * overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
307
+ * supplies a deadline for calls that don't carry one. (execute_code, the
308
+ * optional tenth tool, is registered separately by registerExecuteTool.)
269
309
  */
270
310
  export function createMetaTools(
271
311
  registry: Registry,
272
312
  baseUrl: string,
273
313
  opts: {
274
314
  maxResultBytes?: number;
315
+ /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
316
+ defaultToolTimeoutMs?: number;
275
317
  activity?: ActivityRequestContext;
276
318
  } = {},
277
319
  ) {
278
320
  const cap = opts.maxResultBytes ?? registry.maxResultBytes;
321
+ const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
279
322
  // createMetaTools() is called once per inbound MCP request. Sharing this
280
323
  // identity lets remote connectors reuse one downstream client inside that
281
324
  // request without leaking request-bound I/O into the next one.
@@ -332,11 +375,10 @@ export function createMetaTools(
332
375
  ...(errorCode ? { errorCode } : {}),
333
376
  });
334
377
  };
335
- const failed = (code: string, message: string): RunCallOutcome => {
378
+ const failed = (error: ErrorDetails): RunCallOutcome => {
336
379
  const durationMs = Date.now() - started;
337
- const error = errorDetails(code, message);
338
380
  const diagnostics = timing();
339
- record(code === "timeout" ? "timeout" : "error", code);
381
+ record(error.code === "timeout" ? "timeout" : "error", error.code);
340
382
  return {
341
383
  toolResult:
342
384
  call.resultMode === "value"
@@ -347,7 +389,7 @@ export function createMetaTools(
347
389
  attempts,
348
390
  ...(call.diagnostics ? { timing: diagnostics } : {}),
349
391
  })
350
- : errorResult(message),
392
+ : errorResult(error.message),
351
393
  durationMs,
352
394
  attempts,
353
395
  timing: diagnostics,
@@ -355,14 +397,15 @@ export function createMetaTools(
355
397
  };
356
398
  };
357
399
  if (!resolved) {
358
- return failed("unknown_address", `Unknown address "${call.address}"`);
400
+ return failed(
401
+ errorDetails("unknown_address", `Unknown address "${call.address}"`),
402
+ );
359
403
  }
360
404
  const results = registry.resultsStorage();
361
405
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
362
- const timeoutMs =
363
- call.timeoutMs && call.timeoutMs > 0
364
- ? Math.max(1, Math.trunc(call.timeoutMs))
365
- : undefined;
406
+ // An explicit per-call deadline always wins; the config default only fills
407
+ // the gap, and stays off entirely when the deployment sets none.
408
+ const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
366
409
  const maxRetries = Math.min(
367
410
  2,
368
411
  Math.max(0, Math.trunc(call.maxRetries ?? 0)),
@@ -375,13 +418,17 @@ export function createMetaTools(
375
418
  ).find((tool) => tool.name === resolved.toolName);
376
419
  } catch (err) {
377
420
  catalogMs += Date.now() - catalogStarted;
378
- return failed("catalog_lookup_failed", msg(err));
421
+ // classifyCallError so a typed auth_required thrown while listing tools
422
+ // (e.g. a revoked downstream OAuth grant) keeps its code.
423
+ return failed(classifyCallError(err, "catalog_lookup_failed"));
379
424
  }
380
425
  catalogMs += Date.now() - catalogStarted;
381
426
  if (!definition) {
382
427
  return failed(
383
- "unknown_tool",
384
- `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
428
+ errorDetails(
429
+ "unknown_tool",
430
+ `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
431
+ ),
385
432
  );
386
433
  }
387
434
  const explicitlyReadOnly =
@@ -389,8 +436,10 @@ export function createMetaTools(
389
436
  definition.annotations?.destructiveHint !== true;
390
437
  if (!explicitlyReadOnly && !options.allowDestructive) {
391
438
  return failed(
392
- "destructive_tool_requires_approval",
393
- `Tool "${call.address}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`,
439
+ errorDetails(
440
+ "destructive_tool_requires_approval",
441
+ `Tool "${call.address}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`,
442
+ ),
394
443
  );
395
444
  }
396
445
  const retrySafe =
@@ -420,7 +469,12 @@ export function createMetaTools(
420
469
  pending,
421
470
  new Promise<never>((_, reject) => {
422
471
  timer = setTimeout(() => {
423
- reject(new Error(`Tool call timed out after ${timeoutMs}ms`));
472
+ reject(
473
+ new ConnectorCallError(
474
+ "timeout",
475
+ `Tool call timed out after ${timeoutMs}ms`,
476
+ ),
477
+ );
424
478
  controller?.abort();
425
479
  }, timeoutMs);
426
480
  }),
@@ -441,26 +495,27 @@ export function createMetaTools(
441
495
  } catch (err) {
442
496
  // Includes connector setup, downstream execution, and timeout wait.
443
497
  connectorMs += Date.now() - connectorStarted;
444
- const details = errorDetails("connector_call_failed", msg(err));
498
+ const details = classifyCallError(err);
445
499
  if (attempts <= maxRetries && retrySafe && details.retryable) {
446
- const backoffStarted = Date.now();
447
- await new Promise((resolve) =>
448
- setTimeout(resolve, Math.min(250 * 2 ** (attempts - 1), 1_000)),
449
- );
450
- backoffMs += Date.now() - backoffStarted;
451
- continue;
500
+ const wait = retryBackoffMs(attempts, details.retryAfterMs);
501
+ if (wait !== undefined) {
502
+ const backoffStarted = Date.now();
503
+ if (wait > 0) {
504
+ await new Promise((resolve) => setTimeout(resolve, wait));
505
+ }
506
+ backoffMs += Date.now() - backoffStarted;
507
+ continue;
508
+ }
509
+ // The reported window is longer than the engine will park a
510
+ // synchronous request for. Fall through to failure with
511
+ // retryAfterMs reported verbatim so the agent can re-issue.
452
512
  }
453
513
  registry.recordFailure(
454
514
  resolved.connector.id,
455
515
  Date.now() - started,
456
516
  err,
457
517
  );
458
- return failed(
459
- /timed out|timeout/i.test(msg(err))
460
- ? "timeout"
461
- : "connector_call_failed",
462
- msg(err),
463
- );
518
+ return failed(details);
464
519
  } finally {
465
520
  if (timer) clearTimeout(timer);
466
521
  }
@@ -531,7 +586,7 @@ export function createMetaTools(
531
586
  };
532
587
  } catch (err) {
533
588
  resultProcessingMs += Date.now() - processingStarted;
534
- return failed("result_processing_failed", msg(err));
589
+ return failed(errorDetails("result_processing_failed", msg(err)));
535
590
  }
536
591
  }
537
592
 
@@ -846,12 +901,11 @@ export function createMetaTools(
846
901
  const results = settled.map((s, i) => {
847
902
  const address = args.calls[i].address;
848
903
  if (s.status === "rejected") {
849
- const message = msg(s.reason);
850
904
  return {
851
905
  address,
852
906
  ok: false,
853
- error: message,
854
- errorDetails: errorDetails("batch_call_failed", message),
907
+ error: msg(s.reason),
908
+ errorDetails: classifyCallError(s.reason, "batch_call_failed"),
855
909
  };
856
910
  }
857
911
  const r = s.value;
@@ -985,11 +1039,13 @@ export function registerMetaTools(
985
1039
  ctx: {
986
1040
  baseUrl: string;
987
1041
  maxResultBytes?: number;
1042
+ defaultToolTimeoutMs?: number;
988
1043
  activity?: ActivityRequestContext;
989
1044
  },
990
1045
  ): void {
991
1046
  const mt = createMetaTools(registry, ctx.baseUrl, {
992
1047
  maxResultBytes: ctx.maxResultBytes,
1048
+ defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
993
1049
  activity: ctx.activity,
994
1050
  });
995
1051
 
package/src/node.ts CHANGED
@@ -5,6 +5,7 @@ import { pipeline } from "node:stream/promises";
5
5
  import type { Connecta } from "./index.js";
6
6
 
7
7
  export { fileStorage } from "./storage/file.js";
8
+ export type { FileStorageOptions } from "./storage/file.js";
8
9
 
9
10
  /** 10 MiB. Tool arguments are JSON; nothing legitimate approaches this. */
10
11
  const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
package/src/server.ts CHANGED
@@ -47,6 +47,8 @@ export interface ServerOptions {
47
47
  activityReadGate?: ActivityReadGate;
48
48
  activityDeploymentId?: string;
49
49
  deploymentInfo?: Record<string, unknown>;
50
+ /** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
51
+ defaultToolTimeoutMs?: number;
50
52
  /** When set, the execute_code meta-tool is registered on top of the nine. */
51
53
  executor?: Executor;
52
54
  /** Encrypted connector-credential storage backing the authenticated /ui controls. */
@@ -517,7 +519,11 @@ async function serveMcp(
517
519
  logger: opts.logger,
518
520
  }
519
521
  : undefined;
520
- registerMetaTools(server, opts.registry, { baseUrl, activity });
522
+ registerMetaTools(server, opts.registry, {
523
+ baseUrl,
524
+ activity,
525
+ defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
526
+ });
521
527
  if (opts.executor) {
522
528
  registerExecuteTool(server, opts.registry, {
523
529
  baseUrl,
@@ -6,19 +6,28 @@ import {
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
8
  import { dirname } from "node:path";
9
- import type { KVStorage } from "../types.js";
9
+ import type { KVStorage, Logger } from "../types.js";
10
10
 
11
11
  interface Entry {
12
12
  value: string;
13
13
  exp?: number; // epoch ms
14
14
  }
15
15
 
16
+ export interface FileStorageOptions {
17
+ /** Destination for the corrupt-state-file recovery report. Default console. */
18
+ logger?: Logger;
19
+ }
20
+
16
21
  /**
17
22
  * JSON-file-backed KVStorage for Node. Loads once, persists on every write via
18
23
  * a temp-file + rename (atomic-ish). Only reachable via the "@zackbart/connecta/node"
19
24
  * subpath so the main entry stays Workers-clean.
20
25
  */
21
- export function fileStorage(path: string): KVStorage {
26
+ export function fileStorage(
27
+ path: string,
28
+ opts: FileStorageOptions = {},
29
+ ): KVStorage {
30
+ const logger: Logger = opts.logger ?? console;
22
31
  let data: Record<string, Entry> = {};
23
32
  if (existsSync(path)) {
24
33
  try {
@@ -39,7 +48,7 @@ export function fileStorage(path: string): KVStorage {
39
48
  `than overwrite it. Move or repair the file, then restart.`,
40
49
  );
41
50
  }
42
- console.error(
51
+ logger.error(
43
52
  `[connecta] state file ${path} is not valid JSON ` +
44
53
  `(${error instanceof Error ? error.message : String(error)}) — ` +
45
54
  `moved to ${quarantine}, starting from empty state. Downstream ` +
@@ -0,0 +1,96 @@
1
+ import { Validator } from "@cfworker/json-schema";
2
+ import { ConnectorCallError } from "./errors.js";
3
+ import type { JsonSchema, Logger } from "./types.js";
4
+
5
+ export interface ValidateToolInputOptions {
6
+ /**
7
+ * Tool address used in the error and warning text, conventionally
8
+ * `"connectorId.toolName"`.
9
+ */
10
+ address: string;
11
+ /**
12
+ * Destination for the one-time warning emitted when a schema turns out to be
13
+ * unusable. Default console.
14
+ */
15
+ logger?: Logger;
16
+ }
17
+
18
+ // Lazy validator cache keyed by the schema object itself; null marks a schema
19
+ // the validator rejected (warned once, then passed through rather than
20
+ // breaking a working tool). A WeakMap so schemas belonging to a discarded
21
+ // connector are collectable, the same pattern compactSchema uses.
22
+ const validators = new WeakMap<JsonSchema, Validator | null>();
23
+
24
+ function disableValidation(
25
+ schema: JsonSchema,
26
+ address: string,
27
+ logger: Logger,
28
+ err: unknown,
29
+ ): void {
30
+ validators.set(schema, null);
31
+ logger.warn(
32
+ `[connecta] tool "${address}" has an inputSchema the validator cannot use (${
33
+ err instanceof Error ? err.message : String(err)
34
+ }) — arguments are not validated`,
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Validate call arguments against a tool's JSON Schema.
40
+ *
41
+ * Returns a non-retryable `invalid_args` ConnectorCallError describing the
42
+ * mismatch, or null when the arguments are acceptable. It deliberately returns
43
+ * rather than throws: the caller decides what to do with the failure, which is
44
+ * what lets a connector own its error prose, or strip connector-wide
45
+ * convention arguments (a `confirm` flag on writes, say) that individual tool
46
+ * schemas do not declare before deciding the call is really invalid.
47
+ *
48
+ * A schema the validator cannot compile (or that only fails on first use, e.g.
49
+ * an unresolvable `$ref`) is warned about once and then passed through — a
50
+ * broken schema should not break an otherwise working tool.
51
+ *
52
+ * The compiled validator is cached by **schema object identity**, so pass a
53
+ * stable object: hold the parsed manifest and hand the same schema back on
54
+ * every call. A schema rebuilt per call is a cache miss every time — it still
55
+ * validates correctly, but recompiles the validator on each call, silently and
56
+ * with nothing to show for it but latency.
57
+ *
58
+ * `api()` uses this internally; it is exported for connectors that implement
59
+ * the `Connector` interface directly.
60
+ */
61
+ export function validateToolInput(
62
+ schema: JsonSchema,
63
+ args: unknown,
64
+ opts: ValidateToolInputOptions,
65
+ ): ConnectorCallError | null {
66
+ const logger = opts.logger ?? console;
67
+ let validator = validators.get(schema);
68
+ if (validator === undefined) {
69
+ try {
70
+ validator = new Validator(schema as never, "2020-12", false);
71
+ validators.set(schema, validator);
72
+ } catch (err) {
73
+ disableValidation(schema, opts.address, logger, err);
74
+ validator = null;
75
+ }
76
+ }
77
+ let result;
78
+ try {
79
+ result = validator?.validate(args);
80
+ } catch (err) {
81
+ // e.g. an unresolvable $ref — surfaces on first validate, not compile.
82
+ disableValidation(schema, opts.address, logger, err);
83
+ }
84
+ if (result && !result.valid) {
85
+ const units = result.errors.filter((u) => u.instanceLocation !== "#");
86
+ const detail = (units.length > 0 ? units : result.errors)
87
+ .slice(0, 3)
88
+ .map((u) => `${u.instanceLocation}: ${u.error}`)
89
+ .join("; ");
90
+ return new ConnectorCallError(
91
+ "invalid_args",
92
+ `Invalid arguments for "${opts.address}": ${detail || "input does not match the tool's inputSchema"}`,
93
+ );
94
+ }
95
+ return null;
96
+ }
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.2.1";
7
+ export const CONNECTA_VERSION = "0.4.0";