@zackbart/connecta 0.2.1 → 0.3.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 (45) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/connectors/api.d.ts +14 -0
  3. package/dist/connectors/api.d.ts.map +1 -1
  4. package/dist/connectors/api.js +51 -1
  5. package/dist/connectors/api.js.map +1 -1
  6. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  7. package/dist/connectors/remote-mcp.js +22 -8
  8. package/dist/connectors/remote-mcp.js.map +1 -1
  9. package/dist/credentials.d.ts.map +1 -1
  10. package/dist/credentials.js +4 -1
  11. package/dist/credentials.js.map +1 -1
  12. package/dist/errors.d.ts +33 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +51 -0
  15. package/dist/errors.js.map +1 -0
  16. package/dist/execute.d.ts.map +1 -1
  17. package/dist/execute.js +10 -6
  18. package/dist/execute.js.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/meta-tools.d.ts.map +1 -1
  24. package/dist/meta-tools.js +18 -22
  25. package/dist/meta-tools.js.map +1 -1
  26. package/dist/node.d.ts +1 -0
  27. package/dist/node.d.ts.map +1 -1
  28. package/dist/node.js.map +1 -1
  29. package/dist/storage/file.d.ts +6 -2
  30. package/dist/storage/file.d.ts.map +1 -1
  31. package/dist/storage/file.js +3 -2
  32. package/dist/storage/file.js.map +1 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +4 -1
  36. package/src/connectors/api.ts +70 -1
  37. package/src/connectors/remote-mcp.ts +32 -13
  38. package/src/credentials.ts +4 -1
  39. package/src/errors.ts +83 -0
  40. package/src/execute.ts +13 -6
  41. package/src/index.ts +2 -0
  42. package/src/meta-tools.ts +37 -35
  43. package/src/node.ts +1 -0
  44. package/src/storage/file.ts +12 -3
  45. package/src/version.ts +1 -1
package/src/errors.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Typed failure contract for connector tool calls. Web-API only — no node:
2
+ // imports here.
3
+
4
+ /** Machine-readable classification of a failed connector tool call. */
5
+ export type ConnectorCallErrorCode =
6
+ | "timeout"
7
+ | "auth_required"
8
+ | "rate_limited"
9
+ | "unavailable"
10
+ | "invalid_args"
11
+ | "connector_call_failed";
12
+
13
+ const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
14
+ timeout: true,
15
+ rate_limited: true,
16
+ unavailable: true,
17
+ auth_required: false,
18
+ invalid_args: false,
19
+ connector_call_failed: false,
20
+ };
21
+
22
+ /**
23
+ * Throw from `Connector.callTool` (or anything beneath it) to classify a
24
+ * failure exactly. Untyped errors fall back to a message-text heuristic, so a
25
+ * connector whose legitimate error text mentions "timeout" is misread as a
26
+ * retryable timeout — this class is the escape hatch. `retryable` defaults per
27
+ * code (timeout, rate_limited, and unavailable retry; the rest do not) and may
28
+ * be overridden.
29
+ */
30
+ export class ConnectorCallError extends Error {
31
+ readonly code: ConnectorCallErrorCode;
32
+ readonly retryable: boolean;
33
+
34
+ constructor(
35
+ code: ConnectorCallErrorCode,
36
+ message: string,
37
+ opts: { retryable?: boolean; cause?: unknown } = {},
38
+ ) {
39
+ super(
40
+ message,
41
+ opts.cause !== undefined ? { cause: opts.cause } : undefined,
42
+ );
43
+ this.name = "ConnectorCallError";
44
+ this.code = code;
45
+ this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
46
+ }
47
+ }
48
+
49
+ /** The `error` object surfaced in call_tool/batch_call value-mode results. */
50
+ export interface CallErrorDetails {
51
+ code: string;
52
+ message: string;
53
+ retryable: boolean;
54
+ }
55
+
56
+ const RETRYABLE_MESSAGE_RE =
57
+ /timeout|timed out|econnreset|econnrefused|temporar|rate.?limit|429|502|503|504|refcountedcanceler|different request/i;
58
+ const TIMEOUT_MESSAGE_RE = /timed out|timeout/i;
59
+
60
+ /** Message-text fallback used when an error carries no typed classification. */
61
+ export function messageLooksRetryable(message: string): boolean {
62
+ return RETRYABLE_MESSAGE_RE.test(message);
63
+ }
64
+
65
+ /**
66
+ * Classify a value thrown by a connector call. A `ConnectorCallError` is
67
+ * authoritative; anything else falls back to the historical message-text
68
+ * heuristic.
69
+ */
70
+ export function classifyCallError(
71
+ err: unknown,
72
+ fallbackCode = "connector_call_failed",
73
+ ): CallErrorDetails {
74
+ if (err instanceof ConnectorCallError) {
75
+ return { code: err.code, message: err.message, retryable: err.retryable };
76
+ }
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ return {
79
+ code: TIMEOUT_MESSAGE_RE.test(message) ? "timeout" : fallbackCode,
80
+ message,
81
+ retryable: RETRYABLE_MESSAGE_RE.test(message),
82
+ };
83
+ }
package/src/execute.ts CHANGED
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { compactSchema, rankTools, summarizeDescription } from "./catalog.js";
4
4
  import { recordToolActivity, type ActivityRequestContext } from "./activity.js";
5
5
  import { errorResult, jsonResult, type ToolResult } from "./meta-tools.js";
6
+ import { classifyCallError, ConnectorCallError } from "./errors.js";
6
7
  import { unwrapMcpResult } from "./mcp-result.js";
7
8
  import type { Registry } from "./registry.js";
8
9
  import type {
@@ -191,7 +192,10 @@ export async function buildSandboxProviders(
191
192
  try {
192
193
  timer = setTimeout(() => {
193
194
  controller.abort(
194
- new Error(`Tool call timed out after ${hostCallTimeoutMs}ms`),
195
+ new ConnectorCallError(
196
+ "timeout",
197
+ `Tool call timed out after ${hostCallTimeoutMs}ms`,
198
+ ),
195
199
  );
196
200
  }, hostCallTimeoutMs);
197
201
  const pending = resolved.connector.callTool(
@@ -216,16 +220,16 @@ export async function buildSandboxProviders(
216
220
  return value;
217
221
  } catch (err) {
218
222
  registry.recordFailure(resolved.connector.id, Date.now() - started, err);
219
- const timedOut = /timed out|timeout/i.test(msg(err));
223
+ const details = classifyCallError(err);
220
224
  recordToolActivity(activity, {
221
225
  connectorId: resolved.connector.id,
222
226
  toolName: resolved.toolName,
223
227
  address: `${resolved.connector.id}.${resolved.toolName}`,
224
228
  source: "execute_code",
225
- outcome: timedOut ? "timeout" : "error",
229
+ outcome: details.code === "timeout" ? "timeout" : "error",
226
230
  durationMs: Date.now() - started,
227
231
  attempts: 1,
228
- errorCode: timedOut ? "timeout" : "connector_call_failed",
232
+ errorCode: details.code,
229
233
  });
230
234
  throw err;
231
235
  } finally {
@@ -269,8 +273,11 @@ export async function buildSandboxProviders(
269
273
  );
270
274
  continue;
271
275
  }
276
+ // `await` (not a bare promise return) so a synchronous throw inside
277
+ // callAddress never sits handler-less for the thenable-adoption
278
+ // microtask — workerd reports that gap as an unhandled rejection.
272
279
  fns[key] = async (args: unknown) =>
273
- callAddress(`${connector.id}.${t.name}`, args);
280
+ await callAddress(`${connector.id}.${t.name}`, args);
274
281
  }
275
282
  if (Object.keys(fns).length > 0) {
276
283
  providers.push({ name: ns, fns });
@@ -288,7 +295,7 @@ export async function buildSandboxProviders(
288
295
  `connecta.batch accepts at most ${EXECUTE_MAX_BATCH_CALLS} calls`,
289
296
  );
290
297
  }
291
- return Promise.all(
298
+ return await Promise.all(
292
299
  calls.map(async (call) => {
293
300
  const item = call as { address?: unknown; args?: unknown };
294
301
  try {
package/src/index.ts CHANGED
@@ -159,6 +159,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
159
159
 
160
160
  export { remoteMcp } from "./connectors/remote-mcp.js";
161
161
  export { api } from "./connectors/api.js";
162
+ export { ConnectorCallError } from "./errors.js";
163
+ export type { ConnectorCallErrorCode, CallErrorDetails } from "./errors.js";
162
164
  export { bearerToken } from "./auth/bearer.js";
163
165
  export { memoryStorage } from "./storage/memory.js";
164
166
  export { CONNECTA_VERSION } from "./version.js";
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,11 @@ 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;
52
- }
54
+ type ErrorDetails = CallErrorDetails;
53
55
 
56
+ /** Details for failures that never reached a connector (no thrown value). */
54
57
  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
- };
58
+ return { code, message, retryable: messageLooksRetryable(message) };
63
59
  }
64
60
 
65
61
  /** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
@@ -332,11 +328,10 @@ export function createMetaTools(
332
328
  ...(errorCode ? { errorCode } : {}),
333
329
  });
334
330
  };
335
- const failed = (code: string, message: string): RunCallOutcome => {
331
+ const failed = (error: ErrorDetails): RunCallOutcome => {
336
332
  const durationMs = Date.now() - started;
337
- const error = errorDetails(code, message);
338
333
  const diagnostics = timing();
339
- record(code === "timeout" ? "timeout" : "error", code);
334
+ record(error.code === "timeout" ? "timeout" : "error", error.code);
340
335
  return {
341
336
  toolResult:
342
337
  call.resultMode === "value"
@@ -347,7 +342,7 @@ export function createMetaTools(
347
342
  attempts,
348
343
  ...(call.diagnostics ? { timing: diagnostics } : {}),
349
344
  })
350
- : errorResult(message),
345
+ : errorResult(error.message),
351
346
  durationMs,
352
347
  attempts,
353
348
  timing: diagnostics,
@@ -355,7 +350,9 @@ export function createMetaTools(
355
350
  };
356
351
  };
357
352
  if (!resolved) {
358
- return failed("unknown_address", `Unknown address "${call.address}"`);
353
+ return failed(
354
+ errorDetails("unknown_address", `Unknown address "${call.address}"`),
355
+ );
359
356
  }
360
357
  const results = registry.resultsStorage();
361
358
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
@@ -375,13 +372,17 @@ export function createMetaTools(
375
372
  ).find((tool) => tool.name === resolved.toolName);
376
373
  } catch (err) {
377
374
  catalogMs += Date.now() - catalogStarted;
378
- return failed("catalog_lookup_failed", msg(err));
375
+ // classifyCallError so a typed auth_required thrown while listing tools
376
+ // (e.g. a revoked downstream OAuth grant) keeps its code.
377
+ return failed(classifyCallError(err, "catalog_lookup_failed"));
379
378
  }
380
379
  catalogMs += Date.now() - catalogStarted;
381
380
  if (!definition) {
382
381
  return failed(
383
- "unknown_tool",
384
- `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
382
+ errorDetails(
383
+ "unknown_tool",
384
+ `Unknown tool "${resolved.toolName}" on connector "${resolved.connector.id}"`,
385
+ ),
385
386
  );
386
387
  }
387
388
  const explicitlyReadOnly =
@@ -389,8 +390,10 @@ export function createMetaTools(
389
390
  definition.annotations?.destructiveHint !== true;
390
391
  if (!explicitlyReadOnly && !options.allowDestructive) {
391
392
  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.`,
393
+ errorDetails(
394
+ "destructive_tool_requires_approval",
395
+ `Tool "${call.address}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`,
396
+ ),
394
397
  );
395
398
  }
396
399
  const retrySafe =
@@ -420,7 +423,12 @@ export function createMetaTools(
420
423
  pending,
421
424
  new Promise<never>((_, reject) => {
422
425
  timer = setTimeout(() => {
423
- reject(new Error(`Tool call timed out after ${timeoutMs}ms`));
426
+ reject(
427
+ new ConnectorCallError(
428
+ "timeout",
429
+ `Tool call timed out after ${timeoutMs}ms`,
430
+ ),
431
+ );
424
432
  controller?.abort();
425
433
  }, timeoutMs);
426
434
  }),
@@ -441,7 +449,7 @@ export function createMetaTools(
441
449
  } catch (err) {
442
450
  // Includes connector setup, downstream execution, and timeout wait.
443
451
  connectorMs += Date.now() - connectorStarted;
444
- const details = errorDetails("connector_call_failed", msg(err));
452
+ const details = classifyCallError(err);
445
453
  if (attempts <= maxRetries && retrySafe && details.retryable) {
446
454
  const backoffStarted = Date.now();
447
455
  await new Promise((resolve) =>
@@ -455,12 +463,7 @@ export function createMetaTools(
455
463
  Date.now() - started,
456
464
  err,
457
465
  );
458
- return failed(
459
- /timed out|timeout/i.test(msg(err))
460
- ? "timeout"
461
- : "connector_call_failed",
462
- msg(err),
463
- );
466
+ return failed(details);
464
467
  } finally {
465
468
  if (timer) clearTimeout(timer);
466
469
  }
@@ -531,7 +534,7 @@ export function createMetaTools(
531
534
  };
532
535
  } catch (err) {
533
536
  resultProcessingMs += Date.now() - processingStarted;
534
- return failed("result_processing_failed", msg(err));
537
+ return failed(errorDetails("result_processing_failed", msg(err)));
535
538
  }
536
539
  }
537
540
 
@@ -846,12 +849,11 @@ export function createMetaTools(
846
849
  const results = settled.map((s, i) => {
847
850
  const address = args.calls[i].address;
848
851
  if (s.status === "rejected") {
849
- const message = msg(s.reason);
850
852
  return {
851
853
  address,
852
854
  ok: false,
853
- error: message,
854
- errorDetails: errorDetails("batch_call_failed", message),
855
+ error: msg(s.reason),
856
+ errorDetails: classifyCallError(s.reason, "batch_call_failed"),
855
857
  };
856
858
  }
857
859
  const r = s.value;
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;
@@ -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 ` +
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.3.0";