@zackbart/connecta 0.24.1 → 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 (65) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/clerk.d.ts +0 -5
  4. package/dist/auth/clerk.js +21 -8
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/call-admission.d.ts +4 -0
  8. package/dist/call-admission.js +26 -0
  9. package/dist/catalog-drift.js +9 -4
  10. package/dist/catalog-service.d.ts +2 -0
  11. package/dist/catalog-service.js +25 -8
  12. package/dist/catalog.d.ts +2 -0
  13. package/dist/catalog.js +246 -121
  14. package/dist/connector-access.d.ts +32 -0
  15. package/dist/connector-access.js +79 -0
  16. package/dist/connectors/api.js +11 -1
  17. package/dist/connectors/guarded-fetch.d.ts +1 -1
  18. package/dist/connectors/guarded-fetch.js +27 -20
  19. package/dist/connectors/remote-mcp.js +84 -53
  20. package/dist/errors.d.ts +17 -0
  21. package/dist/errors.js +58 -0
  22. package/dist/execute.js +85 -23
  23. package/dist/executor-result.js +3 -1
  24. package/dist/executors/quickjs-child.js +5 -1
  25. package/dist/executors/quickjs-protocol.d.ts +4 -0
  26. package/dist/executors/quickjs-runtime.d.ts +1 -1
  27. package/dist/executors/quickjs-runtime.js +38 -21
  28. package/dist/executors/quickjs.js +68 -27
  29. package/dist/index.d.ts +37 -1
  30. package/dist/index.js +89 -3
  31. package/dist/invocation.js +134 -93
  32. package/dist/mcp-result.js +3 -2
  33. package/dist/meta-tools.js +118 -39
  34. package/dist/registry.d.ts +29 -1
  35. package/dist/registry.js +122 -15
  36. package/dist/routes/credentials.js +1 -0
  37. package/dist/routes/mcp.d.ts +4 -1
  38. package/dist/routes/mcp.js +112 -12
  39. package/dist/routes/oauth-management.js +1 -0
  40. package/dist/routes/oauth.js +4 -0
  41. package/dist/routes/shared.d.ts +7 -1
  42. package/dist/routes/shared.js +12 -13
  43. package/dist/routes/ui.js +2 -1
  44. package/dist/server.js +15 -3
  45. package/dist/skills.js +6 -5
  46. package/dist/storage/file.d.ts +6 -2
  47. package/dist/storage/file.js +312 -34
  48. package/dist/storage/memory.js +12 -1
  49. package/dist/validate.js +3 -3
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/documentation/architecture.md +30 -9
  53. package/documentation/auth.md +110 -6
  54. package/documentation/call-admission.md +24 -8
  55. package/documentation/code-mode.md +34 -22
  56. package/documentation/connectors.md +47 -5
  57. package/documentation/meta-tools.md +74 -6
  58. package/documentation/operations.md +20 -19
  59. package/documentation/provider-conventions.md +7 -0
  60. package/documentation/request-admission.md +38 -4
  61. package/documentation/storage-and-credentials.md +54 -1
  62. package/documentation/upgrading.md +21 -5
  63. package/ethos.md +1 -1
  64. package/package.json +1 -1
  65. package/templates/node/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { credentialTestRule, describeCredentialTestMismatch, } from "./credential-rules.js";
2
2
  import { Registry } from "./registry.js";
3
+ import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
3
4
  import { createFetchHandler } from "./server.js";
4
5
  import { droppedBrandingUrls, droppedUiAuthUrls } from "./branding.js";
5
6
  import { memoryStorage } from "./storage/memory.js";
@@ -56,8 +57,10 @@ const CONFIG_SCHEMA = {
56
57
  credentialAdministration: null,
57
58
  personalConnection: null,
58
59
  },
60
+ pools: null,
59
61
  storage: null,
60
62
  publicUrl: null,
63
+ allowedOrigins: null,
61
64
  activity: null,
62
65
  vault: null,
63
66
  ui: null,
@@ -68,6 +71,10 @@ const CONFIG_SCHEMA = {
68
71
  staleCatalogSeconds: null,
69
72
  probeTimeoutMs: null,
70
73
  },
74
+ results: {
75
+ maxStashBytes: null,
76
+ maxStashEntries: null,
77
+ },
71
78
  calls: {
72
79
  defaultTimeoutMs: null,
73
80
  maxResultBytes: null,
@@ -133,6 +140,17 @@ function rejectUnknownOptions(paths) {
133
140
  /** Reject JavaScript typos and removed options before construction does work. */
134
141
  function assertKnownConfig(config) {
135
142
  rejectUnknownOptions(unknownOptionPaths(config, "ConnectaConfig", CONFIG_SCHEMA));
143
+ if (config.results !== undefined) {
144
+ if (!config.results || typeof config.results !== "object" || Array.isArray(config.results)) {
145
+ throw new Error("ConnectaConfig.results must be an object");
146
+ }
147
+ for (const key of ["maxStashBytes", "maxStashEntries"]) {
148
+ const value = config.results[key];
149
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
150
+ throw new Error(`ConnectaConfig.results.${key} must be a non-negative safe integer`);
151
+ }
152
+ }
153
+ }
136
154
  if (config.vault && ["get", "getAll", "set", "setAll", "metadata", "delete"].some(key => typeof config.vault[key] !== "function"))
137
155
  throw new Error("ConnectaConfig.vault must implement CredentialVault");
138
156
  if (config.ui && (typeof config.ui.handle !== "function" || typeof config.ui.credentialHandoffUrl !== "function" || !Array.isArray(config.ui.reservedPaths)))
@@ -145,6 +163,67 @@ function assertKnownConfig(config) {
145
163
  throw new Error("ConnectaConfig.activity must be created with activityHistory(...)");
146
164
  }
147
165
  }
166
+ /**
167
+ * Validate declared pools against the connector set. Everything checkable at
168
+ * construction throws here: a malformed name, an unparseable grant, an
169
+ * unknown connector id, a tool address on an `api()` connector whose static
170
+ * catalog lacks it. Remote catalogs load lazily, so their addresses are
171
+ * checked at catalog load instead and stay unreachable until they match.
172
+ */
173
+ function resolvePools(pools, registry) {
174
+ const resolved = new Map();
175
+ if (!pools)
176
+ return resolved;
177
+ if (typeof pools !== "object" || Array.isArray(pools)) {
178
+ throw new Error("ConnectaConfig.pools must be an object keyed by pool name");
179
+ }
180
+ for (const [name, pool] of Object.entries(pools)) {
181
+ if (!POOL_NAME_RE.test(name)) {
182
+ throw new Error(`ConnectaConfig.pools: pool name "${name}" must match [a-z0-9_-]+`);
183
+ }
184
+ if (!pool || typeof pool !== "object" || !Array.isArray(pool.tools)) {
185
+ throw new Error(`ConnectaConfig.pools.${name}: tools must be an array of connector ids or connector.tool addresses`);
186
+ }
187
+ if (pool.grant !== undefined && typeof pool.grant !== "function") {
188
+ throw new Error(`ConnectaConfig.pools.${name}: grant must be a function`);
189
+ }
190
+ // A misspelled `grant` would otherwise boot as a deny-all pool with only a
191
+ // per-request log line to say so; that is fail-closed, but the rule here
192
+ // is that structural mistakes refuse to boot.
193
+ for (const key of Object.keys(pool)) {
194
+ if (key !== "tools" && key !== "grant") {
195
+ throw new Error(`ConnectaConfig.pools.${name}: unknown option "${key}"`);
196
+ }
197
+ }
198
+ let access;
199
+ try {
200
+ access = parseConnectorAccess(pool.tools);
201
+ }
202
+ catch {
203
+ throw new Error(`ConnectaConfig.pools.${name}: tools must be connector ids or connector.tool addresses`);
204
+ }
205
+ if (access.connectorIds === "all" || access.connectorIds.length === 0) {
206
+ throw new Error(`ConnectaConfig.pools.${name}: a pool must name at least one connector or tool`);
207
+ }
208
+ for (const id of access.connectorIds) {
209
+ const connector = registry.getConnector(id);
210
+ if (!connector) {
211
+ throw new Error(`ConnectaConfig.pools.${name}: unknown connector "${id}"`);
212
+ }
213
+ const granted = access.toolAccess?.get(id);
214
+ if (!granted || !connector.staticTools)
215
+ continue;
216
+ const known = new Set(connector.staticTools.map((tool) => tool.name));
217
+ for (const tool of granted) {
218
+ if (!known.has(tool)) {
219
+ throw new Error(`ConnectaConfig.pools.${name}: connector "${id}" has no tool "${tool}"`);
220
+ }
221
+ }
222
+ }
223
+ resolved.set(name, { access, grant: pool.grant ?? (() => false) });
224
+ }
225
+ return resolved;
226
+ }
148
227
  /**
149
228
  * One-time construction warnings for deployment shapes that run fine but are
150
229
  * usually unintended. Warning-only — never throws and never changes behavior;
@@ -154,12 +233,15 @@ function assertKnownConfig(config) {
154
233
  function warnInsecureConfig(config, inboundAuth, logger) {
155
234
  const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
156
235
  const hasCredentialConnector = config.connectors.some((c) => c.credential);
157
- // Open mode (no inbound auth) with connectors that expose credentials or
158
- // downstream OAuth: any caller reaches everything, including the vault.
236
+ // Static API headers can carry secrets without declaring credential hooks.
237
+ // Any configured connector warrants the open-deployment warning.
159
238
  if (inboundAuth.length === 0 &&
160
- (hasCredentialConnector || oauthConnectors.length > 0)) {
239
+ config.connectors.length > 0) {
161
240
  logger.warn("[connecta] running with no inbound authentication: any caller can " +
162
241
  "invoke every shared connector. " +
242
+ (hasCredentialConnector || oauthConnectors.length > 0
243
+ ? "Configured credentials and downstream OAuth grants are exposed to those calls. "
244
+ : "") +
163
245
  "Configure `auth` (for example bearerToken(...) or Clerk) to gate access.");
164
246
  }
165
247
  // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
@@ -267,8 +349,10 @@ export function createConnecta(config) {
267
349
  persistToolCatalog: config.discovery?.persistCatalog,
268
350
  toolCatalogStaleSeconds: config.discovery?.staleCatalogSeconds,
269
351
  maxResultBytes: config.calls?.maxResultBytes,
352
+ results: config.results,
270
353
  });
271
354
  const inboundAuth = configuredAuth;
355
+ const pools = resolvePools(config.pools, registry);
272
356
  warnInsecureConfig(config, inboundAuth, logger);
273
357
  const requestAdmission = admissionController(config.admission?.requests, REQUEST_ADMISSION_DEFAULTS);
274
358
  const configuredCodeAdmission = admissionController(config.admission?.code, CODE_ADMISSION_DEFAULTS);
@@ -290,7 +374,9 @@ export function createConnecta(config) {
290
374
  registry,
291
375
  auth: inboundAuth,
292
376
  identity: config.identity,
377
+ pools,
293
378
  publicUrl: config.publicUrl,
379
+ allowedOrigins: config.allowedOrigins,
294
380
  serverInfo,
295
381
  logger,
296
382
  activity: config.activity?.store,
@@ -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");