@zackbart/connecta 0.24.2 → 0.24.4

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 (97) hide show
  1. package/AGENTS.md +18 -20
  2. package/CHANGELOG.md +205 -1
  3. package/README.md +5 -6
  4. package/dist/auth/bearer.js +2 -0
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/branding.d.ts +31 -2
  8. package/dist/branding.js +116 -8
  9. package/dist/call-admission.d.ts +4 -0
  10. package/dist/call-admission.js +26 -0
  11. package/dist/catalog-drift.js +9 -4
  12. package/dist/catalog-service.d.ts +2 -0
  13. package/dist/catalog-service.js +25 -8
  14. package/dist/catalog.d.ts +2 -0
  15. package/dist/catalog.js +246 -121
  16. package/dist/connectors/api.d.ts +1 -1
  17. package/dist/connectors/api.js +21 -3
  18. package/dist/connectors/guarded-fetch.d.ts +6 -2
  19. package/dist/connectors/guarded-fetch.js +61 -24
  20. package/dist/connectors/remote-mcp.js +92 -57
  21. package/dist/errors.d.ts +28 -3
  22. package/dist/errors.js +60 -1
  23. package/dist/execute.js +85 -23
  24. package/dist/executor-result.js +3 -1
  25. package/dist/executors/quickjs-child.js +5 -1
  26. package/dist/executors/quickjs-protocol.d.ts +4 -0
  27. package/dist/executors/quickjs-runtime.d.ts +1 -1
  28. package/dist/executors/quickjs-runtime.js +38 -21
  29. package/dist/executors/quickjs.js +68 -27
  30. package/dist/index.d.ts +15 -1
  31. package/dist/index.js +36 -4
  32. package/dist/invocation.js +134 -93
  33. package/dist/mcp-result.js +3 -2
  34. package/dist/meta-tools.js +191 -36
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +38 -1
  37. package/dist/operator-ui/view.js +71 -0
  38. package/dist/providers/cloudflare.d.ts +14 -2
  39. package/dist/providers/cloudflare.js +107 -16
  40. package/dist/providers/linear.d.ts +26 -4
  41. package/dist/providers/linear.js +19 -4
  42. package/dist/providers/mixpanel.d.ts +16 -3
  43. package/dist/providers/mixpanel.js +13 -2
  44. package/dist/providers/notion.d.ts +8 -1
  45. package/dist/providers/notion.js +83 -10
  46. package/dist/providers/revenuecat.d.ts +30 -4
  47. package/dist/providers/revenuecat.js +42 -4
  48. package/dist/providers/stripe.d.ts +7 -1
  49. package/dist/providers/stripe.js +30 -4
  50. package/dist/providers/vercel.js +11 -1
  51. package/dist/registry.d.ts +22 -2
  52. package/dist/registry.js +101 -13
  53. package/dist/routes/mcp.d.ts +4 -1
  54. package/dist/routes/mcp.js +84 -13
  55. package/dist/routes/oauth.js +4 -0
  56. package/dist/routes/shared.d.ts +1 -0
  57. package/dist/routes/shared.js +4 -4
  58. package/dist/server.js +15 -3
  59. package/dist/skills.js +6 -5
  60. package/dist/storage/file.d.ts +6 -2
  61. package/dist/storage/file.js +312 -34
  62. package/dist/storage/memory.js +12 -1
  63. package/dist/types.d.ts +37 -0
  64. package/dist/ui.js +18 -10
  65. package/dist/validate.js +3 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/documentation/architecture.md +193 -165
  69. package/documentation/auth.md +199 -145
  70. package/documentation/code-mode.md +433 -316
  71. package/documentation/meta-tools.md +363 -355
  72. package/examples/worker/AGENTS.md +2 -1
  73. package/examples/worker/README.md +12 -10
  74. package/examples/worker/src/index.ts +12 -15
  75. package/package.json +1 -2
  76. package/templates/node/.env.example +3 -3
  77. package/templates/node/AGENTS.md +5 -4
  78. package/templates/node/README.md +2 -1
  79. package/templates/node/package.json +1 -1
  80. package/templates/node/src/index.ts +23 -22
  81. package/documentation/call-admission.md +0 -142
  82. package/documentation/cloudflare.md +0 -471
  83. package/documentation/connector-guides.md +0 -176
  84. package/documentation/connectors.md +0 -389
  85. package/documentation/linear.md +0 -193
  86. package/documentation/mixpanel.md +0 -160
  87. package/documentation/notion.md +0 -308
  88. package/documentation/operations.md +0 -359
  89. package/documentation/operator-ui.md +0 -135
  90. package/documentation/optional-modules-upgrade.md +0 -243
  91. package/documentation/provider-conventions.md +0 -722
  92. package/documentation/request-admission.md +0 -170
  93. package/documentation/revenuecat.md +0 -305
  94. package/documentation/storage-and-credentials.md +0 -201
  95. package/documentation/stripe.md +0 -262
  96. package/documentation/upgrading.md +0 -754
  97. package/documentation/vercel.md +0 -241
@@ -1,14 +1,33 @@
1
- /** See documentation/connectors.md#the-guarded-fetch-transport. Web APIs only. */
2
- import { ConnectorCallError } from "../errors.js";
3
- /** Parse a decimal `Retry-After` header in seconds into milliseconds. */
1
+ /**
2
+ * The safety machinery every hand-written HTTP surface needs, extracted once
3
+ * (#341): path confinement, encoded query and framed bodies, per-request
4
+ * credential headers applied last, a refused redirect, a bounded read, and
5
+ * network-failure normalization. It owns no *meaning* — it never reads a status
6
+ * code and never invents an auth scheme, because the same 403 means an ungranted
7
+ * capability at one provider and a token scope at another, and those want
8
+ * opposite next moves. The provider's `authenticate` supplies headers and its
9
+ * mapper turns one `GuardedResponse` into a result or a typed failure.
10
+ *
11
+ * Not exported this release: the migrations proved the shape preserves behavior
12
+ * for connectors that already had this machinery, not that it is the right
13
+ * public surface. Web APIs only.
14
+ */
15
+ import { ConnectorCallError, unavailableCallError } from "../errors.js";
16
+ /** Parse delta-seconds or an HTTP-date into a non-negative wait window. */
4
17
  export function retryAfterMs(headers) {
5
18
  const raw = headers.get("retry-after");
6
19
  if (!raw)
7
20
  return undefined;
8
21
  const seconds = Number(raw.trim());
9
- if (!Number.isFinite(seconds) || seconds < 0)
22
+ if (Number.isFinite(seconds)) {
23
+ return seconds < 0 ? undefined : Math.trunc(seconds * 1000);
24
+ }
25
+ // Require the HTTP-date shape, so Date.parse cannot reinterpret "-1" as
26
+ // a calendar date on runtimes that accept loose date strings.
27
+ if (!/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(raw.trim()))
10
28
  return undefined;
11
- return Math.trunc(seconds * 1000);
29
+ const date = Date.parse(raw);
30
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
12
31
  }
13
32
  /** Statuses that instruct a client to re-send somewhere else. Never followed. */
14
33
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
@@ -117,7 +136,18 @@ async function drain(provider, stream, limit) {
117
136
  }
118
137
  return body;
119
138
  }
120
- /** See documentation/connectors.md#the-guarded-fetch-transport. */
139
+ /**
140
+ * Every read is capped at `maxResponseBytes`: a streaming body is abandoned at
141
+ * the ceiling rather than buffered past it, and where a runtime gives no body
142
+ * stream the text is measured in UTF-8 bytes before it is accepted, with JSON
143
+ * parsed from that same bounded text. Such a runtime still buffers internally,
144
+ * but cannot return an oversized body as a successful result.
145
+ *
146
+ * A mapper must re-throw `ConnectorCallError` and swallow only what it
147
+ * recognizes: a bare `catch` around `response.json()` eats this refusal along
148
+ * with a parse error and turns a response nobody was allowed to read into an
149
+ * empty success.
150
+ */
121
151
  function boundedResponse(provider, response, limit) {
122
152
  if (BODILESS_STATUSES.has(response.status)) {
123
153
  const emptyJson = async () => undefined;
@@ -144,13 +174,20 @@ function boundedResponse(provider, response, limit) {
144
174
  });
145
175
  return read;
146
176
  };
177
+ let readText;
178
+ const text = () => {
179
+ readText ??= stream
180
+ ? bytes().then((body) => decoder.decode(body))
181
+ : response.text().then((body) => {
182
+ const size = encoder.encode(body).length;
183
+ if (size > limit)
184
+ throw oversized(provider, limit, `${size} bytes`);
185
+ return body;
186
+ });
187
+ return readText;
188
+ };
147
189
  const json = async () => {
148
- // No stream means no bytes to count: a stand-in that answers `json()`
149
- // directly is taken at its word, which is the one accessor on the one
150
- // path where the ceiling cannot be applied.
151
- if (!stream)
152
- return await response.json();
153
- const body = decoder.decode(await bytes());
190
+ const body = await text();
154
191
  return body.trim() === "" ? undefined : JSON.parse(body);
155
192
  };
156
193
  return {
@@ -158,15 +195,7 @@ function boundedResponse(provider, response, limit) {
158
195
  ok: response.ok,
159
196
  headers: response.headers,
160
197
  bytes,
161
- async text() {
162
- if (stream)
163
- return decoder.decode(await bytes());
164
- const body = await response.text();
165
- const size = encoder.encode(body).length;
166
- if (size > limit)
167
- throw oversized(provider, limit, `${size} bytes`);
168
- return body;
169
- },
198
+ text,
170
199
  json,
171
200
  jsonResult: () => jsonResult(json),
172
201
  };
@@ -181,7 +210,11 @@ async function jsonResult(read) {
181
210
  return { parseError: cause };
182
211
  }
183
212
  }
184
- /** Build the guarded transport described in documentation/connectors.md. */
213
+ /**
214
+ * Build the transport a connector sends every request through. `maxResponseBytes`
215
+ * is required rather than defaulted: what counts as an absurd response is a fact
216
+ * about the API, not about HTTP.
217
+ */
185
218
  export function guardedFetch(options) {
186
219
  const { provider, maxResponseBytes: limit } = options;
187
220
  if (!Number.isInteger(limit) || limit < 1) {
@@ -224,13 +257,17 @@ export function guardedFetch(options) {
224
257
  : request.rawBody !== undefined
225
258
  ? { body: request.rawBody }
226
259
  : {}),
227
- // Rationale: documentation/connectors.md#the-guarded-fetch-transport.
260
+ // A redirect is an instruction to re-send the credential to whatever
261
+ // origin `Location` names, and a confinement a redirect can undo was
262
+ // never one. Refused below rather than followed.
228
263
  redirect: "manual",
229
264
  ...(ctx.signal ? { signal: ctx.signal } : {}),
230
265
  });
231
266
  }
232
267
  catch (cause) {
233
- throw new ConnectorCallError("unavailable", `Could not reach the ${provider} API: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
268
+ if (cause instanceof ConnectorCallError)
269
+ throw cause;
270
+ throw unavailableCallError(cause, url.href, `Could not reach the ${provider} API.`);
234
271
  }
235
272
  if (REDIRECT_STATUSES.has(response.status)) {
236
273
  await response.body?.cancel().catch(() => { });
@@ -1,7 +1,7 @@
1
- import { Client, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
1
+ import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
2
2
  import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
3
3
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
4
- import { ConnectorCallError, msg } from "../errors.js";
4
+ import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
5
5
  import { CONNECTA_VERSION } from "../version.js";
6
6
  /**
7
7
  * Apply a maintained provider's slot copy and header framing to credential
@@ -105,8 +105,11 @@ function isCursorShapeError(err) {
105
105
  * close with the session left to age out as it did before.
106
106
  */
107
107
  async function terminateSession(transport, logger, connectorId) {
108
- // See documentation/connectors.md#mcp-version-skew for the legacy DELETE
109
- // that Client.close() does not send.
108
+ // SDK v2's Client.close() does not send the legacy session DELETE on our
109
+ // behalf. Connecta's own endpoint creates no protocol session, but a stateful
110
+ // legacy downstream can still issue `Mcp-Session-Id`, and every path that
111
+ // abandons one — scope teardown, credential rotation, OAuth retirement, an
112
+ // abandoned connect — owes it this best-effort, one-second DELETE.
110
113
  const terminate = transport.terminateSession;
111
114
  if (typeof terminate !== "function")
112
115
  return;
@@ -157,6 +160,28 @@ async function terminateSession(transport, logger, connectorId) {
157
160
  });
158
161
  });
159
162
  }
163
+ /** Classify protocol/status facts; provider prose never decides retryability. */
164
+ function downstreamCallError(error) {
165
+ if (error instanceof ProtocolError && error.code === -32602) {
166
+ return new ConnectorCallError("invalid_args", boundedEchoText(error.message));
167
+ }
168
+ if (error instanceof SdkHttpError && error.status >= 400 && error.status < 500) {
169
+ let message = error.message;
170
+ if (typeof error.data.text === "string") {
171
+ try {
172
+ const body = JSON.parse(error.data.text);
173
+ const detail = body?.message ?? body?.error?.message ?? body?.error_description;
174
+ if (typeof detail === "string")
175
+ message = detail;
176
+ }
177
+ catch {
178
+ // Non-JSON refusals still retain a bounded diagnostic.
179
+ }
180
+ }
181
+ return new ConnectorCallError(error.status === 429 ? "rate_limited" : error.status === 408 ? "timeout" : "connector_call_failed", boundedEchoText(message));
182
+ }
183
+ return error;
184
+ }
160
185
  const encoder = new TextEncoder();
161
186
  /** Base64 of a UTF-8 string, Web-API only so the core still runs on workerd. */
162
187
  function base64Utf8(value) {
@@ -266,10 +291,18 @@ export function redirectSafeFetch(connectorId, policy = "none", baseFetch = fetc
266
291
  const seen = new Set([current.href]);
267
292
  let hops = 0;
268
293
  while (true) {
269
- const response = await baseFetch(current, {
270
- ...init,
271
- redirect: "manual",
272
- });
294
+ let response;
295
+ try {
296
+ response = await baseFetch(current, {
297
+ ...init,
298
+ redirect: "manual",
299
+ });
300
+ }
301
+ catch (cause) {
302
+ if (cause instanceof ConnectorCallError)
303
+ throw cause;
304
+ throw unavailableCallError(cause, current.href);
305
+ }
273
306
  if (!REDIRECT_STATUSES.has(response.status))
274
307
  return response;
275
308
  const location = response.headers.get("location");
@@ -549,19 +582,38 @@ export function remoteMcp(id, opts) {
549
582
  state.credentialDigest = null;
550
583
  // `closed` is deliberately not cleared — see ConnectionState.
551
584
  };
552
- const closeHalf = async (state) => {
585
+ // The context has no deferred-work hook. Detached exits start this bounded
586
+ // best-effort tail immediately; closeScope awaits its own tail so the core
587
+ // can pass it to the runtime's deferred channel.
588
+ const closingSessions = new WeakMap();
589
+ const closeConnection = (client, transport, logger) => {
590
+ const previous = transport && closingSessions.get(transport);
591
+ if (previous)
592
+ return previous;
593
+ const closing = (async () => {
594
+ try {
595
+ if (transport)
596
+ await terminateSession(transport, logger, id);
597
+ if (client)
598
+ await client.close();
599
+ else
600
+ await transport?.close();
601
+ }
602
+ catch {
603
+ // Local close cannot replace the operation's result.
604
+ }
605
+ })();
606
+ // A connect can acquire a session after an early close. Deduplicate only
607
+ // once that session exists, so its late abandonment still sends DELETE.
608
+ if (transport?.sessionId)
609
+ closingSessions.set(transport, closing);
610
+ return closing;
611
+ };
612
+ const closeHalf = (state, ctx) => {
553
613
  const client = state.client;
554
614
  const transport = state.transport;
555
615
  reset(state);
556
- try {
557
- if (client)
558
- await client.close();
559
- else
560
- await transport?.close();
561
- }
562
- catch {
563
- // The discarded state remains authoritative if local close fails.
564
- }
616
+ void closeConnection(client, transport, ctx.logger);
565
617
  };
566
618
  const ensureConnected = async (ctx, state) => {
567
619
  // A 401 after connect is a verdict for the whole request scope, not merely
@@ -581,7 +633,7 @@ export function remoteMcp(id, opts) {
581
633
  if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
582
634
  const connecting = state.connecting;
583
635
  void connecting?.catch(() => { });
584
- await closeHalf(state);
636
+ closeHalf(state, ctx);
585
637
  throw operatorDisconnectedError();
586
638
  }
587
639
  }
@@ -592,7 +644,7 @@ export function remoteMcp(id, opts) {
592
644
  if (state.closed)
593
645
  throw scopeEndedError();
594
646
  if (oauthGeneration !== state.connectedGeneration) {
595
- reset(state);
647
+ closeHalf(state, ctx);
596
648
  }
597
649
  }
598
650
  // The static-credential counterpart of the epoch read above, and
@@ -618,7 +670,7 @@ export function remoteMcp(id, opts) {
618
670
  throw scopeEndedError();
619
671
  const connecting = state.connecting;
620
672
  void connecting?.catch(() => { });
621
- await closeHalf(state);
673
+ closeHalf(state, ctx);
622
674
  }
623
675
  }
624
676
  if (state.closed)
@@ -629,13 +681,8 @@ export function remoteMcp(id, opts) {
629
681
  let attempt;
630
682
  attempt = (async () => {
631
683
  const ownsAttempt = () => state.connecting === attempt && !state.closed;
632
- const abandon = async (owner) => {
633
- try {
634
- await owner.close();
635
- }
636
- catch {
637
- // The attempt is detached either way.
638
- }
684
+ const abandon = (client, transport) => {
685
+ void closeConnection(client, transport, ctx.logger);
639
686
  throw scopeEndedError();
640
687
  };
641
688
  // Let the assignment immediately below this async IIFE publish
@@ -666,7 +713,7 @@ export function remoteMcp(id, opts) {
666
713
  });
667
714
  const t = buildTransport(ctx, provider, credentialFramed);
668
715
  if (!ownsAttempt())
669
- await abandon(t);
716
+ abandon(null, t);
670
717
  state.transport = t;
671
718
  try {
672
719
  await c.connect(t);
@@ -675,7 +722,7 @@ export function remoteMcp(id, opts) {
675
722
  // that race anyway, close the resulting client rather than
676
723
  // resurrecting a session in the detached state object.
677
724
  if (!ownsAttempt())
678
- await abandon(c);
725
+ abandon(c, t);
679
726
  // A force re-auth that landed WHILE we were connecting wiped the
680
727
  // credentials this client just bound to. Discard it rather than
681
728
  // cache a stale-isolate connection.
@@ -685,24 +732,22 @@ export function remoteMcp(id, opts) {
685
732
  // connect succeeded but before this client is cached. Discard the
686
733
  // client on that side of the await too.
687
734
  if (!ownsAttempt())
688
- await abandon(c);
735
+ abandon(c, t);
689
736
  if (generation !== genAtStart) {
690
- try {
691
- await c.close();
692
- }
693
- catch {
694
- // discarding either way
695
- }
737
+ void closeConnection(c, t, ctx.logger);
696
738
  throw new UnauthorizedError("Connector was re-authorized during connect; reconnect required.");
697
739
  }
698
740
  }
699
741
  if (!ownsAttempt())
700
- await abandon(c);
742
+ abandon(c, t);
701
743
  state.client = c;
702
744
  state.connectedGeneration = genAtStart;
703
745
  state.authRequired = false;
704
746
  }
705
747
  catch (err) {
748
+ void closeConnection(c, t, ctx.logger);
749
+ if (ownsAttempt())
750
+ state.transport = null;
706
751
  // Only a real 401/UnauthorizedError means auth is the problem — a
707
752
  // network error on an oauth connector must surface as "error", not
708
753
  // "auth_required".
@@ -748,7 +793,7 @@ export function remoteMcp(id, opts) {
748
793
  // client/transport exists. Reset is unconditional because KV may already
749
794
  // be fenced behind a newer epoch after a cleanup error.
750
795
  void connecting?.catch(() => { });
751
- await closeHalf(state);
796
+ closeHalf(state, ctx);
752
797
  }
753
798
  };
754
799
  const connector = {
@@ -825,8 +870,9 @@ export function remoteMcp(id, opts) {
825
870
  // the accumulator is returned rather than stored: a cursor is opaque and
826
871
  // session-bound, so nothing here may outlive this call.
827
872
  async listTools(ctx) {
828
- // The complete-catalog rule is documented at
829
- // documentation/connectors.md#catalog-contract.
873
+ // The catalog contract: a downstream catalog is complete or it is a
874
+ // failure. Follow every page to the end of the cursor chain, preserve
875
+ // schemas and annotations, and never cache or serve a partial walk.
830
876
  const state = stateFor(ctx);
831
877
  await ensureConnected(ctx, state);
832
878
  // Bind the client once so the whole walk provably rides one session — a
@@ -976,7 +1022,7 @@ export function remoteMcp(id, opts) {
976
1022
  state.authRequired = true;
977
1023
  throw authRequiredError(err);
978
1024
  }
979
- throw err;
1025
+ throw downstreamCallError(err);
980
1026
  }
981
1027
  },
982
1028
  async closeScope(ctx) {
@@ -993,20 +1039,7 @@ export function remoteMcp(id, opts) {
993
1039
  const client = state.client;
994
1040
  const transport = state.transport;
995
1041
  reset(state);
996
- // Ask the downstream to drop its session first — closing only aborts our
997
- // side, and the DELETE that frees the server's rides on the very
998
- // AbortSignal the close is about to trip.
999
- if (transport)
1000
- await terminateSession(transport, ctx.logger, id);
1001
- // Client.close() owns its connected transport. During an unfinished or
1002
- // failed connect there is no cached client yet, so close the transport
1003
- // directly to abort/release that half-open session.
1004
- if (client) {
1005
- await client.close();
1006
- }
1007
- else {
1008
- await transport?.close();
1009
- }
1042
+ await closeConnection(client, transport, ctx.logger);
1010
1043
  },
1011
1044
  async status(ctx) {
1012
1045
  const state = stateFor(ctx);
@@ -1054,7 +1087,9 @@ export function remoteMcp(id, opts) {
1054
1087
  }
1055
1088
  await provider.clearPending();
1056
1089
  // Reset so the next use reconnects with the freshly stored tokens.
1057
- reset(state);
1090
+ if (!state.transport)
1091
+ state.transport = t;
1092
+ closeHalf(state, ctx);
1058
1093
  },
1059
1094
  };
1060
1095
  if (opts.auth?.type === "oauth") {
package/dist/errors.d.ts CHANGED
@@ -2,11 +2,19 @@
2
2
  * Machine-readable classification of a failed connector tool call.
3
3
  *
4
4
  * A code earns its place by changing what the caller does next, never by
5
- * naming a cause — the rule provider conventions call H11.
5
+ * naming a cause — the rule provider conventions call H11, defined in
6
+ * `test/provider-conventions.test.ts`.
6
7
  */
7
8
  export type ConnectorCallErrorCode = "timeout" | "auth_required" | "rate_limited" | "unavailable" | "invalid_args"
8
- /** Provider-owned absence; see provider-conventions.md H11 for the rule and
9
- * the permission-ambiguity exception. */
9
+ /**
10
+ * Provider-owned absence: the caller re-addresses rather than waiting,
11
+ * re-authorizing, or repairing arguments, and a program looping over ids can
12
+ * continue past it where `connector_call_failed` would abort. Map a status
13
+ * here only where the provider distinguishes absence from a permission gap —
14
+ * where it does not (Notion's `object_not_found` covers both), the honest
15
+ * code stays `connector_call_failed` or `auth_required` with a message that
16
+ * states the ambiguity (H11).
17
+ */
10
18
  | "not_found" | "input_required_unsupported" | "connector_call_failed";
11
19
  /** One bounded, payload-free explanation of an input-schema mismatch. */
12
20
  export interface ArgumentValidationIssue {
@@ -39,6 +47,17 @@ export declare function boundedEchoText(value: string, maxBytes?: number): strin
39
47
  export declare function echoedCallArgs(args: unknown): {
40
48
  args?: unknown;
41
49
  };
50
+ /** Optional transport diagnostics; never a URL path or raw runtime message. */
51
+ interface UnavailableDetails {
52
+ /** HTTP(S) origin only, at most 253 UTF-8 bytes. */
53
+ host?: string;
54
+ /** Validated network errno or `timeout`, at most 32 bytes. */
55
+ code?: string;
56
+ }
57
+ /** Runtime fields only: provider prose cannot supply an errno or a deadline. */
58
+ export declare function networkErrorCode(error: unknown): string | undefined;
59
+ /** Use at a fetch boundary where the destination and transport failure are known. */
60
+ export declare function unavailableCallError(cause: unknown, host?: string, message?: string): ConnectorCallError;
42
61
  /** Agent-visible recovery class attached only to `auth_required` failures. */
43
62
  export type AuthRecoveryMode = "oauth" | "operator_config" | "unavailable";
44
63
  /**
@@ -67,15 +86,20 @@ export declare class ConnectorCallError extends Error {
67
86
  readonly retryAfterMs: number | undefined;
68
87
  /** Bounded schema findings for `invalid_args`; never submitted values. */
69
88
  readonly validation: ArgumentValidationDetails | undefined;
89
+ /** Sanitized transport diagnostics for `unavailable` only. */
90
+ readonly details: UnavailableDetails | undefined;
70
91
  constructor(code: ConnectorCallErrorCode, message: string, opts?: {
71
92
  retryable?: boolean;
72
93
  retryAfterMs?: number;
73
94
  cause?: unknown;
74
95
  validation?: ArgumentValidationDetails;
96
+ details?: UnavailableDetails;
75
97
  });
76
98
  }
77
99
  /** The `error` object surfaced in value-mode call results and rejected promises. */
78
100
  export interface CallErrorDetails {
101
+ /** Sanitized transport diagnostics, absent when the runtime supplies none. */
102
+ details?: UnavailableDetails;
79
103
  code: string;
80
104
  message: string;
81
105
  retryable: boolean;
@@ -151,3 +175,4 @@ export declare function framingError(code: string, message: string): CallErrorDe
151
175
  */
152
176
  export declare function classifyCallError(err: unknown, fallbackCode?: string): CallErrorDetails;
153
177
  export declare function msg(err: unknown): string;
178
+ export {};
package/dist/errors.js CHANGED
@@ -20,7 +20,8 @@ function boundedIssueText(value, maxChars) {
20
20
  * the payload lands in both the text content and `structuredContent`. The agent
21
21
  * already holds what it sent; the echo is a convenience, never the record.
22
22
  */
23
- // Shared by argument and text echoes; see meta-tools.md lines 360-372.
23
+ // One budget shared by the argument and text echoes, so a caller cannot make a
24
+ // refusal larger by splitting what it sent across the two.
24
25
  const MAX_ECHOED_BYTES = 512;
25
26
  /**
26
27
  * The same budget spent on caller-authored *text* — the address it mistyped,
@@ -102,6 +103,58 @@ function boundedValidation(details) {
102
103
  ...(truncated ? { truncated: true } : {}),
103
104
  };
104
105
  }
106
+ const NETWORK_ERROR_CODES = new Set([
107
+ "ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN",
108
+ "EAI_FAIL", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EHOSTDOWN",
109
+ "ECONNABORTED", "EPIPE", "EACCES", "EPERM",
110
+ "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
111
+ "UND_ERR_SOCKET", "timeout",
112
+ ]);
113
+ function networkCode(value) {
114
+ return typeof value === "string" && value.length <= 32 && NETWORK_ERROR_CODES.has(value)
115
+ ? value
116
+ : undefined;
117
+ }
118
+ function sanitizedUnavailableDetails(details) {
119
+ if (!details)
120
+ return undefined;
121
+ let host;
122
+ if (typeof details.host === "string") {
123
+ try {
124
+ const url = new URL(details.host);
125
+ if ((url.protocol === "https:" || url.protocol === "http:") &&
126
+ echoEncoder.encode(url.origin).length <= 253)
127
+ host = url.origin;
128
+ }
129
+ catch {
130
+ // An invalid or oversized origin is absent, never a clipped destination.
131
+ }
132
+ }
133
+ const code = networkCode(details.code);
134
+ return host || code ? { ...(host ? { host } : {}), ...(code ? { code } : {}) } : undefined;
135
+ }
136
+ /** Runtime fields only: provider prose cannot supply an errno or a deadline. */
137
+ export function networkErrorCode(error) {
138
+ if (!error || typeof error !== "object")
139
+ return undefined;
140
+ if (error instanceof Error &&
141
+ (error.name === "AbortError" || error.name === "TimeoutError")) {
142
+ return "timeout";
143
+ }
144
+ const runtime = error;
145
+ const cause = runtime.cause;
146
+ return networkCode(runtime.code) ?? (cause && typeof cause === "object"
147
+ ? networkCode(cause.code)
148
+ : undefined);
149
+ }
150
+ /** Use at a fetch boundary where the destination and transport failure are known. */
151
+ export function unavailableCallError(cause, host, message = "Could not reach the downstream service.") {
152
+ const code = networkErrorCode(cause);
153
+ return new ConnectorCallError("unavailable", message, {
154
+ cause,
155
+ details: { ...(host ? { host } : {}), ...(code ? { code } : {}) },
156
+ });
157
+ }
105
158
  const RETRYABLE_BY_CODE = {
106
159
  timeout: true,
107
160
  rate_limited: true,
@@ -146,12 +199,16 @@ export class ConnectorCallError extends Error {
146
199
  retryAfterMs;
147
200
  /** Bounded schema findings for `invalid_args`; never submitted values. */
148
201
  validation;
202
+ /** Sanitized transport diagnostics for `unavailable` only. */
203
+ details;
149
204
  constructor(code, message, opts = {}) {
150
205
  super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
151
206
  this.name = "ConnectorCallError";
152
207
  this.code = code;
153
208
  this.retryable = opts.retryable ?? RETRYABLE_BY_CODE[code];
154
209
  this.retryAfterMs = normalizeRetryAfterMs(opts.retryAfterMs);
210
+ this.details =
211
+ code === "unavailable" ? sanitizedUnavailableDetails(opts.details) : undefined;
155
212
  this.validation =
156
213
  code === "invalid_args" ? boundedValidation(opts.validation) : undefined;
157
214
  }
@@ -164,6 +221,7 @@ export class ConnectorCallError extends Error {
164
221
  * trusts the flag would cheerfully retry a refusal forever.
165
222
  */
166
223
  const NEVER_RETRYABLE_FRAMING = new Set([
224
+ "result_processing_failed",
167
225
  "unknown_address",
168
226
  "unknown_tool",
169
227
  "ambiguous_tool_alias",
@@ -203,6 +261,7 @@ export function classifyCallError(err, fallbackCode = "connector_call_failed") {
203
261
  ? { retryAfterMs: err.retryAfterMs }
204
262
  : {}),
205
263
  ...(err.validation ? { validation: err.validation } : {}),
264
+ ...(err.details ? { details: err.details } : {}),
206
265
  };
207
266
  }
208
267
  // An aborted fetch rejects with a DOMException named "AbortError" whose