@cliwant/mcp-sam-gov 1.5.0 → 1.7.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 (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +248 -231
  3. package/README.ko.md +248 -231
  4. package/README.md +733 -714
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js.map +1 -1
  8. package/dist/feedback.d.ts +64 -0
  9. package/dist/feedback.d.ts.map +1 -0
  10. package/dist/feedback.js +131 -0
  11. package/dist/feedback.js.map +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +48 -2
  14. package/dist/server.js.map +1 -1
  15. package/dist/update-check.d.ts +38 -0
  16. package/dist/update-check.d.ts.map +1 -0
  17. package/dist/update-check.js +85 -0
  18. package/dist/update-check.js.map +1 -0
  19. package/package.json +111 -111
  20. package/src/attachments.ts +652 -652
  21. package/src/bea.ts +372 -372
  22. package/src/bls.ts +1943 -1943
  23. package/src/cache.ts +73 -73
  24. package/src/cbp-border.ts +177 -177
  25. package/src/census-economic.ts +431 -431
  26. package/src/census.ts +735 -735
  27. package/src/ckan.ts +495 -495
  28. package/src/clinicaltrials.ts +923 -923
  29. package/src/cms-facility.ts +379 -379
  30. package/src/cms-hospital.ts +344 -344
  31. package/src/cms-supplier.ts +527 -527
  32. package/src/cms-utilization.ts +389 -389
  33. package/src/cms.ts +634 -634
  34. package/src/coerce.ts +47 -47
  35. package/src/courtlistener.ts +465 -465
  36. package/src/cpsc.ts +333 -333
  37. package/src/datagov-catalog.ts +312 -312
  38. package/src/datagov.ts +907 -907
  39. package/src/datagovKey.ts +68 -68
  40. package/src/datasource.ts +721 -721
  41. package/src/disclosure.ts +61 -61
  42. package/src/dol.ts +515 -515
  43. package/src/ecfr.ts +248 -248
  44. package/src/echo.ts +496 -496
  45. package/src/edgar.ts +3046 -3046
  46. package/src/epa-envirofacts.ts +358 -358
  47. package/src/errors.ts +324 -314
  48. package/src/fac.ts +529 -529
  49. package/src/far.ts +1009 -1009
  50. package/src/fdic.ts +2052 -2052
  51. package/src/federal-register.ts +725 -725
  52. package/src/feedback.ts +160 -0
  53. package/src/fema.ts +680 -680
  54. package/src/fpds.ts +620 -620
  55. package/src/fred.ts +464 -464
  56. package/src/gao.ts +744 -744
  57. package/src/gov-domains.ts +237 -237
  58. package/src/govinfo.ts +497 -497
  59. package/src/grants.ts +290 -290
  60. package/src/gsa-csv.ts +992 -992
  61. package/src/gsa-perdiem.ts +361 -361
  62. package/src/integrity.ts +928 -928
  63. package/src/keys.ts +268 -268
  64. package/src/lda.ts +385 -385
  65. package/src/meta.ts +292 -292
  66. package/src/nhtsa.ts +352 -352
  67. package/src/nih.ts +375 -375
  68. package/src/nist-controls.ts +219 -219
  69. package/src/nonprofit.ts +460 -460
  70. package/src/nppes.ts +834 -834
  71. package/src/nsf.ts +706 -706
  72. package/src/nvd.ts +1124 -1124
  73. package/src/nws-weather.ts +167 -167
  74. package/src/ofac.ts +1166 -1166
  75. package/src/openfda-device.ts +356 -356
  76. package/src/openfda-drugsfda.ts +313 -313
  77. package/src/openfda.ts +518 -518
  78. package/src/pricing.ts +1075 -1075
  79. package/src/sam-gov/client.ts +774 -774
  80. package/src/sam-gov/index.ts +32 -32
  81. package/src/sam-gov/types.ts +152 -152
  82. package/src/sba.ts +357 -357
  83. package/src/server.ts +6692 -6639
  84. package/src/snapshot.ts +223 -223
  85. package/src/socrata.ts +532 -532
  86. package/src/treasury.ts +582 -582
  87. package/src/update-check.ts +88 -0
  88. package/src/usaspending.ts +2852 -2852
  89. package/src/usitc.ts +420 -420
package/src/errors.ts CHANGED
@@ -1,314 +1,324 @@
1
- /**
2
- * Structured error envelope for every tool response.
3
- *
4
- * Why this exists
5
- * ----------------
6
- * Federal APIs fail in 5 distinct ways: rate-limited (429), down
7
- * (5xx), schema-drift (200 with unexpected shape), notice-not-found
8
- * (404), and transient network. Each has a different retry strategy.
9
- * If we just throw, the LLM sees "Tool error: TypeError: x is
10
- * undefined" and gives up.
11
- *
12
- * Every tool should return either:
13
- * { ok: true, data: ... }
14
- * { ok: false, error: { kind, message, retryable, retryAfterSeconds? } }
15
- *
16
- * The MCP server layer surfaces this as JSON to the calling agent.
17
- * The agent can then decide: retry now, retry later, or surface
18
- * the error to the user with appropriate framing.
19
- */
20
-
21
- import { ZodError } from "zod";
22
-
23
- export type ErrorKind =
24
- /** HTTP 429. Retry after `retryAfterSeconds`. */
25
- | "rate_limited"
26
- /** HTTP 5xx or network error. Likely transient. */
27
- | "upstream_unavailable"
28
- /** HTTP 404 / empty results. Don't retry. */
29
- | "not_found"
30
- /** Caller passed bad input (e.g. malformed noticeId). Don't retry. */
31
- | "invalid_input"
32
- /** API returned 200 but we couldn't parse / shape doesn't match. */
33
- | "schema_drift"
34
- /** Anything else. Don't retry. */
35
- | "unknown";
36
-
37
- export type ToolError = {
38
- kind: ErrorKind;
39
- message: string;
40
- /** Whether the agent should retry. Pairs with retryAfterSeconds. */
41
- retryable: boolean;
42
- /** If rate-limited, advisory wait time. Honors `Retry-After` header. */
43
- retryAfterSeconds?: number;
44
- /** Echo upstream HTTP status when available — helps debug. */
45
- upstreamStatus?: number;
46
- /** Endpoint that failed — for ops. */
47
- upstreamEndpoint?: string;
48
- /**
49
- * Set true when the upstream EXPLICITLY asked us to wait — i.e. a 429, or a
50
- * 5xx that CARRIED a `Retry-After` header (ADR-0045 M2). The resilience layer
51
- * (circuit breaker + path-chain) consults `isHonorRetryAfter(err)` and EXCLUDES
52
- * such errors from the breaker failure count AND from fallback (B1-policy): we
53
- * wait and fail honestly as `rate_limited`/`upstream_unavailable`, never route
54
- * around a rate limit onto a mirror/snapshot. Absent (undefined) on a plain 5xx
55
- * with no Retry-After header — that stays a HARD failure the breaker counts, so
56
- * absence must NOT be read as `false`-meaning-"honor". The 429 path never sets
57
- * this flag (it is detected by `kind==="rate_limited"`), keeping the 429 error
58
- * envelope byte-identical to before this ADR.
59
- */
60
- honorRetryAfter?: boolean;
61
- };
62
-
63
- export type ToolResult<T> =
64
- | { ok: true; data: T }
65
- | { ok: false; error: ToolError };
66
-
67
- const RATE_LIMIT_DEFAULT_SECONDS = 30;
68
-
69
- export class ToolErrorCarrier extends Error {
70
- readonly toolError: ToolError;
71
- constructor(toolError: ToolError) {
72
- super(toolError.message);
73
- this.toolError = toolError;
74
- this.name = "ToolErrorCarrier";
75
- }
76
- }
77
-
78
- /**
79
- * Convert a fetch Response into a structured tool error.
80
- *
81
- * Honors `Retry-After` (both seconds-int and HTTP-date forms).
82
- */
83
- export function errorFromResponse(
84
- r: Response,
85
- endpoint: string,
86
- ): ToolError {
87
- const upstreamStatus = r.status;
88
- if (r.status === 429) {
89
- const retryAfter = parseRetryAfter(r.headers.get("Retry-After"));
90
- return {
91
- kind: "rate_limited",
92
- message: `Upstream rate-limited (HTTP 429) at ${endpoint}. Retry after ${retryAfter}s.`,
93
- retryable: true,
94
- retryAfterSeconds: retryAfter,
95
- upstreamStatus,
96
- upstreamEndpoint: endpoint,
97
- };
98
- }
99
- if (r.status === 404) {
100
- return {
101
- kind: "not_found",
102
- message: `Resource not found at ${endpoint} (HTTP 404).`,
103
- retryable: false,
104
- upstreamStatus,
105
- upstreamEndpoint: endpoint,
106
- };
107
- }
108
- if (r.status >= 500) {
109
- const err: ToolError = {
110
- kind: "upstream_unavailable",
111
- message: `Upstream server error (HTTP ${r.status}) at ${endpoint}. Try again later.`,
112
- retryable: true,
113
- retryAfterSeconds: 60,
114
- upstreamStatus,
115
- upstreamEndpoint: endpoint,
116
- };
117
- // ADR-0045 M2 — a 5xx MAY carry Retry-After (e.g. a 503 maintenance window).
118
- // Parse it ONLY when the header is PRESENT (so a plain 5xx is byte-identical
119
- // to before: retryAfterSeconds:60, no honorRetryAfter key), preserving the
120
- // `Math.min(...,60)` worst-case cap. The 429 branch above is UNTOUCHED (it
121
- // already parses Retry-After; re-implementing it is forbidden). Flagging
122
- // honorRetryAfter lets the resilience layer EXCLUDE this from the breaker /
123
- // fallback (B1-policy: honor the explicit wait, never route around it).
124
- const retryAfterHeader = r.headers.get("Retry-After");
125
- if (retryAfterHeader !== null) {
126
- err.retryAfterSeconds = Math.min(parseRetryAfter(retryAfterHeader), 60);
127
- err.honorRetryAfter = true;
128
- }
129
- return err;
130
- }
131
- if (r.status >= 400) {
132
- return {
133
- kind: "invalid_input",
134
- message: `Bad request (HTTP ${r.status}) at ${endpoint}.`,
135
- retryable: false,
136
- upstreamStatus,
137
- upstreamEndpoint: endpoint,
138
- };
139
- }
140
- return {
141
- kind: "unknown",
142
- message: `Unexpected status ${r.status} at ${endpoint}.`,
143
- retryable: false,
144
- upstreamStatus,
145
- upstreamEndpoint: endpoint,
146
- };
147
- }
148
-
149
- /**
150
- * Does this thrown error carry an EXPLICIT "wait, then fail honestly" signal
151
- * from the upstream — a 429 (`rate_limited`), or a 5xx that carried a
152
- * `Retry-After` header (ADR-0045 M2, flagged `honorRetryAfter`)?
153
- *
154
- * The resilience layer (circuit breaker + path-chain, datasource.ts) consults
155
- * this to EXCLUDE such errors from the breaker failure count AND from fallback
156
- * (ADR-0045 B1-policy): a rate limit / honor-Retry-After outcome must NEVER
157
- * count as a breaker "hard failure" nor trigger a mirror/snapshot fallback — we
158
- * wait and fail honestly. This is a POLICY boundary, not a bypass: we honor the
159
- * upstream's explicit throttle, we do not route around it.
160
- */
161
- export function isHonorRetryAfter(err: unknown): boolean {
162
- if (!(err instanceof ToolErrorCarrier)) return false;
163
- const te = err.toolError;
164
- if (te.kind === "rate_limited") return true;
165
- return te.honorRetryAfter === true;
166
- }
167
-
168
- /**
169
- * Wrap a fetch + json call in retry-with-backoff for transient errors.
170
- *
171
- * Strategy: up to 3 attempts. On 429: respect Retry-After up to 60s.
172
- * On 5xx: 1s, 2s, 4s exponential. On parse error: no retry (schema
173
- * drift — needs human investigation).
174
- */
175
- export async function fetchWithRetry(
176
- url: string,
177
- init: RequestInit,
178
- endpointLabel: string,
179
- ): Promise<Response> {
180
- const maxAttempts = 3;
181
- let lastErr: ToolError | undefined;
182
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
183
- try {
184
- const r = await fetch(url, init);
185
- if (r.ok) return r;
186
- const err = errorFromResponse(r, endpointLabel);
187
- if (!err.retryable || attempt === maxAttempts) {
188
- throw new ToolErrorCarrier(err);
189
- }
190
- lastErr = err;
191
- const wait = err.retryAfterSeconds
192
- ? Math.min(err.retryAfterSeconds, 60)
193
- : Math.pow(2, attempt - 1);
194
- await new Promise((res) => setTimeout(res, wait * 1000));
195
- } catch (e) {
196
- if (e instanceof ToolErrorCarrier) throw e;
197
- // A timeout/abort. The caller's AbortSignal.timeout fired (or an
198
- // already-aborted signal is being reused across attempts). Retrying is
199
- // futile within this call's budget: the same signal stays aborted, so
200
- // attempts 2/3 reject immediately without ever reaching the endpoint,
201
- // and a re-driven tool call just re-hits the same wall. Fail fast,
202
- // honestly non-retryable. Keyed on the DOMException NAME only —
203
- // AbortSignal.timeout → "TimeoutError", AbortController.abort() →
204
- // "AbortError" — which is disjoint from a genuine network fault
205
- // (TypeError, name "TypeError"), so a real "fetch failed" falls through
206
- // to the generic retryable branch below UNCHANGED.
207
- if (
208
- e instanceof Error &&
209
- (e.name === "TimeoutError" || e.name === "AbortError")
210
- ) {
211
- // HONESTY (dogfooding 2026-07-16): if a PRIOR attempt already classified a
212
- // real upstream signal — a 429 rate_limit — do NOT mask it as a generic
213
- // "timed out". The abort here is a DOWNSTREAM artifact of waiting out that
214
- // rate limit (the retry-after wait outran getJson's AbortSignal, so the
215
- // next fetch hits the already-aborted signal). Surfacing "timed out" hides
216
- // the true cause (rate-limited) AND its remedy (wait / supply an API key)
217
- // and drops the retryable+retryAfterSeconds guidance. Prefer the real
218
- // rate_limited error. (A pure timeout with no prior 429 keeps "timed out".)
219
- if (lastErr && lastErr.kind === "rate_limited") {
220
- throw new ToolErrorCarrier(lastErr);
221
- }
222
- throw new ToolErrorCarrier({
223
- kind: "upstream_unavailable",
224
- message: `Request to ${endpointLabel} timed out.`,
225
- retryable: false,
226
- upstreamEndpoint: endpointLabel,
227
- });
228
- }
229
- // Network-level error
230
- lastErr = {
231
- kind: "upstream_unavailable",
232
- message: `Network error reaching ${endpointLabel}: ${(e as Error).message}`,
233
- retryable: true,
234
- retryAfterSeconds: 30,
235
- upstreamEndpoint: endpointLabel,
236
- };
237
- if (attempt === maxAttempts) {
238
- throw new ToolErrorCarrier(lastErr);
239
- }
240
- await new Promise((res) =>
241
- setTimeout(res, Math.pow(2, attempt - 1) * 1000),
242
- );
243
- }
244
- }
245
- throw new ToolErrorCarrier(
246
- lastErr ?? {
247
- kind: "unknown",
248
- message: `${endpointLabel} failed after ${maxAttempts} attempts.`,
249
- retryable: false,
250
- upstreamEndpoint: endpointLabel,
251
- },
252
- );
253
- }
254
-
255
- function parseRetryAfter(value: string | null): number {
256
- if (!value) return RATE_LIMIT_DEFAULT_SECONDS;
257
- const asInt = Number.parseInt(value, 10);
258
- if (Number.isFinite(asInt)) return asInt;
259
- // HTTP-date form
260
- const date = Date.parse(value);
261
- if (!Number.isNaN(date)) {
262
- return Math.max(1, Math.ceil((date - Date.now()) / 1000));
263
- }
264
- return RATE_LIMIT_DEFAULT_SECONDS;
265
- }
266
-
267
- /**
268
- * Convert any thrown error into a serializable ToolError envelope.
269
- * Used at the dispatcher boundary — server.ts catches everything
270
- * and wraps before returning to the MCP client.
271
- */
272
- export function toToolError(e: unknown, endpointLabel?: string): ToolError {
273
- if (e instanceof ToolErrorCarrier) return e.toolError;
274
- // A Zod input-validation failure is a CALLER error (e.g. limit above the max,
275
- // a value outside an enum). Classify it as `invalid_input` with a readable
276
- // field-level message — NEVER a generic `unknown` carrying Zod's raw JSON
277
- // issue array (which an agent can't act on, and which mislabels a fixable
278
- // input problem as a mysterious/possibly-transient failure).
279
- if (e instanceof ZodError) {
280
- return {
281
- kind: "invalid_input",
282
- message: `Invalid input${endpointLabel ? ` for ${endpointLabel}` : ""}: ${e.issues
283
- .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
284
- .join("; ")}`,
285
- retryable: false,
286
- upstreamEndpoint: endpointLabel,
287
- };
288
- }
289
- if (e instanceof Error) {
290
- const msg = e.message;
291
- // Common fetch timeout signature
292
- if (e.name === "TimeoutError" || /timeout|aborted/i.test(msg)) {
293
- return {
294
- kind: "upstream_unavailable",
295
- message: `${endpointLabel ?? "upstream"} timed out: ${msg}`,
296
- retryable: true,
297
- retryAfterSeconds: 30,
298
- upstreamEndpoint: endpointLabel,
299
- };
300
- }
301
- return {
302
- kind: "unknown",
303
- message: msg,
304
- retryable: false,
305
- upstreamEndpoint: endpointLabel,
306
- };
307
- }
308
- return {
309
- kind: "unknown",
310
- message: String(e),
311
- retryable: false,
312
- upstreamEndpoint: endpointLabel,
313
- };
314
- }
1
+ /**
2
+ * Structured error envelope for every tool response.
3
+ *
4
+ * Why this exists
5
+ * ----------------
6
+ * Federal APIs fail in 5 distinct ways: rate-limited (429), down
7
+ * (5xx), schema-drift (200 with unexpected shape), notice-not-found
8
+ * (404), and transient network. Each has a different retry strategy.
9
+ * If we just throw, the LLM sees "Tool error: TypeError: x is
10
+ * undefined" and gives up.
11
+ *
12
+ * Every tool should return either:
13
+ * { ok: true, data: ... }
14
+ * { ok: false, error: { kind, message, retryable, retryAfterSeconds? } }
15
+ *
16
+ * The MCP server layer surfaces this as JSON to the calling agent.
17
+ * The agent can then decide: retry now, retry later, or surface
18
+ * the error to the user with appropriate framing.
19
+ */
20
+
21
+ import { ZodError } from "zod";
22
+
23
+ export type ErrorKind =
24
+ /** HTTP 429. Retry after `retryAfterSeconds`. */
25
+ | "rate_limited"
26
+ /** HTTP 5xx or network error. Likely transient. */
27
+ | "upstream_unavailable"
28
+ /** HTTP 404 / empty results. Don't retry. */
29
+ | "not_found"
30
+ /** Caller passed bad input (e.g. malformed noticeId). Don't retry. */
31
+ | "invalid_input"
32
+ /** API returned 200 but we couldn't parse / shape doesn't match. */
33
+ | "schema_drift"
34
+ /** Anything else. Don't retry. */
35
+ | "unknown";
36
+
37
+ export type ToolError = {
38
+ kind: ErrorKind;
39
+ message: string;
40
+ /** Whether the agent should retry. Pairs with retryAfterSeconds. */
41
+ retryable: boolean;
42
+ /** If rate-limited, advisory wait time. Honors `Retry-After` header. */
43
+ retryAfterSeconds?: number;
44
+ /** Echo upstream HTTP status when available — helps debug. */
45
+ upstreamStatus?: number;
46
+ /** Endpoint that failed — for ops. */
47
+ upstreamEndpoint?: string;
48
+ /**
49
+ * PULL-only feedback loop (feedback.ts). A PREFILLED GitHub new-issue URL, set
50
+ * by the dispatcher ONLY for the two "something may be broken" kinds —
51
+ * `schema_drift` and `upstream_unavailable`. The agent MAY relay it to the
52
+ * human, who opens and submits it; the server NEVER posts. Carries only the
53
+ * tool name, error kind, and server version — no arguments, no PII. Absent on
54
+ * user/expected errors (invalid_input, not_found, rate_limited) so the 429 and
55
+ * bad-input envelopes stay byte-identical.
56
+ */
57
+ report?: string;
58
+ /**
59
+ * Set true when the upstream EXPLICITLY asked us to wait — i.e. a 429, or a
60
+ * 5xx that CARRIED a `Retry-After` header (ADR-0045 M2). The resilience layer
61
+ * (circuit breaker + path-chain) consults `isHonorRetryAfter(err)` and EXCLUDES
62
+ * such errors from the breaker failure count AND from fallback (B1-policy): we
63
+ * wait and fail honestly as `rate_limited`/`upstream_unavailable`, never route
64
+ * around a rate limit onto a mirror/snapshot. Absent (undefined) on a plain 5xx
65
+ * with no Retry-After header — that stays a HARD failure the breaker counts, so
66
+ * absence must NOT be read as `false`-meaning-"honor". The 429 path never sets
67
+ * this flag (it is detected by `kind==="rate_limited"`), keeping the 429 error
68
+ * envelope byte-identical to before this ADR.
69
+ */
70
+ honorRetryAfter?: boolean;
71
+ };
72
+
73
+ export type ToolResult<T> =
74
+ | { ok: true; data: T }
75
+ | { ok: false; error: ToolError };
76
+
77
+ const RATE_LIMIT_DEFAULT_SECONDS = 30;
78
+
79
+ export class ToolErrorCarrier extends Error {
80
+ readonly toolError: ToolError;
81
+ constructor(toolError: ToolError) {
82
+ super(toolError.message);
83
+ this.toolError = toolError;
84
+ this.name = "ToolErrorCarrier";
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Convert a fetch Response into a structured tool error.
90
+ *
91
+ * Honors `Retry-After` (both seconds-int and HTTP-date forms).
92
+ */
93
+ export function errorFromResponse(
94
+ r: Response,
95
+ endpoint: string,
96
+ ): ToolError {
97
+ const upstreamStatus = r.status;
98
+ if (r.status === 429) {
99
+ const retryAfter = parseRetryAfter(r.headers.get("Retry-After"));
100
+ return {
101
+ kind: "rate_limited",
102
+ message: `Upstream rate-limited (HTTP 429) at ${endpoint}. Retry after ${retryAfter}s.`,
103
+ retryable: true,
104
+ retryAfterSeconds: retryAfter,
105
+ upstreamStatus,
106
+ upstreamEndpoint: endpoint,
107
+ };
108
+ }
109
+ if (r.status === 404) {
110
+ return {
111
+ kind: "not_found",
112
+ message: `Resource not found at ${endpoint} (HTTP 404).`,
113
+ retryable: false,
114
+ upstreamStatus,
115
+ upstreamEndpoint: endpoint,
116
+ };
117
+ }
118
+ if (r.status >= 500) {
119
+ const err: ToolError = {
120
+ kind: "upstream_unavailable",
121
+ message: `Upstream server error (HTTP ${r.status}) at ${endpoint}. Try again later.`,
122
+ retryable: true,
123
+ retryAfterSeconds: 60,
124
+ upstreamStatus,
125
+ upstreamEndpoint: endpoint,
126
+ };
127
+ // ADR-0045 M2 — a 5xx MAY carry Retry-After (e.g. a 503 maintenance window).
128
+ // Parse it ONLY when the header is PRESENT (so a plain 5xx is byte-identical
129
+ // to before: retryAfterSeconds:60, no honorRetryAfter key), preserving the
130
+ // `Math.min(...,60)` worst-case cap. The 429 branch above is UNTOUCHED (it
131
+ // already parses Retry-After; re-implementing it is forbidden). Flagging
132
+ // honorRetryAfter lets the resilience layer EXCLUDE this from the breaker /
133
+ // fallback (B1-policy: honor the explicit wait, never route around it).
134
+ const retryAfterHeader = r.headers.get("Retry-After");
135
+ if (retryAfterHeader !== null) {
136
+ err.retryAfterSeconds = Math.min(parseRetryAfter(retryAfterHeader), 60);
137
+ err.honorRetryAfter = true;
138
+ }
139
+ return err;
140
+ }
141
+ if (r.status >= 400) {
142
+ return {
143
+ kind: "invalid_input",
144
+ message: `Bad request (HTTP ${r.status}) at ${endpoint}.`,
145
+ retryable: false,
146
+ upstreamStatus,
147
+ upstreamEndpoint: endpoint,
148
+ };
149
+ }
150
+ return {
151
+ kind: "unknown",
152
+ message: `Unexpected status ${r.status} at ${endpoint}.`,
153
+ retryable: false,
154
+ upstreamStatus,
155
+ upstreamEndpoint: endpoint,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Does this thrown error carry an EXPLICIT "wait, then fail honestly" signal
161
+ * from the upstream — a 429 (`rate_limited`), or a 5xx that carried a
162
+ * `Retry-After` header (ADR-0045 M2, flagged `honorRetryAfter`)?
163
+ *
164
+ * The resilience layer (circuit breaker + path-chain, datasource.ts) consults
165
+ * this to EXCLUDE such errors from the breaker failure count AND from fallback
166
+ * (ADR-0045 B1-policy): a rate limit / honor-Retry-After outcome must NEVER
167
+ * count as a breaker "hard failure" nor trigger a mirror/snapshot fallback — we
168
+ * wait and fail honestly. This is a POLICY boundary, not a bypass: we honor the
169
+ * upstream's explicit throttle, we do not route around it.
170
+ */
171
+ export function isHonorRetryAfter(err: unknown): boolean {
172
+ if (!(err instanceof ToolErrorCarrier)) return false;
173
+ const te = err.toolError;
174
+ if (te.kind === "rate_limited") return true;
175
+ return te.honorRetryAfter === true;
176
+ }
177
+
178
+ /**
179
+ * Wrap a fetch + json call in retry-with-backoff for transient errors.
180
+ *
181
+ * Strategy: up to 3 attempts. On 429: respect Retry-After up to 60s.
182
+ * On 5xx: 1s, 2s, 4s exponential. On parse error: no retry (schema
183
+ * drift — needs human investigation).
184
+ */
185
+ export async function fetchWithRetry(
186
+ url: string,
187
+ init: RequestInit,
188
+ endpointLabel: string,
189
+ ): Promise<Response> {
190
+ const maxAttempts = 3;
191
+ let lastErr: ToolError | undefined;
192
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
193
+ try {
194
+ const r = await fetch(url, init);
195
+ if (r.ok) return r;
196
+ const err = errorFromResponse(r, endpointLabel);
197
+ if (!err.retryable || attempt === maxAttempts) {
198
+ throw new ToolErrorCarrier(err);
199
+ }
200
+ lastErr = err;
201
+ const wait = err.retryAfterSeconds
202
+ ? Math.min(err.retryAfterSeconds, 60)
203
+ : Math.pow(2, attempt - 1);
204
+ await new Promise((res) => setTimeout(res, wait * 1000));
205
+ } catch (e) {
206
+ if (e instanceof ToolErrorCarrier) throw e;
207
+ // A timeout/abort. The caller's AbortSignal.timeout fired (or an
208
+ // already-aborted signal is being reused across attempts). Retrying is
209
+ // futile within this call's budget: the same signal stays aborted, so
210
+ // attempts 2/3 reject immediately without ever reaching the endpoint,
211
+ // and a re-driven tool call just re-hits the same wall. Fail fast,
212
+ // honestly non-retryable. Keyed on the DOMException NAME only —
213
+ // AbortSignal.timeout → "TimeoutError", AbortController.abort() →
214
+ // "AbortError" — which is disjoint from a genuine network fault
215
+ // (TypeError, name "TypeError"), so a real "fetch failed" falls through
216
+ // to the generic retryable branch below UNCHANGED.
217
+ if (
218
+ e instanceof Error &&
219
+ (e.name === "TimeoutError" || e.name === "AbortError")
220
+ ) {
221
+ // HONESTY (dogfooding 2026-07-16): if a PRIOR attempt already classified a
222
+ // real upstream signal — a 429 rate_limit — do NOT mask it as a generic
223
+ // "timed out". The abort here is a DOWNSTREAM artifact of waiting out that
224
+ // rate limit (the retry-after wait outran getJson's AbortSignal, so the
225
+ // next fetch hits the already-aborted signal). Surfacing "timed out" hides
226
+ // the true cause (rate-limited) AND its remedy (wait / supply an API key)
227
+ // and drops the retryable+retryAfterSeconds guidance. Prefer the real
228
+ // rate_limited error. (A pure timeout with no prior 429 keeps "timed out".)
229
+ if (lastErr && lastErr.kind === "rate_limited") {
230
+ throw new ToolErrorCarrier(lastErr);
231
+ }
232
+ throw new ToolErrorCarrier({
233
+ kind: "upstream_unavailable",
234
+ message: `Request to ${endpointLabel} timed out.`,
235
+ retryable: false,
236
+ upstreamEndpoint: endpointLabel,
237
+ });
238
+ }
239
+ // Network-level error
240
+ lastErr = {
241
+ kind: "upstream_unavailable",
242
+ message: `Network error reaching ${endpointLabel}: ${(e as Error).message}`,
243
+ retryable: true,
244
+ retryAfterSeconds: 30,
245
+ upstreamEndpoint: endpointLabel,
246
+ };
247
+ if (attempt === maxAttempts) {
248
+ throw new ToolErrorCarrier(lastErr);
249
+ }
250
+ await new Promise((res) =>
251
+ setTimeout(res, Math.pow(2, attempt - 1) * 1000),
252
+ );
253
+ }
254
+ }
255
+ throw new ToolErrorCarrier(
256
+ lastErr ?? {
257
+ kind: "unknown",
258
+ message: `${endpointLabel} failed after ${maxAttempts} attempts.`,
259
+ retryable: false,
260
+ upstreamEndpoint: endpointLabel,
261
+ },
262
+ );
263
+ }
264
+
265
+ function parseRetryAfter(value: string | null): number {
266
+ if (!value) return RATE_LIMIT_DEFAULT_SECONDS;
267
+ const asInt = Number.parseInt(value, 10);
268
+ if (Number.isFinite(asInt)) return asInt;
269
+ // HTTP-date form
270
+ const date = Date.parse(value);
271
+ if (!Number.isNaN(date)) {
272
+ return Math.max(1, Math.ceil((date - Date.now()) / 1000));
273
+ }
274
+ return RATE_LIMIT_DEFAULT_SECONDS;
275
+ }
276
+
277
+ /**
278
+ * Convert any thrown error into a serializable ToolError envelope.
279
+ * Used at the dispatcher boundary — server.ts catches everything
280
+ * and wraps before returning to the MCP client.
281
+ */
282
+ export function toToolError(e: unknown, endpointLabel?: string): ToolError {
283
+ if (e instanceof ToolErrorCarrier) return e.toolError;
284
+ // A Zod input-validation failure is a CALLER error (e.g. limit above the max,
285
+ // a value outside an enum). Classify it as `invalid_input` with a readable
286
+ // field-level message — NEVER a generic `unknown` carrying Zod's raw JSON
287
+ // issue array (which an agent can't act on, and which mislabels a fixable
288
+ // input problem as a mysterious/possibly-transient failure).
289
+ if (e instanceof ZodError) {
290
+ return {
291
+ kind: "invalid_input",
292
+ message: `Invalid input${endpointLabel ? ` for ${endpointLabel}` : ""}: ${e.issues
293
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
294
+ .join("; ")}`,
295
+ retryable: false,
296
+ upstreamEndpoint: endpointLabel,
297
+ };
298
+ }
299
+ if (e instanceof Error) {
300
+ const msg = e.message;
301
+ // Common fetch timeout signature
302
+ if (e.name === "TimeoutError" || /timeout|aborted/i.test(msg)) {
303
+ return {
304
+ kind: "upstream_unavailable",
305
+ message: `${endpointLabel ?? "upstream"} timed out: ${msg}`,
306
+ retryable: true,
307
+ retryAfterSeconds: 30,
308
+ upstreamEndpoint: endpointLabel,
309
+ };
310
+ }
311
+ return {
312
+ kind: "unknown",
313
+ message: msg,
314
+ retryable: false,
315
+ upstreamEndpoint: endpointLabel,
316
+ };
317
+ }
318
+ return {
319
+ kind: "unknown",
320
+ message: String(e),
321
+ retryable: false,
322
+ upstreamEndpoint: endpointLabel,
323
+ };
324
+ }