@zackbart/connecta 0.24.2 → 0.24.3

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 (57) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/downstream-oauth.d.ts +12 -1
  4. package/dist/auth/downstream-oauth.js +147 -35
  5. package/dist/call-admission.d.ts +4 -0
  6. package/dist/call-admission.js +26 -0
  7. package/dist/catalog-drift.js +9 -4
  8. package/dist/catalog-service.d.ts +2 -0
  9. package/dist/catalog-service.js +25 -8
  10. package/dist/catalog.d.ts +2 -0
  11. package/dist/catalog.js +246 -121
  12. package/dist/connectors/api.js +11 -1
  13. package/dist/connectors/guarded-fetch.d.ts +1 -1
  14. package/dist/connectors/guarded-fetch.js +27 -20
  15. package/dist/connectors/remote-mcp.js +84 -53
  16. package/dist/errors.d.ts +17 -0
  17. package/dist/errors.js +58 -0
  18. package/dist/execute.js +85 -23
  19. package/dist/executor-result.js +3 -1
  20. package/dist/executors/quickjs-child.js +5 -1
  21. package/dist/executors/quickjs-protocol.d.ts +4 -0
  22. package/dist/executors/quickjs-runtime.d.ts +1 -1
  23. package/dist/executors/quickjs-runtime.js +38 -21
  24. package/dist/executors/quickjs.js +68 -27
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +24 -3
  27. package/dist/invocation.js +134 -93
  28. package/dist/mcp-result.js +3 -2
  29. package/dist/meta-tools.js +118 -39
  30. package/dist/registry.d.ts +14 -2
  31. package/dist/registry.js +87 -13
  32. package/dist/routes/mcp.d.ts +4 -1
  33. package/dist/routes/mcp.js +84 -13
  34. package/dist/routes/oauth.js +4 -0
  35. package/dist/routes/shared.d.ts +1 -0
  36. package/dist/routes/shared.js +4 -4
  37. package/dist/server.js +15 -3
  38. package/dist/skills.js +6 -5
  39. package/dist/storage/file.d.ts +6 -2
  40. package/dist/storage/file.js +312 -34
  41. package/dist/storage/memory.js +12 -1
  42. package/dist/validate.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/documentation/architecture.md +22 -6
  46. package/documentation/auth.md +42 -9
  47. package/documentation/call-admission.md +24 -8
  48. package/documentation/code-mode.md +34 -22
  49. package/documentation/connectors.md +47 -5
  50. package/documentation/meta-tools.md +74 -6
  51. package/documentation/operations.md +19 -19
  52. package/documentation/provider-conventions.md +7 -0
  53. package/documentation/request-admission.md +38 -4
  54. package/documentation/storage-and-credentials.md +54 -1
  55. package/documentation/upgrading.md +18 -4
  56. package/package.json +1 -1
  57. package/templates/node/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { isCallAdmissionError } from "./call-admission.js";
2
- import { classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
2
+ import { boundedEchoText, classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
3
3
  import { unwrapMcpResult } from "./mcp-result.js";
4
4
  import { splitAddress } from "./registry.js";
5
5
  import { isExplicitlyReadOnly } from "./tool-safety.js";
@@ -43,10 +43,10 @@ function assertRawMcpSuccess(kind, result) {
43
43
  const mcpResult = result;
44
44
  if (!mcpResult.isError)
45
45
  return;
46
- throw new Error(mcpResult.content
46
+ throw new Error(boundedEchoText(mcpResult.content
47
47
  ?.filter((block) => block.type === "text")
48
48
  .map((block) => block.text ?? "")
49
- .join("") || "Downstream tool call failed");
49
+ .join("") || "Downstream tool call failed"));
50
50
  }
51
51
  export class InvocationFailure extends Error {
52
52
  details;
@@ -196,6 +196,8 @@ export class InvocationService {
196
196
  tool: target.toolName,
197
197
  source: context.source,
198
198
  code: details.code,
199
+ // Sanitized transport diagnostics (origin and errno only, #539).
200
+ ...(details.details ? { details: details.details } : {}),
199
201
  attempts,
200
202
  durationMs: Date.now() - started,
201
203
  message: String(details.message ?? "").slice(0, 300),
@@ -215,111 +217,150 @@ export class InvocationService {
215
217
  error: details,
216
218
  };
217
219
  };
218
- const resolution = await this.catalog.resolveTool(address, defined({ signal: context.requestSignal }));
219
- catalogMs += resolution.catalogMs;
220
- if (!resolution.ok) {
221
- if (resolution.connector && resolution.toolName) {
222
- activityTarget = {
223
- connector: resolution.connector,
224
- toolName: resolution.toolName,
225
- };
226
- }
227
- if (resolution.cause && context.requestSignal?.aborted) {
228
- return failed(callerCancelledDetails());
229
- }
230
- return failed(resolution.error);
231
- }
232
- resolved = resolution.resolved;
233
- activityTarget = resolved;
234
- if (!isExplicitlyReadOnly(resolved.definition) && !context.allowDestructive) {
235
- const canonicalAddress = `${resolved.connector.id}.${resolved.toolName}`;
236
- return failed(framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`));
237
- }
238
- // Remote MCP tools advertise their input schema in the catalog. Validate
239
- // against that same request-local definition before admission or provider
240
- // dispatch, so a predictable mismatch stays structured instead of being
241
- // flattened into provider-specific error prose. Unsupported schemas retain
242
- // validateToolInput's fail-open behavior and reach the downstream normally.
243
- if (resolved.connector.kind === "mcp" &&
244
- resolved.definition.inputSchema) {
245
- const invalid = validateToolInput(resolved.definition.inputSchema, args ?? {}, {
246
- address: `${resolved.connector.id}.${resolved.toolName}`,
247
- logger: this.registry.contextFor(resolved.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
248
- });
249
- if (invalid)
250
- return failed(classifyCallError(invalid));
251
- }
252
- try {
253
- context.beforeDispatch?.();
254
- }
255
- catch (error) {
256
- return failed(error instanceof InvocationFailure
257
- ? error.details
258
- : classifyCallError(error));
220
+ if (context.requestSignal?.aborted) {
221
+ // Preserve the cancelled admission-attempt count without starting discovery.
222
+ attempts = 1;
223
+ return failed(callerCancelledDetails());
259
224
  }
260
225
  let result;
261
226
  let observedResult;
262
- attempts = 1;
263
- let permit;
264
- let attemptError;
265
- let attemptFailed = false;
227
+ // One deadline owns discovery, queue admission, and provider dispatch.
228
+ // Only the caller of this function records the final outcome, so a late
229
+ // cancellation cannot append a second activity event after the deadline.
230
+ const dispatch = async (callSignal) => {
231
+ const resolution = await this.catalog.resolveTool(address, defined({ signal: callSignal }));
232
+ catalogMs += resolution.catalogMs;
233
+ if (callSignal?.aborted)
234
+ throw callSignal.reason;
235
+ if (!resolution.ok) {
236
+ if (resolution.connector && resolution.toolName) {
237
+ activityTarget = {
238
+ connector: resolution.connector,
239
+ toolName: resolution.toolName,
240
+ };
241
+ }
242
+ if (resolution.cause && context.requestSignal?.aborted) {
243
+ return callerCancelledDetails();
244
+ }
245
+ return resolution.error;
246
+ }
247
+ const target = resolution.resolved;
248
+ resolved = target;
249
+ activityTarget = target;
250
+ if (!isExplicitlyReadOnly(target.definition) && !context.allowDestructive) {
251
+ const canonicalAddress = `${target.connector.id}.${target.toolName}`;
252
+ return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`);
253
+ }
254
+ // Remote MCP tools advertise their input schema in the catalog. Validate
255
+ // against that same request-local definition before admission or provider
256
+ // dispatch, so a predictable mismatch stays structured instead of being
257
+ // flattened into provider-specific error prose. Unsupported schemas retain
258
+ // validateToolInput's fail-open behavior and reach the downstream normally.
259
+ if (target.connector.kind === "mcp" &&
260
+ target.definition.inputSchema) {
261
+ const invalid = validateToolInput(target.definition.inputSchema, args ?? {}, {
262
+ address: `${target.connector.id}.${target.toolName}`,
263
+ logger: this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
264
+ });
265
+ if (invalid)
266
+ return classifyCallError(invalid);
267
+ }
268
+ try {
269
+ context.beforeDispatch?.();
270
+ }
271
+ catch (error) {
272
+ return error instanceof InvocationFailure
273
+ ? error.details
274
+ : classifyCallError(error);
275
+ }
276
+ if (callSignal?.aborted)
277
+ throw callSignal.reason;
278
+ attempts = 1;
279
+ let permit;
280
+ let attemptError;
281
+ let attemptFailed = false;
282
+ try {
283
+ permit = await timed((elapsed) => { admissionMs += elapsed; }, () => this.registry.admitCall(target.connector.id, {
284
+ toolName: target.toolName,
285
+ args: args ?? {},
286
+ ...defined({ signal: callSignal }),
287
+ }));
288
+ const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
289
+ const call = () => {
290
+ const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
291
+ if (target.connector.credential &&
292
+ !connectorContext.credential) {
293
+ throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
294
+ `authorize_connector({ connector: "${target.connector.id}" }).`);
295
+ }
296
+ // Cancellation can arrive during admission or context construction.
297
+ if (callSignal?.aborted)
298
+ throw callSignal.reason;
299
+ return target.connector.callTool(target.toolName, args ?? {}, connectorContext);
300
+ };
301
+ // Race cancellation here too, so an uncooperative connector cannot
302
+ // retain its admission permit after the enclosing deadline expires.
303
+ return callSignal
304
+ ? withDeadline(call, {
305
+ signal: callSignal,
306
+ timeoutError: new ConnectorCallError("timeout", "Tool call timed out"),
307
+ })
308
+ : call();
309
+ });
310
+ // isError is checked here for BOTH result shapes so every adapter
311
+ // reports the same downstream-failure wording, and the throw lands
312
+ // inside the attempt where it feeds health.
313
+ assertRawMcpSuccess(target.connector.kind, raw);
314
+ observedResult = unwrapMcpResult(target.connector.kind, raw);
315
+ result = context.unwrapResult ? observedResult : raw;
316
+ }
317
+ catch (error) {
318
+ attemptFailed = true;
319
+ attemptError = error;
320
+ }
321
+ finally {
322
+ permit?.release();
323
+ }
324
+ if (attemptFailed) {
325
+ if (callSignal?.aborted)
326
+ throw callSignal.reason;
327
+ const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
328
+ const details = callerCancelled
329
+ ? callerCancelledDetails()
330
+ : classifyCallError(attemptError);
331
+ return details;
332
+ }
333
+ return undefined;
334
+ };
266
335
  try {
267
- permit = await timed((elapsed) => { admissionMs += elapsed; }, () => this.registry.admitCall(resolved.connector.id, {
268
- toolName: resolved.toolName,
269
- args: args ?? {},
270
- ...defined({ signal: context.requestSignal }),
271
- }));
272
- const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
273
- const call = (callSignal) => {
274
- const connectorContext = this.registry.contextFor(resolved.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
275
- if (resolved.connector.credential &&
276
- !connectorContext.credential) {
277
- throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
278
- `authorize_connector({ connector: "${resolved.connector.id}" }).`);
279
- }
280
- // Cancellation can arrive during admission or context construction.
281
- if (callSignal?.aborted)
282
- throw callSignal.reason;
283
- return resolved.connector.callTool(resolved.toolName, args ?? {}, connectorContext);
284
- };
285
- if (!context.timeoutMs && !context.requestSignal)
286
- return call();
287
- return withDeadline(call, {
336
+ const error = context.timeoutMs || context.requestSignal
337
+ ? await withDeadline(dispatch, {
288
338
  ...defined({
289
339
  timeoutMs: context.timeoutMs,
290
340
  signal: context.requestSignal,
291
341
  }),
292
342
  timeoutError: new ConnectorCallError("timeout", `Tool call timed out after ${context.timeoutMs}ms`),
293
- });
294
- });
295
- // isError is checked here for BOTH result shapes so every adapter
296
- // reports the same downstream-failure wording, and the throw lands
297
- // inside the attempt where it feeds health.
298
- assertRawMcpSuccess(resolved.connector.kind, raw);
299
- observedResult = unwrapMcpResult(resolved.connector.kind, raw);
300
- result = context.unwrapResult ? observedResult : raw;
343
+ })
344
+ : await dispatch();
345
+ if (error)
346
+ return failed(error);
301
347
  }
302
348
  catch (error) {
303
- attemptFailed = true;
304
- attemptError = error;
305
- }
306
- finally {
307
- permit?.release();
308
- }
309
- if (attemptFailed) {
310
- const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
311
- const details = callerCancelled
349
+ return failed(context.requestSignal?.aborted
312
350
  ? callerCancelledDetails()
313
- : classifyCallError(attemptError);
314
- return failed(details);
351
+ : classifyCallError(error));
315
352
  }
353
+ // A dispatch that returned no refusal resolved a concrete tool.
354
+ const completed = resolved;
355
+ if (!completed)
356
+ throw new Error("Invocation completed without a resolved tool");
316
357
  try {
317
358
  const value = await timed((elapsed) => { resultProcessingMs += elapsed; }, async () => {
318
359
  const processed = context.processResult
319
- ? await context.processResult(result, resolved)
360
+ ? await context.processResult(result, completed)
320
361
  : result;
321
362
  try {
322
- this.registry.observeOutputShape(resolved.connector.id, resolved.definition, observedResult);
363
+ this.registry.observeOutputShape(completed.connector.id, completed.definition, observedResult);
323
364
  }
324
365
  catch {
325
366
  // Shape learning is advisory. It cannot change a completed call.
@@ -332,14 +373,14 @@ export class InvocationService {
332
373
  return {
333
374
  ok: true,
334
375
  value,
335
- resolved,
376
+ resolved: completed,
336
377
  durationMs: Date.now() - started,
337
378
  attempts,
338
379
  timing: diagnostics,
339
380
  };
340
381
  }
341
- catch (error) {
342
- return failed(framingError("result_processing_failed", error instanceof Error ? error.message : String(error)));
382
+ catch {
383
+ return failed(framingError("result_processing_failed", "The downstream call completed, but its result could not be processed. Do not repeat the call to recover its result."));
343
384
  }
344
385
  }
345
386
  }
@@ -1,3 +1,4 @@
1
+ import { boundedEchoText } from "./errors.js";
1
2
  /**
2
3
  * Unwrap an MCP CallToolResult into an ordinary JavaScript value:
3
4
  * `toolResult` wins when present, then `structuredContent`; all-text content is
@@ -17,9 +18,9 @@ export function unwrapMcpResult(kind, result) {
17
18
  .filter((c) => c.type === "text")
18
19
  .map((c) => c.text ?? "")
19
20
  .join("\n");
20
- throw new Error(text || "Tool call failed");
21
+ throw new Error(boundedEchoText(text || "Tool call failed"));
21
22
  }
22
- if (r.structuredContent != null)
23
+ if (r.structuredContent !== undefined)
23
24
  return r.structuredContent;
24
25
  if (content.length > 0 && content.every((c) => c.type === "text")) {
25
26
  const text = content.map((c) => c.text ?? "").join("\n");
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
3
3
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
4
- import { msg } from "./errors.js";
4
+ import { boundedEchoText, msg } from "./errors.js";
5
5
  import { serializeResultText } from "./executor-result.js";
6
6
  import { InvocationService, } from "./invocation.js";
7
7
  import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
@@ -37,7 +37,12 @@ async function discoveryResult(operation, hint) {
37
37
  try {
38
38
  const value = await operation();
39
39
  const text = boundedDiscoveryText(value, hint);
40
- return jsonResult(value, text);
40
+ const result = jsonResult(value, text);
41
+ const bytes = enc.encode(JSON.stringify(result)).length;
42
+ if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
43
+ throw new DiscoveryPolicyError("result_too_large", `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`);
44
+ }
45
+ return result;
41
46
  }
42
47
  catch (err) {
43
48
  if (err instanceof DiscoveryPolicyError) {
@@ -94,29 +99,37 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
94
99
  }
95
100
  return e;
96
101
  }
97
- // --- result-size guard + get_result (feature 1) ---------------------------
98
- /**
99
- * The one serialization every result guard measures, stashes, and pages: JSON
100
- * text for whatever JSON can represent, and `String(value)` for the returns
101
- * JSON renders as `undefined` — a handler that returns nothing, a function, or
102
- * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
103
- * returning `undefined` for those, which is how a handler returning `undefined`
104
- * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
105
- * size guard measured `enc.encode(undefined)` — the empty string, per the
106
- * WebIDL default — and emitted the non-string unchanged (issue #42). `null`
107
- * needs no special case; JSON renders it as `"null"`.
108
- *
109
- * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
110
- * the three give one answer to the same question. A value JSON cannot serialize
111
- * at all (a BigInt) still throws, as before, and is reported as a failure.
112
- */
113
- /**
114
- * Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
115
- * truncation notice every over-cap path hands back.
116
- */
117
- async function stashResult(text, results, totalBytes) {
102
+ /** Stash a completed result, or return a notice without a paging route. */
103
+ async function stashResult(bytes, results) {
104
+ const totalBytes = bytes.length;
118
105
  const id = crypto.randomUUID();
119
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
106
+ try {
107
+ // Base64 permits byte-range decoding after a KV read, without scanning or
108
+ // re-encoding all preceding text. Each chunk is a multiple of three bytes.
109
+ const chunks = [];
110
+ for (let offset = 0; offset < bytes.length; offset += 12_288) {
111
+ chunks.push(btoa(String.fromCharCode(...bytes.subarray(offset, offset + 12_288))));
112
+ }
113
+ const stored = `connecta-result-v1:${totalBytes}:${chunks.join("")}`;
114
+ if (!await results.set(`result:${id}`, stored, RESULT_TTL_SECONDS)) {
115
+ throw new Error("Result stash capacity exhausted");
116
+ }
117
+ }
118
+ catch {
119
+ // Paging is advisory after a completed call, including an approved write.
120
+ // Neither backend prose nor a retry hint belongs in this successful result.
121
+ try {
122
+ results.warn();
123
+ }
124
+ catch {
125
+ // Logging cannot change the call either.
126
+ }
127
+ return {
128
+ truncated: true,
129
+ totalBytes,
130
+ hint: "Paging is unavailable. Use execute_code to reduce read-only results before returning them. Do not repeat a completed write to recover its result.",
131
+ };
132
+ }
120
133
  return {
121
134
  truncated: true,
122
135
  resultId: id,
@@ -141,7 +154,7 @@ async function guardEncoded(text, bytes, results, cap) {
141
154
  truncated: false,
142
155
  };
143
156
  }
144
- const notice = await stashResult(text, results, bytes.length);
157
+ const notice = await stashResult(bytes, results);
145
158
  const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
146
159
  return {
147
160
  result: {
@@ -166,8 +179,12 @@ async function guardValue(value, results, cap) {
166
179
  const bytes = enc.encode(text);
167
180
  if (bytes.length <= cap)
168
181
  return { result: value, truncated: false };
182
+ const notice = await stashResult(bytes, results);
169
183
  return {
170
- result: await stashResult(text, results, bytes.length),
184
+ result: notice.resultId ? notice : {
185
+ ...notice,
186
+ preview: dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length))),
187
+ },
171
188
  truncated: true,
172
189
  };
173
190
  }
@@ -210,7 +227,7 @@ async function guardContent(content, results, cap) {
210
227
  if (content.every((b) => b.type === "text")) {
211
228
  return guardEncoded(text, bytes, results, cap);
212
229
  }
213
- const notice = await stashResult(text, results, bytes.length);
230
+ const notice = await stashResult(bytes, results);
214
231
  return {
215
232
  result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
216
233
  truncated: true,
@@ -259,7 +276,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
259
276
  const invocation = new InvocationService(registry, catalog, opts.activity);
260
277
  /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
261
278
  async function runCall(call, source, options = {}) {
262
- const results = registry.resultsStorage();
263
279
  const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
264
280
  const outcome = await invocation.invoke(call.address, call.args ?? {}, {
265
281
  source,
@@ -272,6 +288,13 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
272
288
  : {}),
273
289
  unwrapResult: call.resultMode === "value",
274
290
  processResult: async (result, resolved) => {
291
+ const results = {
292
+ set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
293
+ warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
294
+ connector: resolved.connector.id,
295
+ tool: resolved.toolName,
296
+ }),
297
+ };
275
298
  // Result-size cap for THIS call: the connector's own override wins,
276
299
  // then the deployment-wide value, then the built-in default (already
277
300
  // folded into `globalCap`). Resolved per call so one request can
@@ -292,7 +315,14 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
292
315
  }
293
316
  if (resolved.connector.kind === "mcp") {
294
317
  const mcpResult = result;
295
- const content = mcpResult?.content ?? [];
318
+ let content = mcpResult?.content ?? [];
319
+ if (!content.some((block) => block.type === "text") &&
320
+ mcpResult?.structuredContent !== undefined) {
321
+ content = [...content, {
322
+ type: "text",
323
+ text: JSON.stringify(mcpResult.structuredContent),
324
+ }];
325
+ }
296
326
  const guarded = await guardContent(content, results, cap);
297
327
  return processed(guarded.result, guarded.truncated);
298
328
  }
@@ -305,12 +335,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
305
335
  if (!outcome.ok) {
306
336
  const structuredRecovery = outcome.error.nextAction !== undefined;
307
337
  const recoveryRequired = structuredRecovery ||
338
+ // Sanitized `unavailable` diagnostics ride the structured shape too;
339
+ // the plain-text path would drop them (#539).
340
+ outcome.error.details !== undefined ||
308
341
  [
309
342
  "auth_required",
310
343
  "invalid_args",
311
344
  "input_required_unsupported",
312
345
  ].includes(outcome.error.code);
313
- const failedResult = recoveryRequired ||
346
+ const makeFailedResult = () => recoveryRequired ||
314
347
  call.resultMode === "value"
315
348
  ? jsonResult({
316
349
  ok: false,
@@ -320,6 +353,18 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
320
353
  ...(call.diagnostics ? { timing: outcome.timing } : {}),
321
354
  })
322
355
  : errorResult(outcome.error.message);
356
+ let failedResult = makeFailedResult();
357
+ // Value mode repeats the error in text and structuredContent. Account for
358
+ // both copies and JSON escaping when the bounded provider reason is large.
359
+ if (!recoveryRequired) {
360
+ const cap = resolveMaxResultBytes(outcome.resolved?.connector.maxResultBytes, globalCap);
361
+ let budget = 512;
362
+ while (enc.encode(JSON.stringify(failedResult)).length > cap && budget > 0) {
363
+ budget = Math.floor(budget / 2);
364
+ outcome.error.message = boundedEchoText(outcome.error.message, budget);
365
+ failedResult = makeFailedResult();
366
+ }
367
+ }
323
368
  if (recoveryRequired) {
324
369
  failedResult.isError = true;
325
370
  }
@@ -372,6 +417,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
372
417
  return { content: [{ type: "text", text: skill.content }] };
373
418
  },
374
419
  async searchTools(args) {
420
+ if (args.connector !== undefined && enc.encode(args.connector).length > 512) {
421
+ return discoveryErrorResult(new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes."));
422
+ }
375
423
  return discoveryResult(async () => groupedSearchResult(await catalog.search({
376
424
  ...args,
377
425
  includeSchemaKeys: args.includeSchemas !== undefined,
@@ -401,29 +449,60 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
401
449
  `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
402
450
  }
403
451
  const results = registry.resultsStorage();
404
- const stored = await results.get(`result:${args.id}`);
452
+ let stored;
453
+ try {
454
+ stored = await results.get(`result:${args.id}`);
455
+ }
456
+ catch {
457
+ return {
458
+ ...jsonResult({
459
+ error: {
460
+ code: "unavailable",
461
+ message: "Result paging storage is unavailable.",
462
+ retryable: true,
463
+ },
464
+ }),
465
+ isError: true,
466
+ };
467
+ }
405
468
  if (stored === null || stored === undefined) {
406
- return errorResult(`Unknown or expired result id "${args.id}"`);
469
+ return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
470
+ }
471
+ const requestedOffset = args.offset ?? 0;
472
+ const maxBytes = args.maxBytes ?? globalCap;
473
+ // Decode only this page plus UTF-8 boundary lookaround. Legacy raw-text
474
+ // entries remain readable for their short TTL after an upgrade.
475
+ const header = /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
476
+ let bytes;
477
+ let total;
478
+ let start = 0;
479
+ if (header) {
480
+ total = Number(header[1]);
481
+ start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
482
+ const end = Math.min(total, requestedOffset + maxBytes + 4);
483
+ const binary = atob(stored.slice(header[0].length + start / 3 * 4, header[0].length + Math.ceil(end / 3) * 4));
484
+ bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
485
+ }
486
+ else {
487
+ bytes = enc.encode(stored);
488
+ total = bytes.length;
407
489
  }
408
- const bytes = enc.encode(stored);
409
- const total = bytes.length;
410
490
  // Validated above, so no coercion is needed here — only alignment. A
411
491
  // client that computes its own offsets can land inside a multi-byte
412
492
  // character, which would decode as U+FFFD; the offset actually served is
413
493
  // the boundary at or before it, and it is what the response reports back
414
494
  // as `offset` (issue #38).
415
- const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
495
+ const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
416
496
  // Page size only: a stashed result carries no connector identity, so
417
497
  // get_result keeps the deployment-wide default when none is requested.
418
498
  // Both sides are validated by now — the argument above, `globalCap` at
419
499
  // intake — so `offset + maxBytes` always reaches past `offset`.
420
- const maxBytes = args.maxBytes ?? globalCap;
421
500
  // Align the slice end to a codepoint boundary so a multi-byte char is
422
501
  // never split across pages (which would emit U+FFFD on both sides).
423
502
  // `nextOffset` is this aligned end, so it is a valid boundary for the
424
503
  // next call and paging reassembles the original byte-for-byte.
425
- const end = alignEndToCharBoundary(bytes, offset, offset + maxBytes, total);
426
- const slice = dec.decode(bytes.slice(offset, end));
504
+ const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
505
+ const slice = dec.decode(bytes.subarray(offset - start, end - start));
427
506
  const nextOffset = end < total ? end : undefined;
428
507
  return jsonResult({
429
508
  offset,
@@ -435,7 +514,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
435
514
  async authorizeConnector(args) {
436
515
  const connector = registry.getConnector(args.connector);
437
516
  if (!connector) {
438
- return errorResult(`Unknown connector "${args.connector}"`);
517
+ return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
439
518
  }
440
519
  if (!connector.startAuth) {
441
520
  if (!connector.credential) {
@@ -61,6 +61,10 @@ export interface RegistryOptions {
61
61
  * to the default 50_000.
62
62
  */
63
63
  maxResultBytes?: number | undefined;
64
+ results?: {
65
+ maxStashBytes?: number;
66
+ maxStashEntries?: number;
67
+ } | undefined;
64
68
  /**
65
69
  * Where payload-free catalog-drift observations go. Present only when the
66
70
  * deployment configured an activity store; drift is reported through
@@ -103,6 +107,8 @@ export interface RegistryView {
103
107
  signal?: AbortSignal;
104
108
  }): Promise<CallAdmissionPermit>;
105
109
  resultsStorage(): KVStorage;
110
+ /** Reserve runtime-wide capacity before writing a paging envelope. */
111
+ stashResult(key: string, value: string, ttlSeconds: number): Promise<boolean>;
106
112
  /** Local declared-vs-stored credential mismatch, with no downstream I/O. */
107
113
  credentialDriftFor(id: string): Promise<string | undefined>;
108
114
  /** Value-free shape learned from successful calls, never a provider declaration. */
@@ -156,9 +162,13 @@ export declare class Registry implements RegistryView {
156
162
  private readonly persistToolCatalog;
157
163
  /** Result-size guard cap threaded to the meta-tools. */
158
164
  readonly maxResultBytes: number;
165
+ /** Only keys, byte counts, and expiry survive requests; never write promises. */
166
+ private readonly resultStash;
167
+ private resultStashBytes;
159
168
  private readonly configuredConnectors;
160
169
  private readonly personalRegistries;
161
- /** `connector.tool` grants that matched nothing, warned once per isolate. */
170
+ private callAdmissionClosed;
171
+ /** Bounded FIFO of absent grants already warned about. */
162
172
  private readonly warnedAbsentGrants;
163
173
  constructor(connectors: Connector[], opts: RegistryOptions);
164
174
  personalRegistry(principalKey: string): Registry;
@@ -202,7 +212,7 @@ export declare class Registry implements RegistryView {
202
212
  args: unknown;
203
213
  signal?: AbortSignal;
204
214
  }): Promise<CallAdmissionPermit>;
205
- /** Payload-free aggregate state for the open health endpoint. */
215
+ /** Connector totals across root and personal controllers; health removes ids. */
206
216
  callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot>;
207
217
  /**
208
218
  * Payload-free drift counts for the open health endpoint, so `connecta
@@ -230,6 +240,8 @@ export declare class Registry implements RegistryView {
230
240
  private observeCatalogDrift;
231
241
  /** Reject queued/future downstream admission; active permits release safely. */
232
242
  closeCallAdmission(): void;
243
+ /** Reserve capacity and write one ASCII paging envelope in this runtime. */
244
+ stashResult(key: string, value: string, ttlSeconds: number, prefix?: string): Promise<boolean>;
233
245
  /**
234
246
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
235
247
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.