@zackbart/connecta 0.40.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 (195) hide show
  1. package/CHANGELOG.md +148 -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 +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import type { ConnectaBranding } from "./types.js";
2
- import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity } from "./activity.js";
2
+ import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogChangeActivity, recordOperationActivity } from "./activity.js";
3
3
  import type { RouteContext } from "./routes/shared.js";
4
4
  /** Construction-time UI contract. Core never imports the implementation. */
5
5
  export interface OperatorSurface {
@@ -10,6 +10,8 @@ export interface OperatorSurface {
10
10
  }
11
11
  /** Optional recording and reading callbacks supplied by /activity. */
12
12
  export interface ActivityModule {
13
+ readonly operations?: boolean;
14
+ readonly recordOperation?: typeof recordOperationActivity;
13
15
  readonly store: ActivityStore;
14
16
  readonly deploymentId?: string;
15
17
  readonly readGate?: ActivityReadGate;
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { resolvedConnectorGuides } from "./skills.js";
1
2
  import { bytesToHex } from "./bytes.js";
2
3
  import { routeNames } from "./agent-routes.js";
3
4
  import { duringViewEffect } from "./runtime/call.js";
@@ -11,6 +12,7 @@ import { withAuthorizationHandoff } from "./authorization-handoff.js";
11
12
  import { classifyCallError } from "./errors.js";
12
13
  import { nativeRecovery } from "./native-recovery.js";
13
14
  import { CatalogService } from "./catalog-service.js";
15
+ import { catalogStaleness } from "./catalog-freshness.js";
14
16
  import { jsonResult } from "./meta-tools.js";
15
17
  import { failureRecord, logFailure } from "./operator-record.js";
16
18
  /**
@@ -23,8 +25,19 @@ export const NATIVE_NAME_MAX = 48;
23
25
  const MAX_NATIVE_TOOLS = 1_000;
24
26
  /** Native `tools/list` cache hint. Shorter than the meta surface's hour: the list follows downstream catalogs. */
25
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;
26
30
  const HASH_CHARS = 6;
27
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;
28
41
  /** No `__`, no leading `_`: the last `__` of a plain name is then always its separator. */
29
42
  const PLAIN_TOOL = /^[A-Za-z0-9-](?:[A-Za-z0-9-]|_(?!_))*$/;
30
43
  const NATIVE_NAME = new RegExp(`^[A-Za-z][A-Za-z0-9_-]{0,${NATIVE_NAME_MAX - 1}}$`);
@@ -48,28 +61,82 @@ const shortHashNative = /* @__PURE__ */ registerAuth(shortHash, function (text)
48
61
  function connectorPart(connectorId) {
49
62
  return /^[a-z]/.test(connectorId) ? connectorId : `C${connectorId}`;
50
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
+ }
51
68
  /**
52
- * A pure function of the connector id and downstream tool name, so it is
53
- * stable across catalog refreshes: `<connector>__<tool>` when that is exact,
54
- * legal and short, otherwise its head plus a six-hex SHA-256 suffix of the
55
- * 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.
56
82
  */
57
- export function nativeToolName(connectorId, toolName) {
58
- return runEdge(nativeToolNameNative(connectorId, toolName));
83
+ export function nativeToolName(connectorId, toolName, classification) {
84
+ return runEdge(nativeToolNameNative(connectorId, toolName, classification));
59
85
  }
60
- const nativeToolNameNative = /* @__PURE__ */ registerAuth(nativeToolName, function (connectorId, toolName) {
86
+ const nativeToolNameNative = /* @__PURE__ */ registerAuth(nativeToolName, function (connectorId, toolName, classification) {
61
87
  return authFlow(function* () {
88
+ const connector = connectorPart(connectorId);
62
89
  const tool = toolName.replace(/[^A-Za-z0-9_-]/g, "_");
63
- const plain = `${connectorPart(connectorId)}${SEPARATOR}${tool}`;
64
- 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)))
65
96
  return plain;
66
- const head = plain.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "");
67
- 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}`;
68
105
  });
69
106
  });
70
- /** Read or write, from the served classification only. */
71
- function annotationsFor(tool) {
72
- 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
73
140
  ? { readOnlyHint: true, destructiveHint: false, openWorldHint: true }
74
141
  : { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
75
142
  }
@@ -129,10 +196,12 @@ function passThroughSchema(schema) {
129
196
  },
130
197
  };
131
198
  }
132
- function describe(entry, executeCode) {
199
+ function describe(entry, executeCode, guideName) {
133
200
  const lines = [entry.tool.description?.trim() || entry.tool.title || entry.tool.name];
134
201
  if (executeCode)
135
202
  lines.push(`Program address: ${entry.connector.id}.${entry.tool.name}`);
203
+ if (guideName)
204
+ lines.push(`Guide: skills ${guideName}`);
136
205
  return lines.join("\n\n");
137
206
  }
138
207
  export class NativeSurface {
@@ -157,10 +226,13 @@ export class NativeSurface {
157
226
  /**
158
227
  * Every listable native tool. A connector whose catalog fails is omitted
159
228
  * whole, never partially, with an operator warning; two tools claiming one
160
- * 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.
161
233
  */
162
- claims(connectors = this.registry.listConnectors()) {
163
- 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]));
164
236
  }
165
237
  list() {
166
238
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "list", []));
@@ -173,20 +245,38 @@ export class NativeSurface {
173
245
  resolve(name) {
174
246
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "resolve", [name]));
175
247
  }
176
- /** 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
+ */
177
253
  call(name, args, request) {
178
254
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "call", [name, args, request]));
179
255
  }
180
- registerTool(server, name, entry) {
256
+ registerTool(server, name, entry, guideName) {
181
257
  server.registerTool(name, {
182
258
  ...(entry?.tool.title ? { title: entry.tool.title } : {}),
183
- ...(entry ? { description: describe(entry, this.options.executeCode) } : {}),
259
+ ...(entry ? { description: describe(entry, this.options.executeCode, guideName) } : {}),
184
260
  inputSchema: passThroughSchema(entry?.tool.inputSchema),
185
- ...(entry ? { annotations: annotationsFor(entry.tool) } : {}),
186
- ...(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
+ : {}),
187
278
  }, authMethod((args, request) => Effect.gen({ self: this }, function* () {
188
- const result = yield* authInvoke(this, "call", [name, args ?? {}, request]);
189
- return nativeRecovery(result, name);
279
+ return yield* authInvoke(this, "call", [name, args ?? {}, request]);
190
280
  })));
191
281
  }
192
282
  /**
@@ -220,8 +310,12 @@ export class NativeSurface {
220
310
  });
221
311
  return;
222
312
  }
313
+ const guides = yield* upwardStep(authCall(resolvedConnectorGuides, [
314
+ self.registry.listConnectors(),
315
+ ]));
316
+ const guideNames = new Map(guides.map(({ connector, reference }) => [connector.id, reference]));
223
317
  for (const entry of tools)
224
- self.registerTool(server, entry.name, entry);
318
+ self.registerTool(server, entry.name, entry, guideNames.get(entry.connector.id));
225
319
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
226
320
  }, NativeSurface.prototype.register);
227
321
  registerAuthMethod(NativeSurface.prototype, "call", function (name, args, request) {
@@ -259,14 +353,15 @@ export class NativeSurface {
259
353
  error: yield* upwardStep(authCall(withAuthorizationHandoff, [self.options.requestScope, error])),
260
354
  });
261
355
  result.isError = true;
262
- return result;
356
+ return nativeRecovery(result, name);
263
357
  }
264
358
  const address = `${target.entry.connector.id}.${target.entry.tool.name}`;
265
- return yield* upwardStep(authInvoke(self.options, "route", [
266
- 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",
267
361
  { address, args },
268
362
  request,
269
363
  ]));
364
+ return nativeRecovery(result, recoveryName(result, name, target.entry.names));
270
365
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
271
366
  }, NativeSurface.prototype.call);
272
367
  registerAuthMethod(NativeSurface.prototype, "resolve", function (name) {
@@ -275,19 +370,20 @@ export class NativeSurface {
275
370
  requestScope: this.options.requestScope,
276
371
  signal: this.options.requestSignal,
277
372
  }, () => authFlow(function* () {
278
- const prefixed = self.registry.listConnectors().filter((connector) => {
279
- const prefix = `${connectorPart(connector.id)}${SEPARATOR}`;
280
- // A hashed name can truncate even the connector prefix, and strips
281
- // trailing underscores from its head. Include every possible claimant.
282
- const head = prefix.slice(0, NATIVE_NAME_MAX - HASH_CHARS - 1).replace(/_+$/, "");
283
- // A hashed name carries a stripped head only as exactly `${head}_` + six hex digits.
284
- return (name.startsWith(prefix) ||
285
- (name.length === head.length + 1 + HASH_CHARS &&
286
- name.startsWith(`${head}_`) &&
287
- /^[0-9a-f]+$/.test(name.slice(-HASH_CHARS))));
288
- });
289
- const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [prefixed]));
290
- 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
+ }
291
387
  if (found === null)
292
388
  return { kind: "ambiguous" };
293
389
  // An unavailable possible claimant cannot prove uniqueness.
@@ -303,8 +399,10 @@ export class NativeSurface {
303
399
  requestScope: this.options.requestScope,
304
400
  signal: this.options.requestSignal,
305
401
  }, () => authFlow(function* () {
306
- const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", []));
402
+ const { loaded, byName } = yield* upwardStep(authInvoke(self, "claims", [undefined, true]));
307
403
  for (const entry of loaded) {
404
+ if ("tools" in entry && catalogStaleness(entry.tools))
405
+ self.options.staleCatalogs.push(entry.connector.id);
308
406
  // Validate the whole connector before registering any of its tools.
309
407
  const tool = "tools" in entry
310
408
  ? entry.tools.find((tool) => tool.inputSchema !== undefined && !nativeInputSchema(tool.inputSchema))
@@ -314,32 +412,56 @@ export class NativeSurface {
314
412
  self.options.catalogErrors.push(entry.connector.id);
315
413
  logFailure(self.options.logger, "native catalog unavailable", failureRecord({ connector: entry.connector.id, ...(tool ? { tool } : {}) }, "error" in entry ? entry.error : undefined));
316
414
  }
317
- 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 &&
318
419
  !self.options.catalogErrors.includes(entry.connector.id) &&
319
- (!(self.options.readOnly || entry.connector.readOnly) || entry.tool.classification === "read"));
420
+ (!(self.options.readOnly || entry.connector.readOnly) || entry.tool.classification === "read")
421
+ ? [entry]
422
+ : []);
320
423
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
321
424
  }, NativeSurface.prototype.list);
322
- registerAuthMethod(NativeSurface.prototype, "claims", function (connectors) {
425
+ registerAuthMethod(NativeSurface.prototype, "claims", function (connectors, allowStale = false) {
323
426
  const self = this;
324
427
  return duringViewEffect({
325
428
  requestScope: this.options.requestScope,
326
429
  signal: this.options.requestSignal,
327
430
  }, () => authFlow(function* () {
328
- const loaded = yield* upwardStep(authInvoke(self.catalog, "listCatalogs", [true, connectors ?? self.registry.listConnectors()]));
329
- 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();
330
434
  for (const entry of loaded) {
331
435
  if (!("tools" in entry))
332
436
  continue;
333
437
  for (const tool of entry.tools) {
334
- const name = yield* upwardStep(authCall(nativeToolName, [entry.connector.id, tool.name]));
335
- if (byName.has(name)) {
336
- byName.set(name, null);
337
- logFailure(self.options.logger, "native name collision", failureRecord({ connector: entry.connector.id, tool }));
338
- }
339
- else
340
- 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);
341
454
  }
342
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
+ }
343
465
  return { loaded, byName };
344
466
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
345
467
  }, NativeSurface.prototype.claims);
package/dist/node.d.ts CHANGED
@@ -8,6 +8,13 @@ export interface ListenOptions {
8
8
  host?: string;
9
9
  /** Pre-auth buffer cap, independent of the authenticated MCP cap. Default 4 MiB. */
10
10
  maxBodyBytes?: number;
11
+ /**
12
+ * Cap on the body bytes all in-flight requests hold together, counted as
13
+ * each arrives and released when its response finishes or its client leaves.
14
+ * Past it a request is answered 503 with `Retry-After`. Default 64 MiB, or
15
+ * `maxBodyBytes` when larger. May not be below `maxBodyBytes`.
16
+ */
17
+ maxInFlightBodyBytes?: number;
11
18
  /**
12
19
  * Stop accepting connections on SIGTERM/SIGINT, let in-flight requests
13
20
  * finish, drain deferred work, then exit. Default true — Docker sends
package/dist/node.js CHANGED
@@ -5,13 +5,40 @@ import { failureRecord, logFailure } from "./operator-record.js";
5
5
  import { configureGuardedTransport } from "./guarded-transport.js";
6
6
  import { createNodeGuardedTransport } from "./http/node-transport.js";
7
7
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
8
+ import { ConfigError } from "./config-schema.js";
8
9
  /** The adapter buffers before auth, bounded by the same default as the core. */
9
10
  const DEFAULT_MAX_BODY_BYTES = CONFIG_DEFAULTS.admission.requests.maxBodyBytes;
11
+ /** Sixteen full default bodies; well above ordinary MCP traffic, whose bodies are small. */
12
+ const DEFAULT_MAX_IN_FLIGHT_BODY_BYTES = 64 * 1024 * 1024;
10
13
  const BODY_TOO_LARGE = Symbol("body-too-large");
11
- async function toRequest(req, maxBodyBytes, signal) {
14
+ const BODIES_AT_CAPACITY = Symbol("bodies-at-capacity");
15
+ /** A name, IPv4 or bracketed IPv6 host with an optional port, and nothing else. */
16
+ const REQUEST_HOST = /^(?:[a-z0-9._-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
17
+ /** `Host`, `X-Forwarded-Proto` or the request target is not a URL part; answered 400. */
18
+ class InvalidRequestUrl extends Error {
19
+ }
20
+ /**
21
+ * The request's URL, read from values a client chooses. Each is checked on its
22
+ * own and the origin is parsed before the target joins it, so no value can move
23
+ * the boundary between authority, path and query for the routes behind it.
24
+ */
25
+ function requestUrl(req) {
12
26
  const host = req.headers.host ?? "localhost";
13
27
  const proto = req.headers["x-forwarded-proto"] ?? "http";
14
- const url = `${proto}://${host}${req.url ?? "/"}`;
28
+ // Origin-form only. Absolute-form and `*` have no path to route.
29
+ const target = req.url ?? "/";
30
+ if (!REQUEST_HOST.test(host) || typeof proto !== "string" || !/^https?$/i.test(proto) || !target.startsWith("/")) {
31
+ throw new InvalidRequestUrl();
32
+ }
33
+ try {
34
+ return `${new URL(`${proto}://${host}`).origin}${target}`;
35
+ }
36
+ catch {
37
+ throw new InvalidRequestUrl();
38
+ }
39
+ }
40
+ async function toRequest(req, maxBodyBytes, holdBodyBytes, signal) {
41
+ const url = requestUrl(req);
15
42
  const method = req.method ?? "GET";
16
43
  const headers = new Headers();
17
44
  for (const [key, value] of Object.entries(req.headers)) {
@@ -29,6 +56,8 @@ async function toRequest(req, maxBodyBytes, signal) {
29
56
  // rest of a body we have already decided to reject.
30
57
  if (size > maxBodyBytes)
31
58
  return BODY_TOO_LARGE;
59
+ if (!holdBodyBytes(chunk.length))
60
+ return BODIES_AT_CAPACITY;
32
61
  chunks.push(chunk);
33
62
  }
34
63
  if (chunks.length)
@@ -64,6 +93,13 @@ export function listen(connecta, portOrOptions) {
64
93
  const opts = typeof portOrOptions === "number" ? { port: portOrOptions } : portOrOptions;
65
94
  configureGuardedTransport(createNodeGuardedTransport(opts.proxy));
66
95
  const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
96
+ const maxInFlightBodyBytes = opts.maxInFlightBodyBytes === undefined
97
+ ? Math.max(DEFAULT_MAX_IN_FLIGHT_BODY_BYTES, maxBodyBytes)
98
+ : opts.maxInFlightBodyBytes;
99
+ if (!Number.isSafeInteger(maxInFlightBodyBytes) || maxInFlightBodyBytes < 1 || maxInFlightBodyBytes < maxBodyBytes) {
100
+ throw new ConfigError("listen: maxInFlightBodyBytes must be a whole number of bytes, at least maxBodyBytes.");
101
+ }
102
+ let inFlightBodyBytes = 0;
67
103
  // Node has no ExecutionContext, so deferred work (activity sinks) would
68
104
  // otherwise be an untracked floating promise. Track it here and drain it on
69
105
  // shutdown so both runtimes behave the same.
@@ -78,6 +114,8 @@ export function listen(connecta, portOrOptions) {
78
114
  const activeResponses = new Map();
79
115
  let shuttingDown = false;
80
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();
81
119
  awaitingRequest.delete(req.socket);
82
120
  const socket = req.socket;
83
121
  activeResponses.set(socket, (activeResponses.get(socket) ?? 0) + 1);
@@ -101,7 +139,19 @@ export function listen(connecta, portOrOptions) {
101
139
  res.once("close", finished);
102
140
  void (async () => {
103
141
  const controller = new AbortController();
142
+ let heldBodyBytes = 0;
143
+ let bodyBytesReleased = false;
144
+ // Pipeline teardown can wait forever on a response stream's cancel().
145
+ // Release on the HTTP lifecycle too, and forbid late upload reservations.
146
+ const releaseBodyBytes = () => {
147
+ if (bodyBytesReleased)
148
+ return;
149
+ bodyBytesReleased = true;
150
+ inFlightBodyBytes -= heldBodyBytes;
151
+ heldBodyBytes = 0;
152
+ };
104
153
  const abortRequest = () => {
154
+ releaseBodyBytes();
105
155
  if (!controller.signal.aborted) {
106
156
  controller.abort(new Error("HTTP client disconnected."));
107
157
  }
@@ -117,8 +167,28 @@ export function listen(connecta, portOrOptions) {
117
167
  req.once("aborted", abortRequest);
118
168
  req.once("close", abortIncompleteRequest);
119
169
  res.once("close", abortIncompleteResponse);
170
+ res.once("finish", releaseBodyBytes);
171
+ res.once("close", releaseBodyBytes);
172
+ // A queued pipelined response gets neither finish nor close when the
173
+ // socket goes away, and its completed upload's request is already closed.
174
+ socket.once("close", abortRequest);
175
+ const holdBodyBytes = (bytes) => {
176
+ if (bodyBytesReleased || inFlightBodyBytes + bytes > maxInFlightBodyBytes)
177
+ return false;
178
+ inFlightBodyBytes += bytes;
179
+ heldBodyBytes += bytes;
180
+ return true;
181
+ };
120
182
  try {
121
- const request = await toRequest(req, maxBodyBytes, controller.signal);
183
+ const request = await toRequest(req, maxBodyBytes, holdBodyBytes, controller.signal);
184
+ if (request === BODIES_AT_CAPACITY) {
185
+ res.statusCode = 503;
186
+ res.setHeader("Retry-After", "1");
187
+ // Abandoned mid-body, like the 413 below.
188
+ res.setHeader("Connection", "close");
189
+ res.end("Service Unavailable");
190
+ return;
191
+ }
122
192
  if (request === BODY_TOO_LARGE) {
123
193
  res.statusCode = 413;
124
194
  // The request stream was abandoned mid-body, so this connection
@@ -128,10 +198,17 @@ export function listen(connecta, portOrOptions) {
128
198
  res.end("Payload Too Large");
129
199
  return;
130
200
  }
131
- const response = await connecta.fetch(request, undefined, ctx);
201
+ const response = await connecta.fetch(request, undefined, { ...ctx, requestArrivedAt });
132
202
  await writeResponse(res, response);
133
203
  }
134
204
  catch (error) {
205
+ if (error instanceof InvalidRequestUrl) {
206
+ res.statusCode = 400;
207
+ // Refused before reading the body, so the connection cannot be reused.
208
+ res.setHeader("Connection", "close");
209
+ res.end("Bad Request");
210
+ return;
211
+ }
135
212
  // A client that deliberately went away can make either the request
136
213
  // handler or response pipeline reject. The request's abort signal is
137
214
  // the authoritative classification: there is no caller left to answer,
@@ -152,6 +229,10 @@ export function listen(connecta, portOrOptions) {
152
229
  res.end("Internal Server Error");
153
230
  }
154
231
  finally {
232
+ releaseBodyBytes();
233
+ res.removeListener("finish", releaseBodyBytes);
234
+ res.removeListener("close", releaseBodyBytes);
235
+ socket.removeListener("close", abortRequest);
155
236
  req.removeListener("aborted", abortRequest);
156
237
  req.removeListener("close", abortIncompleteRequest);
157
238
  res.removeListener("close", abortIncompleteResponse);