@zackbart/connecta 0.3.0 → 0.4.1

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 (69) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/README.md +34 -14
  3. package/SECURITY.md +1 -1
  4. package/dist/catalog.d.ts.map +1 -1
  5. package/dist/catalog.js +62 -0
  6. package/dist/catalog.js.map +1 -1
  7. package/dist/connectors/api.d.ts +10 -0
  8. package/dist/connectors/api.d.ts.map +1 -1
  9. package/dist/connectors/api.js +16 -39
  10. package/dist/connectors/api.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts +14 -1
  12. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  13. package/dist/connectors/remote-mcp.js +29 -0
  14. package/dist/connectors/remote-mcp.js.map +1 -1
  15. package/dist/credentials.d.ts +2 -1
  16. package/dist/credentials.d.ts.map +1 -1
  17. package/dist/credentials.js +4 -2
  18. package/dist/credentials.js.map +1 -1
  19. package/dist/errors.d.ts +20 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +39 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/executors/quickjs.d.ts.map +1 -1
  24. package/dist/executors/quickjs.js +32 -4
  25. package/dist/executors/quickjs.js.map +1 -1
  26. package/dist/index.d.ts +34 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +45 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/json-schema.d.ts +3 -0
  31. package/dist/json-schema.d.ts.map +1 -0
  32. package/dist/json-schema.js +6 -0
  33. package/dist/json-schema.js.map +1 -0
  34. package/dist/meta-tools.d.ts +34 -2
  35. package/dist/meta-tools.d.ts.map +1 -1
  36. package/dist/meta-tools.js +104 -15
  37. package/dist/meta-tools.js.map +1 -1
  38. package/dist/server.d.ts +4 -0
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/server.js +34 -6
  41. package/dist/server.js.map +1 -1
  42. package/dist/storage/file.d.ts.map +1 -1
  43. package/dist/storage/file.js +19 -3
  44. package/dist/storage/file.js.map +1 -1
  45. package/dist/ui.d.ts +1 -1
  46. package/dist/ui.d.ts.map +1 -1
  47. package/dist/ui.js +13 -9
  48. package/dist/ui.js.map +1 -1
  49. package/dist/validate.d.ts +71 -0
  50. package/dist/validate.d.ts.map +1 -0
  51. package/dist/validate.js +96 -0
  52. package/dist/validate.js.map +1 -0
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +5 -1
  56. package/src/catalog.ts +61 -0
  57. package/src/connectors/api.ts +25 -50
  58. package/src/connectors/remote-mcp.ts +52 -0
  59. package/src/credentials.ts +6 -3
  60. package/src/errors.ts +45 -2
  61. package/src/executors/quickjs.ts +32 -4
  62. package/src/index.ts +94 -1
  63. package/src/json-schema.ts +11 -0
  64. package/src/meta-tools.ts +156 -28
  65. package/src/server.ts +39 -6
  66. package/src/storage/file.ts +18 -2
  67. package/src/ui.ts +13 -8
  68. package/src/validate.ts +154 -0
  69. package/src/version.ts +1 -1
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 {
@@ -32,6 +32,14 @@ export interface QuickJsExecutorOptions {
32
32
  }
33
33
 
34
34
  const MAX_LOG_ENTRIES = 200;
35
+ // Cap each entry AND the cumulative buffer at capture time so untrusted guest
36
+ // code can't retain unbounded host memory: a single `console.log("x".repeat(N))`
37
+ // otherwise copies the whole N-char guest string into a host array we hold for
38
+ // the entire execution. 8k chars/entry is generous for glue-code logging (the
39
+ // join in execute.ts trims the assembled log to 4k anyway), and 256k total
40
+ // keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
41
+ const MAX_LOG_ENTRY_CHARS = 8_000;
42
+ const MAX_LOG_TOTAL_CHARS = 256_000;
35
43
 
36
44
  function msg(err: unknown): string {
37
45
  return err instanceof Error ? err.message : String(err);
@@ -123,12 +131,32 @@ function installBridge(
123
131
  };
124
132
  armWake(bridge);
125
133
 
134
+ // Running total of chars actually retained in `logs`; once the cumulative
135
+ // budget is spent we push one marker and drop the rest, so a flood of large
136
+ // entries can't grow the host array without bound.
137
+ let logTotalChars = 0;
138
+ let logBudgetSpent = false;
126
139
  const logFn = ctx.newFunction("__log", (h) => {
127
- if (logs.length < MAX_LOG_ENTRIES) {
128
- logs.push(ctx.getString(h));
129
- } else if (logs.length === MAX_LOG_ENTRIES) {
130
- logs.push(`[log truncated after ${MAX_LOG_ENTRIES} entries]`);
140
+ if (logs.length >= MAX_LOG_ENTRIES) {
141
+ if (logs.length === MAX_LOG_ENTRIES) {
142
+ logs.push(`[log truncated after ${MAX_LOG_ENTRIES} entries]`);
143
+ }
144
+ return;
145
+ }
146
+ if (logBudgetSpent) return;
147
+ // Cap the entry before retaining it: `getString` yields a transient copy,
148
+ // but slicing here keeps only a bounded string alive in `logs`.
149
+ let entry = ctx.getString(h);
150
+ if (entry.length > MAX_LOG_ENTRY_CHARS) {
151
+ entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
152
+ }
153
+ if (logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS) {
154
+ logs.push("[log truncated: size budget exceeded]");
155
+ logBudgetSpent = true;
156
+ return;
131
157
  }
158
+ logs.push(entry);
159
+ logTotalChars += entry.length;
132
160
  });
133
161
  ctx.setProp(ctx.global, "__log", logFn);
134
162
  logFn.dispose();
package/src/index.ts CHANGED
@@ -61,6 +61,38 @@ 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;
80
+ /**
81
+ * Deadline (ms) applied to each individual downstream probe/catalog call that
82
+ * the discovery meta-tools fan out — `list_connectors` (with `probe`),
83
+ * `search_tools`, and `describe_tools` — so a single hung connector can no
84
+ * longer stall the whole meta-tool call. **Defaults to a generous 30_000**,
85
+ * chosen to trip only on a pathological hang, not on a realistically slow
86
+ * probe, so having it on by default will not break existing deployments.
87
+ * Bounds one downstream call, not the whole fan-out: a connector that outruns
88
+ * it degrades to an unavailable/errored entry while the rest are unaffected.
89
+ *
90
+ * Does NOT apply to `call_tool`/`batch_call` — those carry their own budget
91
+ * via `defaultToolTimeoutMs` or a per-call `timeoutMs`. Note this bounds the
92
+ * caller-facing wait only; the underlying fetch is not currently aborted, so
93
+ * real cancellation of the downstream request is a deferred follow-up.
94
+ */
95
+ probeTimeoutMs?: number;
64
96
  serverInfo?: {
65
97
  name?: string;
66
98
  version?: string;
@@ -106,6 +138,58 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
106
138
  });
107
139
  }
108
140
 
141
+ /**
142
+ * One-time construction warnings for deployment shapes that run fine but are
143
+ * usually unintended. Warning-only — never throws and never changes behavior;
144
+ * each condition emits at most one `logger.warn`. Iterates connectors once.
145
+ */
146
+ function warnInsecureConfig(
147
+ config: ConnectaConfig,
148
+ inboundAuth: InboundAuth[],
149
+ logger: Logger,
150
+ ): void {
151
+ const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
152
+ const hasCredentialConnector = config.connectors.some((c) => c.credential);
153
+
154
+ // Open mode (no inbound auth) with connectors that expose credentials or
155
+ // downstream OAuth: any caller reaches everything, including the vault.
156
+ if (
157
+ inboundAuth.length === 0 &&
158
+ (hasCredentialConnector || oauthConnectors.length > 0)
159
+ ) {
160
+ logger.warn(
161
+ "[connecta] running with no inbound authentication: any caller can " +
162
+ "invoke every connector and read or overwrite stored credentials. " +
163
+ "Configure `auth` (for example bearerToken(...) or Clerk) to gate access.",
164
+ );
165
+ }
166
+
167
+ // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
168
+ // derived per-request from the attacker-influenced inbound Host header.
169
+ if (oauthConnectors.length > 0 && !config.publicUrl) {
170
+ logger.warn(
171
+ "[connecta] publicUrl is unset while OAuth connectors are configured: " +
172
+ "the downstream OAuth redirect_uri is derived per-request from the " +
173
+ "inbound Host header, so an attacker who controls that header can point " +
174
+ "it at their own host and capture the authorization code. Set " +
175
+ "`publicUrl` to a fixed https origin.",
176
+ );
177
+ }
178
+
179
+ // OAuth connectors whose callback performs no state/CSRF check: the public
180
+ // /oauth/callback/<id> route would exchange any delivered code.
181
+ for (const connector of oauthConnectors) {
182
+ if (!connector.verifyState) {
183
+ logger.warn(
184
+ `[connecta] connector "${connector.id}" has an OAuth callback with no ` +
185
+ `state/CSRF check: /oauth/callback/${connector.id} will exchange any ` +
186
+ "delivered code. Implement `verifyState` (the shipped remoteMcp " +
187
+ "connector already does).",
188
+ );
189
+ }
190
+ }
191
+ }
192
+
109
193
  export function createConnecta(config: ConnectaConfig): Connecta {
110
194
  const storage = config.storage ?? memoryStorage();
111
195
  const logger = config.logger ?? defaultLogger();
@@ -127,9 +211,11 @@ export function createConnecta(config: ConnectaConfig): Connecta {
127
211
  toolCatalogStaleSeconds: config.toolCatalogStaleSeconds,
128
212
  maxResultBytes: config.maxResultBytes,
129
213
  });
214
+ const inboundAuth = normalizeAuth(config.auth);
215
+ warnInsecureConfig(config, inboundAuth, logger);
130
216
  const handler = createFetchHandler({
131
217
  registry,
132
- auth: normalizeAuth(config.auth),
218
+ auth: inboundAuth,
133
219
  publicUrl: config.publicUrl,
134
220
  serverInfo: {
135
221
  ...config.serverInfo,
@@ -141,6 +227,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
141
227
  activityReadGate: config.activityReadGate,
142
228
  activityDeploymentId: config.activityDeploymentId,
143
229
  executor: config.executor,
230
+ defaultToolTimeoutMs: config.defaultToolTimeoutMs,
231
+ probeTimeoutMs: config.probeTimeoutMs,
144
232
  credentialVault,
145
233
  deploymentInfo: config.deploymentInfo,
146
234
  branding: config.branding,
@@ -161,6 +249,11 @@ export { remoteMcp } from "./connectors/remote-mcp.js";
161
249
  export { api } from "./connectors/api.js";
162
250
  export { ConnectorCallError } from "./errors.js";
163
251
  export type { ConnectorCallErrorCode, CallErrorDetails } from "./errors.js";
252
+ // The same argument validation api() performs, usable by connectors that
253
+ // implement the Connector interface directly. Returns the error rather than
254
+ // throwing so the caller decides what to do with it.
255
+ export { validateToolInput } from "./validate.js";
256
+ export type { ValidateToolInputOptions } from "./validate.js";
164
257
  export { bearerToken } from "./auth/bearer.js";
165
258
  export { memoryStorage } from "./storage/memory.js";
166
259
  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
@@ -15,7 +15,7 @@ import {
15
15
  } from "./errors.js";
16
16
  import type { Registry } from "./registry.js";
17
17
  import { AVAILABLE_SKILLS } from "./skills.js";
18
- import type { KVStorage, ToolDef } from "./types.js";
18
+ import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
19
19
 
20
20
  interface TextContent {
21
21
  type: "text";
@@ -53,6 +53,85 @@ 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
+ * Generous default bound for a single downstream probe/catalog call in the
77
+ * list/search/describe fan-out. High enough to trip only on a pathological
78
+ * hang, not a realistically slow probe.
79
+ */
80
+ const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
81
+
82
+ /**
83
+ * Reject `promise` after `ms` if it has not settled, so one hung downstream
84
+ * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
85
+ * the registry probe methods take no AbortSignal, so the underlying fetch is
86
+ * NOT cancelled and keeps running in the background. Real cancellation
87
+ * (AbortSignal plumbed through the registry) is a deferred follow-up.
88
+ */
89
+ function withTimeout<T>(
90
+ promise: Promise<T>,
91
+ ms: number,
92
+ label: string,
93
+ ): Promise<T> {
94
+ return new Promise<T>((resolve, reject) => {
95
+ const timer = setTimeout(() => {
96
+ reject(new Error(`${label} timed out after ${ms}ms`));
97
+ }, ms);
98
+ promise.then(
99
+ (value) => {
100
+ clearTimeout(timer);
101
+ resolve(value);
102
+ },
103
+ (err) => {
104
+ clearTimeout(timer);
105
+ reject(err);
106
+ },
107
+ );
108
+ });
109
+ }
110
+
111
+ /**
112
+ * How long to wait before the next attempt, or `undefined` for "don't retry".
113
+ *
114
+ * A connector that read a `Retry-After` header knows the window exactly, so it
115
+ * is honoured **exactly or not at all**: truncating an exponential *guess* is
116
+ * harmless, but truncating a *known* window means deliberately retrying inside
117
+ * a rate limit — the harm this channel exists to prevent. A window longer than
118
+ * `MAX_RETRY_BACKOFF_MS` therefore declines the retry rather than shortening
119
+ * it. (`retryAfterMs` is normalized non-negative, so `0` means "retry now".)
120
+ * Connectors that report no window keep the historical exponential guess.
121
+ *
122
+ * Waits are per attempt, matching the per-attempt `timeoutMs` race in
123
+ * `runCall`. Exported for direct testing.
124
+ */
125
+ export function retryBackoffMs(
126
+ attempt: number,
127
+ retryAfterMs: number | undefined,
128
+ ): number | undefined {
129
+ if (retryAfterMs === undefined) {
130
+ return Math.min(250 * 2 ** (attempt - 1), 1_000);
131
+ }
132
+ return retryAfterMs <= MAX_RETRY_BACKOFF_MS ? retryAfterMs : undefined;
133
+ }
134
+
56
135
  /** Details for failures that never reached a connector (no thrown value). */
57
136
  function errorDetails(code: string, message: string): ErrorDetails {
58
137
  return { code, message, retryable: messageLooksRetryable(message) };
@@ -260,18 +339,26 @@ export interface SkillArgs {
260
339
  /**
261
340
  * The nine meta-tool handlers over a registry. Exported for direct testing;
262
341
  * 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.)
342
+ * overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
343
+ * supplies a deadline for calls that don't carry one. (execute_code, the
344
+ * optional tenth tool, is registered separately by registerExecuteTool.)
265
345
  */
266
346
  export function createMetaTools(
267
347
  registry: Registry,
268
348
  baseUrl: string,
269
349
  opts: {
270
350
  maxResultBytes?: number;
351
+ /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
352
+ defaultToolTimeoutMs?: number;
353
+ /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
354
+ probeTimeoutMs?: number;
271
355
  activity?: ActivityRequestContext;
272
356
  } = {},
273
357
  ) {
274
358
  const cap = opts.maxResultBytes ?? registry.maxResultBytes;
359
+ const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
360
+ const probeTimeoutMs =
361
+ normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
275
362
  // createMetaTools() is called once per inbound MCP request. Sharing this
276
363
  // identity lets remote connectors reuse one downstream client inside that
277
364
  // request without leaking request-bound I/O into the next one.
@@ -356,10 +443,9 @@ export function createMetaTools(
356
443
  }
357
444
  const results = registry.resultsStorage();
358
445
  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;
446
+ // An explicit per-call deadline always wins; the config default only fills
447
+ // the gap, and stays off entirely when the deployment sets none.
448
+ const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
363
449
  const maxRetries = Math.min(
364
450
  2,
365
451
  Math.max(0, Math.trunc(call.maxRetries ?? 0)),
@@ -451,12 +537,18 @@ export function createMetaTools(
451
537
  connectorMs += Date.now() - connectorStarted;
452
538
  const details = classifyCallError(err);
453
539
  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;
540
+ const wait = retryBackoffMs(attempts, details.retryAfterMs);
541
+ if (wait !== undefined) {
542
+ const backoffStarted = Date.now();
543
+ if (wait > 0) {
544
+ await new Promise((resolve) => setTimeout(resolve, wait));
545
+ }
546
+ backoffMs += Date.now() - backoffStarted;
547
+ continue;
548
+ }
549
+ // The reported window is longer than the engine will park a
550
+ // synchronous request for. Fall through to failure with
551
+ // retryAfterMs reported verbatim so the agent can re-issue.
460
552
  }
461
553
  registry.recordFailure(
462
554
  resolved.connector.id,
@@ -570,25 +662,45 @@ export function createMetaTools(
570
662
  const checkedAt = new Date().toISOString();
571
663
  const statusStarted = Date.now();
572
664
  const observed = registry.healthFor(c.id);
573
- let status = probe
574
- ? await registry.statusFor(c.id, baseUrl, requestScope)
575
- : {
576
- state:
577
- observed?.consecutiveFailures &&
578
- observed.consecutiveFailures > 0
579
- ? ("error" as const)
580
- : observed?.lastSuccessAt || c.kind === "api"
581
- ? ("ok" as const)
582
- : ("unknown" as const),
583
- ...(observed?.lastError ? { message: observed.lastError } : {}),
584
- };
665
+ let status:
666
+ | ConnectorStatus
667
+ | { state: "ok" | "error" | "unknown"; message?: string };
668
+ if (probe) {
669
+ try {
670
+ status = await withTimeout(
671
+ registry.statusFor(c.id, baseUrl, requestScope),
672
+ probeTimeoutMs,
673
+ `list_connectors probe of "${c.id}"`,
674
+ );
675
+ } catch (err) {
676
+ // A probe that outran probeTimeoutMs (or otherwise threw)
677
+ // degrades this connector to an error status rather than
678
+ // hanging the whole list_connectors call.
679
+ status = { state: "error", message: msg(err) };
680
+ }
681
+ } else {
682
+ status = {
683
+ state:
684
+ observed?.consecutiveFailures &&
685
+ observed.consecutiveFailures > 0
686
+ ? ("error" as const)
687
+ : observed?.lastSuccessAt || c.kind === "api"
688
+ ? ("ok" as const)
689
+ : ("unknown" as const),
690
+ ...(observed?.lastError ? { message: observed.lastError } : {}),
691
+ };
692
+ }
585
693
  let tools = registry.peekTools(c.id);
586
694
  // An auth_required status may have just started OAuth. A second
587
695
  // listTools probe would overwrite its state/verifier while returning
588
696
  // the first (now stale) authorization URL.
589
697
  if (probe && status.state === "ok") {
590
698
  try {
591
- tools = await registry.refreshTools(c.id, baseUrl, requestScope);
699
+ tools = await withTimeout(
700
+ registry.refreshTools(c.id, baseUrl, requestScope),
701
+ probeTimeoutMs,
702
+ `list_connectors catalog refresh of "${c.id}"`,
703
+ );
592
704
  registry.recordSuccess(c.id, Date.now() - statusStarted);
593
705
  } catch (err) {
594
706
  status = { state: "error" as const, message: msg(err) };
@@ -635,7 +747,13 @@ export function createMetaTools(
635
747
  order: number;
636
748
  }> = [];
637
749
  const catalogs = await Promise.allSettled(
638
- conns.map((c) => registry.getTools(c.id, baseUrl, requestScope)),
750
+ conns.map((c) =>
751
+ withTimeout(
752
+ registry.getTools(c.id, baseUrl, requestScope),
753
+ probeTimeoutMs,
754
+ `search_tools probe of "${c.id}"`,
755
+ ),
756
+ ),
639
757
  );
640
758
  let orderBase = 0;
641
759
  catalogs.forEach((catalog, connectorIndex) => {
@@ -739,7 +857,13 @@ export function createMetaTools(
739
857
  ),
740
858
  ];
741
859
  const loaded = await Promise.allSettled(
742
- connectorIds.map((id) => registry.getTools(id, baseUrl, requestScope)),
860
+ connectorIds.map((id) =>
861
+ withTimeout(
862
+ registry.getTools(id, baseUrl, requestScope),
863
+ probeTimeoutMs,
864
+ `describe_tools probe of "${id}"`,
865
+ ),
866
+ ),
743
867
  );
744
868
  const catalogs = new Map<string, ToolDef[] | Error>();
745
869
  loaded.forEach((result, index) => {
@@ -987,11 +1111,15 @@ export function registerMetaTools(
987
1111
  ctx: {
988
1112
  baseUrl: string;
989
1113
  maxResultBytes?: number;
1114
+ defaultToolTimeoutMs?: number;
1115
+ probeTimeoutMs?: number;
990
1116
  activity?: ActivityRequestContext;
991
1117
  },
992
1118
  ): void {
993
1119
  const mt = createMetaTools(registry, ctx.baseUrl, {
994
1120
  maxResultBytes: ctx.maxResultBytes,
1121
+ defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1122
+ probeTimeoutMs: ctx.probeTimeoutMs,
995
1123
  activity: ctx.activity,
996
1124
  });
997
1125
 
package/src/server.ts CHANGED
@@ -47,6 +47,10 @@ 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;
52
+ /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
53
+ probeTimeoutMs?: number;
50
54
  /** When set, the execute_code meta-tool is registered on top of the nine. */
51
55
  executor?: Executor;
52
56
  /** Encrypted connector-credential storage backing the authenticated /ui controls. */
@@ -59,6 +63,14 @@ function msg(err: unknown): string {
59
63
  return err instanceof Error ? err.message : String(err);
60
64
  }
61
65
 
66
+ /** Per-request base64 nonce for the /ui page's inline scripts (Node 20+ and Workers). */
67
+ function uiScriptNonce(): string {
68
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
69
+ let binary = "";
70
+ for (const byte of bytes) binary += String.fromCharCode(byte);
71
+ return btoa(binary);
72
+ }
73
+
62
74
  function escapeHtml(s: string): string {
63
75
  return s
64
76
  .replaceAll("&", "&amp;")
@@ -229,9 +241,12 @@ function withSecurityHeaders(
229
241
  headers.set("Strict-Transport-Security", "max-age=31536000");
230
242
  }
231
243
  if (path === "/ui") {
232
- // A directive-only CSP does not interfere with the UI's existing scripts,
233
- // while preventing the authenticated operator surface from being framed.
234
- headers.set("Content-Security-Policy", "frame-ancestors 'none'");
244
+ // The /ui GET response ships its own nonce-based script CSP (which already
245
+ // includes frame-ancestors 'none'); only fall back to the framing-only
246
+ // directive when no CSP is present (e.g. HTTPS redirects, error responses).
247
+ if (!headers.has("Content-Security-Policy")) {
248
+ headers.set("Content-Security-Policy", "frame-ancestors 'none'");
249
+ }
235
250
  headers.set("X-Frame-Options", "DENY");
236
251
  }
237
252
  return new Response(response.body, {
@@ -517,7 +532,12 @@ async function serveMcp(
517
532
  logger: opts.logger,
518
533
  }
519
534
  : undefined;
520
- registerMetaTools(server, opts.registry, { baseUrl, activity });
535
+ registerMetaTools(server, opts.registry, {
536
+ baseUrl,
537
+ activity,
538
+ defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
539
+ probeTimeoutMs: opts.probeTimeoutMs,
540
+ });
521
541
  if (opts.executor) {
522
542
  registerExecuteTool(server, opts.registry, {
523
543
  baseUrl,
@@ -687,9 +707,22 @@ export function createFetchHandler(
687
707
  // Open shell — carries no data; data comes only from the gated /ui/data.
688
708
  const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
689
709
  const mcpUrl = new URL("/mcp", baseUrl).toString();
690
- return new Response(renderUiHtml(uiAuth, mcpUrl, opts.branding), {
710
+ // Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
711
+ // lets scripts the nonced Clerk loader injects at runtime execute; the
712
+ // https:/'unsafe-inline' fallbacks are ignored by CSP3 browsers that
713
+ // honour the nonce and only cover legacy ones. No default-src, so Clerk's
714
+ // style/font/network needs and the page's inline <style> stay unrestricted
715
+ // — only script execution, the XSS sink, is gated.
716
+ const nonce = uiScriptNonce();
717
+ return new Response(renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce), {
691
718
  status: 200,
692
- headers: { "Content-Type": "text/html; charset=utf-8" },
719
+ headers: {
720
+ "Content-Type": "text/html; charset=utf-8",
721
+ "Content-Security-Policy":
722
+ `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
723
+ "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
724
+ "X-Content-Type-Options": "nosniff",
725
+ },
693
726
  });
694
727
  }
695
728
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ chmodSync,
2
3
  existsSync,
3
4
  mkdirSync,
4
5
  readFileSync,
@@ -28,8 +29,20 @@ export function fileStorage(
28
29
  opts: FileStorageOptions = {},
29
30
  ): KVStorage {
30
31
  const logger: Logger = opts.logger ?? console;
32
+ // The state file holds downstream OAuth access/refresh tokens in cleartext,
33
+ // so keep it owner-only. Repair is best-effort: chmod is a no-op or throws on
34
+ // non-POSIX filesystems, and a loose mode must never keep the store from
35
+ // starting.
36
+ const tighten = () => {
37
+ try {
38
+ chmodSync(path, 0o600);
39
+ } catch {
40
+ // Non-POSIX filesystem or a race on the file — leave the mode as-is.
41
+ }
42
+ };
31
43
  let data: Record<string, Entry> = {};
32
44
  if (existsSync(path)) {
45
+ tighten();
33
46
  try {
34
47
  data = JSON.parse(readFileSync(path, "utf8")) as Record<string, Entry>;
35
48
  } catch (error) {
@@ -60,10 +73,13 @@ export function fileStorage(
60
73
  }
61
74
  const persist = () => {
62
75
  const dir = dirname(path);
63
- if (dir) mkdirSync(dir, { recursive: true });
76
+ if (dir) mkdirSync(dir, { recursive: true, mode: 0o700 });
64
77
  const tmp = `${path}.tmp`;
65
- writeFileSync(tmp, JSON.stringify(data));
78
+ // 0o600 on the tmp file; the atomic rename below preserves it, so the live
79
+ // state file is never briefly world-readable.
80
+ writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
66
81
  renameSync(tmp, path);
82
+ tighten();
67
83
  };
68
84
  const fresh = (key: string): Entry | null => {
69
85
  const e = data[key];