@zackbart/connecta 0.3.0 → 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.
@@ -1,5 +1,4 @@
1
- import { Validator } from "@cfworker/json-schema";
2
- import { ConnectorCallError } from "../errors.js";
1
+ import { validateToolInput } from "../validate.js";
3
2
  import type {
4
3
  Connector,
5
4
  ConnectorCredentialConfig,
@@ -74,34 +73,6 @@ export function api(id: string, opts: ApiOptions): Connector {
74
73
  }));
75
74
  const byName = new Map(opts.tools.map((t) => [t.name, t]));
76
75
  const validateArgs = opts.validateArgs ?? true;
77
- // Lazy per-tool validator cache; null marks a schema the validator rejected
78
- // (warned once, then passed through rather than breaking a working tool).
79
- const validators = new Map<string, Validator | null>();
80
- const disableValidation = (
81
- tool: ApiTool,
82
- ctx: ConnectorContext,
83
- err: unknown,
84
- ) => {
85
- validators.set(tool.name, null);
86
- ctx.logger.warn(
87
- `[connecta] tool "${id}.${tool.name}" has an inputSchema the validator cannot use (${
88
- err instanceof Error ? err.message : String(err)
89
- }) — arguments are not validated`,
90
- );
91
- };
92
- const validatorFor = (tool: ApiTool, ctx: ConnectorContext) => {
93
- let validator = validators.get(tool.name);
94
- if (validator === undefined) {
95
- try {
96
- validator = new Validator(tool.inputSchema as never, "2020-12", false);
97
- validators.set(tool.name, validator);
98
- } catch (err) {
99
- disableValidation(tool, ctx, err);
100
- validator = null;
101
- }
102
- }
103
- return validator;
104
- };
105
76
  return {
106
77
  id,
107
78
  title: opts.title,
@@ -121,27 +92,11 @@ export function api(id: string, opts: ApiOptions): Connector {
121
92
  }
122
93
  const input = args ?? {};
123
94
  if (validateArgs && tool.inputSchema) {
124
- const validator = validatorFor(tool, ctx);
125
- let result;
126
- try {
127
- result = validator?.validate(input);
128
- } catch (err) {
129
- // e.g. an unresolvable $ref — surfaces on first validate, not compile.
130
- disableValidation(tool, ctx, err);
131
- }
132
- if (result && !result.valid) {
133
- const units = result.errors.filter(
134
- (u) => u.instanceLocation !== "#",
135
- );
136
- const detail = (units.length > 0 ? units : result.errors)
137
- .slice(0, 3)
138
- .map((u) => `${u.instanceLocation}: ${u.error}`)
139
- .join("; ");
140
- throw new ConnectorCallError(
141
- "invalid_args",
142
- `Invalid arguments for "${id}.${name}": ${detail || "input does not match the tool's inputSchema"}`,
143
- );
144
- }
95
+ const invalid = validateToolInput(tool.inputSchema, input, {
96
+ address: `${id}.${name}`,
97
+ logger: ctx.logger,
98
+ });
99
+ if (invalid) throw invalid;
145
100
  }
146
101
  return tool.handler(input, ctx);
147
102
  },
package/src/errors.ts CHANGED
@@ -19,6 +19,13 @@ const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
19
19
  connector_call_failed: false,
20
20
  };
21
21
 
22
+ /** Non-negative integer milliseconds, or undefined for anything else. */
23
+ function normalizeRetryAfterMs(value: number | undefined): number | undefined {
24
+ if (value === undefined) return undefined;
25
+ if (!Number.isFinite(value) || value < 0) return undefined;
26
+ return Math.trunc(value);
27
+ }
28
+
22
29
  /**
23
30
  * Throw from `Connector.callTool` (or anything beneath it) to classify a
24
31
  * failure exactly. Untyped errors fall back to a message-text heuristic, so a
@@ -26,15 +33,28 @@ const RETRYABLE_BY_CODE: Record<ConnectorCallErrorCode, boolean> = {
26
33
  * retryable timeout — this class is the escape hatch. `retryable` defaults per
27
34
  * code (timeout, rate_limited, and unavailable retry; the rest do not) and may
28
35
  * be overridden.
36
+ *
37
+ * `retryAfterMs` carries a wait window the connector already knows — a
38
+ * `Retry-After` header, say — so the engine can wait that long instead of
39
+ * guessing, and so an agent that receives the failure can decide when to
40
+ * re-issue.
29
41
  */
30
42
  export class ConnectorCallError extends Error {
31
43
  readonly code: ConnectorCallErrorCode;
32
44
  readonly retryable: boolean;
45
+ /**
46
+ * Connector-known wait window in ms before this call is worth repeating,
47
+ * or undefined when the connector reported none. Always an own property —
48
+ * under ES2022 class fields the declaration itself defines it, so guarding
49
+ * the assignment would not keep it off the instance. Keeping the window out
50
+ * of the wire format is `classifyCallError`'s job, not this constructor's.
51
+ */
52
+ readonly retryAfterMs?: number;
33
53
 
34
54
  constructor(
35
55
  code: ConnectorCallErrorCode,
36
56
  message: string,
37
- opts: { retryable?: boolean; cause?: unknown } = {},
57
+ opts: { retryable?: boolean; retryAfterMs?: number; cause?: unknown } = {},
38
58
  ) {
39
59
  super(
40
60
  message,
@@ -43,6 +63,7 @@ export class ConnectorCallError extends Error {
43
63
  this.name = "ConnectorCallError";
44
64
  this.code = code;
45
65
  this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
66
+ this.retryAfterMs = normalizeRetryAfterMs(opts.retryAfterMs);
46
67
  }
47
68
  }
48
69
 
@@ -51,6 +72,12 @@ export interface CallErrorDetails {
51
72
  code: string;
52
73
  message: string;
53
74
  retryable: boolean;
75
+ /**
76
+ * Connector-reported wait window in ms, when known. Reported verbatim — the
77
+ * engine bounds how long it will itself wait, but the caller sees the real
78
+ * window so it can schedule a re-issue.
79
+ */
80
+ retryAfterMs?: number;
54
81
  }
55
82
 
56
83
  const RETRYABLE_MESSAGE_RE =
@@ -72,7 +99,23 @@ export function classifyCallError(
72
99
  fallbackCode = "connector_call_failed",
73
100
  ): CallErrorDetails {
74
101
  if (err instanceof ConnectorCallError) {
75
- return { code: err.code, message: err.message, retryable: err.retryable };
102
+ return {
103
+ code: err.code,
104
+ message: err.message,
105
+ retryable: err.retryable,
106
+ ...(err.retryAfterMs !== undefined
107
+ ? { retryAfterMs: err.retryAfterMs }
108
+ : {}),
109
+ };
110
+ }
111
+ // An aborted fetch rejects with a DOMException named "AbortError" whose
112
+ // message ("The operation was aborted", and variants across runtimes) matches
113
+ // neither heuristic below — so a call the engine itself cancelled would read
114
+ // as a non-retryable failure, the opposite of the truth. Note this also
115
+ // covers an abort the connector triggered for its own reasons; running out of
116
+ // time is by far the likelier cause and retryable/timeout is the safer read.
117
+ if (err instanceof Error && err.name === "AbortError") {
118
+ return { code: "timeout", message: err.message, retryable: true };
76
119
  }
77
120
  const message = err instanceof Error ? err.message : String(err);
78
121
  return {
package/src/index.ts CHANGED
@@ -61,6 +61,22 @@ export interface ConnectaConfig {
61
61
  * stash the full text for get_result paging. Default 50_000.
62
62
  */
63
63
  maxResultBytes?: number;
64
+ /**
65
+ * Deadline (ms) applied to call_tool/batch_call calls that pass no
66
+ * `timeoutMs`, giving the connector both a budget (`ctx.timeoutMs`) and a
67
+ * cancellation signal (`ctx.signal`). An explicit per-call `timeoutMs` always
68
+ * wins. **Opt-in — undefined by default**, because switching it on globally
69
+ * would put a deadline on every call in an existing deployment and the
70
+ * failure mode is a working long-running call starting to time out.
71
+ * `execute_code` host calls are unaffected; they already carry a 15 s bound.
72
+ *
73
+ * Bounds a single attempt, not the whole call — the same as an explicit
74
+ * `timeoutMs` has always done. A call that also passes `maxRetries` can
75
+ * therefore run to roughly `(maxRetries + 1)` times this value plus backoff.
76
+ * `maxRetries` defaults to 0, so this is the total for every call that does
77
+ * not explicitly ask to retry.
78
+ */
79
+ defaultToolTimeoutMs?: number;
64
80
  serverInfo?: {
65
81
  name?: string;
66
82
  version?: string;
@@ -141,6 +157,7 @@ export function createConnecta(config: ConnectaConfig): Connecta {
141
157
  activityReadGate: config.activityReadGate,
142
158
  activityDeploymentId: config.activityDeploymentId,
143
159
  executor: config.executor,
160
+ defaultToolTimeoutMs: config.defaultToolTimeoutMs,
144
161
  credentialVault,
145
162
  deploymentInfo: config.deploymentInfo,
146
163
  branding: config.branding,
@@ -161,6 +178,11 @@ export { remoteMcp } from "./connectors/remote-mcp.js";
161
178
  export { api } from "./connectors/api.js";
162
179
  export { ConnectorCallError } from "./errors.js";
163
180
  export type { ConnectorCallErrorCode, CallErrorDetails } from "./errors.js";
181
+ // The same argument validation api() performs, usable by connectors that
182
+ // implement the Connector interface directly. Returns the error rather than
183
+ // throwing so the caller decides what to do with it.
184
+ export { validateToolInput } from "./validate.js";
185
+ export type { ValidateToolInputOptions } from "./validate.js";
164
186
  export { bearerToken } from "./auth/bearer.js";
165
187
  export { memoryStorage } from "./storage/memory.js";
166
188
  export { CONNECTA_VERSION } from "./version.js";
@@ -0,0 +1,11 @@
1
+ // Public re-export of the JSON Schema validator connecta itself uses, so
2
+ // downstream code that validates at build time (a manifest generator asserting
3
+ // its own output, say) resolves the same implementation and version through an
4
+ // explicit subpath rather than through npm hoisting.
5
+ export { Validator } from "@cfworker/json-schema";
6
+ export type {
7
+ OutputUnit,
8
+ Schema,
9
+ SchemaDraft,
10
+ ValidationResult,
11
+ } from "@cfworker/json-schema";
package/src/meta-tools.ts CHANGED
@@ -53,6 +53,49 @@ const dec = new TextDecoder();
53
53
 
54
54
  type ErrorDetails = CallErrorDetails;
55
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;
97
+ }
98
+
56
99
  /** Details for failures that never reached a connector (no thrown value). */
57
100
  function errorDetails(code: string, message: string): ErrorDetails {
58
101
  return { code, message, retryable: messageLooksRetryable(message) };
@@ -260,18 +303,22 @@ export interface SkillArgs {
260
303
  /**
261
304
  * The nine meta-tool handlers over a registry. Exported for direct testing;
262
305
  * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
263
- * overrides the registry's default result-size cap. (execute_code, the optional
264
- * 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.)
265
309
  */
266
310
  export function createMetaTools(
267
311
  registry: Registry,
268
312
  baseUrl: string,
269
313
  opts: {
270
314
  maxResultBytes?: number;
315
+ /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
316
+ defaultToolTimeoutMs?: number;
271
317
  activity?: ActivityRequestContext;
272
318
  } = {},
273
319
  ) {
274
320
  const cap = opts.maxResultBytes ?? registry.maxResultBytes;
321
+ const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
275
322
  // createMetaTools() is called once per inbound MCP request. Sharing this
276
323
  // identity lets remote connectors reuse one downstream client inside that
277
324
  // request without leaking request-bound I/O into the next one.
@@ -356,10 +403,9 @@ export function createMetaTools(
356
403
  }
357
404
  const results = registry.resultsStorage();
358
405
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
359
- const timeoutMs =
360
- call.timeoutMs && call.timeoutMs > 0
361
- ? Math.max(1, Math.trunc(call.timeoutMs))
362
- : 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;
363
409
  const maxRetries = Math.min(
364
410
  2,
365
411
  Math.max(0, Math.trunc(call.maxRetries ?? 0)),
@@ -451,12 +497,18 @@ export function createMetaTools(
451
497
  connectorMs += Date.now() - connectorStarted;
452
498
  const details = classifyCallError(err);
453
499
  if (attempts <= maxRetries && retrySafe && details.retryable) {
454
- const backoffStarted = Date.now();
455
- await new Promise((resolve) =>
456
- setTimeout(resolve, Math.min(250 * 2 ** (attempts - 1), 1_000)),
457
- );
458
- backoffMs += Date.now() - backoffStarted;
459
- 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.
460
512
  }
461
513
  registry.recordFailure(
462
514
  resolved.connector.id,
@@ -987,11 +1039,13 @@ export function registerMetaTools(
987
1039
  ctx: {
988
1040
  baseUrl: string;
989
1041
  maxResultBytes?: number;
1042
+ defaultToolTimeoutMs?: number;
990
1043
  activity?: ActivityRequestContext;
991
1044
  },
992
1045
  ): void {
993
1046
  const mt = createMetaTools(registry, ctx.baseUrl, {
994
1047
  maxResultBytes: ctx.maxResultBytes,
1048
+ defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
995
1049
  activity: ctx.activity,
996
1050
  });
997
1051
 
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,
@@ -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.3.0";
7
+ export const CONNECTA_VERSION = "0.4.0";