@zackbart/connecta 0.41.0 → 0.42.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 (148) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -20,11 +20,14 @@ export function nativeRecovery(result, wireName) {
20
20
  : { operator: "Check connector authentication in deployment configuration." };
21
21
  }
22
22
  else if (action?.tool === "call_tool" || action?.tool === "call_destructive_tool") {
23
+ const purpose = error.code === "destructive_tool_requires_approval"
24
+ ? "Nothing was sent. Refresh the host's tool list, then call this tool only if the write is still wanted; the host asks for approval."
25
+ : (action.purpose ?? "Retry the original native call.");
23
26
  error.nextAction = wireName
24
27
  ? {
25
28
  tool: wireName,
26
29
  arguments: action.arguments?.args ?? {},
27
- purpose: `${action.purpose ?? "Retry the original native call."}${action.arguments?.argsRedacted ? " Resend the original arguments; sensitive fields were omitted." : ""}`,
30
+ purpose: `${purpose}${action.arguments?.argsRedacted ? " Resend the original arguments; sensitive fields were omitted." : ""}`,
28
31
  }
29
32
  : { host: "search", purpose: action.purpose };
30
33
  }
@@ -12,6 +12,7 @@ import { withAuthorizationHandoff } from "./authorization-handoff.js";
12
12
  import { classifyCallError } from "./errors.js";
13
13
  import { nativeRecovery } from "./native-recovery.js";
14
14
  import { CatalogService } from "./catalog-service.js";
15
+ import { catalogStaleness } from "./catalog-freshness.js";
15
16
  import { jsonResult } from "./meta-tools.js";
16
17
  import { failureRecord, logFailure } from "./operator-record.js";
17
18
  /**
@@ -24,8 +25,19 @@ export const NATIVE_NAME_MAX = 48;
24
25
  const MAX_NATIVE_TOOLS = 1_000;
25
26
  /** Native `tools/list` cache hint. Shorter than the meta surface's hour: the list follows downstream catalogs. */
26
27
  export const NATIVE_TOOLS_LIST_TTL_MS = 300_000;
28
+ /** The hint when a listing omits a connector or lists one stale: about when that connector is next asked. */
29
+ export const NATIVE_DEGRADED_TOOLS_LIST_TTL_MS = 60_000;
27
30
  const HASH_CHARS = 6;
28
31
  const SEPARATOR = "__";
32
+ /**
33
+ * What every write's name carries. It sits before the tool, because Eve displays
34
+ * a name's last `__` segment and a suffix there would show every write as "write".
35
+ */
36
+ const WRITE_SEGMENT = `${SEPARATOR}WRITE${SEPARATOR}`;
37
+ /** How every hashed read ends, and so what no write's name may end in. See {@link nativeToolName}. */
38
+ const READ_HASH_TAIL = /_[0-9a-f]{6}$/;
39
+ /** A hashed write keeps this much of its connector, leaving the rest of the budget to its tool. */
40
+ const WRITE_CONNECTOR_HEAD = 24;
29
41
  /** No `__`, no leading `_`: the last `__` of a plain name is then always its separator. */
30
42
  const PLAIN_TOOL = /^[A-Za-z0-9-](?:[A-Za-z0-9-]|_(?!_))*$/;
31
43
  const NATIVE_NAME = new RegExp(`^[A-Za-z][A-Za-z0-9_-]{0,${NATIVE_NAME_MAX - 1}}$`);
@@ -49,28 +61,82 @@ const shortHashNative = /* @__PURE__ */ registerAuth(shortHash, function (text)
49
61
  function connectorPart(connectorId) {
50
62
  return /^[a-z]/.test(connectorId) ? connectorId : `C${connectorId}`;
51
63
  }
64
+ /** The classification a wire name is bound to. A name never changes what it is bound to. */
65
+ function boundClassification(name) {
66
+ return name.includes(WRITE_SEGMENT) && !READ_HASH_TAIL.test(name) ? "write" : "read";
67
+ }
52
68
  /**
53
- * A pure function of the connector id and downstream tool name, so it is
54
- * stable across catalog refreshes: `<connector>__<tool>` when that is exact,
55
- * legal and short, otherwise its head plus a six-hex SHA-256 suffix of the
56
- * original pair.
69
+ * A pure function of the connector id, downstream tool name and classification,
70
+ * so it is stable across catalog refreshes until the classification changes. A
71
+ * read is `<connector>__<tool>` when that is exact, legal and short, otherwise its
72
+ * head plus `_` and a six-hex SHA-256 of the original pair. A write is
73
+ * `<connector>__WRITE__<tool>` under the same conditions and when that does not end
74
+ * like a hashed read, otherwise a connector head, the marker, a tool head, `_w` and
75
+ * the same hash.
76
+ *
77
+ * No read's name is ever a write's, including a name a host cached before writes
78
+ * had their own, because a read's name has not changed. A read can carry the marker
79
+ * only when hashed: connector ids have no uppercase, so `WRITE` and the `__` after
80
+ * it would fall inside the tool, and a plain tool has no `__`. Every hashed read
81
+ * ends in `_` and six hex digits. Every write carries the marker and never ends so.
57
82
  */
58
- export function nativeToolName(connectorId, toolName) {
59
- return runEdge(nativeToolNameNative(connectorId, toolName));
83
+ export function nativeToolName(connectorId, toolName, classification) {
84
+ return runEdge(nativeToolNameNative(connectorId, toolName, classification));
60
85
  }
61
- const nativeToolNameNative = /* @__PURE__ */ registerAuth(nativeToolName, function (connectorId, toolName) {
86
+ const nativeToolNameNative = /* @__PURE__ */ registerAuth(nativeToolName, function (connectorId, toolName, classification) {
62
87
  return authFlow(function* () {
88
+ const connector = connectorPart(connectorId);
63
89
  const tool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
64
- const plain = `${connectorPart(connectorId)}${SEPARATOR}${tool}`;
65
- if (tool === toolName && PLAIN_TOOL.test(tool) && plain.length <= NATIVE_NAME_MAX)
90
+ const write = classification === "write";
91
+ const plain = `${connector}${write ? WRITE_SEGMENT : SEPARATOR}${tool}`;
92
+ if (tool === toolName &&
93
+ PLAIN_TOOL.test(tool) &&
94
+ plain.length <= NATIVE_NAME_MAX &&
95
+ !(write && READ_HASH_TAIL.test(plain)))
66
96
  return plain;
67
- const head = plain.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "");
68
- return `${head}_${yield* upwardStep(authCall(shortHash, [`${connectorId}.${toolName}`]))}`;
97
+ const hash = yield* upwardStep(authCall(shortHash, [`${connectorId}.${toolName}`]));
98
+ if (write) {
99
+ const owner = connector.slice(0, WRITE_CONNECTOR_HEAD);
100
+ const suffix = `_w${hash}`;
101
+ const room = NATIVE_NAME_MAX - suffix.length - owner.length - WRITE_SEGMENT.length;
102
+ return `${owner}${WRITE_SEGMENT}${tool.slice(0, room).replace(/_+$/, "")}${suffix}`;
103
+ }
104
+ return `${plain.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "")}_${hash}`;
69
105
  });
70
106
  });
71
- /** Read or write, from the served classification only. */
72
- function annotationsFor(tool) {
73
- return tool.classification === "read"
107
+ /**
108
+ * Whether any tool of a connector could hold a name: every shape in
109
+ * {@link nativeToolName}, including truncated connector prefixes.
110
+ */
111
+ function couldClaim(connector, name) {
112
+ const part = connectorPart(connector.id);
113
+ const prefix = `${part}${SEPARATOR}`;
114
+ // A hashed name can truncate even the connector prefix, and strips trailing
115
+ // underscores from its head. Such a name is exactly `${head}_` + six hex digits.
116
+ const head = prefix.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "");
117
+ return (name.startsWith(prefix) ||
118
+ // A hashed write keeps only the head of a long connector id.
119
+ name.startsWith(`${part.slice(0, WRITE_CONNECTOR_HEAD)}${WRITE_SEGMENT}`) ||
120
+ (name.length === head.length + 1 + HASH_CHARS &&
121
+ name.startsWith(`${head}_`) &&
122
+ /^[0-9a-f]+$/.test(name.slice(-HASH_CHARS))));
123
+ }
124
+ /**
125
+ * The name recovery should give: what the refusing verdict selects, because a
126
+ * catalog can change between resolving a name and invoking it.
127
+ */
128
+ function recoveryName(result, called, names) {
129
+ const error = result?.structuredContent?.error;
130
+ const route = error?.code === "destructive_tool_requires_approval" ? "call_destructive_tool" : error?.nextAction?.tool;
131
+ return route === "call_destructive_tool" ? names.write : route === "call_tool" ? names.read : called;
132
+ }
133
+ /**
134
+ * Read or write, from the served classification only. A stale listing cannot
135
+ * vouch for a read: the connector may have reclassified the tool since, so it
136
+ * fails closed (INV-1). The call's own verdict comes from a live catalog.
137
+ */
138
+ function annotationsFor(tool, stale) {
139
+ return tool.classification === "read" && !stale
74
140
  ? { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
75
141
  : { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
76
142
  }
@@ -160,10 +226,13 @@ export class NativeSurface {
160
226
  /**
161
227
  * Every listable native tool. A connector whose catalog fails is omitted
162
228
  * whole, never partially, with an operator warning; two tools claiming one
163
- * name are both omitted and stay reachable by address in execute_code.
229
+ * name are both omitted and stay reachable by address in execute_code. A tool
230
+ * claims its read and write names alike, whichever it is now, so a collision
231
+ * does not come and go with a classification and a name cached under the other
232
+ * classification still finds its tool.
164
233
  */
165
- claims(connectors = this.registry.listConnectors()) {
166
- return runEdge(authOwnMethod(NativeSurface.prototype, this, "claims", [connectors]));
234
+ claims(connectors = this.registry.listConnectors(), allowStale = false) {
235
+ return runEdge(authOwnMethod(NativeSurface.prototype, this, "claims", [connectors, allowStale]));
167
236
  }
168
237
  list() {
169
238
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "list", []));
@@ -176,7 +245,11 @@ export class NativeSurface {
176
245
  resolve(name) {
177
246
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "resolve", [name]));
178
247
  }
179
- /** A native call: the existing route its current verdict selects. */
248
+ /**
249
+ * A native call: the existing route the name is bound to, never one chosen from
250
+ * the current verdict. A read name takes the read route, which refuses a current
251
+ * write before dispatch, so a host's replay of a cached read cannot reach a write.
252
+ */
180
253
  call(name, args, request) {
181
254
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "call", [name, args, request]));
182
255
  }
@@ -185,11 +258,25 @@ export class NativeSurface {
185
258
  ...(entry?.tool.title ? { title: entry.tool.title } : {}),
186
259
  ...(entry ? { description: describe(entry, this.options.executeCode, guideName) } : {}),
187
260
  inputSchema: passThroughSchema(entry?.tool.inputSchema),
188
- ...(entry ? { annotations: annotationsFor(entry.tool) } : {}),
189
- ...(entry ? { _meta: { "dev.connecta/address": `${entry.connector.id}.${entry.tool.name}` } } : {}),
261
+ ...(entry ? { annotations: annotationsFor(entry.tool, entry.stale !== undefined) } : {}),
262
+ ...(entry
263
+ ? {
264
+ _meta: {
265
+ "dev.connecta/address": `${entry.connector.id}.${entry.tool.name}`,
266
+ ...(entry.stale
267
+ ? {
268
+ "dev.connecta/stale": {
269
+ code: entry.stale.code,
270
+ ageMs: entry.stale.ageMs,
271
+ ...(entry.stale.retryAfterMs === undefined ? {} : { retryAfterMs: entry.stale.retryAfterMs }),
272
+ },
273
+ }
274
+ : {}),
275
+ },
276
+ }
277
+ : {}),
190
278
  }, authMethod((args, request) => Effect.gen({ self: this }, function* () {
191
- const result = yield* authInvoke(this, "call", [name, args ?? {}, request]);
192
- return nativeRecovery(result, name);
279
+ return yield* authInvoke(this, "call", [name, args ?? {}, request]);
193
280
  })));
194
281
  }
195
282
  /**
@@ -266,14 +353,15 @@ export class NativeSurface {
266
353
  error: yield* upwardStep(authCall(withAuthorizationHandoff, [self.options.requestScope, error])),
267
354
  });
268
355
  result.isError = true;
269
- return result;
356
+ return nativeRecovery(result, name);
270
357
  }
271
358
  const address = `${target.entry.connector.id}.${target.entry.tool.name}`;
272
- return yield* upwardStep(authInvoke(self.options, "route", [
273
- target.entry.tool.classification === "read" ? "call_tool" : "call_destructive_tool",
359
+ const result = yield* upwardStep(authInvoke(self.options, "route", [
360
+ boundClassification(name) === "read" ? "call_tool" : "call_destructive_tool",
274
361
  { address, args },
275
362
  request,
276
363
  ]));
364
+ return nativeRecovery(result, recoveryName(result, name, target.entry.names));
277
365
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
278
366
  }, NativeSurface.prototype.call);
279
367
  registerAuthMethod(NativeSurface.prototype, "resolve", function (name) {
@@ -282,19 +370,20 @@ export class NativeSurface {
282
370
  requestScope: this.options.requestScope,
283
371
  signal: this.options.requestSignal,
284
372
  }, () => authFlow(function* () {
285
- const prefixed = self.registry.listConnectors().filter((connector) => {
286
- const prefix = `${connectorPart(connector.id)}${SEPARATOR}`;
287
- // A hashed name can truncate even the connector prefix, and strips
288
- // trailing underscores from its head. Include every possible claimant.
289
- const head = prefix.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "");
290
- // A hashed name carries a stripped head only as exactly `${head}_` + six hex digits.
291
- return (name.startsWith(prefix) ||
292
- (name.length === head.length + 1 + HASH_CHARS &&
293
- name.startsWith(`${head}_`) &&
294
- /^[0-9a-f]+$/.test(name.slice(-HASH_CHARS))));
295
- });
296
- const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [prefixed]));
297
- const found = byName.get(name);
373
+ const possible = (...names) => self.registry.listConnectors().filter((connector) => names.some((held) => couldClaim(connector, held)));
374
+ const prefixed = possible(name);
375
+ let { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [prefixed]));
376
+ let found = byName.get(name);
377
+ if (found) {
378
+ // A tool that loses either name loses both, as listing omits it, so the
379
+ // other name's claimants decide this one too. One step is enough: only a
380
+ // tool sharing one of these two names can take them from this tool.
381
+ const every = possible(found.names.read, found.names.write);
382
+ if (every.length > prefixed.length) {
383
+ ({ loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [every])));
384
+ found = byName.get(name);
385
+ }
386
+ }
298
387
  if (found === null)
299
388
  return { kind: "ambiguous" };
300
389
  // An unavailable possible claimant cannot prove uniqueness.
@@ -310,8 +399,10 @@ export class NativeSurface {
310
399
  requestScope: this.options.requestScope,
311
400
  signal: this.options.requestSignal,
312
401
  }, () => authFlow(function* () {
313
- const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", []));
402
+ const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [undefined, true]));
314
403
  for (const entry of loaded) {
404
+ if ("tools" in entry && catalogStaleness(entry.tools))
405
+ self.options.staleCatalogs.push(entry.connector.id);
315
406
  // Validate the whole connector before registering any of its tools.
316
407
  const tool = "tools" in entry
317
408
  ? entry.tools.find((tool) => tool.inputSchema !== undefined && !nativeInputSchema(tool.inputSchema))
@@ -321,32 +412,56 @@ export class NativeSurface {
321
412
  self.options.catalogErrors.push(entry.connector.id);
322
413
  logFailure(self.options.logger, "native catalog unavailable", failureRecord({ connector: entry.connector.id, ...(tool ? { tool } : {}) }, "error" in entry ? entry.error : undefined));
323
414
  }
324
- return [...byName.values()].filter((entry) => entry !== null &&
415
+ if (self.options.catalogErrors.length || self.options.staleCatalogs.length)
416
+ self.options.onDegradedListing?.();
417
+ return [...byName].flatMap(([name, entry]) => entry !== null &&
418
+ entry.name === name &&
325
419
  !self.options.catalogErrors.includes(entry.connector.id) &&
326
- (!(self.options.readOnly || entry.connector.readOnly) || entry.tool.classification === "read"));
420
+ (!(self.options.readOnly || entry.connector.readOnly) || entry.tool.classification === "read")
421
+ ? [entry]
422
+ : []);
327
423
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
328
424
  }, NativeSurface.prototype.list);
329
- registerAuthMethod(NativeSurface.prototype, "claims", function (connectors) {
425
+ registerAuthMethod(NativeSurface.prototype, "claims", function (connectors, allowStale = false) {
330
426
  const self = this;
331
427
  return duringViewEffect({
332
428
  requestScope: this.options.requestScope,
333
429
  signal: this.options.requestSignal,
334
430
  }, () => authFlow(function* () {
335
- const loaded = yield* upwardStep(authInvoke(self.catalog, "listCatalogs", [true, connectors ?? self.registry.listConnectors()]));
336
- const byName = new Map();
431
+ const loaded = yield* upwardStep(authInvoke(self.catalog, "listCatalogs", [true, connectors ?? self.registry.listConnectors(), allowStale]));
432
+ const claimed = [];
433
+ const claimants = new Map();
337
434
  for (const entry of loaded) {
338
435
  if (!("tools" in entry))
339
436
  continue;
340
437
  for (const tool of entry.tools) {
341
- const name = yield* upwardStep(authCall(nativeToolName, [entry.connector.id, tool.name]));
342
- if (byName.has(name)) {
343
- byName.set(name, null);
344
- logFailure(self.options.logger, "native name collision", failureRecord({ connector: entry.connector.id, tool }));
345
- }
346
- else
347
- byName.set(name, { name, connector: entry.connector, tool });
438
+ const read = yield* upwardStep(authCall(nativeToolName, [entry.connector.id, tool.name, "read"]));
439
+ const write = yield* upwardStep(authCall(nativeToolName, [entry.connector.id, tool.name, "write"]));
440
+ const name = tool.classification === "read" ? read : write;
441
+ const stale = catalogStaleness(entry.tools);
442
+ claimed.push({
443
+ names: [read, write],
444
+ claim: {
445
+ name,
446
+ names: { read, write },
447
+ connector: entry.connector,
448
+ tool,
449
+ ...(stale ? { stale } : {}),
450
+ },
451
+ });
452
+ for (const held of [read, write])
453
+ claimants.set(held, (claimants.get(held) ?? 0) + 1);
348
454
  }
349
455
  }
456
+ const byName = new Map();
457
+ for (const { names, claim } of claimed) {
458
+ // A tool that loses either name loses both: it is omitted, not half listed.
459
+ const collides = names.some((name) => claimants.get(name) > 1);
460
+ if (collides)
461
+ logFailure(self.options.logger, "native name collision", failureRecord({ connector: claim.connector.id, tool: claim.tool }));
462
+ for (const name of names)
463
+ byName.set(name, collides ? null : claim);
464
+ }
350
465
  return { loaded, byName };
351
466
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
352
467
  }, NativeSurface.prototype.claims);
package/dist/node.js CHANGED
@@ -114,6 +114,8 @@ export function listen(connecta, portOrOptions) {
114
114
  const activeResponses = new Map();
115
115
  let shuttingDown = false;
116
116
  const server = createServer((req, res) => {
117
+ // Before the body: a client's timer has been running through its upload too.
118
+ const requestArrivedAt = Date.now();
117
119
  awaitingRequest.delete(req.socket);
118
120
  const socket = req.socket;
119
121
  activeResponses.set(socket, (activeResponses.get(socket) ?? 0) + 1);
@@ -196,7 +198,7 @@ export function listen(connecta, portOrOptions) {
196
198
  res.end("Payload Too Large");
197
199
  return;
198
200
  }
199
- const response = await connecta.fetch(request, undefined, ctx);
201
+ const response = await connecta.fetch(request, undefined, { ...ctx, requestArrivedAt });
200
202
  await writeResponse(res, response);
201
203
  }
202
204
  catch (error) {
@@ -4,6 +4,9 @@ import { authCall, authInvoke, authFlow, authStorage, registerAuth } from "./aut
4
4
  import { runEdge } from "./runtime/run.js";
5
5
  import { oauthConnectKeys } from "./storage/keys.js";
6
6
  const HANDOFF_TTL_MS = 15 * 60_000;
7
+ // A flow can issue a link until its ten-minute retry state ends, and that link lives
8
+ // fifteen more, so a reset's record outlives every link that could repeat it.
9
+ const FLOW_RESET_EXPIRY = { ttlSeconds: 30 * 60 };
7
10
  const INTERACTIVE_OAUTH_REQUIRED = "An interactive provider (Clerk or Cloudflare Access) is required to connect OAuth connectors.";
8
11
  // Length-prefixed fields avoid JSON keys and escaping without reserving any
9
12
  // character in an identity. This is an opaque wire encoding, not encryption.
@@ -16,7 +19,7 @@ function encodeHandoff(h) {
16
19
  h.expiresAt.toString(36),
17
20
  h.nonce,
18
21
  h.force ? "1" : "0",
19
- h.after ?? "",
22
+ h.flow ?? "",
20
23
  ]
21
24
  .map((field) => `${field.length}:${field}`)
22
25
  .join("");
@@ -36,7 +39,7 @@ function decodeHandoff(value) {
36
39
  fields.push(value.slice(offset, offset + length));
37
40
  offset += length;
38
41
  }
39
- const [connector, owner, principal, origin, expiry, nonce, force, after] = fields;
42
+ const [connector, owner, principal, origin, expiry, nonce, force, flow] = fields;
40
43
  if (fields.length !== 8 || offset !== value.length || !/^[0-9a-z]+$/.test(expiry) || !/^[01]$/.test(force))
41
44
  throw new Error("Invalid handoff fields");
42
45
  return {
@@ -47,7 +50,7 @@ function decodeHandoff(value) {
47
50
  expiresAt: parseInt(expiry, 36),
48
51
  nonce: nonce,
49
52
  force: force === "1",
50
- ...(after ? { after } : {}),
53
+ ...(flow ? { flow } : {}),
51
54
  };
52
55
  }
53
56
  function encodeSegment(value) {
@@ -89,10 +92,10 @@ const oauthConnectUrlNative = /* @__PURE__ */ registerAuth(oauthConnectUrl, func
89
92
  ]))).url;
90
93
  });
91
94
  });
92
- export function oauthConnectLink(opts, baseUrl, connectorId, principal, force = false, opaque = false, after) {
93
- return runEdge(oauthConnectLinkNative(opts, baseUrl, connectorId, principal, force, opaque, after));
95
+ export function oauthConnectLink(opts, baseUrl, connectorId, principal, force = false, opaque = false, flow) {
96
+ return runEdge(oauthConnectLinkNative(opts, baseUrl, connectorId, principal, force, opaque, flow));
94
97
  }
95
- const oauthConnectLinkNative = /* @__PURE__ */ registerAuth(oauthConnectLink, function (opts, baseUrl, connectorId, principal, force = false, opaque = false, after) {
98
+ const oauthConnectLinkNative = /* @__PURE__ */ registerAuth(oauthConnectLink, function (opts, baseUrl, connectorId, principal, force = false, opaque = false, flow) {
96
99
  return authFlow(function* () {
97
100
  const unavailable = oauthConnectUnavailable(opts);
98
101
  if (unavailable)
@@ -108,7 +111,7 @@ const oauthConnectLinkNative = /* @__PURE__ */ registerAuth(oauthConnectLink, fu
108
111
  expiresAt: Date.now() + HANDOFF_TTL_MS,
109
112
  nonce: crypto.randomUUID(),
110
113
  force,
111
- ...(after ? { after } : {}),
114
+ ...(flow ? { flow } : {}),
112
115
  };
113
116
  const vault = opts.config.vault;
114
117
  if (opaque && (!vault.seal || !vault.open))
@@ -126,32 +129,14 @@ const oauthConnectLinkNative = /* @__PURE__ */ registerAuth(oauthConnectLink, fu
126
129
  });
127
130
  });
128
131
  /** A claim precedes OAuth reset; only a completed start can prove recovery. */
129
- export function oauthConnectLinkProgress(opts, baseUrl, id, nonce) {
130
- return runEdge(oauthConnectLinkProgressNative(opts, baseUrl, id, nonce));
132
+ export function oauthConnectLinkStarted(opts, baseUrl, id, nonce) {
133
+ return runEdge(oauthConnectLinkStartedNative(opts, baseUrl, id, nonce));
131
134
  }
132
- const oauthConnectLinkProgressNative = /* @__PURE__ */ registerAuth(oauthConnectLinkProgress, function (opts, baseUrl, id, nonce) {
135
+ const oauthConnectLinkStartedNative = /* @__PURE__ */ registerAuth(oauthConnectLinkStarted, function (opts, baseUrl, id, nonce) {
133
136
  return authFlow(function* () {
134
- const stage = yield* upwardStep(authStorage(opts.registry.contextFor(id, baseUrl).storage, "get", [
137
+ return ((yield* upwardStep(authStorage(opts.registry.contextFor(id, baseUrl).storage, "get", [
135
138
  oauthConnectKeys.used(nonce),
136
- ]));
137
- return stage === "used" ? "claimed" : stage === "started" || stage === "failed" ? stage : undefined;
138
- });
139
- });
140
- /** Make outstanding flow links single-use refusals before a completion check. */
141
- export function closeOAuthConnectLinks(opts, baseUrl, id, nonces) {
142
- return runEdge(closeOAuthConnectLinksNative(opts, baseUrl, id, nonces));
143
- }
144
- const closeOAuthConnectLinksNative = /* @__PURE__ */ registerAuth(closeOAuthConnectLinks, function (opts, baseUrl, id, nonces) {
145
- return authFlow(function* () {
146
- const storage = opts.registry.contextFor(id, baseUrl).storage;
147
- for (const nonce of nonces) {
148
- yield* upwardStep(authStorage(storage, "compareAndSet", [
149
- oauthConnectKeys.used(nonce),
150
- null,
151
- "closed",
152
- { ttlSeconds: HANDOFF_TTL_MS / 1000 },
153
- ]));
154
- }
139
+ ]))) === "started");
155
140
  });
156
141
  });
157
142
  /** Consuming a round prevents replay from creating sibling retry lineages. */
@@ -206,6 +191,9 @@ const verifyOAuthHandoffNative = /* @__PURE__ */ registerAuth(verifyOAuthHandoff
206
191
  atob(payload.slice(3)),
207
192
  ]))
208
193
  : atob(payload));
194
+ // A retired predecessor binding: nothing here can honor it, so it must not be ignored.
195
+ if ("after" in h)
196
+ return null;
209
197
  }
210
198
  const connector = opts.registry.getConnector(connectorId);
211
199
  if (!connector ||
@@ -221,8 +209,8 @@ const verifyOAuthHandoffNative = /* @__PURE__ */ registerAuth(verifyOAuthHandoff
221
209
  !h.nonce ||
222
210
  typeof h.force !== "boolean")
223
211
  return null;
224
- if (h.after !== undefined &&
225
- (typeof h.after !== "string" || !/^[a-f0-9-]{36}$/.test(h.after) || h.after === h.nonce || h.force))
212
+ if (h.flow !== undefined &&
213
+ (typeof h.flow !== "string" || !/^[a-f0-9-]{36}$/.test(h.flow) || h.flow === h.nonce || !h.force))
226
214
  return null;
227
215
  if (yield* upwardStep(authStorage(opts.registry.contextFor(connectorId, baseUrl).storage, "get", [
228
216
  oauthConnectKeys.used(h.nonce),
@@ -235,21 +223,34 @@ const verifyOAuthHandoffNative = /* @__PURE__ */ registerAuth(verifyOAuthHandoff
235
223
  }
236
224
  });
237
225
  });
238
- /** Claim only after browser identity and permissions pass, before starting OAuth. */
239
- export function consumeOAuthConnectLink(opts, handoff) {
240
- return runEdge(consumeOAuthConnectLinkNative(opts, handoff));
226
+ /**
227
+ * Claim only after browser identity and permissions pass, before starting OAuth.
228
+ * A forced flow resets once, whichever of its links arrives first. A later visitor
229
+ * continues that attempt, waits on it without spending its link, or, once it has
230
+ * failed, takes over the reset.
231
+ */
232
+ export function claimOAuthConnectStart(opts, handoff) {
233
+ return runEdge(claimOAuthConnectStartNative(opts, handoff));
241
234
  }
242
- const consumeOAuthConnectLinkNative = /* @__PURE__ */ registerAuth(consumeOAuthConnectLink, function (opts, handoff) {
235
+ const claimOAuthConnectStartNative = /* @__PURE__ */ registerAuth(claimOAuthConnectStart, function (opts, handoff) {
243
236
  return authFlow(function* () {
244
237
  if (handoff.expiresAt <= Date.now())
245
- return false;
238
+ return "refused";
246
239
  const storage = opts.registry.contextFor(handoff.connector, handoff.origin).storage;
247
- if (handoff.after &&
248
- (yield* upwardStep(authStorage(storage, "get", [oauthConnectKeys.used(handoff.after)]))) !==
249
- "started")
250
- return false;
240
+ const reset = handoff.force ? oauthConnectKeys.reset(handoff.flow ?? handoff.nonce) : undefined;
241
+ const prior = reset ? yield* upwardStep(authStorage(storage, "get", [reset])) : "started";
242
+ if (prior === "used")
243
+ return "pending";
244
+ const claiming = prior !== "started";
245
+ if (claiming &&
246
+ !(yield* upwardStep(authStorage(storage, "compareAndSet", [reset, prior, "used", FLOW_RESET_EXPIRY]))))
247
+ return "pending";
251
248
  const expiry = { ttlSeconds: Math.max(1, Math.ceil((handoff.expiresAt - Date.now()) / 1000)) };
252
- return yield* upwardStep(authStorage(storage, "compareAndSet", [oauthConnectKeys.used(handoff.nonce), null, "used", expiry]));
249
+ if (yield* upwardStep(authStorage(storage, "compareAndSet", [oauthConnectKeys.used(handoff.nonce), null, "used", expiry])))
250
+ return claiming ? "reset" : "continue";
251
+ if (claiming)
252
+ yield* upwardStep(authStorage(storage, "compareAndSet", [reset, "used", prior, FLOW_RESET_EXPIRY]));
253
+ return "refused";
253
254
  });
254
255
  });
255
256
  /** Terminal marker only after the start and every tracked reset have settled. */
@@ -258,9 +259,18 @@ export function finishOAuthConnectStart(opts, handoff, outcome) {
258
259
  }
259
260
  const finishOAuthConnectStartNative = /* @__PURE__ */ registerAuth(finishOAuthConnectStart, function (opts, handoff, outcome) {
260
261
  return authFlow(function* () {
262
+ const storage = opts.registry.contextFor(handoff.connector, handoff.origin).storage;
263
+ // Settled first, and whatever the link's expiry: a finished link proves its flow's
264
+ // reset finished, and only the visitor who claimed a reset can find it still claimed.
265
+ if (handoff.force)
266
+ yield* upwardStep(authStorage(storage, "compareAndSet", [
267
+ oauthConnectKeys.reset(handoff.flow ?? handoff.nonce),
268
+ "used",
269
+ outcome,
270
+ FLOW_RESET_EXPIRY,
271
+ ]));
261
272
  if (handoff.expiresAt <= Date.now())
262
273
  return false;
263
- const storage = opts.registry.contextFor(handoff.connector, handoff.origin).storage;
264
274
  const expiry = { ttlSeconds: Math.max(1, Math.ceil((handoff.expiresAt - Date.now()) / 1000)) };
265
275
  return yield* upwardStep(authStorage(storage, "compareAndSet", [oauthConnectKeys.used(handoff.nonce), "used", outcome, expiry]));
266
276
  });
@@ -5,7 +5,7 @@ import { ExecutorAdmissionError } from "./executor-admission.js";
5
5
  import { DiscoveryPolicyError } from "./catalog-service.js";
6
6
  import { classificationCode, classifyCallError } from "./errors.js";
7
7
  import { usesCallerIdentity } from "./connector-caller.js";
8
- export function operationActivity(context, registry, name, work, counts) {
8
+ export function operationActivity(context, registry, name, work, counts, source) {
9
9
  if (!context?.recordOperation)
10
10
  return work;
11
11
  return Effect.suspend(() => {
@@ -53,6 +53,7 @@ export function operationActivity(context, registry, name, work, counts) {
53
53
  personal,
54
54
  ...(counts ? { hostCalls: counts() } : {}),
55
55
  },
56
+ ...(source ? { source } : {}),
56
57
  durationMs: performance.now() - started,
57
58
  resultBytes,
58
59
  outcome: !failed
@@ -193,6 +193,12 @@ export function registerConnectorVocabulary(connector, declarations) {
193
193
  if (entries.length)
194
194
  connectors.set(connector, new Map(entries));
195
195
  }
196
+ /** Give a connector that delegates to an api() connector the vocabulary its hatch registered there. */
197
+ export function carryConnectorVocabulary(source, target) {
198
+ const registered = connectors.get(source);
199
+ if (registered)
200
+ connectors.set(target, registered);
201
+ }
196
202
  /**
197
203
  * Read tools whose operation vocabulary matches every query word, and the
198
204
  * fully determined reads among those operations, each through a visible read
@@ -118,7 +118,7 @@ export declare function failureRecord(subject: FailureSubject, failure?: unknown
118
118
  * The events an operator log reports failures as. Closed: a new one is a
119
119
  * reviewed line here, never a string assembled at the call site.
120
120
  */
121
- export type FailureEvent = "Authentication admission refused" | "Cloudflare Access verification unavailable" | "operator_refused" | "Authentication released after its pending work became unreachable" | "negotiation cache read failed" | "negotiation cache write failed" | "discovery health read failed" | "discovery health write failed" | "pool tool grant is unreachable" | "call failed" | "input schema unusable; arguments are not validated" | "catalog read failed" | "catalog persistence failed" | "catalog invalidation failed" | "catalog refresh observation failed" | "catalog refresh failed; serving stale catalog" | "deferred catalog refresh failed" | "deferred catalog refresh could not attach to the runtime" | "catalog drift check failed" | "schema digest check failed; serving digested reviews as writes" | "credential shape read failed" | "credential test failed" | "credential test threw" | "credential verification record failed" | "session termination refused or failed; the downstream session may remain until its provider timeout" | "activity record failed" | "catalog drift record failed" | "activity read failed" | "OAuth start failed" | "OAuth disconnect failed" | "OAuth callback verifyState threw; no authorization code was exchanged" | "OAuth callback handoff could not be consumed; no authorization code was exchanged" | "MCP default endpoint request denied" | "MCP pool request denied" | "MCP agent endpoint request denied" | "OAuth error callback consent could not be consumed; no authorization code was exchanged" | "MCP handler error" | "operator status" | "operator agent slots read failed" | "result paging unavailable" | "native catalog unavailable" | "native name collision" | "native tools/list refused a pool over the 1,000-tool native cap" | "request failed" | "Clerk request denied" | "Clerk authentication failed";
121
+ export type FailureEvent = "Authentication admission refused" | "Cloudflare Access verification unavailable" | "operator_refused" | "Authentication released after its pending work became unreachable" | "negotiation cache read failed" | "negotiation cache write failed" | "discovery health read failed" | "discovery health write failed" | "pool tool grant is unreachable" | "call failed" | "input schema unusable; arguments are not validated" | "catalog chunk cleanup failed" | "catalog fence failed" | "catalog read failed" | "catalog persistence failed" | "catalog invalidation failed" | "catalog refresh observation failed" | "catalog refresh failed; serving stale catalog" | "deferred catalog refresh failed" | "deferred catalog refresh could not attach to the runtime" | "catalog drift check failed" | "schema digest check failed; serving digested reviews as writes" | "credential shape read failed" | "credential test failed" | "credential test threw" | "credential verification record failed" | "session termination refused or failed; the downstream session may remain until its provider timeout" | "activity record failed" | "catalog drift record failed" | "activity read failed" | "OAuth start failed" | "OAuth disconnect failed" | "OAuth callback verifyState threw; no authorization code was exchanged" | "OAuth callback handoff could not be consumed; no authorization code was exchanged" | "MCP default endpoint request denied" | "MCP pool request denied" | "MCP agent endpoint request denied" | "OAuth error callback consent could not be consumed; no authorization code was exchanged" | "MCP handler error" | "operator status" | "operator agent slots read failed" | "result paging unavailable" | "native catalog unavailable" | "native name collision" | "native tools/list refused a pool over the 1,000-tool native cap" | "request failed" | "Clerk request denied" | "Clerk authentication failed";
122
122
  /**
123
123
  * Log one failure as `[connecta] <event>` and its record. A record
124
124
  * `failureRecord` did not build is replaced, whatever its shape, by a fixed
@@ -47,6 +47,7 @@ const SOURCES = new Set([
47
47
  "call_destructive_tool",
48
48
  "batch_call",
49
49
  "execute_code",
50
+ "execute_read_code",
50
51
  "resume_execution",
51
52
  ]);
52
53
  const MODES = new Set(["continue", "restart"]);