@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,28 +1,35 @@
1
+ import { Effect } from "effect";
2
+ import { operationActivity } from "./operation-activity.js";
3
+ import { SkillLookupError } from "./skills.js";
4
+ import { skillLookupRecovery, utilityRecovery } from "./utility-recovery.js";
1
5
  import { META_ROUTES } from "./agent-routes.js";
2
6
  import { toolError } from "./tool-error.js";
3
7
  import { upwardStep } from "./upward.js";
4
8
  import { authCall, authInvoke, authFlow, authMethod, authStorage, registerAuth } from "./auth/native.js";
5
9
  import { registryProgram } from "./runtime/registry.js";
6
- import { runEdge } from "./runtime/run.js";
10
+ import { runEdge, withDeadlineEffect } from "./runtime/run.js";
7
11
  import { authorizationHandoff, authorizeConnector, bindAuthorizationHandoffs } from "./authorization-handoff.js";
8
12
  import { bindMcpClient } from "./mcp-client-context.js";
9
13
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
10
14
  import * as z from "zod";
11
15
  import { advertisedSchema } from "./advertised-schema.js";
12
- import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, flatSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
16
+ import { boundedDiscoveryText, CatalogService, DiscoveryPolicyError, flatSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
13
17
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
14
- import { boundedEchoText, ConnectorCallError, msg } from "./errors.js";
18
+ import { boundedEchoText, clipText, ConnectorCallError, MAX_TOOL_ERROR_BYTES, msg, } from "./errors.js";
15
19
  import { failureRecord, logFailure } from "./operator-record.js";
20
+ import { RESULT_ENVELOPE_V4 } from "./result-seal.js";
16
21
  import { guardExecuteResultValue, MAX_EXECUTE_RESULT_CHARS, serializeResultText } from "./executor-result.js";
17
22
  import { InvocationService } from "./invocation.js";
18
23
  import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
19
24
  import { hasConnectorGuides, SkillsRegistry } from "./skills.js";
20
25
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
21
- import { AUTHORIZE_OUTPUT, SEARCH_OUTPUT, SKILLS_OUTPUT } from "./meta-output.js";
26
+ import { isWriteLike } from "./tool-safety.js";
27
+ import { AUTHORIZE_OUTPUT, SEARCH_OUTPUT } from "./meta-output.js";
22
28
  import { RESULT_TTL_SECONDS, resultKeys } from "./storage/keys.js";
23
29
  import { agentOutputOperations, sentSecretsForRequest } from "./sent-secrets.js";
24
30
  import { captureDownstreamInput } from "./downstream-input.js";
25
31
  import { PROGRAM_RESULT_PAGE_BYTES, PROGRAM_RESULT_WIRE_BYTES } from "./program-result.js";
32
+ import { previewResult } from "./result-preview.js";
26
33
  export { MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT };
27
34
  const enc = new TextEncoder();
28
35
  // Each page is a slice of payload bytes, so a leading U+FEFF is data.
@@ -38,6 +45,9 @@ export function jsonResult(obj, text = JSON.stringify(obj)) {
38
45
  function errorResult(message) {
39
46
  return { content: [{ type: "text", text: message }], isError: true };
40
47
  }
48
+ function resultError(message) {
49
+ return toolError(utilityRecovery("invalid_args", message));
50
+ }
41
51
  function discoveryErrorResult(error) {
42
52
  return toolError({ code: error.code, message: error.message, retryable: false });
43
53
  }
@@ -110,8 +120,8 @@ function alignEndToCharBoundary(bytes, offset, end, total) {
110
120
  return e;
111
121
  }
112
122
  /** Every readable stash carries host-admitted bindings; older entries fail closed. */
113
- const RESULT_ENVELOPE_V4 = "connecta-result-v4:";
114
123
  const RESULT_ENVELOPE_V4_HEADER = /^connecta-result-v4:([A-Za-z0-9+/=]+):(\d+):(\d+):(\d+):/;
124
+ const RESULT_REFUSED_V4_HEADER = /^connecta-result-refused-v4:([A-Za-z0-9+/=]+):$/;
115
125
  /**
116
126
  * Smallest chunk of result text stored under one key. A multiple of three so
117
127
  * every chunk's base64 stands alone and a byte offset inside it lands on a
@@ -131,6 +141,30 @@ const RESULT_MAX_CHUNKS = 32;
131
141
  function resultChunkBytes(totalBytes) {
132
142
  return Math.max(RESULT_CHUNK_BYTES, Math.ceil(totalBytes / RESULT_MAX_CHUNKS / 3) * 3);
133
143
  }
144
+ const isBinary = (item) => ArrayBuffer.isView(item) || item instanceof ArrayBuffer;
145
+ /**
146
+ * JSON text, and whether a binary value went into it: as written (the holder's value, before
147
+ * any toJSON) or as a toJSON returned it. Whatever JSON makes of one, redaction checks its bytes
148
+ * only while they are binary. Text results have no binary values to lose.
149
+ */
150
+ function serializedWithBinary(value) {
151
+ let binary = false;
152
+ const text = JSON.stringify(value, function (key, item) {
153
+ binary ||= isBinary(this[key]) || isBinary(item);
154
+ return item;
155
+ });
156
+ return { text: text === undefined ? String(value) : text, binary };
157
+ }
158
+ /**
159
+ * Give back a stash nothing can name any more; its layout follows from its size. Sealing
160
+ * writes a header for whatever the ledger still holds, so the id leaves the ledger first.
161
+ * Once sealing has begun it owns the entry, which keeps its charge until it expires.
162
+ */
163
+ export function releaseStashedResult(registry, held, id, totalBytes) {
164
+ return Effect.suspend(() => held && !held.discard(id)
165
+ ? Effect.void
166
+ : registryProgram(registry, "releaseResult", id, Math.max(1, Math.ceil(totalBytes / resultChunkBytes(totalBytes)))));
167
+ }
134
168
  /**
135
169
  * Base64 of `bytes`, in three-byte-aligned batches so the argument list of one
136
170
  * spread never grows with the result. Alignment matters: an unaligned batch
@@ -143,6 +177,26 @@ function base64Of(bytes) {
143
177
  }
144
178
  return out;
145
179
  }
180
+ /** Calls a whole-result loop leaves the program, so it can also act on what it finds. */
181
+ const LOOP_SPARE_HOST_CALLS = 2;
182
+ /**
183
+ * Host calls to read every page of a retained result at the default page size.
184
+ * A page can stop up to three bytes short to keep a character whole, so the
185
+ * count assumes every one does.
186
+ */
187
+ const resultPages = (cap, totalBytes) => Math.ceil(totalBytes / Math.max(1, Math.min(cap, PROGRAM_RESULT_PAGE_BYTES) - 3));
188
+ /** Whether a program with this many calls left can walk every page of a retained result. */
189
+ const loopFits = (cap, totalBytes, calls) => resultPages(cap, totalBytes) + LOOP_SPARE_HOST_CALLS <= calls;
190
+ /**
191
+ * A read handle's advice, sized to the calls its program has left. Overrunning
192
+ * them ends the run and discards everything it read, so a result that can't fit
193
+ * pages across programs, which carry the offset and reduction between them.
194
+ */
195
+ function readHandleHint(id, cap, totalBytes, calls) {
196
+ if (loopFits(cap, totalBytes, calls.left))
197
+ return "Loop connecta.result(handle, { offset }) until !hasMore, setting offset to nextOffset.";
198
+ return `Needs up to ${resultPages(cap, totalBytes)} host calls; this program has ${calls.left} left, and overrunning returns nothing. Call connecta.result(${JSON.stringify(id)}, { offset }) at most ${Math.max(0, calls.left - LOOP_SPARE_HOST_CALLS)} times here and ${Math.max(1, calls.max - LOOP_SPARE_HOST_CALLS)} per new program, return your reduction and nextOffset, and continue from both within ${RESULT_TTL_SECONDS / 60} minutes.`;
199
+ }
146
200
  /**
147
201
  * The page a notice's nextAction asks a program to read: it fits the program's
148
202
  * return even if every byte escapes to six JSON characters, with room for the
@@ -152,6 +206,28 @@ function programPageBytes(results) {
152
206
  const fits = Math.floor(((results.programChars ?? MAX_EXECUTE_RESULT_CHARS) - 1024) / 6);
153
207
  return Math.max(4, Math.min(fits, results.cap, PROGRAM_RESULT_PAGE_BYTES));
154
208
  }
209
+ /** A retained result its run could not seal: a typed answer, and repeating this page cannot help. */
210
+ function resultNotRetained(write, id) {
211
+ const label = `"${boundedEchoText(id, 96)}"`;
212
+ return write
213
+ ? {
214
+ code: "result_not_retained",
215
+ message: `Result ${label} is not retrievable: it was not retained past the run that produced it. ${WRITE_ALREADY_RAN}`,
216
+ retryable: false,
217
+ nextAction: {
218
+ action: "recover_result",
219
+ purpose: "Read the write's target for its outcome, or report the missing output. Never repeat the write.",
220
+ },
221
+ }
222
+ : {
223
+ code: "result_not_retained",
224
+ message: `Result ${label} was not retained past the run that produced it. Rerun the read for a fresh result.`,
225
+ retryable: false,
226
+ nextAction: { action: "recover_result", purpose: "Rerun the read in a new call or program." },
227
+ };
228
+ }
229
+ /** nextAction for a whole read: runs as written, and is meant to be edited first. */
230
+ const reduceWhole = (id, results) => `async () => { const { data } = await connecta.result(${JSON.stringify(id)}, { all: true }); return /* reduce data to what you need */ JSON.stringify(data).slice(0, ${programPageBytes(results)}); }`;
155
231
  /** Opens a write's notice, ahead of any paging instruction. */
156
232
  const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see its result.";
157
233
  /** UTF-8 cannot represent lone UTF-16 surrogates without changing them. */
@@ -180,7 +256,9 @@ function isWellFormedText(text) {
180
256
  * it is the one thing its notice forbids.
181
257
  */
182
258
  const readReduceFirst = (routes) => `To find something specific, repeat this read inside ${routes.program} with connecta.call and filter or search the result there, returning only what matters`;
183
- function pagingHint(results, totalBytes, preview) {
259
+ function pagingHint(results, id, totalBytes, preview) {
260
+ if (results.readBytes !== undefined && totalBytes <= results.readBytes)
261
+ return `${results.write ? WRITE_ALREADY_RAN + " " : ""}Read this completed return with connecta.result(id, { all: true }) inside ${results.routes.program} and reduce it, as nextAction does.`;
184
262
  if (results.pagingTool === "read_result") {
185
263
  const shown = preview.kind === "prefix"
186
264
  ? `Bytes 0-${preview.bytes} of ${totalBytes} follow; page the rest`
@@ -197,6 +275,11 @@ function pagingHint(results, totalBytes, preview) {
197
275
  : preview.kind === "text-of-envelope"
198
276
  ? ["Its text follows.", `to read the full ${totalBytes}-byte content array as JSON, page it`]
199
277
  : [`The result is ${totalBytes} bytes.`, "to read it in full, page it"];
278
+ // Stop fetching again when one program can reach every retained byte: the reduction
279
+ // still sees the whole value, and the downstream is asked only once. Beyond that the
280
+ // loop would end in budget_exceeded with nothing returned, so a program repeats the read.
281
+ if (loopFits(results.cap, totalBytes, results.programHostCalls ?? CONFIG_DEFAULTS.execute.maxHostCalls))
282
+ return `${shown} The full result is retained for ${RESULT_TTL_SECONDS / 60} minutes. To find something in it, loop connecta.result(${JSON.stringify(id)}, { offset }) from offset 0 until !hasMore, setting offset to nextOffset, in one ${results.routes.program} program; filter there and return the matches. To read on, run nextAction.`;
200
283
  return `${shown} ${readReduceFirst(results.routes)}; ${full} with connecta.result using nextAction.`;
201
284
  }
202
285
  const body = preview.kind === "prefix"
@@ -236,7 +319,8 @@ const stashResult = function (bytes, results, preview, rawText) {
236
319
  // reader below maps a byte offset back to chunk index and base64 quad.
237
320
  const chunkBytes = resultChunkBytes(totalBytes);
238
321
  const binding = base64Of(enc.encode(JSON.stringify(results.binding)));
239
- const chunks = [`${RESULT_ENVELOPE_V4}${binding}:${totalBytes}:${chunkBytes}:${results.cap}:`];
322
+ const head = `${RESULT_ENVELOPE_V4}${binding}:${totalBytes}:${chunkBytes}:${results.cap}:`;
323
+ const chunks = [head];
240
324
  for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
241
325
  const chunk = base64Of(bytes.subarray(offset, offset + chunkBytes));
242
326
  if (offset === 0)
@@ -244,9 +328,30 @@ const stashResult = function (bytes, results, preview, rawText) {
244
328
  else
245
329
  chunks.push(chunk);
246
330
  }
247
- if (!(yield* upwardStep(authInvoke(results, "set", [id, chunks, RESULT_TTL_SECONDS])))) {
331
+ const held = results.held;
332
+ // Taken before the booking, so a held header can never outlive the chunks it describes.
333
+ const expiresAtMs = Date.now() + RESULT_TTL_SECONDS * 1000;
334
+ if (held && !held.results.accepts())
335
+ throw new Error("The run already sealed its results");
336
+ const stashed = yield* upwardStep(authInvoke(results, "set", [id, chunks, RESULT_TTL_SECONDS, ...(held ? [{ holdFirst: true }] : [])]));
337
+ if (!stashed)
248
338
  throw new Error("Result stash capacity exhausted");
249
- }
339
+ if (held &&
340
+ !held.results.add({
341
+ id,
342
+ binding,
343
+ total: totalBytes,
344
+ chunkBytes,
345
+ cap: results.cap,
346
+ first: chunks[0].slice(head.length),
347
+ chunks: chunks.length,
348
+ passed: held.passed,
349
+ format: held.format,
350
+ binary: held.binary,
351
+ write: results.binding.classification === "write",
352
+ expiresAtMs,
353
+ }))
354
+ throw new Error("The run already sealed its results");
250
355
  }
251
356
  catch {
252
357
  // Paging is advisory after a completed call, including an approved write.
@@ -271,7 +376,7 @@ const stashResult = function (bytes, results, preview, rawText) {
271
376
  truncated: true,
272
377
  resultId: id,
273
378
  totalBytes,
274
- hint: pagingHint(results, totalBytes, preview),
379
+ hint: pagingHint(results, id, totalBytes, preview),
275
380
  nextOffset: preview.kind === "prefix" ? preview.bytes : 0,
276
381
  nextAction: results.pagingTool === "read_result"
277
382
  ? {
@@ -279,32 +384,37 @@ const stashResult = function (bytes, results, preview, rawText) {
279
384
  arguments: { id, offset: preview.kind === "prefix" ? preview.bytes : 0 },
280
385
  }
281
386
  : {
282
- tool: "execute_code",
387
+ tool: results.routes.program,
283
388
  // A byte may cost six JSON characters when escaped. Leave room for the
284
389
  // result id, page counters, and continuation metadata in the returned page.
285
390
  arguments: {
286
- code: `async () => await connecta.result(${JSON.stringify(id)}, { offset: ${preview.kind === "prefix" ? preview.bytes : 0}, maxBytes: ${programPageBytes(results)} })`,
391
+ code: results.readBytes !== undefined && totalBytes <= results.readBytes
392
+ ? reduceWhole(id, results)
393
+ : `async () => await connecta.result(${JSON.stringify(id)}, { offset: ${preview.kind === "prefix" ? preview.bytes : 0}, maxBytes: ${programPageBytes(results)} })`,
287
394
  },
288
395
  },
289
396
  };
290
397
  });
291
398
  };
292
399
  /** Program calls use the direct-call envelope, ledger, expiry and authority. */
293
- export function pageProgramResult(registry, baseUrl, resolved, value, format, secrets, requestScope, wireBytes = PROGRAM_RESULT_WIRE_BYTES) {
294
- return runEdge(pageProgramResultNative(registry, baseUrl, resolved, value, format, secrets, requestScope, wireBytes));
400
+ export function pageProgramResult(registry, baseUrl, resolved, value, format, secrets, requestScope, hostCalls, wireBytes = PROGRAM_RESULT_WIRE_BYTES, routes = META_ROUTES, held, readBytes) {
401
+ return runEdge(pageProgramResultNative(registry, baseUrl, resolved, value, format, secrets, requestScope, hostCalls, wireBytes, routes, held, readBytes));
295
402
  }
296
- const pageProgramResultNative = /* @__PURE__ */ registerAuth(pageProgramResult, function (registry, baseUrl, resolved, value, format, secrets, requestScope, wireBytes = PROGRAM_RESULT_WIRE_BYTES) {
403
+ const pageProgramResultNative = /* @__PURE__ */ registerAuth(pageProgramResult, function (registry, baseUrl, resolved, value, format, secrets, requestScope, hostCalls, wireBytes = PROGRAM_RESULT_WIRE_BYTES, routes = META_ROUTES, held, readBytes) {
297
404
  return authFlow(function* () {
405
+ const passed = held?.secrets.revision ?? 0;
298
406
  const data = secrets.redact(value);
299
407
  const wire = JSON.stringify({ data, format });
300
408
  if (enc.encode(wire).length <= wireBytes)
301
409
  return { data, format };
302
- const text = secrets.text(format === "text" ? String(data) : serializeResultText(data));
410
+ const serialized = format === "text" ? { text: String(data), binary: false } : serializedWithBinary(data);
411
+ const text = secrets.text(serialized.text);
303
412
  const bytes = enc.encode(text);
304
413
  const write = resolved.definition.classification !== "read";
414
+ const cap = resolveMaxResultBytes(resolved.connector.maxResultBytes, registry.maxResultBytes);
305
415
  const notice = yield* upwardStep(stashResult(bytes, {
306
- // Program-call pages name only execute_code, listed wherever programs run.
307
- routes: META_ROUTES,
416
+ // Pages name the program route the run is on, listed wherever it runs.
417
+ routes,
308
418
  binding: {
309
419
  identity: registry.resultIdentity(),
310
420
  baseUrl,
@@ -313,8 +423,9 @@ const pageProgramResultNative = /* @__PURE__ */ registerAuth(pageProgramResult,
313
423
  classification: write ? "write" : "read",
314
424
  },
315
425
  write,
316
- cap: resolveMaxResultBytes(resolved.connector.maxResultBytes, registry.maxResultBytes),
317
- set: authMethod((id, chunks, ttl) => registryProgram(registry, "stashResult", id, chunks, ttl)),
426
+ cap,
427
+ ...(held ? { held: { results: held, passed, format, binary: serialized.binary } } : {}),
428
+ set: authMethod((id, chunks, ttl, options) => registryProgram(registry, "stashResult", id, chunks, ttl, options)),
318
429
  warn: () => logFailure(registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger, "result paging unavailable", failureRecord({ connector: resolved.connector.id, tool: resolved.definition })),
319
430
  }, { kind: "none" }, format === "text" ? text : undefined));
320
431
  return {
@@ -324,28 +435,35 @@ const pageProgramResultNative = /* @__PURE__ */ registerAuth(pageProgramResult,
324
435
  totalBytes: bytes.length,
325
436
  truncated: true,
326
437
  hint: notice.resultId
327
- ? `${write ? WRITE_ALREADY_RAN + " " : ""}Page this result with connecta.result(handle); do not repeat the call for output.`
438
+ ? readBytes !== undefined && bytes.length <= readBytes
439
+ ? `${write ? `${WRITE_ALREADY_RAN} ` : ""}Read it whole with connecta.result(handle, { all: true }), then reduce it.`
440
+ : write
441
+ ? `${WRITE_ALREADY_RAN} Page this result with connecta.result(handle); do not repeat the call for output.`
442
+ : readHandleHint(notice.resultId, cap, bytes.length, hostCalls)
328
443
  : notice.hint,
329
444
  };
330
445
  });
331
446
  });
332
447
  /** Program returns share the direct stash, with all host-admitted call dependencies. */
333
- export function guardProgramReturn(value, registry, baseUrl, dependencies, secrets, logger, maxChars, completedWrite, programChars) {
334
- return runEdge(guardProgramReturnNative(value, registry, baseUrl, dependencies, secrets, logger, maxChars, completedWrite, programChars));
448
+ export function guardProgramReturn(value, registry, baseUrl, dependencies, secrets, logger, maxChars, completedWrite, programChars, held, storeBy, readBytes, routes) {
449
+ return runEdge(guardProgramReturnNative(value, registry, baseUrl, dependencies, secrets, logger, maxChars, completedWrite, programChars, held, storeBy, readBytes, routes));
335
450
  }
336
- const guardProgramReturnNative = /* @__PURE__ */ registerAuth(guardProgramReturn, function (value, registry, baseUrl, dependencies, secrets, logger, maxChars, completedWrite, programChars) {
451
+ const guardProgramReturnNative = /* @__PURE__ */ registerAuth(guardProgramReturn, function (value, registry, baseUrl, dependencies, secrets, logger, maxChars, completedWrite, programChars, held,
452
+ // Stalled storage costs the paging, never the answer.
453
+ storeBy, readBytes, routes = META_ROUTES) {
337
454
  return authFlow(function* () {
338
- const serialized = serializeResultText(value);
339
- if (serialized.length <= maxChars)
455
+ if (serializeResultText(value).length <= maxChars)
340
456
  return value;
341
- const text = secrets.text(serialized);
457
+ const passed = held?.secrets.revision ?? 0;
458
+ const serialized = serializedWithBinary(value);
459
+ const text = secrets.text(serialized.text);
342
460
  const bytes = enc.encode(text);
343
461
  const write = dependencies.some((dependency) => dependency.classification === "write");
344
462
  const cap = Math.min(registry.maxResultBytes, ...dependencies.map((dependency) => resolveMaxResultBytes(registry.getConnector(dependency.connector)?.maxResultBytes, registry.maxResultBytes)));
345
463
  const results = {
346
464
  secrets,
347
- // A program return names only execute_code, which every surface running programs lists.
348
- routes: META_ROUTES,
465
+ // A return names the program route the run is on, listed wherever it runs.
466
+ routes,
349
467
  binding: {
350
468
  identity: registry.resultIdentity(),
351
469
  baseUrl,
@@ -355,7 +473,14 @@ const guardProgramReturnNative = /* @__PURE__ */ registerAuth(guardProgramReturn
355
473
  write: completedWrite,
356
474
  cap,
357
475
  ...(programChars !== undefined ? { programChars } : {}),
358
- set: authMethod((id, chunks, ttl) => registryProgram(registry, "stashResult", id, chunks, ttl)),
476
+ ...(readBytes !== undefined ? { readBytes } : {}),
477
+ ...(held ? { held: { results: held, passed, format: "json", binary: serialized.binary } } : {}),
478
+ set: authMethod((id, chunks, ttl, options) => storeBy === undefined
479
+ ? registryProgram(registry, "stashResult", id, chunks, ttl, options)
480
+ : withDeadlineEffect(() => registryProgram(registry, "stashResult", id, chunks, ttl, options), {
481
+ timeoutMs: Math.max(1, storeBy - Date.now()),
482
+ timeoutError: new Error("Result storage did not answer."),
483
+ })),
359
484
  warn: () => logFailure(logger, "result paging unavailable", failureRecord({})),
360
485
  };
361
486
  const notice = yield* upwardStep(stashResult(bytes, results, { kind: "none" }, text));
@@ -380,6 +505,39 @@ function noticeFirst(notice, preview) {
380
505
  content: [{ type: "text", text: `${JSON.stringify(notice)}\n${preview}` }],
381
506
  };
382
507
  }
508
+ /**
509
+ * The view of a read past its cap, or undefined for its first bytes. A call that
510
+ * may have written is never reshaped, whatever a deployment override calls it: its
511
+ * result is its only copy, and repeating it to look again is forbidden (INV-9).
512
+ */
513
+ function previewOf(text, bytes, results, format) {
514
+ if (!results.programTool || results.write)
515
+ return undefined;
516
+ // The program this offers has to reach every byte within the run's host calls.
517
+ if (!loopFits(results.cap, bytes.length, results.programHostCalls ?? CONFIG_DEFAULTS.execute.maxHostCalls))
518
+ return undefined;
519
+ return previewResult(text, format, results.cap);
520
+ }
521
+ /**
522
+ * A preview's notice. It says what the body holds and what it leaves out, and
523
+ * since a host may prompt for or refuse the program, what to do without it.
524
+ */
525
+ function previewNotice(view, id, totalBytes, tool) {
526
+ return {
527
+ truncated: true,
528
+ resultId: id,
529
+ totalBytes,
530
+ ...view.fields,
531
+ hint: `${view.shown} All of it is retained for ${RESULT_TTL_SECONDS / 60} minutes: run nextAction in ${tool}, editing its last line to ${view.noun === "rows" ? "filter, join or count the rows" : "search for what you need"}, instead of reading pages by hand. If ${tool} is refused or unavailable, the rest can't be read here: narrow this read with its own filter, limit or fields arguments, or say what was not read.`,
532
+ nextOffset: 0,
533
+ nextAction: {
534
+ tool,
535
+ arguments: {
536
+ code: `async () => { let text = "", offset = 0, page; do { page = await connecta.result(${JSON.stringify(id)}, { offset }); text += page.text; offset = page.nextOffset; } while (page.hasMore); ${view.reduce} }`,
537
+ },
538
+ },
539
+ };
540
+ }
383
541
  /**
384
542
  * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
385
543
  * full text and return a JSON truncation notice pointing at connecta.result,
@@ -388,7 +546,7 @@ function noticeFirst(notice, preview) {
388
546
  * `text` already encoded, so a caller that had to measure it to make this
389
547
  * decision doesn't encode it twice.
390
548
  */
391
- const guardEncoded = function (text, bytes, results, cap) {
549
+ const guardEncoded = function (text, bytes, results, cap, format) {
392
550
  return authFlow(function* () {
393
551
  if (bytes.length <= cap) {
394
552
  return {
@@ -405,11 +563,17 @@ const guardEncoded = function (text, bytes, results, cap) {
405
563
  kind: "prefix",
406
564
  bytes: head.length,
407
565
  }));
566
+ // A preview needs the retained copy its program reads, so a failed stash keeps the first bytes.
567
+ const view = notice.resultId === undefined ? undefined : previewOf(text, bytes, results, format);
568
+ if (view && notice.resultId !== undefined) {
569
+ const shown = previewNotice(view, notice.resultId, bytes.length, results.programTool);
570
+ return { result: noticeFirst(shown, view.body), truncated: true };
571
+ }
408
572
  return { result: noticeFirst(notice, dec.decode(head)), truncated: true };
409
573
  });
410
574
  };
411
575
  /** {@link guardEncoded} over a string that has not been measured yet. */
412
- const guardText = function (text, results, cap) {
576
+ const guardText = function (text, results, cap, format) {
413
577
  return authFlow(function* () {
414
578
  // `JSON.stringify`'s type says `string` where its behavior says `string |
415
579
  // undefined`, so TypeScript alone does not keep a non-string out of here.
@@ -418,7 +582,7 @@ const guardText = function (text, results, cap) {
418
582
  // through it the way issue #42 describes.
419
583
  const serialized = typeof text === "string" ? text : serializeResultText(text);
420
584
  const body = results.secrets?.text(serialized) ?? serialized;
421
- return yield* upwardStep(guardEncoded(body, enc.encode(body), results, cap));
585
+ return yield* upwardStep(guardEncoded(body, enc.encode(body), results, cap, format));
422
586
  });
423
587
  };
424
588
  /** Store an oversized JSON value and replace it with a page handle. */
@@ -474,7 +638,7 @@ function escapedHeadOf(bytes, cap) {
474
638
  * the head of a half-written base64 image is of no use to anyone. Either way
475
639
  * the full envelope is stashed and pages through `connecta.result`.
476
640
  */
477
- const guardContent = function (content, results, cap) {
641
+ const guardContent = function (content, results, cap, format) {
478
642
  return authFlow(function* () {
479
643
  content = results.secrets?.redact({ content }).content ?? content;
480
644
  const only = content.length === 1 ? content[0] : undefined;
@@ -483,7 +647,7 @@ const guardContent = function (content, results, cap) {
483
647
  // Under the cap the block passes through untouched, annotations included.
484
648
  if (bytes.length <= cap)
485
649
  return { result: { content }, truncated: false };
486
- return yield* upwardStep(guardEncoded(only.text, bytes, results, cap));
650
+ return yield* upwardStep(guardEncoded(only.text, bytes, results, cap, format));
487
651
  }
488
652
  let text;
489
653
  try {
@@ -540,6 +704,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
540
704
  // Already normalized and warned about at registry construction.
541
705
  const globalCap = registry.maxResultBytes;
542
706
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs) ?? CONFIG_DEFAULTS.execute.hostCallTimeoutMs;
707
+ const responseCeilingMs = normalizeTimeoutMs(opts.responseCeilingMs) ?? CONFIG_DEFAULTS.execute.watchdogMs;
543
708
  const probeTimeoutMs = normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
544
709
  const discoveryConcurrency = resolveDiscoveryConcurrency(opts.discoveryConcurrency);
545
710
  // createMetaTools() is called once per inbound MCP request. Sharing this
@@ -558,6 +723,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
558
723
  }
559
724
  const sentSecrets = sentSecretsForRequest(requestScope);
560
725
  const skills = new SkillsRegistry(registry, baseUrl, {
726
+ readOnly: opts.readOnly,
561
727
  requestScope,
562
728
  requestSignal: opts.requestSignal,
563
729
  probeTimeoutMs,
@@ -582,7 +748,22 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
582
748
  }
583
749
  const runCallNative = /* @__PURE__ */ registerAuth(runCall, function (call, source) {
584
750
  return authFlow(function* () {
585
- const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
751
+ // A deadline past the client's would trade a typed result for its transport timeout.
752
+ const now = Date.now();
753
+ const ceilingAt = (opts.requestStartedAt ?? now) + responseCeilingMs;
754
+ const remainingMs = ceilingAt - now;
755
+ const timeoutMs = Math.min(normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs, Math.max(1, remainingMs));
756
+ const pastCeiling = () => ({
757
+ code: "timeout",
758
+ message: `This request used its ${responseCeilingMs}ms response ceiling before ${boundedEchoText(call.address, 512)} started, so it was not sent. Send it again.`,
759
+ retryable: true,
760
+ details: {
761
+ operation: boundedEchoText(call.address, 512),
762
+ stage: "response ceiling",
763
+ elapsedMs: responseCeilingMs - (ceilingAt - Date.now()),
764
+ deadlineMs: responseCeilingMs,
765
+ },
766
+ });
586
767
  const outcome = yield* upwardStep(authInvoke(invocation, "invoke", [
587
768
  call.address,
588
769
  call.args ?? {},
@@ -592,7 +773,11 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
592
773
  sentSecrets,
593
774
  readOnly: opts.readOnly,
594
775
  routes,
595
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
776
+ timeoutMs,
777
+ // Auth or queueing spent the time, and a 1 ms deadline could still lose
778
+ // to a dispatch. A known "not sent" beats an answer the client won't hear.
779
+ ...(remainingMs <= 0 ? { refusal: pastCeiling() } : {}),
780
+ dispatchDeadline: { at: ceilingAt, refusal: pastCeiling },
596
781
  ...(opts.requestSignal !== undefined ? { requestSignal: opts.requestSignal } : {}),
597
782
  unwrapResult: call.resultMode === "value",
598
783
  ...(opts.downstreamInput
@@ -628,10 +813,12 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
628
813
  tool: resolved.definition.name,
629
814
  classification: resolved.definition.classification === "read" ? "read" : "write",
630
815
  },
631
- write: resolved.definition.classification !== "read",
816
+ write: isWriteLike(resolved.definition),
632
817
  cap,
633
818
  ...(opts.pagingTool ? { pagingTool: opts.pagingTool } : {}),
819
+ ...(opts.programTool ? { programTool: opts.programTool } : {}),
634
820
  ...(opts.maxProgramResultChars !== undefined ? { programChars: opts.maxProgramResultChars } : {}),
821
+ ...(opts.maxProgramHostCalls !== undefined ? { programHostCalls: opts.maxProgramHostCalls } : {}),
635
822
  set: authMethod((id, value, ttlSeconds) => registryProgram(registry, "stashResult", id, value, ttlSeconds)),
636
823
  // The catalog entry, never `toolName`: a record names a tool
637
824
  // only through the grammar check (src/operator-record.ts).
@@ -660,14 +847,14 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
660
847
  },
661
848
  ];
662
849
  }
663
- const guarded = yield* upwardStep(guardContent(content, results, cap));
850
+ const guarded = yield* upwardStep(guardContent(content, results, cap, format));
664
851
  // Native blocks and bounded previews are the result. A format-only
665
852
  // structuredContent hides them in clients that prefer that field.
666
853
  guarded.result._meta = { "dev.connecta/format": format };
667
854
  return processed(guarded.result, guarded.truncated);
668
855
  }
669
856
  const value = result;
670
- const guarded = yield* upwardStep(guardText(serializeResultText(value), results, cap));
857
+ const guarded = yield* upwardStep(guardText(serializeResultText(value), results, cap, format));
671
858
  guarded.result._meta = { "dev.connecta/format": format };
672
859
  return processed(guarded.result, guarded.truncated, { value });
673
860
  })),
@@ -696,10 +883,11 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
696
883
  // both copies and JSON escaping when the bounded provider reason is large.
697
884
  if (!recoveryRequired) {
698
885
  const cap = resolveMaxResultBytes(outcome.resolved?.connector.maxResultBytes, globalCap);
699
- let budget = 512;
886
+ const { message } = outcome.error;
887
+ let budget = 2 * MAX_TOOL_ERROR_BYTES;
700
888
  while (enc.encode(JSON.stringify(failedResult)).length > cap && budget > 0) {
701
889
  budget = Math.floor(budget / 2);
702
- outcome.error.message = boundedEchoText(outcome.error.message, budget);
890
+ outcome.error.message = clipText(message, budget);
703
891
  failedResult = makeFailedResult();
704
892
  }
705
893
  }
@@ -731,7 +919,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
731
919
  };
732
920
  });
733
921
  });
734
- return {
922
+ const methods = {
735
923
  skills: authMethod((args = {}) => authFlow(function* () {
736
924
  try {
737
925
  if (!args.name) {
@@ -748,10 +936,13 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
748
936
  };
749
937
  }
750
938
  const text = yield* upwardStep(authInvoke(skills, "text", [args.name]));
751
- return { content: [{ type: "text", text }], structuredContent: { name: args.name, format: "text", text } };
939
+ // Guidance is text-only: a structured copy would double every fetch on the wire (result-representation).
940
+ return { content: [{ type: "text", text }] };
752
941
  }
753
942
  catch (error) {
754
- return errorResult(error instanceof ConnectorCallError ? error.message : "Skills are unavailable.");
943
+ if (error instanceof SkillLookupError)
944
+ return toolError(skillLookupRecovery(error.message));
945
+ return toolError(utilityRecovery(error instanceof ConnectorCallError ? error.code : "unavailable", "Skills are unavailable.", error instanceof ConnectorCallError && error.retryable));
755
946
  }
756
947
  })),
757
948
  searchTools: authMethod((args) => authFlow(function* () {
@@ -781,25 +972,21 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
781
972
  // Defense for in-process callers too: guest paging validates these domains
782
973
  // before calling here, while internal consumers may call readResult directly.
783
974
  if (args.maxBytes !== undefined && !isValidMaxResultBytes(args.maxBytes)) {
784
- return errorResult(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
975
+ return resultError(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
785
976
  `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`);
786
977
  }
787
978
  if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
788
- return errorResult(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
979
+ return resultError(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
789
980
  `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
790
981
  }
791
982
  if (args.page !== undefined &&
792
983
  (!Number.isSafeInteger(args.page) || args.page < 0 || args.offset !== undefined)) {
793
- return errorResult("Use a whole page >= 0, without offset.");
984
+ return resultError("Use a whole page >= 0, without offset.");
794
985
  }
795
986
  const results = registry.resultsStorage();
796
987
  const unavailableResult = () => ({
797
988
  ...jsonResult({
798
- error: {
799
- code: "unavailable",
800
- message: "Result paging storage is unavailable.",
801
- retryable: true,
802
- },
989
+ error: utilityRecovery("unavailable", "Result paging storage is unavailable.", true),
803
990
  }),
804
991
  isError: true,
805
992
  });
@@ -821,14 +1008,16 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
821
1008
  options.signal?.throwIfAborted();
822
1009
  return value;
823
1010
  }));
824
- const stored = yield* upwardStep(authCall(read, [resultKeys.chunk(args.id, 0)]));
1011
+ const stored = opts.heldResults?.stored(args.id) ??
1012
+ (yield* upwardStep(authCall(read, [resultKeys.chunk(args.id, 0)])));
825
1013
  if (stored === false)
826
1014
  return unavailableResult();
827
1015
  if (stored === null) {
828
- return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
1016
+ return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
829
1017
  }
830
- const denied = () => errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
831
- const header = RESULT_ENVELOPE_V4_HEADER.exec(stored);
1018
+ const denied = () => toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
1019
+ const refused = RESULT_REFUSED_V4_HEADER.exec(stored);
1020
+ const header = refused ?? RESULT_ENVELOPE_V4_HEADER.exec(stored);
832
1021
  if (!header)
833
1022
  return denied();
834
1023
  let binding;
@@ -858,6 +1047,8 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
858
1047
  !["read", "write"].includes(dependency.classification))) ||
859
1048
  !["read", "write"].includes(binding.classification))
860
1049
  return denied();
1050
+ if (refused)
1051
+ return toolError(resultNotRetained(binding.classification === "write", args.id));
861
1052
  const allowed = authMethod(() => authFlow(function* () {
862
1053
  options.onStage?.("access validation");
863
1054
  options.signal?.throwIfAborted();
@@ -941,7 +1132,7 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
941
1132
  if (encoded === null) {
942
1133
  // A chunk expired or was evicted under its own header; the id can no
943
1134
  // longer serve this range, and inventing U+0000 filler would be worse.
944
- return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
1135
+ return toolError(utilityRecovery("not_found", `Unknown or expired result id "${boundedEchoText(args.id, 96)}"`));
945
1136
  }
946
1137
  const chunkStart = index * chunkBytes;
947
1138
  // Both bounds are chunk-local. `from` inherits `start`'s three-byte
@@ -999,18 +1190,27 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
999
1190
  }
1000
1191
  })),
1001
1192
  };
1193
+ if (!opts.activity?.recordOperation)
1194
+ return methods;
1195
+ const track = (name, method) => authMethod((...args) => operationActivity(opts.activity, registry, name, authCall(method, args)));
1196
+ return {
1197
+ ...methods,
1198
+ skills: track("skill", methods.skills),
1199
+ searchTools: track("search", methods.searchTools),
1200
+ readResult: track("result", methods.readResult),
1201
+ };
1002
1202
  }
1003
1203
  // Each description states only its own tool's scope; the full routing rule
1004
1204
  // lives once in the server instructions. A client that drops instructions
1005
1205
  // still routes correctly from these scopes plus execute_code's terse lead.
1006
1206
  // Only the meta surface lists these tools, so they use its names.
1007
- const SEARCH_DESC = (readOnly) => `${readOnly ? "Catalog inspection; this read-only endpoint omits write tools." : `Catalog inspection, or finding a write before ${META_ROUTES.write}.`} For read work, search inside one ${META_ROUTES.program} program that also calls and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; the default limit is ${DEFAULT_SEARCH_LIMIT}. Set connector when known. safety="readOnly" selects reads; "approvalRequired" selects the fail-closed write complement; neither grants authority. Empty query browses. Read catalogErrors and absence before selecting a tool. Compact schemas have schemaFormat="text".`;
1008
- const CALL_DESC = (readOnly) => `Call one known-address read: a tool explicitly annotated readOnlyHint: true. Other read work: ${META_ROUTES.program}. ${readOnly ? "This endpoint is read-only and refuses every write." : `Unannotated or write-capable tools fail closed to ${META_ROUTES.write}.`} A truncated result carries a connecta.result action.`;
1009
- const CALL_DESTRUCTIVE_DESC = "Call any tool not explicitly annotated readOnlyHint: true. Include a short reason for the human reviewer after checking the schema and consequences. The reason grants no authority and is not sent downstream.";
1207
+ const SEARCH_DESC = (readOnly, program) => `${readOnly ? "Inspect the catalog; this read-only endpoint omits write tools." : `Inspect the catalog, or find a write before ${META_ROUTES.write}.`} For read work, search inside one ${program} program that also calls and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; set connector when known; an empty query browses. safety filters by classification and grants nothing. Read catalogErrors and absence before choosing a tool.`;
1208
+ const CALL_DESC = (readOnly, program) => `Call one known-address read: a tool explicitly annotated readOnlyHint: true. Other read work: ${program}. ${readOnly ? "This endpoint is read-only and refuses every write." : `Unannotated or write-capable tools fail closed to ${META_ROUTES.write}.`} A truncated result carries nextAction.`;
1209
+ const CALL_DESTRUCTIVE_DESC = "Call one tool not explicitly annotated readOnlyHint: true. Check its schema and consequences, then include a short reason for the human reviewer; it grants nothing and is not sent downstream. Sent once: never resend it to see output.";
1010
1210
  /** A read-only endpoint keeps the six-tool surface, so this tool says why it never writes here. */
1011
1211
  const CALL_DESTRUCTIVE_READ_ONLY_DESC = `This endpoint is read-only: search and describe omit write tools, and every write call here is refused. Reads still run; prefer ${META_ROUTES.read} or ${META_ROUTES.program} for them.`;
1012
- const AUTHORIZE_DESC = "Use after auth_required. Returns an OAuth or operator-credential handoff, or required deployment configuration. A configured credential reports presence only: if a call just failed with an authentication or operator-configuration error, give the retained handoff to the operator; otherwise proceed with the call. force=true requests an OAuth restart when the verified user opens the /connect URL; this tool never accepts credentials.";
1013
- const SKILLS_DESC = "List or fetch on-demand guidance. Fetch usage only when the always-loaded instructions are insufficient or a program needs repair.";
1212
+ const AUTHORIZE_DESC = "Use after auth_required when the error has no authorizationUrl or operatorUrl, or for a fresh link; otherwise pass that on. Returns an OAuth or operator-credential handoff, or required deployment configuration. A configured credential reports presence only: if a call just failed on authentication or configuration, give the retained handoff to the operator; otherwise proceed with the call. force=true restarts OAuth when the verified user opens the /connect URL. Never accepts credentials.";
1213
+ const SKILLS_DESC = "List or fetch guidance by exact name. Fetch usage only when guidance is missing or a program needs repair.";
1014
1214
  /**
1015
1215
  * Sentences appended to a meta-tool description only when this connection
1016
1216
  * actually has connector guides. Tool descriptions are always-loaded context,
@@ -1020,9 +1220,9 @@ const SKILLS_DESC = "List or fetch on-demand guidance. Fetch usage only when the
1020
1220
  * Registration is per connection and reads the configured connector set.
1021
1221
  */
1022
1222
  const GUIDE_NOTES = {
1023
- skills: " Also lists this deployment's connector guides by exact name.",
1024
- search: " A result with guideRequired: true requires its exact named connector guide before the call.",
1025
- destructive: " Fetch any exact connector guide named by discovery before the call.",
1223
+ skills: " Includes connector guides.",
1224
+ search: " guideRequired: true means fetch its named guide before the call.",
1225
+ destructive: " Fetch any connector guide discovery names first.",
1026
1226
  };
1027
1227
  /** `base`, plus its guide note when any VISIBLE connector carries a guide. */
1028
1228
  function describedFor(registry, base, note) {
@@ -1047,7 +1247,10 @@ const READ_ONLY_LOCAL = {
1047
1247
  openWorldHint: false,
1048
1248
  };
1049
1249
  const CALL_INPUT_SCHEMA = {
1050
- address: z.string().meta({ "x-mcp-header": "Address" }),
1250
+ // Error text only: neither message reaches the advertised schema or widens what is accepted.
1251
+ address: z
1252
+ .string({ error: 'expected a string such as "connector.tool". Send { address, args }.' })
1253
+ .meta({ "x-mcp-header": "Address" }),
1051
1254
  args: z.record(z.string(), z.unknown()).optional(),
1052
1255
  resultMode: z.enum(["mcp", "value"]).optional(),
1053
1256
  timeoutMs: z.number().int().positive().optional(),
@@ -1067,7 +1270,13 @@ const SEARCH_INPUT = advertisedSchema(z.object({
1067
1270
  fullDescriptions: z.boolean().optional(),
1068
1271
  includeSchemas: z.enum(["compact", "json", "typescript"]).optional(),
1069
1272
  }));
1070
- const CALL_INPUT = advertisedSchema(z.strictObject(CALL_INPUT_SCHEMA));
1273
+ /** MCP's own `tools/call` spells these `name` and `arguments`; say so at the point of failure. */
1274
+ const CALL_SHAPE = {
1275
+ error: (issue) => issue.code === "unrecognized_keys"
1276
+ ? `Unrecognized key${issue.keys?.length === 1 ? "" : "s"}: ${issue.keys?.map((key) => JSON.stringify(key)).join(", ")}. Send { address, args }, with the tool's arguments in args.`
1277
+ : undefined,
1278
+ };
1279
+ const CALL_INPUT = advertisedSchema(z.strictObject(CALL_INPUT_SCHEMA, CALL_SHAPE));
1071
1280
  const CALL_DESTRUCTIVE_INPUT = advertisedSchema(z.strictObject({
1072
1281
  ...CALL_INPUT_SCHEMA,
1073
1282
  // Bounded above, but with no lower bound: a model that sends `""` or
@@ -1075,8 +1284,8 @@ const CALL_DESTRUCTIVE_INPUT = advertisedSchema(z.strictObject({
1075
1284
  // call over a cosmetic field the host merely displays is the wrong
1076
1285
  // trade. It is normalized to absent in the handler instead.
1077
1286
  reason: z.string().max(500).optional(),
1078
- }));
1079
- const READ_RESULT_DESC = "Page by resultId and nextAction from the truncation notice. UTF-8 byte ranges: continue from nextOffset while hasMore. Never repeat a write for output.";
1287
+ }, CALL_SHAPE));
1288
+ const READ_RESULT_DESC = "Page a truncated result: run nextAction, then continue from nextOffset while hasMore. Never repeat a write for output.";
1080
1289
  const READ_RESULT_INPUT = advertisedSchema(z.strictObject({
1081
1290
  id: z.string(),
1082
1291
  offset: z.number().int().nonnegative().optional(),
@@ -1098,6 +1307,8 @@ const AUTHORIZE_INPUT = advertisedSchema(z.object({
1098
1307
  export function registerMetaTools(server, registry, ctx) {
1099
1308
  const mt = createMetaTools(registry, ctx.baseUrl, {
1100
1309
  maxProgramResultChars: ctx.maxProgramResultChars,
1310
+ maxProgramHostCalls: ctx.maxProgramHostCalls,
1311
+ programTool: ctx.programTool,
1101
1312
  routes: ctx.routes,
1102
1313
  ...(ctx.native && !ctx.native.executeCode ? { pagingTool: "read_result" } : {}),
1103
1314
  ...(ctx.native ? { activitySource: "native" } : {}),
@@ -1105,6 +1316,8 @@ export function registerMetaTools(server, registry, ctx) {
1105
1316
  requestScope: ctx.requestScope,
1106
1317
  readOnly: ctx.readOnly,
1107
1318
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1319
+ responseCeilingMs: ctx.responseCeilingMs,
1320
+ requestStartedAt: ctx.requestStartedAt,
1108
1321
  probeTimeoutMs: ctx.probeTimeoutMs,
1109
1322
  discoveryConcurrency: ctx.discoveryConcurrency,
1110
1323
  activity: ctx.activity,
@@ -1116,6 +1329,7 @@ export function registerMetaTools(server, registry, ctx) {
1116
1329
  requestSignal: ctx.requestSignal,
1117
1330
  defer: ctx.defer,
1118
1331
  });
1332
+ const routeProgram = ctx.routes?.program ?? META_ROUTES.program;
1119
1333
  const bindRequest = (request) => {
1120
1334
  if (ctx.client)
1121
1335
  bindMcpClient(request, ctx.client, ctx.activity);
@@ -1124,7 +1338,6 @@ export function registerMetaTools(server, registry, ctx) {
1124
1338
  description: describedFor(registry, SKILLS_DESC, "skills"),
1125
1339
  ...(ctx.native ? { _meta: { "dev.connecta/surface": { surface: "native", ...ctx.native } } } : {}),
1126
1340
  inputSchema: SKILLS_INPUT,
1127
- outputSchema: SKILLS_OUTPUT,
1128
1341
  annotations: READ_ONLY_LOCAL,
1129
1342
  }, authMethod((args, request) => authFlow(function* () {
1130
1343
  bindRequest(request);
@@ -1179,7 +1392,7 @@ export function registerMetaTools(server, registry, ctx) {
1179
1392
  });
1180
1393
  }
1181
1394
  server.registerTool("search_tools", {
1182
- description: describedFor(registry, SEARCH_DESC(ctx.readOnly === true), "search"),
1395
+ description: describedFor(registry, SEARCH_DESC(ctx.readOnly === true, routeProgram), "search"),
1183
1396
  inputSchema: SEARCH_INPUT,
1184
1397
  outputSchema: SEARCH_OUTPUT,
1185
1398
  annotations: READ_ONLY_REMOTE,
@@ -1188,7 +1401,7 @@ export function registerMetaTools(server, registry, ctx) {
1188
1401
  return yield* upwardStep(authInvoke(mt, "searchTools", [args]));
1189
1402
  })));
1190
1403
  server.registerTool("call_tool", {
1191
- description: CALL_DESC(ctx.readOnly === true),
1404
+ description: CALL_DESC(ctx.readOnly === true, routeProgram),
1192
1405
  inputSchema: CALL_INPUT,
1193
1406
  // Default mode returns native content, so no structured result is promised.
1194
1407
  // call_tool admits only tools that are themselves explicitly read-only;