@pipeworx/mcp-edgar 0.3.2 → 0.3.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.
package/src/index.ts CHANGED
@@ -1,11 +1,19 @@
1
1
  interface McpToolDefinition {
2
2
  name: string;
3
3
  description: string;
4
+ /** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
5
+ * description. Kept in step with shared/src/types.ts — scripts/lib/
6
+ * check-inlined-types.mjs reports drift at publish time. */
7
+ summary?: string;
4
8
  inputSchema: {
5
9
  type: 'object';
6
10
  properties: Record<string, unknown>;
7
11
  required?: string[];
12
+ anyOf?: Array<{ required: string[] }>;
13
+ oneOf?: Array<{ required: string[] }>;
14
+ allOf?: Array<{ required: string[] }>;
8
15
  };
16
+ outputSchema?: Record<string, unknown>;
9
17
  }
10
18
 
11
19
  interface McpToolExport {
@@ -16,6 +24,654 @@ interface McpToolExport {
16
24
  provider?: string;
17
25
  }
18
26
 
27
+ /**
28
+ * Was this failure OUR OWN web service? — the other half of `internal-db-class.ts`.
29
+ *
30
+ * fleet #1089 pulled failures from our own Postgres out of `upstream_down` by
31
+ * keying on the SQLSTATE inside PostgREST's four-key error envelope. That
32
+ * covered the majority and structurally could not cover the rest: the rest
33
+ * never reach Postgres, so they carry no SQLSTATE. What was left, measured over
34
+ * the 24h to 2026-09-02T15:00Z (fleet #1096):
35
+ *
36
+ * 5 pipeworx-catalog get_pack_tools Pipeworx catalog error: 522 — error code: 522
37
+ * 3 fleet fleet_list_open … upstream_down: Fleet task queue did not respond within 25s
38
+ *
39
+ * 521/522/523/526 are Cloudflare saying its edge could not reach an ORIGIN, and
40
+ * in both of those rows the origin is ours — `gateway.pipeworx.io` for the
41
+ * catalog pack (it self-fetches when the gateway hasn't injected a manifest),
42
+ * our own Supabase for fleet. There is no third party anywhere in either call.
43
+ * Same defect as #1089: our own outage filed under `upstream_down`, the one
44
+ * class that means "the source is unreachable and there is nothing for us to
45
+ * fix", which is why the problem-tools triage skips it.
46
+ *
47
+ * WHY NOT A WORDING RULE. The obvious fix is to match `fleet db error:` and
48
+ * `Pipeworx catalog error:` in classifyToolError. Each is emitted from exactly
49
+ * one site today, so it would work today. It would also rot the first time
50
+ * somebody rewords a label — silently, and in the direction of hiding our own
51
+ * outage, which is worse than the bug being fixed. Every prose rule in
52
+ * error-class.ts has needed widening as packs invented new wording (#409/#450/
53
+ * #584); that history is most of that file's comment budget.
54
+ *
55
+ * WHAT THIS KEYS ON INSTEAD: **the host the call actually reached.** A URL's
56
+ * hostname is a fact about the call, not a guess about its prose. Two
57
+ * consequences that a pack-level flag could not give us, and the reason the
58
+ * flag was rejected:
59
+ *
60
+ * - It describes the CALL, not the pack. `govcon-intel` fans out to our own
61
+ * Supabase AND to genuine third parties; `court-listener` holds our cache
62
+ * in Supabase and fetches courtlistener.com. An `internallyHosted: true` on
63
+ * either pack would relabel a real third-party outage as ours — inventing
64
+ * work, which is the same class of error in the opposite direction.
65
+ * - It covers every future internal pack for free, instead of one declared
66
+ * slug at a time.
67
+ *
68
+ * WHY IT SURVIVES A REWORD. The marker below is not matched as a literal by two
69
+ * separate files. `markInternalOrigin()` writes it and `internalHostMetricsClass()`
70
+ * reads it, both from the single exported `INTERNAL_ORIGIN_MARKER` constant in
71
+ * this module — so changing the wording changes both sides in the same edit and
72
+ * cannot desynchronise them. The pack's own label (`fleet db error:`,
73
+ * `Pipeworx catalog error:`) is not read at all: reword it freely, the class is
74
+ * unaffected. That is the property `stripClassPrefix` lacked when it drifted
75
+ * from its own classifier three times and needed a CI gate to hold them
76
+ * together.
77
+ *
78
+ * WHERE THE 5xx TEST LIVES. `markInternalOrigin` is called from the places that
79
+ * hold the real `Response` — `httpError`/`httpErrorMessage` and the timeout
80
+ * branch of `fetchWithTimeout` in `shared/src/http.ts` — so "is this an
81
+ * availability failure" is decided from the actual status code, never re-derived
82
+ * by scraping a number out of a sentence. A 404 from our own registry for a slug
83
+ * that does not exist is a caller's bad argument and is deliberately NOT marked.
84
+ */
85
+
86
+ /**
87
+ * OUR OWN web service was unreachable — not an upstream, and never `upstream_down`.
88
+ *
89
+ * ONE value, not three, unlike `internal_db_*`. That split existed because a
90
+ * slow query, an exhausted pool and an unknown SQLSTATE have different owners
91
+ * and different fixes. Here there is only one story to tell — an origin we run
92
+ * did not answer the edge — and one owner. A bucket with no distinct owner per
93
+ * value is decoration; #724 is what happens when a class holds several
94
+ * situations, and inventing sub-values ahead of a reason to act on them
95
+ * differently is the same mistake with the sign flipped.
96
+ *
97
+ * METRICS ONLY, exactly like PLATFORM_KEY_ERROR_CLASS and the internal_db
98
+ * values. `classifyToolError` still answers `upstream_down` for the retry and
99
+ * hint paths, which only care whether retrying or a sibling tool might work —
100
+ * and it might. Nothing a caller sees or is charged changes here.
101
+ *
102
+ * READ SIDE: this value is in BROKEN_TOOL_CLASSES, FAULT_CLASSES and
103
+ * ALL_ERROR_CLASSES in `workers/registry-api/src/index.ts`. All three, or it
104
+ * lands on no dashboard — fleet #721 is the warning, where the #719 split
105
+ * worked on the write side and was invisible for weeks.
106
+ */
107
+ const INTERNAL_SERVICE_UNREACHABLE_CLASS = 'internal_service_unreachable';
108
+
109
+ /**
110
+ * The token that carries "this origin is ours" from the call site to the
111
+ * classifier.
112
+ *
113
+ * Appended to the error message rather than attached to the Error object,
114
+ * because the object does not survive the trip: 275 packs return `{ error:
115
+ * string }` instead of throwing, the gateway reads `observedError` as a string,
116
+ * and the fleet pack rebuilds its error from a captured status + body across a
117
+ * retry loop. A property on an Error would be dropped by every one of those
118
+ * paths and the class would work in tests and vanish in production.
119
+ *
120
+ * WORDING IS LOAD-BEARING, same rule as labelAge's note in authority.ts. This
121
+ * string is appended to a pack's thrown Error message (shared/src/http.ts),
122
+ * and a thrown Error's message is exactly what the gateway hands back to the
123
+ * caller as `content[0].text` when nothing rewrites it (workers/gateway/src
124
+ * catches the throw and sets `rawResult.message = stripClassPrefix(error)`,
125
+ * which does not touch this suffix) — so the original wording,
126
+ * " [pipeworx-hosted origin — our own service, not a third party]", was not a
127
+ * theoretical leak: it shipped live on pipeworx-catalog's 522s, 7 times in 6
128
+ * hours on 2026-09-02 (see tests/golden-internal-service.test.ts), verbatim
129
+ * naming Pipeworx as the host. check:hosting-claims never caught it because it
130
+ * did not scan shared/ at all (task #2009). Reworded to describe the
131
+ * OBSERVATION (the origin did not answer) without a claim about who runs it —
132
+ * the identical fix labelAge got: drop the possessive, keep the fact.
133
+ */
134
+ const INTERNAL_ORIGIN_MARKER = ' [origin did not respond — retry before concluding the named source is down]';
135
+
136
+ /**
137
+ * Supabase's data plane for a project is `<ref>.supabase.co`, where the ref is
138
+ * exactly twenty lowercase letters (ours is `pqauisounztsgdgfkhke`).
139
+ *
140
+ * Matching the shape rather than listing the ref keeps this correct when we add
141
+ * a project — `supabaseEnv` on a pack entry already points some packs at a
142
+ * second one — while still excluding `status.supabase.co`, which is Supabase's
143
+ * own status page and emphatically not our database. Verified 2026-09-02 by
144
+ * `grep -rhoE '[a-z0-9-]+\.supabase\.(co|in)' mcps shared workers scripts`: the
145
+ * only real project ref anywhere in the tree is ours, the rest are doc
146
+ * placeholders (`abc`, `xyz`, `example`) which this pattern also excludes. Same
147
+ * finding internal-db-class.ts relies on for the PostgREST envelope being ours
148
+ * by construction.
149
+ */
150
+ const SUPABASE_PROJECT_HOST = /^[a-z]{20}\.supabase\.(co|in)$/;
151
+
152
+ /**
153
+ * Is this a host WE run?
154
+ *
155
+ * Deliberately NOT including `*.workers.dev`: plenty of third-party APIs are
156
+ * hosted on workers.dev, so the suffix says where something runs and not who
157
+ * owns it. Every internal call we actually make goes to a `pipeworx.io`
158
+ * hostname or to our Supabase project, both of which are ownership facts.
159
+ *
160
+ * `workers/gateway/src/provenance.ts`'s `OUR_HOSTS` answers the same
161
+ * question and DOES include `workers.dev` — a documented divergence
162
+ * (task #2051), not a bug to converge. That list decides what a response may
163
+ * cite as a data SOURCE, where a false negative (citing our own worker as an
164
+ * external source) is the hosting-disclosure leak this whole file exists to
165
+ * prevent, so it errs broad. This one decides who gets BLAMED for a 5xx in
166
+ * outage metrics read by on-call, where a false positive (crediting our own
167
+ * infra with a third party's outage) hides the real failure, so it errs
168
+ * narrow. Same suffix, opposite direction, because they are never called for
169
+ * the same reason.
170
+ *
171
+ * Returns false on anything unparseable rather than throwing — this runs inside
172
+ * an error path, and an error path that can itself throw turns a diagnosable
173
+ * failure into a mystery.
174
+ */
175
+ function isPipeworxOrigin(url: string | URL | undefined | null): boolean {
176
+ if (!url) return false;
177
+ let host: string;
178
+ try {
179
+ host = new URL(url instanceof URL ? url.href : url).hostname.toLowerCase();
180
+ } catch {
181
+ return false;
182
+ }
183
+ if (host === 'pipeworx.io' || host.endsWith('.pipeworx.io')) return true;
184
+ return SUPABASE_PROJECT_HOST.test(host);
185
+ }
186
+
187
+ /**
188
+ * Append the marker when this failure was OUR origin failing to answer.
189
+ *
190
+ * `status` is the HTTP status when there is one, and omitted for a timeout —
191
+ * where there is no response at all, and "the origin did not answer" is the
192
+ * whole observation. Statuses below 500 are left alone: a 404 from our own
193
+ * registry for a slug that does not exist is the caller's argument, not our
194
+ * outage, and marking it would put ordinary 404s on the incident dashboard.
195
+ *
196
+ * Idempotent, so a message that is wrapped and re-marked on the way up (the
197
+ * fleet pack's retry loop re-throws through two layers) carries the marker once.
198
+ */
199
+ function markInternalOrigin(
200
+ message: string,
201
+ url: string | URL | undefined | null,
202
+ status?: number,
203
+ ): string {
204
+ if (status !== undefined && status < 500) return message;
205
+ if (!isPipeworxOrigin(url)) return message;
206
+ if (message.includes(INTERNAL_ORIGIN_MARKER)) return message;
207
+ return message + INTERNAL_ORIGIN_MARKER;
208
+ }
209
+
210
+ /**
211
+ * Which blob4 value a failure from our own web services books as, or undefined
212
+ * if this is not one.
213
+ *
214
+ * Ordered AFTER `internalDbMetricsClass` at the call site: a PostgREST envelope
215
+ * from our own Supabase is a strictly more specific statement about the same
216
+ * row (which of our services, and why), and the two cannot disagree about
217
+ * whether the failure is ours.
218
+ */
219
+ function internalHostMetricsClass(error: string): string | undefined {
220
+ return error.includes(INTERNAL_ORIGIN_MARKER) ? INTERNAL_SERVICE_UNREACHABLE_CLASS : undefined;
221
+ }
222
+
223
+
224
+ /**
225
+ * One place to turn a failed `fetch` into an error a caller can act on.
226
+ *
227
+ * Nearly every pack was written the same way:
228
+ *
229
+ * if (!res.ok) throw new Error(`Unsplash: ${res.status}`);
230
+ *
231
+ * which discards the response body — and the body is usually where the upstream
232
+ * says what was actually wrong ("**symbol** not found: GBP", "parameter `year`
233
+ * out of range", "unknown taxonomy id"). The caller gets a number, cannot
234
+ * self-correct, and retries the same broken call. A 2026-07-31 sweep found this
235
+ * shape in 481 of 1,400 packs, 47 of them PLATFORM-keyed.
236
+ *
237
+ * It also hides bugs one level down. Two of the first three packs audited had a
238
+ * second defect that only existed because of this line: unsplash's rate-limit
239
+ * branch sat BELOW a catch-all and was unreachable, and bea-gov parsed
240
+ * `BEAAPI.Error.APIErrorDescription` below a `!res.ok` throw that made the
241
+ * parsing dead code for every non-200.
242
+ *
243
+ * DELIBERATELY NOT A CLASSIFIER. It does not add `user_error:` /
244
+ * `upstream_down:` prefixes. Those decide which tier a failure lands in, and the
245
+ * `error` tier is what the daily problem-tools list is built from — it means
246
+ * "Pipeworx has a defect". A 400 is genuinely ambiguous: often a caller's bad
247
+ * argument, but sometimes a query WE built wrong (ted-eu comma-joined its CPV
248
+ * values into something TED rejected, and that bug was found only because it sat
249
+ * in `error`). Blanket-classifying 400s as caller mistakes would have hidden it.
250
+ * A pack that KNOWS which it is should keep saying so explicitly; this helper is
251
+ * for the 481 that say nothing at all.
252
+ */
253
+
254
+ /** Longest upstream explanation we'll pass through. Enough for a real message,
255
+ * short enough that an HTML page or a stack trace can't swamp the error. */
256
+
257
+ const MAX_DETAIL = 300;
258
+
259
+ /**
260
+ * Default bound for `fetchWithTimeout` when a pack doesn't state its own.
261
+ *
262
+ * 25s mirrors the number `epo-ops` landed on after measuring the real failure:
263
+ * a degraded upstream that doesn't error, it just never answers, and a Worker
264
+ * sits in `await fetch()` until ITS OWN execution budget kills the request —
265
+ * which can take minutes, not seconds (epo_ops_search_patents measured 4-8
266
+ * MINUTE hangs before this existed). 25s is short enough that a caller gets a
267
+ * fast, actionable error instead of holding the connection, and long enough
268
+ * that it doesn't false-trip on a merely-slow-but-alive upstream.
269
+ */
270
+ const DEFAULT_FETCH_TIMEOUT_MS = 25_000;
271
+
272
+ /**
273
+ * Read the body of a failed response and fold it into a throwable Error.
274
+ *
275
+ * Usage — note the `await`, which is the one thing that makes this a mechanical
276
+ * change rather than a drop-in:
277
+ *
278
+ * if (!res.ok) throw await httpError(res, 'Unsplash');
279
+ *
280
+ * Safe to call on any non-ok response: a body that is missing, empty, unreadable
281
+ * or HTML degrades to exactly the old `Name: 404` string rather than throwing
282
+ * something new from inside the error path.
283
+ */
284
+ async function httpError(res: Response, name: string): Promise<Error> {
285
+ return new Error(await httpErrorMessage(res, name));
286
+ }
287
+
288
+ /** The message text without constructing an Error — for packs that need to wrap
289
+ * it in their own envelope or add an explicit classification prefix. */
290
+ async function httpErrorMessage(res: Response, name: string): Promise<string> {
291
+ // The one place a 5xx from a host WE run gets stamped as ours. `res.url` is
292
+ // the URL the fetch actually resolved to (after redirects), so this is a fact
293
+ // about the call rather than a guess from the `name` the pack passed in —
294
+ // reword that label freely, the class does not move. See
295
+ // internal-host-class.ts; no-op for every third-party upstream, which is why
296
+ // this touches 481 packs' error text and changes none of it.
297
+ return markInternalOrigin(
298
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
299
+ res.url,
300
+ res.status,
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Just the upstream's own explanation — no name, no status.
306
+ *
307
+ * For a pack that has already said both in its own sentence. epo-ops reads
308
+ * `EPO rejected this search as too large (HTTP 413) — ${httpErrorMessage(…)}`,
309
+ * which rendered as `… (HTTP 413) — EPO: 413.` once the XML detail was being
310
+ * dropped: the upstream named twice, the status twice, and the one thing EPO
311
+ * actually said ("Not enough characters before truncation character") nowhere
312
+ * (fleet #712). Returns '' when the body carries nothing readable, so a caller
313
+ * can fall back to its own wording.
314
+ */
315
+ async function upstreamDetail(res: Response): Promise<string> {
316
+ return readDetail(res);
317
+ }
318
+
319
+ /**
320
+ * Read a SUCCESSFUL response as JSON, failing loudly when it isn't JSON.
321
+ *
322
+ * `httpError` above only ever runs on `!res.ok`, which leaves the nastier half
323
+ * of the problem unhandled: an upstream that answers **HTTP 200 with an HTML
324
+ * page**. A bot wall, a login redirect, a maintenance interstitial and a CDN
325
+ * error page are all 200s, so `res.ok` is true, and `res.json()` then throws
326
+ * `Unexpected token '<', "<!DOCTYPE "... is not valid JSON`.
327
+ *
328
+ * That string is the problem. It names no upstream, carries no status, and
329
+ * reads like a parser bug in Pipeworx — so it lands in the `error` tier, which
330
+ * means "we have a defect", and the caller is told nothing they can act on.
331
+ * data.govt.nz sat dead behind an Imperva challenge this way and every
332
+ * status-code health check we own reported it green (7889a845). A zero-length
333
+ * body has the same shape: `Unexpected end of JSON input`, seen this week on
334
+ * uk-gazette (83% of external calls) and census.
335
+ *
336
+ * UNLIKE `httpError`, this one DOES classify, and the asymmetry is deliberate.
337
+ * A 400 is genuinely ambiguous — often the caller's bad argument, sometimes a
338
+ * query we built wrong — so blanket-classifying it would hide our own bugs.
339
+ * There is no such ambiguity here: **no argument a caller can pass makes a JSON
340
+ * API return an HTML page.** It is always the upstream, so `upstream_down:` is
341
+ * a statement of fact rather than a guess, and it keeps these out of the
342
+ * problem-tools list where they crowd out real defects.
343
+ *
344
+ * const data = await parseJson<Feed>(res, 'UK Gazette');
345
+ *
346
+ * Call it only after the `!res.ok` check — on a failed response you want
347
+ * `httpError`, which mines the body for the upstream's own explanation.
348
+ */
349
+ async function parseJson<T>(res: Response, name: string): Promise<T> {
350
+ let raw: string;
351
+ try {
352
+ raw = await res.text();
353
+ } catch {
354
+ throw new Error(
355
+ `upstream_down: ${name} returned a body that could not be read (HTTP ${res.status}). ` +
356
+ 'The connection most likely dropped mid-response; retrying is reasonable.',
357
+ );
358
+ }
359
+
360
+ const type = res.headers.get('content-type') ?? 'no content-type';
361
+
362
+ if (!raw.trim()) {
363
+ throw new Error(
364
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected (${type}). ` +
365
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
366
+ );
367
+ }
368
+
369
+ // Checked before parsing rather than in the catch, because knowing it is
370
+ // markup is what turns "we failed to parse something" into "they served a
371
+ // web page" — the second is diagnosable, the first is not.
372
+ const head = raw.slice(0, 200).trimStart().toLowerCase();
373
+ if (head.startsWith('<!doctype') || head.startsWith('<html') || head.startsWith('<?xml')) {
374
+ const kind = head.startsWith('<?xml') ? 'an XML document' : 'an HTML page';
375
+ // The summary, not the source. Pasting the first 120 characters of a web
376
+ // page handed the agent `<!DOCTYPE html><html lang="en"…` — the same leak
377
+ // this branch exists to describe (fleet #712).
378
+ throw new Error(
379
+ `upstream_down: ${name} answered HTTP ${res.status} with ${kind} instead of JSON (${type}). ` +
380
+ 'That is typically a bot wall, a login redirect or a maintenance page — it is returned as a SUCCESS, ' +
381
+ `so status-code health checks read it as fine. No argument change will get past it. ` +
382
+ `The page says: ${summarizeErrorBody(raw) || 'nothing readable'}`,
383
+ );
384
+ }
385
+
386
+ try {
387
+ return JSON.parse(raw) as T;
388
+ } catch {
389
+ throw new Error(
390
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON (${type}). ` +
391
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
392
+ );
393
+ }
394
+ }
395
+
396
+ /**
397
+ * `fetch`, but bounded — the fix for a systemic gap found 2026-08-30: a grep
398
+ * audit of every pack's `mcps/*\/src/index.ts` found 1,339 of ~1,500 call
399
+ * `fetch()` with NO timeout guard anywhere in the file. Two of those
400
+ * (epo-ops, statcan) were confirmed live-hanging for 4-8 minutes before this
401
+ * existed — every unguarded call carries the same risk, just unconfirmed.
402
+ *
403
+ * Mirrors the `epoFetch` wrapper `mcps/epo-ops/src/index.ts` shipped first:
404
+ * bound the request with `AbortSignal.timeout`, and on a timeout/abort throw
405
+ * an `upstream_down:` error that names the upstream and the bound rather than
406
+ * letting the raw `TimeoutError`/`AbortError` (which names neither) propagate.
407
+ * `upstream_down:` is deliberate, same reasoning as `parseJson` above — no
408
+ * argument a caller passes can make an upstream hang, so it is always the
409
+ * upstream's fault, and marking it that way keeps a slow API off the
410
+ * problem-tools list where it would crowd out our own defects.
411
+ *
412
+ * Usage — a mechanical swap for a bare `fetch(url, init)`:
413
+ *
414
+ * const res = await fetchWithTimeout(url, init, 'Some API');
415
+ *
416
+ * Pass `timeoutMs` as a fourth argument to override the default for a pack
417
+ * with a known-slower upstream; the label should be the same short name you'd
418
+ * pass to `httpError`/`httpErrorMessage` for that call.
419
+ */
420
+ async function fetchWithTimeout(
421
+ url: string | URL,
422
+ init: RequestInit = {},
423
+ name: string,
424
+ timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
425
+ ): Promise<Response> {
426
+ try {
427
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
428
+ } catch (err) {
429
+ if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
430
+ // States the OBSERVATION (no response in N seconds), not a diagnosis.
431
+ // "appears to be degraded" is an inference about the vendor that we have
432
+ // not checked, and it is wrong in a way that misdirects whoever reads it:
433
+ // a timeout from a Worker can equally mean OUR egress is blocked.
434
+ //
435
+ // Measured today (2026-09-01, fleet #1047): every call to
436
+ // mainnet.base.org failed from the x402 facilitator while the identical
437
+ // request from a laptop returned 200. Base was entirely healthy; the
438
+ // public RPC refuses Cloudflare Worker egress. Had this message fired
439
+ // there it would have blamed Base by name, and the next person would have
440
+ // waited for a vendor outage to clear that did not exist.
441
+ // A timeout has no status to test — there is no response at all — so
442
+ // `markInternalOrigin` is called without one: an origin we run that never
443
+ // answered is an availability failure by definition. This is the half of
444
+ // fleet #1096 with neither a SQLSTATE nor a status code to key on.
445
+ throw new Error(
446
+ markInternalOrigin(
447
+ `upstream_down: ${name} did not respond within ${timeoutMs / 1000}s. ` +
448
+ `That can be ${name} being slow or down, or this environment being unable to reach it ` +
449
+ `(some hosts refuse datacenter/Worker egress) — retry shortly, and check reachability ` +
450
+ `from elsewhere before concluding ${name} is down.`,
451
+ url,
452
+ ),
453
+ );
454
+ }
455
+ // Fleet #2382. Everything that isn't a timeout/abort here is a genuine
456
+ // NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
457
+ // Cloudflare's own "Network connection lost." — meaning `fetch()` itself
458
+ // threw and no HTTP response of any kind was ever received. Until this fix
459
+ // that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
460
+ // (or the Workers-runtime equivalent) names no upstream, carries no class
461
+ // token, and reads exactly like a defect in OUR code — because it says
462
+ // nothing about the call at all. It landed in `error`, the tier that means
463
+ // "Pipeworx has a defect", for every one of the (at the time of writing)
464
+ // ~470 packs that call this helper directly with no wrapper of their own.
465
+ //
466
+ // `dexscreener` hit this independently (fleet #1579) and fixed it with a
467
+ // bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
468
+ // but only covers one pack; every other caller of this shared helper still
469
+ // leaked the raw exception. Moving the same fix HERE — the one place that
470
+ // already carries the timeout case — covers every pack that uses
471
+ // `fetchWithTimeout` without a wrapper, for free, and without widening
472
+ // `classifyToolError`'s regex list: the fix is giving the message a proper
473
+ // `upstream_down:` token at the point the two facts (no response was ever
474
+ // received, and which host we were trying to reach) are actually in hand,
475
+ // not teaching the classifier to guess from prose after the fact.
476
+ //
477
+ // Safe on the same grounds as the timeout branch above: no argument a
478
+ // caller passes can make `fetch()` itself throw a connection-level error,
479
+ // so this is always an availability failure, never a caller mistake. Same
480
+ // `markInternalOrigin` treatment — an origin we run that never answered is
481
+ // still ours, not a third party's outage.
482
+ const raw = err instanceof Error ? err.message : String(err);
483
+ throw new Error(
484
+ markInternalOrigin(
485
+ `upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
486
+ `No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
487
+ 'are valid — do not re-check them on the strength of this error. Retry shortly.',
488
+ url,
489
+ ),
490
+ );
491
+ }
492
+ }
493
+
494
+ function detailSuffix(detail: string): string {
495
+ return detail ? ` — ${detail}` : '';
496
+ }
497
+
498
+ async function readDetail(res: Response): Promise<string> {
499
+ let raw: string;
500
+ try {
501
+ raw = await res.text();
502
+ } catch {
503
+ // Body already consumed, or the connection died mid-read. The status alone
504
+ // is still worth throwing — never let the error path throw its own error.
505
+ return '';
506
+ }
507
+ return summarizeErrorBody(raw);
508
+ }
509
+
510
+ /**
511
+ * Turn ANY error body — JSON, HTML, XML or plain text — into one short phrase
512
+ * that never contains markup.
513
+ *
514
+ * This used to just drop an HTML or XML body on the floor, on the reasoning
515
+ * that markup crowds out the status. That was half right. Dropping it loses the
516
+ * one sentence a caller could have acted on: an `Access Denied` title, an SDMX
517
+ * `<message:Error>` text, an OPS fault string. A 2026-08-30 support sweep
518
+ * measured 13 of 291 caller-facing error rows carrying a raw page or document
519
+ * verbatim, across 11 packs, and in every one of them the useful content —
520
+ * "Access Denied", "Invalid country code", "SCRAPE_TIMEOUT" — was in there,
521
+ * buried in markup the agent had to parse out of a string (fleet #712).
522
+ *
523
+ * So: extract the meaning, discard the markup. The output is passed through
524
+ * `stripMarkup` unconditionally, which is what lets `check:error-body-leak`
525
+ * assert mechanically that no caller-facing message can contain `<?xml`,
526
+ * `<!DOCTYPE` or `<html`.
527
+ */
528
+ function summarizeErrorBody(raw: string): string {
529
+ if (!raw || !raw.trim()) return '';
530
+
531
+ const head = raw.slice(0, 400).trimStart().toLowerCase();
532
+
533
+ // An HTML error page (Cloudflare interstitial, nginx default, a login
534
+ // redirect) says what it is in its <title>, and almost nowhere else.
535
+ if (head.startsWith('<!doctype') || head.startsWith('<html')) {
536
+ const title = htmlTitle(raw);
537
+ return title
538
+ ? `${title} (upstream returned an HTML error page, not an API response)`
539
+ : 'upstream returned an HTML error page, not an API response';
540
+ }
541
+
542
+ // XML fault documents — EPO OPS, SDMX (`<message:Error>`), SOAP faults. The
543
+ // human sentence sits in a child element whose tag name says what it is.
544
+ if (head.startsWith('<?xml') || head.startsWith('<')) {
545
+ const fault = xmlFaultText(raw);
546
+ return fault
547
+ ? `${stripMarkup(fault).slice(0, MAX_DETAIL)} (from the upstream's XML error document)`
548
+ : 'upstream returned an XML error document with no readable message';
549
+ }
550
+
551
+ // Most JSON error bodies bury one human sentence among ids and echoed request
552
+ // params. Prefer that sentence; fall back to the whole body when the shape is
553
+ // unfamiliar, since an unfamiliar shape is exactly when we can least afford to
554
+ // guess wrong and show nothing.
555
+ const fromJson = messageFromJson(raw);
556
+ return stripMarkup(fromJson ?? raw).slice(0, MAX_DETAIL);
557
+ }
558
+
559
+ /** The `<title>` of an HTML error page, or its first `<h1>` — the two places a
560
+ * bot wall, a 502 and an "Access Denied" all state what happened. */
561
+ function htmlTitle(raw: string): string | null {
562
+ const head = raw.slice(0, 4000);
563
+ for (const re of [/<title[^>]*>([\s\S]*?)<\/title>/i, /<h1[^>]*>([\s\S]*?)<\/h1>/i]) {
564
+ const m = re.exec(head);
565
+ const text = m ? stripMarkup(m[1]) : '';
566
+ if (text) return text.slice(0, 160);
567
+ }
568
+ return null;
569
+ }
570
+
571
+ /** Tag names that carry the explanation in an XML fault document, namespace
572
+ * prefix optional (`<message:Error>`, `<com:Text>`, `<faultstring>`). */
573
+ const XML_FAULT_TAG_RE =
574
+ /<(?:[A-Za-z0-9_.-]+:)?(?:text|message|description|faultstring|reason|detail|title|errormessage|error)\b[^>]*>([^<]{2,400})</i;
575
+
576
+ function xmlFaultText(raw: string): string | null {
577
+ const head = raw.slice(0, 8000);
578
+ const tagged = XML_FAULT_TAG_RE.exec(head);
579
+ if (tagged && tagged[1].trim()) return tagged[1];
580
+
581
+ // Nothing conventionally named — take the longest text node instead. A fault
582
+ // document with one sentence in an oddly named element is still readable;
583
+ // returning nothing at all is not.
584
+ let best = '';
585
+ for (const m of head.matchAll(/>([^<>]{8,400})</g)) {
586
+ const text = m[1].trim();
587
+ if (text.length > best.length) best = text;
588
+ }
589
+ return best || null;
590
+ }
591
+
592
+ /**
593
+ * Remove every tag and stray angle bracket, then collapse whitespace.
594
+ *
595
+ * Applied to everything on the way out, including the JSON and plain-text
596
+ * paths, because an upstream is free to embed markup in a JSON string field —
597
+ * and a leak is a leak regardless of which branch produced it.
598
+ */
599
+ function stripMarkup(s: string): string {
600
+ return collapse(decodeEntities$shared(s.replace(/<[^>]*>/g, ' ')).replace(/[<>]/g, ' '));
601
+ }
602
+
603
+ /** The handful of entities that show up in error-page titles. Decoded AFTER
604
+ * tags are stripped and BEFORE the angle-bracket sweep, so `&lt;script&gt;`
605
+ * in a title cannot decode into markup that survives — EMBL-EBI's ChEMBL 500
606
+ * page renders as `500 Internal Server Error &lt; EMBL-EBI` otherwise. */
607
+ function decodeEntities$shared(s: string): string {
608
+ return s
609
+ .replace(/&(?:amp|#0*38);/gi, '&')
610
+ .replace(/&(?:lt|#0*60);/gi, '<')
611
+ .replace(/&(?:gt|#0*62);/gi, '>')
612
+ .replace(/&(?:quot|#0*34);/gi, '"')
613
+ .replace(/&(?:#0*39|apos|#x0*27);/gi, "'")
614
+ .replace(/&nbsp;/gi, ' ');
615
+ }
616
+
617
+ /** The conventional "what went wrong" field, under any of the names upstreams
618
+ * actually use. Checked in order; first non-empty string wins. */
619
+ const MESSAGE_KEYS = [
620
+ 'message', 'error_message', 'errorMessage', 'detail', 'details',
621
+ 'description', 'error_description', 'reason', 'title', 'fault',
622
+ ];
623
+
624
+ function messageFromJson(raw: string): string | null {
625
+ let parsed: unknown;
626
+ try {
627
+ parsed = JSON.parse(raw);
628
+ } catch {
629
+ return null;
630
+ }
631
+ return pickMessage(parsed, 0);
632
+ }
633
+
634
+ function pickMessage(node: unknown, depth: number): string | null {
635
+ // Two levels covers `{error: {message}}` and `{errors: [{detail}]}`, the two
636
+ // shapes that account for nearly all of them, without walking a large payload.
637
+ if (depth > 2 || node == null) return null;
638
+
639
+ if (typeof node === 'string') return node.trim() || null;
640
+
641
+ if (Array.isArray(node)) {
642
+ for (const item of node) {
643
+ const found = pickMessage(item, depth + 1);
644
+ if (found) return found;
645
+ }
646
+ return null;
647
+ }
648
+
649
+ if (typeof node !== 'object') return null;
650
+ const obj = node as Record<string, unknown>;
651
+
652
+ for (const key of MESSAGE_KEYS) {
653
+ const v = obj[key];
654
+ if (typeof v === 'string' && v.trim()) return v.trim();
655
+ }
656
+ // `{error: …}` where error is itself an object or a string — the single most
657
+ // common wrapper, so it is worth descending into by name rather than scanning
658
+ // every key and risking picking up an echoed request parameter.
659
+ for (const key of ['error', 'errors', 'fault', 'Error', 'data']) {
660
+ if (key in obj) {
661
+ const found = pickMessage(obj[key], depth + 1);
662
+ if (found) return found;
663
+ }
664
+ }
665
+ return null;
666
+ }
667
+
668
+ /** Errors are read in a single line of log output; newlines and runs of
669
+ * whitespace make a multi-line body unreadable there. */
670
+ function collapse(s: string): string {
671
+ return s.replace(/\s+/g, ' ').trim();
672
+ }
673
+
674
+
19
675
  // Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
20
676
  // imports — so publish-pack.sh can inline it into standalone pack builds the same
21
677
  // way it inlines the McpToolExport type.
@@ -67,7 +723,7 @@ function rankMatches<T>(
67
723
  return scored.map(({ item, kind, score }) => ({ item, kind, score }));
68
724
  }
69
725
 
70
- interface SecTickerRow { cik_str: number; ticker: string; title: string }
726
+ interface SecTickerRow$shared { cik_str: number; ticker: string; title: string }
71
727
 
72
728
  interface SecEntity {
73
729
  ticker: string;
@@ -98,7 +754,7 @@ async function resolveSecEntity(
98
754
  const doFetch = opts.fetchImpl ?? fetch;
99
755
  const res = await doFetch(SEC_TICKERS_URL, { headers: opts.headers });
100
756
  if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
101
- const data = (await res.json()) as Record<string, SecTickerRow>;
757
+ const data = (await res.json()) as Record<string, SecTickerRow$shared>;
102
758
  const rows = Object.values(data);
103
759
 
104
760
  // 1) Exact ticker match — the common, unambiguous case.
@@ -120,10 +776,10 @@ async function resolveSecEntity(
120
776
  return best;
121
777
  }
122
778
 
123
- throw new Error(`No SEC company matches "${query}". Pass a ticker like "AAPL" or a company name like "Apple Inc.".`);
779
+ throw new Error(`No SEC company matches "${query}". Pass a US-listed ticker ("AAPL") or the exact listed-company name ("Apple Inc."). If this is a clinical-trial sponsor, an operating subsidiary (e.g. "Merck Sharp & Dohme" → Merck & Co), or a foreign/private entity, call sponsor_to_filer({sponsor}) instead — it resolves subsidiaries to the listed parent and honestly reports when no US-listed filer exists.`);
124
780
  }
125
781
 
126
- function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEntity {
782
+ function toEntity(r: SecTickerRow$shared, matched_by: 'ticker' | 'company_name'): SecEntity {
127
783
  return {
128
784
  ticker: r.ticker,
129
785
  cik: String(r.cik_str),
@@ -133,7 +789,320 @@ function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEn
133
789
  };
134
790
  }
135
791
 
792
+ const GENERIC_CORP_WORDS = new Set([
793
+ 'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED',
794
+ 'LLC', 'LP', 'PLC', 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
795
+ 'GROUP', 'HOLDINGS', 'HOLDING', 'AND', 'OF', 'US', 'USA', 'INTERNATIONAL',
796
+ 'GLOBAL',
797
+ ]);
798
+
799
+ /**
800
+ * Split a corporate/organization name into its SIGNIFICANT tokens — words
801
+ * that aren't generic corporate boilerplate (Inc, Co, Ltd, Group, ...) or
802
+ * punctuation — sorted LONGEST FIRST. Built for cross-registry name joins
803
+ * where the two registries anchor on different words of the same name: SEC
804
+ * lists Eli Lilly as "ELI LILLY & Co", but Drugs@FDA's sponsor_name field
805
+ * uses "LILLY" — the longer, more distinctive token, not the first one
806
+ * ("ELI" alone is short and matches too loosely). Pure (no I/O); callers
807
+ * typically try tokens in order until one call to their OWN registry
808
+ * returns a result.
809
+ */
810
+ function significantNameTokens(name: string): string[] {
811
+ const tokens = name
812
+ .toUpperCase()
813
+ .replace(/[.,&/()-]/g, ' ')
814
+ .split(/\s+/)
815
+ .filter((t) => t.length > 1 && !GENERIC_CORP_WORDS.has(t));
816
+ return [...new Set(tokens)].sort((a, b) => b.length - a.length);
817
+ }
818
+
819
+
820
+ /**
821
+ * Pick "the most recent annual value" out of SEC companyfacts XBRL data —
822
+ * the one operation every company-facts tool does, and the one that shipped
823
+ * wrong twice (sec + edgar packs, identical code) for the largest filers.
824
+ *
825
+ * Two traps in the raw companyfacts feed, both confirmed live 2026-08-28
826
+ * (fleet #594):
827
+ *
828
+ * 1. `fy` is the fiscal year of the FILING, not of the reported period. A
829
+ * 10-K carries two or three prior years as comparatives, and every one of
830
+ * them is stamped with the filing's fy. Amazon's FY2020 net income
831
+ * ($21.3B, period 2020-01-01..2020-12-31) appears with fy=2022 because it
832
+ * was restated in the FY2022 10-K — and Amazon's actual FY2022 figure is a
833
+ * $2.7B LOSS. Sorting by fy therefore mislabels the year AND can pick a
834
+ * comparative over the current period. The period is `start`/`end`; the
835
+ * year has to be derived from `end`.
836
+ *
837
+ * 2. `frame` is assigned to the LAST-FILED fact for a calendar period, not to
838
+ * the 10-K that first reported it. Since proxies (DEF 14A) began carrying
839
+ * XBRL, Amazon's CY2021..CY2025 annual frames all sit on the 2026 proxy,
840
+ * so a `form === '10-K' && frame` filter sees no 10-K annual fact newer
841
+ * than CY2020 and confidently returns that.
842
+ *
843
+ * And one presentation trap: a concept the filer has RETIRED (Microsoft's
844
+ * `Revenues` stops at FY2010 — it moved to
845
+ * RevenueFromContractWithCustomerExcludingAssessedTax under ASC 606) is
846
+ * accurate per-concept but wrong the moment it is presented under
847
+ * "most recent" beside concepts that are genuinely current. The fix is
848
+ * labelling and precedence, not deletion: a caller building a long series
849
+ * legitimately wants the retired concept, so it stays — flagged `stale`,
850
+ * sorted last, with the current concept surfaced in `latest_annual`.
851
+ *
852
+ * Kept dependency-free on purpose: publish-pack.sh inlines this file into the
853
+ * standalone npm build of any pack that imports it.
854
+ */
855
+
856
+ interface XbrlFactEntry {
857
+ start?: string;
858
+ end: string;
859
+ val: number;
860
+ accn?: string;
861
+ fy?: number;
862
+ fp?: string;
863
+ form?: string;
864
+ filed?: string;
865
+ frame?: string;
866
+ }
867
+
868
+ interface XbrlConcept {
869
+ label: string;
870
+ description?: string;
871
+ units: Record<string, XbrlFactEntry[]>;
872
+ }
873
+
874
+ type XbrlConceptMap = Record<string, XbrlConcept>;
875
+
876
+ interface AnnualValue {
877
+ /** Fiscal year the period ENDS in (derived from period_end — see fiscalYearOf). */
878
+ year: number;
879
+ value: number;
880
+ filed: string;
881
+ /** Present for duration concepts (income statement); absent for instants (balance sheet). */
882
+ period_start?: string;
883
+ period_end: string;
884
+ form: string;
885
+ unit: string;
886
+ concept: string;
887
+ }
888
+
889
+ interface KeyFinancial {
890
+ label: string;
891
+ most_recent_annual: Omit<AnnualValue, 'concept'> | null;
892
+ /**
893
+ * true when this concept's latest annual value ends before the filer's latest
894
+ * annual report period — i.e. the filer stopped reporting it. The number is
895
+ * real history, not the company's current figure.
896
+ */
897
+ stale: boolean;
898
+ stale_note?: string;
899
+ }
900
+
901
+ /** Annual-report forms. 10-KT (transition period) is deliberately excluded. */
902
+ const ANNUAL_FORMS$shared = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
903
+ /** Preferred unit per concept, in order. Anything else falls back to the first unit present. */
904
+ const UNIT_PREFERENCE = ['USD', 'USD/shares', 'shares', 'pure'];
905
+ const DAY_MS = 86_400_000;
906
+
907
+ /**
908
+ * Fiscal year a period belongs to, by the near-universal convention of naming
909
+ * the year the period ENDS in (Apple FY2025 ends 2025-09-27, Walmart FY2026
910
+ * ends 2026-01-31). The one exception handled: 52/53-week years that end in
911
+ * the first days of January belong to the prior year (a year ending
912
+ * 2027-01-02 is fiscal 2026).
913
+ */
914
+ function fiscalYearOf(periodEnd: string): number {
915
+ const y = Number(periodEnd.slice(0, 4));
916
+ const m = Number(periodEnd.slice(5, 7));
917
+ const d = Number(periodEnd.slice(8, 10));
918
+ return m === 1 && d <= 7 ? y - 1 : y;
919
+ }
920
+
921
+ function preferredUnit(units: Record<string, XbrlFactEntry[]>): string | null {
922
+ for (const u of UNIT_PREFERENCE) if (units[u]?.length) return u;
923
+ const first = Object.keys(units).find((u) => units[u]?.length);
924
+ return first ?? null;
925
+ }
926
+
927
+ function isAnnualPeriod(e: XbrlFactEntry): boolean {
928
+ if (!e.start) return true; // instant (balance-sheet) fact
929
+ const days = (Date.parse(e.end) - Date.parse(e.start)) / DAY_MS;
930
+ return days >= 340 && days <= 380; // 52/53-week years included, quarters/YTD excluded
931
+ }
932
+
933
+ /**
934
+ * The most recent annual value for one concept: latest period_end among
935
+ * annual-report facts, ties broken by latest filed (a restated figure from a
936
+ * later 10-K wins over the original).
937
+ */
938
+ function latestAnnual(concepts: XbrlConceptMap, concept: string): AnnualValue | null {
939
+ const fact = concepts[concept];
940
+ if (!fact?.units) return null;
941
+ const unit = preferredUnit(fact.units);
942
+ if (!unit) return null;
943
+ let best: XbrlFactEntry | null = null;
944
+ for (const e of fact.units[unit]) {
945
+ if (!e.form || !ANNUAL_FORMS$shared.has(e.form) || !e.end || typeof e.val !== 'number') continue;
946
+ if (!isAnnualPeriod(e)) continue;
947
+ if (
948
+ !best ||
949
+ e.end > best.end ||
950
+ (e.end === best.end && (e.filed ?? '') > (best.filed ?? ''))
951
+ ) best = e;
952
+ }
953
+ if (!best) return null;
954
+ return {
955
+ year: fiscalYearOf(best.end),
956
+ value: best.val,
957
+ filed: best.filed ?? '',
958
+ ...(best.start ? { period_start: best.start } : {}),
959
+ period_end: best.end,
960
+ form: best.form ?? '',
961
+ unit,
962
+ concept,
963
+ };
964
+ }
965
+
966
+ /**
967
+ * Among alternative concepts for the same line item (filers migrate tags —
968
+ * Revenues → RevenueFromContractWithCustomerExcludingAssessedTax under ASC
969
+ * 606), the one with the most recent period wins; ties go to list order.
970
+ */
971
+ function freshestOf(concepts: XbrlConceptMap, candidates: string[]): AnnualValue | null {
972
+ let best: AnnualValue | null = null;
973
+ for (const c of candidates) {
974
+ const hit = latestAnnual(concepts, c);
975
+ if (hit && (!best || hit.period_end > best.period_end)) best = hit;
976
+ }
977
+ return best;
978
+ }
979
+
980
+ /** Canonical line items → the us-gaap concepts filers use for them, most-current-first. */
981
+ const LINE_ITEM_CONCEPTS: Record<string, string[]> = {
982
+ revenue: [
983
+ 'RevenueFromContractWithCustomerExcludingAssessedTax',
984
+ 'Revenues',
985
+ 'SalesRevenueNet',
986
+ 'RevenueFromContractWithCustomerIncludingAssessedTax',
987
+ 'SalesRevenueGoodsNet',
988
+ 'RevenuesNetOfInterestExpense',
989
+ ],
990
+ net_income: ['NetIncomeLoss', 'ProfitLoss'],
991
+ operating_income: ['OperatingIncomeLoss'],
992
+ gross_profit: ['GrossProfit'],
993
+ total_assets: ['Assets'],
994
+ total_liabilities: ['Liabilities'],
995
+ stockholders_equity: [
996
+ 'StockholdersEquity',
997
+ 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest',
998
+ ],
999
+ cash_and_equivalents: [
1000
+ 'CashAndCashEquivalentsAtCarryingValue',
1001
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
1002
+ 'Cash',
1003
+ ],
1004
+ eps_basic: ['EarningsPerShareBasic'],
1005
+ eps_diluted: ['EarningsPerShareDiluted'],
1006
+ shares_outstanding: ['CommonStockSharesOutstanding'],
1007
+ research_and_development: ['ResearchAndDevelopmentExpense'],
1008
+ };
1009
+
1010
+ interface AnnualFinancialsSummary {
1011
+ /** Fiscal year of the filer's most recent annual report, across every concept examined. */
1012
+ latest_fiscal_year: number | null;
1013
+ latest_period_end: string | null;
1014
+ /** One entry per canonical line item, choosing whichever concept the filer currently reports under. */
1015
+ latest_annual: Record<string, AnnualValue | null>;
1016
+ /** Per-concept detail. Current concepts first, retired (stale) concepts last. */
1017
+ key_financials: Record<string, KeyFinancial>;
1018
+ /** Concepts the filer has stopped reporting — present in key_financials, flagged, never in latest_annual. */
1019
+ stale_concepts: string[];
1020
+ year_note: string;
1021
+ }
136
1022
 
1023
+ /**
1024
+ * Build the whole "key financials" view: canonical line items resolved to the
1025
+ * concept the filer currently uses, plus per-concept detail with staleness
1026
+ * flagged at the point of use and stale concepts sorted last.
1027
+ */
1028
+ function summarizeAnnualFinancials(concepts: XbrlConceptMap): AnnualFinancialsSummary {
1029
+ // Every concept any line item could draw from, in a stable order.
1030
+ const perConcept: { concept: string; label: string; value: AnnualValue | null }[] = [];
1031
+ const seen = new Set<string>();
1032
+ for (const candidates of Object.values(LINE_ITEM_CONCEPTS)) {
1033
+ for (const c of candidates) {
1034
+ if (seen.has(c) || !concepts[c]) continue;
1035
+ seen.add(c);
1036
+ perConcept.push({ concept: c, label: concepts[c].label, value: latestAnnual(concepts, c) });
1037
+ }
1038
+ }
1039
+
1040
+ let latestEnd: string | null = null;
1041
+ for (const row of perConcept) {
1042
+ if (row.value && (!latestEnd || row.value.period_end > latestEnd)) latestEnd = row.value.period_end;
1043
+ }
1044
+ const latestFy = latestEnd ? fiscalYearOf(latestEnd) : null;
1045
+
1046
+ // A concept whose latest annual period ends more than ~100 days before the
1047
+ // filer's latest annual period has been retired by the filer. (Instants and
1048
+ // durations in the same 10-K share an end date, so anything beyond a few
1049
+ // days is a genuinely older report.)
1050
+ const isStale = (v: AnnualValue | null) =>
1051
+ !!v && !!latestEnd && (Date.parse(latestEnd) - Date.parse(v.period_end)) / DAY_MS > 100;
1052
+
1053
+ // Canonical line items: the freshest NON-STALE concept per item. A line the
1054
+ // filer only ever reported under a since-retired concept (Amazon's
1055
+ // GrossProfit stops at FY2009) is null here — the stale value stays in
1056
+ // key_financials, flagged, where it cannot be read as current.
1057
+ const latest_annual: Record<string, AnnualValue | null> = {};
1058
+ for (const [item, candidates] of Object.entries(LINE_ITEM_CONCEPTS)) {
1059
+ const v = freshestOf(concepts, candidates);
1060
+ latest_annual[item] = v && !isStale(v) ? v : null;
1061
+ }
1062
+
1063
+ const itemOf = (concept: string) =>
1064
+ Object.entries(LINE_ITEM_CONCEPTS).find(([, cs]) => cs.includes(concept))?.[0];
1065
+
1066
+ const current = perConcept.filter((r) => !isStale(r.value));
1067
+ const stale = perConcept.filter((r) => isStale(r.value));
1068
+ const key_financials: Record<string, KeyFinancial> = {};
1069
+ for (const r of [...current, ...stale]) {
1070
+ const entry: KeyFinancial = {
1071
+ label: r.label,
1072
+ most_recent_annual: r.value ? stripConcept(r.value) : null,
1073
+ stale: isStale(r.value),
1074
+ };
1075
+ if (entry.stale && r.value) {
1076
+ const item = itemOf(r.concept);
1077
+ const replacement = item ? latest_annual[item] : null;
1078
+ const successor =
1079
+ replacement && replacement.concept !== r.concept
1080
+ ? ` The filer now reports this line under ${replacement.concept} (FY${replacement.year}) — see latest_annual.${item}.`
1081
+ : ' No current concept for this line item was found in the filer\'s us-gaap facts.';
1082
+ entry.stale_note =
1083
+ `STALE: this concept's latest annual value is for FY${r.value.year} (period ending ${r.value.period_end}), ` +
1084
+ `but the filer's most recent annual report covers FY${latestFy}. The number is real history, not the company's current figure.` +
1085
+ successor;
1086
+ }
1087
+ key_financials[r.concept] = entry;
1088
+ }
1089
+
1090
+ return {
1091
+ latest_fiscal_year: latestFy,
1092
+ latest_period_end: latestEnd,
1093
+ latest_annual,
1094
+ key_financials,
1095
+ stale_concepts: stale.map((r) => r.concept),
1096
+ year_note:
1097
+ '`year` is the fiscal year the reporting period ENDS in, derived from period_end. It is NOT the SEC `fy` field, ' +
1098
+ 'which stamps prior-year comparatives with the year of the filing that restated them.',
1099
+ };
1100
+ }
1101
+
1102
+ function stripConcept(v: AnnualValue): Omit<AnnualValue, 'concept'> {
1103
+ const { concept: _concept, ...rest } = v;
1104
+ return rest;
1105
+ }
137
1106
  /**
138
1107
  * EDGAR MCP — SEC EDGAR public APIs (free, no auth)
139
1108
  *
@@ -143,12 +1112,24 @@ function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEn
143
1112
  * - edgar_company_facts: get structured XBRL financial data for a company
144
1113
  * - edgar_company_concept: get a specific financial metric over time
145
1114
  * - edgar_ticker_to_cik: look up CIK from ticker symbol
1115
+ * - edgar_company_snapshot: CIK + recent filings + headline XBRL figures in ONE call
1116
+ * (the resolve -> filings -> concept chain, collapsed; fleet #2182)
146
1117
  * - edgar_filing_documents: list the documents inside one filing by accession number
1118
+ * - edgar_filing_text: full/paged plaintext of a filing's primary document (by section optional)
147
1119
  *
148
1120
  * Note: SEC requires a descriptive User-Agent header per their guidelines.
149
1121
  */
150
1122
 
151
1123
 
1124
+ // Bound every fetch() in this pack to a fixed timeout — an upstream that
1125
+ // degrades without erroring would otherwise hold the Worker in `await fetch()`
1126
+ // until its own execution budget kills the request (minutes, not seconds).
1127
+ // Mirrors the epoFetch / usaspending retryFetch pattern (fleet #685).
1128
+ async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response> {
1129
+ return fetchWithTimeout(url, init ?? {}, 'Edgar');
1130
+ }
1131
+
1132
+
152
1133
  const EFTS_BASE = 'https://efts.sec.gov/LATEST';
153
1134
  const DATA_BASE = 'https://data.sec.gov';
154
1135
  const SEC_HEADERS: Record<string, string> = {
@@ -160,7 +1141,8 @@ const tools: McpToolExport['tools'] = [
160
1141
  {
161
1142
  name: 'edgar_search_filings',
162
1143
  description:
163
- 'PREFER OVER WEB SEARCH for "what did $COMPANY say about X in their SEC filings" or "find filings that mention Y". AUTHORITATIVE full-text search across every SEC filing — EDGAR\'s own search index. Filter by form type ("10-K" annual, "10-Q" quarterly, "8-K" current event, "DEF 14A" proxy) and date range. Returns filing metadata + accession numbers + document links. Use when you need to find filings matching a topic across the whole market, not for a specific company (for that use edgar_company_filings).',
1144
+ 'PREFER OVER WEB SEARCH for "what did $COMPANY say about X in their SEC filings" or "find filings that mention Y". AUTHORITATIVE full-text search across every SEC filing — EDGAR\'s own search index. Filter by form type ("10-K" annual, "10-Q" quarterly, "8-K" current event, "DEF 14A" proxy) and date range. Returns entity name, CIK, form type, filing/period dates, location, accession number (feed straight into edgar_filing_text / edgar_filing_documents — no second lookup), and — for 8-K results — the `items` array of item codes (e.g. "3.01" listing deficiency vs "1.01" material agreement vs "3.02" unregistered sale), which carry the actual signal. Use when you need to find filings matching a topic across the whole market, not for a specific company (for that use edgar_company_filings).',
1145
+ summary: 'Full-text search across every SEC filing, using EDGAR\'s own search index.',
164
1146
  inputSchema: {
165
1147
  type: 'object' as const,
166
1148
  properties: {
@@ -179,143 +1161,397 @@ const tools: McpToolExport['tools'] = [
179
1161
  {
180
1162
  name: 'edgar_company_filings',
181
1163
  description:
182
- 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Pass a ticker ("AAPL") or CIK ("320193"). Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts.',
1164
+ 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Send the company as `ticker_or_cik` — that argument takes a ticker ("AAPL") or a CIK ("320193"), and `cik` / `ticker` are accepted as aliases for it. Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts. If you also need the headline financials alongside the filings, edgar_company_snapshot returns both in one call.',
1165
+ summary: 'Every SEC filing a US public company has made, newest first.',
183
1166
  inputSchema: {
184
1167
  type: 'object' as const,
185
1168
  properties: {
186
1169
  ticker_or_cik: {
187
1170
  type: 'string',
188
- description: 'Ticker symbol (e.g., "AAPL") or CIK number (e.g., "320193")',
1171
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker symbol (e.g., "AAPL") or CIK number (e.g., "320193")',
189
1172
  },
190
1173
  form_type: {
191
1174
  type: 'string',
192
1175
  description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K"). Omit for all types.',
193
1176
  },
194
1177
  limit: { type: 'number', description: 'Max filings to return (1-40, default 20)' },
1178
+ cik: {
1179
+ type: 'string',
1180
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1181
+ },
1182
+ ticker: {
1183
+ type: 'string',
1184
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1185
+ },
195
1186
  },
196
- required: ['ticker_or_cik'],
1187
+ // Deliberately empty, with the check done in the handler instead. A flat
1188
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1189
+ // declaring the aliases above and keeping the canonical name required
1190
+ // would reject every call that used one — the gateway's pre-flight
1191
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1192
+ required: [],
1193
+
197
1194
  },
198
1195
  },
199
1196
  {
200
1197
  name: 'edgar_company_facts',
201
1198
  description:
202
- 'AUTHORITATIVE full XBRL fundamentals dump for a US public company by CIK. Returns every reported financial metric (hundreds of concepts: revenue, net income, assets, liabilities, EPS, cash flow lines, segment breakdowns) with annual and historical values pulled straight from the company\'s SEC filings — the official numbers, not estimates. Use when you need the complete fundamental picture vs. one metric (for one metric use edgar_company_concept). Large payload; agents typically use this once to discover available concepts then narrow to edgar_company_concept for follow-up queries.',
1199
+ 'AUTHORITATIVE full XBRL fundamentals dump for a US public company. Send the company as `cik` — that argument takes a TICKER ("NVDA") or a CIK ("320193"), and `ticker` / `ticker_or_cik` are accepted as aliases for it. Returns every reported financial metric (hundreds of concepts: revenue, net income, assets, liabilities, EPS, cash flow lines, segment breakdowns) with annual and historical values pulled straight from the company\'s SEC filings — the official numbers, not estimates. Use when you need the complete fundamental picture vs. one metric (for one metric use edgar_company_concept). Leads with latest_annual — revenue, net income, assets, cash, EPS for the most recent fiscal year, resolved to whichever XBRL concept the filer currently reports under — and flags retired concepts (e.g. a pre-ASC-606 Revenues tag) as stale so a 2010 figure is never mistaken for current. Large payload; agents typically use this once to discover available concepts then narrow to edgar_company_concept for follow-up queries. For just the headline figures plus the recent filings list, edgar_company_snapshot is the smaller one-call answer.',
1200
+ summary: 'Every figure a US public company has reported to the SEC in XBRL, annual and quarterly.',
203
1201
  inputSchema: {
204
1202
  type: 'object' as const,
205
1203
  properties: {
206
1204
  cik: {
207
1205
  type: 'string',
208
- description: 'Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
1206
+ description: 'REQUIRED (or one of its aliases `ticker` / `ticker_or_cik`). Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
1207
+ },
1208
+ ticker: {
1209
+ type: 'string',
1210
+ description: 'Alias for `cik` — same thing, a ticker or a CIK. The spelling edgar_fund_holdings and edgar_ticker_to_cik use.',
1211
+ },
1212
+ ticker_or_cik: {
1213
+ type: 'string',
1214
+ description: 'Alias for `cik` — the spelling edgar_company_filings, edgar_insider_transactions and edgar_product_revenue use.',
209
1215
  },
210
1216
  },
211
- required: ['cik'],
1217
+ // Deliberately empty, with the check done in the handler instead. A flat
1218
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1219
+ // declaring the aliases above and keeping the canonical name required
1220
+ // would reject every call that used one — the gateway's pre-flight
1221
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1222
+ required: [],
1223
+
212
1224
  },
213
1225
  },
214
1226
  {
215
1227
  name: 'edgar_company_concept',
216
1228
  description:
217
- 'AUTHORITATIVE historical financials for any US public company. Source: SEC XBRL filings (the official numbers companies file, not third-party scrapes). Pass a ticker or CIK plus a friendly metric name — Revenue, NetIncomeLoss, Cash, LongTermDebt, EarningsPerShareDiluted — and the tool resolves the right XBRL tag for that filer (post-ASC-606 companies use RevenueFromContractWithCustomerExcludingAssessedTax instead of "Revenues", etc.). Returns annual values with fiscal years, period ends, filing types. Use for "what was AAPL\'s revenue in 2024", "show me NVDA\'s long-term debt trend", anything where you need the SEC-filed number rather than an estimate.',
1229
+ 'AUTHORITATIVE historical financials for any US public company. Source: SEC XBRL filings (the official numbers companies file, not third-party scrapes). Send the company as `cik` — that argument takes a TICKER ("AAPL") or a CIK ("320193"), and `ticker` / `ticker_or_cik` are accepted as aliases for it — plus the metric as `concept` (alias `metric`), which takes a friendly name: Revenue, NetIncomeLoss, Cash, LongTermDebt, EarningsPerShareDiluted. The tool resolves the right XBRL tag for that filer (post-ASC-606 companies use RevenueFromContractWithCustomerExcludingAssessedTax instead of "Revenues", etc.). Returns both ANNUAL (10-K) and QUARTERLY (10-Q) values by default, each labeled with fiscal_period (FY/Q1/Q2/Q3/Q4) and form, newest first, PLUS a `latest` field holding the single freshest data point. Q4 rows are DERIVED (FY minus Q1-Q3, marked derived:true) because SEC filers never report a standalone Q4 fact — so "revenue Q4 2024" questions are answerable directly from `values`. For one specific period, pass `fiscal_year` and/or `fiscal_period` as ARGUMENTS and the values array comes back filtered to it (they are also the names of the fields on each returned row, which is what to match on if you ask for every period instead); do not default to `latest` for a period question. Use `latest` for point-in-time metrics like cash, runway, and debt — it is the newest 10-Q when one is more recent than the last 10-K, so a stale annual figure never masks a newer quarter. Use for "what was AAPL\'s revenue in 2024", "NVDA\'s latest cash position", "show me long-term debt trend", anything where you need the SEC-filed number rather than an estimate.',
1230
+ summary: 'One financial metric for a US public company across every period it has reported, from SEC XBRL.',
218
1231
  inputSchema: {
219
1232
  type: 'object' as const,
220
1233
  properties: {
221
1234
  cik: {
222
1235
  type: 'string',
223
- description: 'Ticker (e.g., "AAPL") or CIK number (e.g., "320193"). Tickers are auto-resolved.',
1236
+ description: 'REQUIRED (or one of its aliases `ticker` / `ticker_or_cik`). Ticker (e.g., "AAPL") or CIK number (e.g., "320193"). Tickers are auto-resolved.',
224
1237
  },
225
1238
  concept: {
226
1239
  type: 'string',
227
1240
  description:
228
- 'Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
1241
+ 'REQUIRED (alias `metric`). Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
1242
+ },
1243
+ period: {
1244
+ type: 'string',
1245
+ description: 'Which reporting periods to return: "all" (default — annual 10-K + quarterly 10-Q), "annual" (10-K/20-F/40-F only), or "quarterly" (10-Q only). Point-in-time metrics (cash/runway/debt) usually want the default so the freshest quarter is included; use "annual" for clean year-over-year trends.',
1246
+ enum: ['all', 'annual', 'quarterly'],
1247
+ },
1248
+ fiscal_year: {
1249
+ type: 'string',
1250
+ description: 'Optional filter: return only rows for this fiscal year, e.g. "2024". This is the filer\'s OWN fiscal year label (NVDA\'s FY2024 ended Jan 2024), not a calendar year. Unmatched years are reported with the years that ARE available rather than as an empty result.',
1251
+ },
1252
+ fiscal_period: {
1253
+ type: 'string',
1254
+ description: 'Optional filter: return only rows for this period within the fiscal year — "FY" (annual), "Q1", "Q2", "Q3", or "Q4" (derived: FY minus Q1-Q3). Combine with fiscal_year for a single figure.',
1255
+ enum: ['FY', 'Q1', 'Q2', 'Q3', 'Q4'],
1256
+ },
1257
+ ticker: {
1258
+ type: 'string',
1259
+ description: 'Alias for `cik` — same thing, a ticker or a CIK. Declared because sibling SEC tools name this argument differently (edgar_fund_holdings uses `ticker`, edgar_company_filings uses `ticker_or_cik`) and a caller filling arguments from prose reaches for whichever it read; all three spellings work here.',
1260
+ },
1261
+ ticker_or_cik: {
1262
+ type: 'string',
1263
+ description: 'Alias for `cik` — the spelling used by edgar_company_filings, edgar_insider_transactions and edgar_product_revenue.',
1264
+ },
1265
+ metric: {
1266
+ type: 'string',
1267
+ description: 'Alias for `concept` — the word this tool\'s own description uses for the thing you are asking about.',
229
1268
  },
230
1269
  },
231
- required: ['cik', 'concept'],
1270
+ // Deliberately empty, with the check done in companyConcept() instead —
1271
+ // the same pattern edgar_filing_text/edgar_filing_documents already use.
1272
+ // A flat `required` list cannot express "cik OR ticker OR ticker_or_cik",
1273
+ // so declaring the aliases above and keeping `cik` required would reject
1274
+ // every call that used one, which is exactly the production failure this
1275
+ // replaces: 12 of 12 `partial` outcomes on this tool over 3 days had the
1276
+ // adjudicator fill `ticker` (4 of them `metric` too), and the gateway's
1277
+ // pre-flight rejected them with "cik is required" before the pack ran
1278
+ // (fleet #2058). The pack-side check below names all accepted spellings.
1279
+ required: [],
232
1280
  },
233
1281
  },
234
1282
  {
235
1283
  name: 'edgar_insider_transactions',
236
1284
  description:
237
- 'AUTHORITATIVE insider trading activity (SEC Form 3/4/5) for a US public company — who bought or sold, how many shares, at what price, and what they hold now. Pass a ticker ("TSLA") or CIK. Returns each recent Form 4 filing parsed into structured transactions: reporting owner + role (director/officer/10% holder), transaction code (P=open-market purchase, S=sale, A=grant/award, M=option exercise, G=gift, F=tax-withholding), shares, price per share, acquired/disposed, and shares owned after. Use for "insider buying at $TICKER", "did executives sell recently", "latest Form 4 activity". Open-market purchases (code P) are the strongest conviction signal; awards (code A) are routine comp. For the raw filing list use edgar_company_filings with form_type:"4".',
1285
+ 'AUTHORITATIVE insider trading activity (SEC Form 3/4/5) for a US public company — who bought or sold, how many shares, at what price, and what they hold now. Send the company as `ticker_or_cik` — a ticker ("TSLA") or a CIK — and `cik` / `ticker` are accepted as aliases for it. Returns each recent Form 4 filing parsed into structured transactions: reporting owner + role (director/officer/10% holder), transaction code (P=open-market purchase, S=sale, A=grant/award, M=option exercise, G=gift, F=tax-withholding), shares, price per share, acquired/disposed, and shares owned after. Use for "insider buying at $TICKER", "did executives sell recently", "latest Form 4 activity". Open-market purchases (code P) are the strongest conviction signal; awards (code A) are routine comp. For the raw filing list use edgar_company_filings with form_type:"4".',
1286
+ summary: 'Who bought or sold a company\'s stock as an insider, from SEC Forms 3, 4 and 5.',
238
1287
  inputSchema: {
239
1288
  type: 'object' as const,
240
1289
  properties: {
241
1290
  ticker_or_cik: {
242
1291
  type: 'string',
243
- description: 'Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
1292
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
244
1293
  },
245
1294
  limit: { type: 'number', description: 'Max Form 4/3/5 filings to parse (1-25, default 10)' },
246
1295
  include_derivatives: {
247
1296
  type: 'boolean',
248
1297
  description: 'Also include derivative (options/RSU) transactions. Default false (non-derivative common-stock only).',
249
1298
  },
1299
+ cik: {
1300
+ type: 'string',
1301
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1302
+ },
1303
+ ticker: {
1304
+ type: 'string',
1305
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1306
+ },
250
1307
  },
251
- required: ['ticker_or_cik'],
1308
+ // Deliberately empty, with the check done in the handler instead. A flat
1309
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1310
+ // declaring the aliases above and keeping the canonical name required
1311
+ // would reject every call that used one — the gateway's pre-flight
1312
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1313
+ required: [],
1314
+
252
1315
  },
253
1316
  },
254
1317
  {
255
1318
  name: 'edgar_institutional_holdings',
256
1319
  description:
257
- "AUTHORITATIVE stock portfolio of a large institutional investor (SEC Form 13F-HR) — what a fund/manager owns, share counts, and position values. Pass the MANAGER's ticker or CIK (e.g. \"BRK-B\" or CIK \"1067983\" for Berkshire Hathaway; \"1350694\" for Bridgewater). Returns the latest quarterly 13F: top holdings aggregated by issuer with value (USD), shares, and % of portfolio, plus the report period. Use for \"what does Berkshire own\", \"Bridgewater's biggest positions\", \"which funds hold $TICKER\" (run per manager). Note: 13F covers US-listed long equity + options held by managers with >$100M AUM, filed ~45 days after quarter-end; it excludes shorts, cash, and non-US holdings. Values are whole USD for filings since 2023; older ones are in thousands.",
1320
+ "AUTHORITATIVE stock portfolio of a large institutional investor (SEC Form 13F-HR) — what a fund/manager owns, share counts, and position values. Pass the MANAGER's ticker or CIK (e.g. \"BRK-B\" or CIK \"1067983\" for Berkshire Hathaway; \"1350694\" for Bridgewater). Returns the latest quarterly 13F: top holdings aggregated by issuer with value (USD), shares, and % of portfolio, plus the report period. Use for \"what does Berkshire own\", \"Bridgewater's biggest positions\", \"which funds hold $TICKER\" (run per manager). Note: 13F covers US-listed long equity + options held by managers with >$100M AUM, filed ~45 days after quarter-end; it excludes shorts, cash, and non-US holdings. Values are whole USD for filings since 2023; older ones are in thousands. IMPORTANT: rows carry a `put_call` field and a plain-English `direction`. A `put` row is a BEARISH bet AGAINST that issuer — never report it as a holding the manager owns — and for option rows the value is the underlying's notional, not premium or capital at risk. Rank real holdings by `pct_of_long_equity`, and read `position_summary` + `interpretation_note` before summarising.",
1321
+ summary: 'What a large institutional investor holds, from its SEC Form 13F filing.',
258
1322
  inputSchema: {
259
1323
  type: 'object' as const,
260
1324
  properties: {
261
1325
  ticker_or_cik: {
262
1326
  type: 'string',
263
- description: 'The institutional manager\'s ticker (e.g. "BRK-B") or CIK (e.g. "1067983"). NOT the held stock — the fund/manager doing the filing.',
1327
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). The institutional manager\'s ticker (e.g. "BRK-B") or CIK (e.g. "1067983"). NOT the held stock — the fund/manager doing the filing.',
264
1328
  },
265
1329
  limit: { type: 'number', description: 'Top N holdings by value to return (1-100, default 25)' },
1330
+ cik: {
1331
+ type: 'string',
1332
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1333
+ },
1334
+ ticker: {
1335
+ type: 'string',
1336
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1337
+ },
266
1338
  },
267
- required: ['ticker_or_cik'],
1339
+ // Deliberately empty, with the check done in the handler instead. A flat
1340
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1341
+ // declaring the aliases above and keeping the canonical name required
1342
+ // would reject every call that used one — the gateway's pre-flight
1343
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1344
+ required: [],
1345
+
268
1346
  },
269
1347
  },
270
1348
  {
271
1349
  name: 'edgar_fund_holdings',
272
1350
  description:
273
1351
  "AUTHORITATIVE portfolio holdings of a US ETF or mutual fund (SEC Form N-PORT) — what the fund actually owns. Pass the FUND's ticker (e.g. \"ARKK\", \"QQQ\", \"VTI\", \"VOO\", \"IVV\"). Returns the latest monthly portfolio: net assets, holdings count, and top positions by weight — each with name, CUSIP, value (USD), and % of fund. Use for \"what does ARKK hold\", \"top holdings of QQQ\", \"is $STOCK in VTI\". Distinct from edgar_institutional_holdings (13F = what an investment MANAGER like Berkshire owns); this is a registered fund's own N-PORT. Covers US-registered open-end funds + ETFs; data is ~30-60 days delayed. Note: a few legacy ETFs structured as unit investment trusts (e.g. SPY, DIA) don't file N-PORT and won't resolve — use IVV or VOO for S&P 500 exposure.",
1352
+ summary: 'What a US ETF or mutual fund owns, from its monthly SEC Form N-PORT filing.',
274
1353
  inputSchema: {
275
1354
  type: 'object' as const,
276
1355
  properties: {
277
- ticker: { type: 'string', description: 'ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
1356
+ ticker: { type: 'string', description: 'REQUIRED (or its alias `ticker_or_cik`). ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
278
1357
  limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
1358
+ ticker_or_cik: {
1359
+ type: 'string',
1360
+ description: 'Alias for `ticker` — the spelling sibling SEC tools (edgar_company_filings, edgar_insider_transactions) use. Must still be a FUND ticker: N-PORT funds are keyed by ticker, so a bare CIK will not resolve here.',
1361
+ },
1362
+ },
1363
+ // Deliberately empty, with the check done in fundHoldings() instead —
1364
+ // a flat `required` list cannot express "ticker OR ticker_or_cik", so
1365
+ // keeping `ticker` required would reject every aliased call at the
1366
+ // gateway's pre-flight before the pack ran (fleet #2069).
1367
+ required: [],
1368
+ },
1369
+ },
1370
+ {
1371
+ name: 'edgar_company_snapshot',
1372
+ description:
1373
+ 'ONE CALL for "give me the SEC picture on $TICKER" / "what has $COMPANY filed recently and what are its numbers" / "pull the filings and financials for X". Resolves a ticker, company name or CIK and returns, from SEC EDGAR, the three things callers otherwise chain by hand across edgar_ticker_to_cik -> edgar_company_filings -> edgar_company_concept: the identity (cik, company_name, tickers, SIC code, fiscal year end), the recent filings list (accession numbers, form types, filing dates, document links — by default the substantive forms 10-K/10-Q/8-K/20-F/40-F/6-K/DEF 14A, so insider Form 4 noise is excluded; pass `form_type` for one form or "all"), and the headline XBRL figures from the latest annual report (revenue, net income, operating income, gross profit, assets, liabilities, equity, cash, EPS, shares, R&D — each resolved to the concept the filer CURRENTLY reports under, with retired concepts listed separately as stale). Send the company as `ticker_or_cik`; `cik` / `ticker` are accepted aliases. A filer with no XBRL facts (a fund, a trust, a foreign private issuer on paper forms) still returns its filings, with `financials_status: "unavailable"` and a reason, not an error. Drill down from here: edgar_filing_text for a filing\'s text, edgar_company_concept for one metric\'s multi-year history, edgar_company_facts for every concept. For a cross-source view (patents, contracts, hiring, news) use entity_profile instead.',
1374
+ summary: 'A US public company\'s SEC identity, recent filings and headline financials in one call.',
1375
+ inputSchema: {
1376
+ type: 'object' as const,
1377
+ properties: {
1378
+ ticker_or_cik: {
1379
+ type: 'string',
1380
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker ("AAPL"), company name ("Apple Inc") or CIK ("320193"). Tickers and names are resolved to a CIK internally.',
1381
+ },
1382
+ cik: {
1383
+ type: 'string',
1384
+ description: 'Alias for `ticker_or_cik` — same thing. The spelling edgar_company_facts and edgar_company_concept use.',
1385
+ },
1386
+ ticker: {
1387
+ type: 'string',
1388
+ description: 'Alias for `ticker_or_cik` — same thing. The spelling edgar_ticker_to_cik uses.',
1389
+ },
1390
+ form_type: {
1391
+ type: 'string',
1392
+ description: 'Which filings to list. Omit for the substantive default set (10-K, 10-K/A, 10-Q, 10-Q/A, 8-K, 20-F, 40-F, 6-K, DEF 14A). Pass one form ("10-K") to list only that form, or "all" for every form including Form 4 insider filings.',
1393
+ },
1394
+ filings_limit: {
1395
+ type: 'number',
1396
+ description: 'How many filings to return after the form filter (1-40, default 10).',
1397
+ },
279
1398
  },
280
- required: ['ticker'],
1399
+ // Deliberately empty — a flat `required` list cannot express
1400
+ // "ticker_or_cik OR cik OR ticker"; the handler refuses with a message
1401
+ // that names all three (fleet #2058 / #2069).
1402
+ required: [],
281
1403
  },
282
1404
  },
283
1405
  {
284
1406
  name: 'edgar_ticker_to_cik',
285
1407
  description:
286
- 'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` for disambiguation. Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data.',
1408
+ 'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` for disambiguation. Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data. The response carries a `next` hint: the usual NEXT step after resolving is edgar_company_snapshot({ticker_or_cik}), which returns the recent filings list AND the headline XBRL financials in one call — do not chain edgar_company_filings then edgar_company_concept by hand to get that.',
1409
+ summary: 'The SEC CIK number for a stock ticker or company name, from EDGAR\'s company list.',
287
1410
  inputSchema: {
288
1411
  type: 'object' as const,
289
1412
  properties: {
290
1413
  ticker: {
291
1414
  type: 'string',
292
- description: 'Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA") or company name (e.g., "Apple", "Microsoft")',
1415
+ description: 'REQUIRED (or one of its aliases `ticker_or_cik` / `company`). Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA") or company name (e.g., "Apple", "Microsoft")',
293
1416
  },
1417
+ ticker_or_cik: {
1418
+ type: 'string',
1419
+ description: 'Alias for `ticker` — the spelling edgar_company_filings and edgar_insider_transactions use. A ticker or company name; this tool RESOLVES to a CIK, so passing a bare CIK has nothing to look up.',
1420
+ },
1421
+ company: {
1422
+ type: 'string',
1423
+ description: 'Alias for `ticker` — use it when what you have is a company NAME ("Apple Inc.") rather than a symbol. Same argument, same behaviour.',
1424
+ },
1425
+ },
1426
+ // Deliberately empty, with the check done in tickerToCik() instead —
1427
+ // a flat `required` list cannot express "ticker OR ticker_or_cik OR
1428
+ // company" (fleet #2069).
1429
+ required: [],
1430
+ },
1431
+ },
1432
+ {
1433
+ name: 'sponsor_to_filer',
1434
+ description:
1435
+ 'Resolve an organization NAME — especially a clinical-trial sponsor, drug developer, or operating subsidiary — to the US-listed public FILER that reports it (ticker + SEC CIK). Built for the join that plain ticker/name lookup fails: trial registries (ClinicalTrials.gov) name operating subsidiaries ("Merck Sharp and Dohme"), while SEC names the listed parent ("Merck & Co", MRK). This tool bridges that gap and, crucially, tells you WHY a name does not resolve instead of collapsing every miss to "not found". Returns a `status`: "resolved" (name is itself a US-listed filer), "resolved_via_parent" (name is a subsidiary; resolved to its listed parent, with evidence + confidence), "us_registrant_unlisted" (has an SEC CIK but no public listing and no listed parent — typically a private company that filed a Form D or draft registration), or "no_us_registrant" (no US SEC presence at all — typically a non-US-listed or foreign private company). Use before joining trial sponsors to public financials, ownership, or filings.',
1436
+ summary: 'The SEC filer entities linked to a plan sponsor, from EDGAR Form 5500 filings.',
1437
+ inputSchema: {
1438
+ type: 'object' as const,
1439
+ properties: {
1440
+ sponsor: {
1441
+ type: 'string',
1442
+ description: 'Organization name to resolve — a trial sponsor, drug developer, or company name, e.g. "Merck Sharp and Dohme", "Lexeo Therapeutics", "Dizal Pharmaceuticals".',
1443
+ },
1444
+ },
1445
+ required: ['sponsor'],
1446
+ },
1447
+ },
1448
+ {
1449
+ name: 'filer_to_sponsors',
1450
+ description:
1451
+ 'The REVERSE of sponsor_to_filer: given a US-listed public FILER (parent company), list its operating subsidiaries as disclosed in Exhibit 21 of its most recent 10-K (Item 601(b)(21) — "significant subsidiaries"). Built for the same trial-sponsor/entity-resolution join, run the other direction: instead of ~10 calls guessing candidate subsidiary names and confirming each via sponsor_to_filer, get the parent\'s full disclosed subsidiary list (with jurisdiction of incorporation) in one call, straight from SEC — e.g. Merck (MRK/CIK 310158) -> "Merck Sharp & Dohme LLC" among hundreds of others. Pass `name_filter` (case-insensitive substring) to check whether a specific candidate name is among the subsidiaries without reading the whole list. Every result carries provenance (accession number, filing date, exhibit URL) so the join is auditable. Smaller filers or ones with no significant subsidiaries can genuinely have no Exhibit 21 — status distinguishes that from a lookup failure. Foreign private issuers (20-F filers) are not yet covered.',
1452
+ summary: 'The plan sponsors linked to an SEC filer entity, from EDGAR Form 5500 filings.',
1453
+ inputSchema: {
1454
+ type: 'object' as const,
1455
+ properties: {
1456
+ ticker_or_cik: {
1457
+ type: 'string',
1458
+ description: 'REQUIRED (or one of its aliases `cik` / `ticker`). The PARENT company\'s ticker (e.g. "MRK") or CIK (e.g. "310158"). Tickers are auto-resolved to CIKs.',
1459
+ },
1460
+ name_filter: {
1461
+ type: 'string',
1462
+ description: 'Optional case-insensitive substring to filter subsidiary names by, e.g. "Sharp & Dohme" to check whether that entity is among the parent\'s disclosed subsidiaries. Subsidiary names in Exhibit 21 use "&", not "and".',
1463
+ },
1464
+ limit: { type: 'number', description: 'Max subsidiaries to return (1-500, default 200). Large parents can disclose 500+.' },
1465
+ cik: {
1466
+ type: 'string',
1467
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1468
+ },
1469
+ ticker: {
1470
+ type: 'string',
1471
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1472
+ },
1473
+ },
1474
+ // Deliberately empty, with the check done in the handler instead. A flat
1475
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1476
+ // declaring the aliases above and keeping the canonical name required
1477
+ // would reject every call that used one — the gateway's pre-flight
1478
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1479
+ required: [],
1480
+
1481
+ },
1482
+ },
1483
+ {
1484
+ name: 'edgar_xbrl_frames',
1485
+ description:
1486
+ 'Compare ONE financial metric across ALL public companies for a single period (SEC XBRL "frames"). PREFER OVER WEB SEARCH for "which companies had the most revenue/net income/assets in <year>", "rank companies by <metric>", cross-company financial comparison. concept is a US-GAAP tag (e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense", "CashAndCashEquivalentsAtCarryingValue"). period is a calendar frame: "CY2023" (annual), "CY2023Q1" (quarter), or "CY2023Q1I" (instant/balance-sheet, period-end). Returns companies + values, sorted descending by default. Differs from edgar_company_concept (one company over time) — this is one period across every filer.',
1487
+ summary: 'One XBRL financial concept\'s reported value across every US public company for one period, from EDGAR.',
1488
+ inputSchema: {
1489
+ type: 'object' as const,
1490
+ properties: {
1491
+ concept: { type: 'string', description: 'US-GAAP (or dei) tag, e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense".' },
1492
+ period: { type: 'string', description: 'Calendar frame: "CY2023" (annual duration), "CY2023Q1" (quarterly duration), or "CY2023Q1I" (instant, balance-sheet items at period end).' },
1493
+ unit: { type: 'string', description: 'Unit of measure (default "USD"). Use "shares" for share counts, "USD-per-shares" for per-share.' },
1494
+ taxonomy: { type: 'string', description: 'Taxonomy: "us-gaap" (default) or "dei".' },
1495
+ sort: { type: 'string', description: '"desc" (default, largest first) or "asc".', enum: ['desc', 'asc'] },
1496
+ limit: { type: 'number', description: 'Max companies to return (1-200, default 25).' },
1497
+ },
1498
+ required: ['concept', 'period'],
1499
+ },
1500
+ },
1501
+ {
1502
+ name: 'edgar_companies_by_sic',
1503
+ description:
1504
+ 'AUTHORITATIVE peer / competitor lookup: find every SEC filer classified under one SIC (Standard Industrial Classification) industry code. PREFER OVER WEB SEARCH for "who are $COMPANY\'s public competitors/peers", "list companies in <industry>", "which filers are in SIC <code>". Pass EITHER `sic` directly (a 2-4 digit code, e.g. "3571" = Electronic Computers) OR `ticker_or_cik` for a company whose own SIC should be looked up first and then used to find its peers (self excluded by default). Returns each peer\'s CIK, and ticker + company_name when the filer has a listed ticker — results are sorted so currently-listed peers come first, since an SIC bucket covers every filer that EVER filed the form (many delisted/defunct); unlisted registrants still appear after them with ticker:null rather than being dropped. Source: SEC EDGAR company-search (browse-edgar), filtered to filers who have filed the given form_type (default "10-K", i.e. active public reporters — omitting this filter is unreliable upstream). Note: SIC is a broad, sometimes dated bucket assigned once at registration — treat this as a peer-set STARTING POINT, not a precise competitor list.',
1505
+ summary: 'Every SEC filer classified under one industry code — a company\'s public peers.',
1506
+ inputSchema: {
1507
+ type: 'object' as const,
1508
+ properties: {
1509
+ sic: { type: 'string', description: 'SIC code to search directly, e.g. "3571" (Electronic Computers), "2836" (Biological Products), "6021" (National Commercial Banks). Provide this OR ticker_or_cik.' },
1510
+ ticker_or_cik: { type: 'string', description: 'Ticker (e.g. "AAPL") or CIK of a company whose SIC should be resolved first, then used to find its peers. Provide this OR sic. Aliases: `cik`, `ticker`.' },
1511
+ cik: { type: 'string', description: 'Alias for `ticker_or_cik` — takes a ticker or a CIK. Declared because sibling SEC tools spell this argument differently.' },
1512
+ ticker: { type: 'string', description: 'Alias for `ticker_or_cik` — takes a ticker or a CIK. Declared because sibling SEC tools spell this argument differently.' },
1513
+ form_type: { type: 'string', description: 'Only include filers who have filed this form type (default "10-K" — active public reporters). SEC\'s upstream search is unreliable with this left blank.' },
1514
+ exclude_self: { type: 'boolean', description: 'When resolving via ticker_or_cik, exclude that company itself from the peer list. Default true.' },
1515
+ limit: { type: 'number', description: 'Max peers to return (1-100, default 25).' },
294
1516
  },
295
- required: ['ticker'],
296
1517
  },
297
1518
  },
298
1519
  {
299
- name: 'edgar_xbrl_frames',
1520
+ name: 'edgar_product_revenue',
300
1521
  description:
301
- 'Compare ONE financial metric across ALL public companies for a single period (SEC XBRL "frames"). PREFER OVER WEB SEARCH for "which companies had the most revenue/net income/assets in <year>", "rank companies by <metric>", cross-company financial comparison. concept is a US-GAAP tag (e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense", "CashAndCashEquivalentsAtCarryingValue"). period is a calendar frame: "CY2023" (annual), "CY2023Q1" (quarter), or "CY2023Q1I" (instant/balance-sheet, period-end). Returns companies + values, sorted descending by default. Differs from edgar_company_concept (one company over time) — this is one period across every filer.',
1522
+ 'PRODUCT-LEVEL or segment-level revenue as STRUCTURED data — e.g. "how much revenue did Keytruda generate", "AAPL revenue by product line". Regular XBRL tools (edgar_company_concept, edgar_company_facts) only expose UNDIMENSIONED totals; a filer\'s product/segment breakdown is tagged with an XBRL dimension (e.g. a "Keytruda [Member]"), which those APIs cannot see no matter which concept is requested. This tool reads SEC\'s own standardized "Financial Report" rendering of that dimensional data straight out of the annual or quarterly segment-reporting / revenue-disaggregation note — 10-K and 10-Q for US filers, 20-F for foreign private issuers (Novartis, AstraZeneca, GSK, Sanofi, Novo Nordisk, Takeda) and 40-F for Canadian MJDS filers, resolved automatically and reported back as `resolved_form` — the same note human analysts read, but pre-parsed into rows. Pass `product_filter` (case-insensitive substring, matched against the dimension breadcrumb, e.g. "Keytruda") to get just one product/segment instead of the whole table. Every result carries a citation (accession, filing date, exact report + URL it came from). Not every filer discloses product-level revenue in XBRL, and a small fraction use a non-standard table layout this parser can\'t read — both are reported as an explicit status rather than a silent empty array, with a fallback to edgar_filing_text for the prose note.',
1523
+ summary: 'A US public company\'s revenue broken out by product or segment, from its EDGAR XBRL filings.',
302
1524
  inputSchema: {
303
1525
  type: 'object' as const,
304
1526
  properties: {
305
- concept: { type: 'string', description: 'US-GAAP (or dei) tag, e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense".' },
306
- period: { type: 'string', description: 'Calendar frame: "CY2023" (annual duration), "CY2023Q1" (quarterly duration), or "CY2023Q1I" (instant, balance-sheet items at period end).' },
307
- unit: { type: 'string', description: 'Unit of measure (default "USD"). Use "shares" for share counts, "USD-per-shares" for per-share.' },
308
- taxonomy: { type: 'string', description: 'Taxonomy: "us-gaap" (default) or "dei".' },
309
- sort: { type: 'string', description: '"desc" (default, largest first) or "asc".', enum: ['desc', 'asc'] },
310
- limit: { type: 'number', description: 'Max companies to return (1-200, default 25).' },
1527
+ ticker_or_cik: { type: 'string', description: 'REQUIRED (or one of its aliases `cik` / `ticker`). Ticker (e.g. "MRK") or CIK (e.g. "310158"). Tickers are auto-resolved.' },
1528
+ product_filter: { type: 'string', description: 'Optional case-insensitive substring to match against the product/segment dimension, e.g. "Keytruda". Omit to get every disaggregated row in the table.' },
1529
+ form_type: { type: 'string', description: 'Filing type to read the note from. Omit it to try 10-K, then 20-F, then 40-F automatically — foreign private issuers (NVS, AZN, GSK, SNY, NVO, TAK) file 20-F and Canadian MJDS filers file 40-F. "10-Q" also works for filers that disaggregate revenue quarterly. The form actually used comes back as `resolved_form`.' },
1530
+ accession: { type: 'string', description: 'Optional exact accession number (from edgar_company_filings) to read a specific past filing instead of the latest matching form_type.' },
1531
+ limit: { type: 'number', description: 'Max rows to return (1-200, default 100).' },
1532
+ cik: {
1533
+ type: 'string',
1534
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_company_concept and edgar_company_facts use for the same thing. Takes a ticker or a CIK.',
1535
+ },
1536
+ ticker: {
1537
+ type: 'string',
1538
+ description: 'Alias for `ticker_or_cik` — the spelling edgar_fund_holdings and edgar_ticker_to_cik use for the same thing. Takes a ticker or a CIK.',
1539
+ },
311
1540
  },
312
- required: ['concept', 'period'],
1541
+ // Deliberately empty, with the check done in the handler instead. A flat
1542
+ // `required` list cannot express "ticker_or_cik OR cik OR ticker", so
1543
+ // declaring the aliases above and keeping the canonical name required
1544
+ // would reject every call that used one — the gateway's pre-flight
1545
+ // refuses before the pack ever runs (fleet #2058, swept in #2069).
1546
+ required: [],
1547
+
313
1548
  },
314
1549
  },
315
1550
  {
316
1551
  name: 'edgar_filing_documents',
317
1552
  description:
318
1553
  'AUTHORITATIVE list of the SEC filing documents inside ONE specific filing, by accession number. Retrieve a filing / its contents / attachments: pass the accession (e.g. "0000320193-25-000079", with or without dashes) plus the filer\'s ticker ("AAPL") or CIK ("320193"). Returns every document in the filing folder — the primary document (10-K / 10-Q / 8-K body), all exhibits, and XBRL files — each with name, type, size, and a direct https URL, plus the filing\'s form type, filing date, and human -index.html page. Set include_primary_text:true to also pull the primary document\'s text (HTML stripped to plaintext, ~40k chars). Use to list a 10-K / 10-Q / 8-K\'s exhibits, retrieve filing contents/attachments, or fetch the text of a filing. You can pass an exact accession, OR just a ticker + form_type to auto-resolve the latest matching filing (no accession lookup needed). Examples: edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the documents in NVIDIA\'s latest annual report; edgar_filing_documents({accession: "0000320193-25-000079", ticker: "AAPL", include_primary_text: true}) for a specific filing\'s text.',
1554
+ summary: 'The documents and attachments inside one SEC filing, by accession number.',
319
1555
  inputSchema: {
320
1556
  type: 'object' as const,
321
1557
  properties: {
@@ -337,7 +1573,72 @@ const tools: McpToolExport['tools'] = [
337
1573
  },
338
1574
  include_primary_text: {
339
1575
  type: 'boolean',
340
- description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false.',
1576
+ description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false. For the FULL, pageable document text — or just one section like going-concern/liquidity — use edgar_filing_text instead.',
1577
+ },
1578
+ ticker_or_cik: {
1579
+ type: 'string',
1580
+ description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
1581
+ },
1582
+ },
1583
+ required: [],
1584
+ },
1585
+ },
1586
+ {
1587
+ name: 'edgar_filing_text',
1588
+ description:
1589
+ 'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page (and how far `search` can scan) and can make a late section or search term come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
1590
+ summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1591
+ inputSchema: {
1592
+ type: 'object' as const,
1593
+ properties: {
1594
+ accession: {
1595
+ type: 'string',
1596
+ description: 'SEC accession number, dashed or not (e.g. "0001683168-26-003909"). Omit to auto-resolve the latest filing of form_type for the given ticker/cik.',
1597
+ },
1598
+ ticker: {
1599
+ type: 'string',
1600
+ description: 'Filer ticker (e.g. "ACTU"). Provide this OR cik. ONLY pass a ticker you are CERTAIN of — a wrong remembered ticker silently retrieves a DIFFERENT company\'s filing as a clean success (a "SpaceX" question filled with SPCE returns Virgin Galactic\'s S-1). For a recent IPO or any uncertain ticker, resolve first: edgar_company_filings accepts the company NAME and returns the cik — pass that cik here.',
1601
+ },
1602
+ cik: {
1603
+ type: 'string',
1604
+ description: 'Filer CIK number (e.g. "1652935"). Provide this OR ticker.',
1605
+ },
1606
+ form_type: {
1607
+ type: 'string',
1608
+ description: 'When accession is omitted, the form type of the latest filing to fetch — "10-K", "10-Q", "8-K", "DEF 14A", etc. A question asking for the "most recent 10-K OR 10-Q" (or otherwise not committed to one type) should OMIT this field entirely rather than guess "10-K" by habit — omitting form_type (along with accession) returns the single most recent filing of ANY type, and a 10-Q is very often more recent than the last 10-K since it files quarterly while the 10-K only files once a year (verified live 2026-09-25, fleet #2450: NTRA\'s most recent 10-Q was filed 2026-08-07, five months after its 2026-02-27 10-K — passing form_type:"10-K" here silently skips the newer filing and the KPI it asked about).',
1609
+ },
1610
+ section: {
1611
+ type: 'string',
1612
+ enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events'],
1613
+ description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false).',
1614
+ },
1615
+ max_chars: {
1616
+ type: 'number',
1617
+ description: 'Max characters to return in this page (1000–100000, default 50000). Doc text past this is available via next_offset.',
1618
+ },
1619
+ offset: {
1620
+ type: 'number',
1621
+ description: 'Character offset to start from (default 0). Pass the prior result\'s next_offset to page forward.',
1622
+ },
1623
+ search: {
1624
+ type: 'string',
1625
+ description: 'Find a specific fact instead of paging blind. This is a SUBSTRING match, not a relevance search — pass the exact 2-4 word phrase most likely to appear VERBATIM in the prose ("tests processed", "processed approximately", "going concern"), never a compound of every concept in the question. A phrase that ANDs several unrelated question-words together ("oncology Signatera revenue test volume") returns ZERO matches even in the CORRECT filing, because the filing\'s own sentence never contains all of those words together — verified live 2026-09-25 (fleet #2450) on a real Natera 10-Q that DOES report the exact number asked for: "Signatera revenue" and the 4-word compound above both found nothing, while the filing\'s own wording ("processed approximately", "tests processed") is what actually appears. When unsure of the filing\'s exact phrasing, prefer the SHORTEST distinctive 2-3 word fragment of the metric name itself (a unit, a verb+noun like "processed approximately") over restating the question. Case-insensitive substring match over the WHOLE document text (or the whole `section` slice, if both are given), run BEFORE max_chars/offset windowing. Returns every matching passage (surrounding context + its own offset in the document) instead of the normal paged `text` — use the returned offsets with a follow-up call (no `search`, `offset` set to one of them) if you need more surrounding text than the passage gives. Zero matches means try again with a SHORTER, more literal phrase before concluding the filing does not disclose it — this is a real answer about wording, not a tool failure. Accepted aliases: `contains`, `find`, `phrase`.',
1626
+ },
1627
+ contains: {
1628
+ type: 'string',
1629
+ description: 'Alias for `search`.',
1630
+ },
1631
+ find: {
1632
+ type: 'string',
1633
+ description: 'Alias for `search`.',
1634
+ },
1635
+ phrase: {
1636
+ type: 'string',
1637
+ description: 'Alias for `search`.',
1638
+ },
1639
+ ticker_or_cik: {
1640
+ type: 'string',
1641
+ description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
341
1642
  },
342
1643
  },
343
1644
  required: [],
@@ -374,21 +1675,25 @@ async function searchFilings(
374
1675
  if (endDate) params.set('enddt', endDate);
375
1676
  }
376
1677
 
377
- const res = await fetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
378
- if (!res.ok) throw new Error(`SEC EDGAR search error: ${res.status}`);
1678
+ const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
1679
+ if (!res.ok) throw await httpError(res, 'SEC EDGAR search error');
379
1680
 
380
1681
  const data = (await res.json()) as {
381
1682
  hits: {
382
1683
  hits: {
383
1684
  _source: {
384
- entity_name: string;
385
- entity_id: string;
386
- file_num: string;
387
- form_type: string;
388
- file_date: string;
389
- period_of_report: string;
390
- biz_location: string;
391
- category: string;
1685
+ ciks?: string[];
1686
+ display_names?: string[];
1687
+ form?: string;
1688
+ file_type?: string;
1689
+ root_forms?: string[];
1690
+ file_date?: string;
1691
+ period_ending?: string;
1692
+ biz_locations?: string[];
1693
+ biz_states?: string[];
1694
+ adsh?: string;
1695
+ items?: string[];
1696
+ file_description?: string;
392
1697
  };
393
1698
  _id: string;
394
1699
  }[];
@@ -396,15 +1701,30 @@ async function searchFilings(
396
1701
  };
397
1702
  };
398
1703
 
1704
+ // EFTS `_source` field names (verified against efts.sec.gov): display_names
1705
+ // ["Name (TICKER) (CIK 000...)"], ciks[], form, period_ending, biz_locations[],
1706
+ // adsh (accession), and — for 8-Ks — items[] (e.g. "3.01"). The prior mapper
1707
+ // read entity_name/form_type/biz_location, which do NOT exist in this payload,
1708
+ // so every field but the date/id serialized as undefined and dropped.
399
1709
  const results = (data.hits?.hits ?? []).map((hit) => {
400
1710
  const s = hit._source;
1711
+ const display = s.display_names?.[0] ?? '';
1712
+ // display_names is "NAME (TICKER) (CIK 000...)" (double-space before each
1713
+ // paren group; TICKER paren is absent for non-tickered filers). Take the name
1714
+ // before the first double-space-paren so both shapes strip cleanly.
1715
+ const entityName = display.split(/\s{2,}\(/)[0].trim() || display;
1716
+ const cik = s.ciks?.[0] ? String(parseInt(s.ciks[0], 10)) : undefined;
401
1717
  return {
402
- entity_name: s.entity_name,
403
- cik: s.entity_id,
404
- form_type: s.form_type,
1718
+ entity_name: entityName || undefined,
1719
+ cik,
1720
+ form_type: s.form ?? s.file_type ?? s.root_forms?.[0],
1721
+ // 8-K item codes — the signal (3.01 delisting vs 1.01 agreement). Empty for non-8-Ks.
1722
+ items: s.items && s.items.length ? s.items : undefined,
405
1723
  filing_date: s.file_date,
406
- period_of_report: s.period_of_report,
407
- location: s.biz_location,
1724
+ period_of_report: s.period_ending,
1725
+ location: s.biz_locations?.[0] ?? s.biz_states?.[0],
1726
+ // Accession — feed straight into edgar_filing_documents/edgar_filing_text, no second lookup.
1727
+ accession: s.adsh,
408
1728
  filing_id: hit._id,
409
1729
  };
410
1730
  });
@@ -422,16 +1742,319 @@ async function searchFilings(
422
1742
  // identity. Delegates to the shared resolver so the ticker/name-resolution logic
423
1743
  // stays consistent across every SEC-keyed pack (edgar, sec).
424
1744
  async function tickerToCik(ticker: string) {
425
- return resolveSecEntity(ticker, { headers: SEC_HEADERS });
1745
+ if (typeof ticker !== 'string' || !ticker.trim()) {
1746
+ throw new Error(
1747
+ 'A company is required: pass `ticker`, or its aliases `ticker_or_cik` / `company` — ' +
1748
+ 'all three take a stock ticker ("AAPL") or a company name ("Apple Inc."). ' +
1749
+ 'Example: edgar_ticker_to_cik({ticker: "AAPL"}).'
1750
+ );
1751
+ }
1752
+ let entity: Awaited<ReturnType<typeof resolveSecEntity>> | FilerNameMatch;
1753
+ try {
1754
+ entity = await resolveSecEntity(ticker, { headers: SEC_HEADERS });
1755
+ } catch (err) {
1756
+ if (!/No SEC company matches/.test(String((err as Error)?.message))) throw err;
1757
+ entity = await resolveAnyFilerByName(ticker, err as Error);
1758
+ }
1759
+ // Measured (docs/edgar-cohort-retention-2026-09-17.md, fleet #2182): 66% of
1760
+ // one-shot EDGAR callers call this tool and nothing else — they resolved a
1761
+ // ticker and had no signal about what to do next — while 90%+ of depth
1762
+ // callers go on to chain edgar_company_filings + edgar_company_concept by
1763
+ // hand. Name the one-call path in the response, where a direct-tool caller
1764
+ // actually sees it (a meta-tool's description is invisible to them).
1765
+ return {
1766
+ ...entity,
1767
+ next: {
1768
+ tool: 'edgar_company_snapshot',
1769
+ args: { ticker_or_cik: entity.cik },
1770
+ why: 'Returns the recent filings list AND the headline XBRL financials for this CIK in one call — the usual next step, instead of chaining edgar_company_filings then edgar_company_concept.',
1771
+ },
1772
+ };
1773
+ }
1774
+
1775
+ // --- delisted / non-listed filers by name (fleet #2305) ---------------------
1776
+ // company_tickers.json lists only CURRENTLY-listed companies, so a bankrupt or
1777
+ // delisted filer — exactly the ones people research — resolved to "No SEC
1778
+ // company matches" by name even though EDGAR holds all its filings under a
1779
+ // CIK (Amyris, CIK 1365916, tickers: []). EDGAR's own entity typeahead
1780
+ // (efts search-index, the box on efts full-text search) covers every filer.
1781
+ // Accept a hit only when its distinctive name tokens EQUAL the query's: a
1782
+ // looser match would hand back a different entity ("WNA Private Equity Fund
1783
+ // B-Amyris LLC" for "Amyris") as a clean success.
1784
+
1785
+ interface FilerNameMatch {
1786
+ ticker: null;
1787
+ cik: string;
1788
+ cik_padded: string;
1789
+ company_name: string;
1790
+ matched_by: 'edgar_entity_search';
1791
+ note: string;
1792
+ alternatives?: { company_name: string; cik: string }[];
1793
+ }
1794
+
1795
+ async function resolveAnyFilerByName(name: string, original: Error): Promise<FilerNameMatch> {
1796
+ const q = name.trim();
1797
+ const url = `https://efts.sec.gov/LATEST/search-index?keysTyped=${encodeURIComponent(q)}`;
1798
+ const res = await pwFetch(url, { headers: SEC_HEADERS }).catch(() => null);
1799
+ if (!res || !res.ok) throw original;
1800
+ const data = (await res.json().catch(() => null)) as
1801
+ | { hits?: { hits?: { _id: string; _source?: { entity?: string; rank?: number } }[] } }
1802
+ | null;
1803
+ const hits = (data?.hits?.hits ?? [])
1804
+ .filter((h) => /^\d+$/.test(h._id) && h._source?.entity)
1805
+ .map((h) => ({ cik: String(Number(h._id)), company_name: h._source!.entity!, rank: h._source!.rank ?? 0 }));
1806
+ const key = (s: string) => [...new Set(significantTokens(s))].sort().join(' ');
1807
+ const want = key(q);
1808
+ const exact = want ? hits.filter((h) => key(h.company_name) === want).sort((a, b) => b.rank - a.rank) : [];
1809
+ if (!exact.length) {
1810
+ const seen = hits.slice(0, 5).map((h) => `${h.company_name} (CIK ${h.cik})`).join('; ');
1811
+ throw new Error(
1812
+ `${original.message}${seen ? ` EDGAR's all-filer name index (which includes delisted and unlisted filers) has no exact match either; nearest: ${seen}. Pass one of those CIKs as ticker_or_cik.` : ''} ` +
1813
+ 'A delisted company\'s OLD ticker cannot be resolved (SEC keeps no ticker history) — pass the company name or CIK instead.'
1814
+ );
1815
+ }
1816
+ const best = exact[0];
1817
+ const alts = exact.slice(1, 4).map((h) => ({ company_name: h.company_name, cik: h.cik }));
1818
+ return {
1819
+ ticker: null,
1820
+ cik: best.cik,
1821
+ cik_padded: best.cik.padStart(10, '0'),
1822
+ company_name: best.company_name,
1823
+ matched_by: 'edgar_entity_search',
1824
+ note: 'Not a currently-listed company (no ticker on SEC\'s current list) — likely delisted, acquired, bankrupt or never listed. Resolved by name from EDGAR\'s all-filer index; its full filing history is still available under this CIK.',
1825
+ ...(alts.length ? { alternatives: alts } : {}),
1826
+ };
1827
+ }
1828
+
1829
+ // --- sponsor_to_filer -------------------------------------------------------
1830
+ // Resolve a sponsor/subsidiary NAME to the US-listed filer that reports it.
1831
+ // The hard case: trial registries name operating subsidiaries ("Merck Sharp
1832
+ // and Dohme"); SEC names the listed parent ("Merck & Co", MRK). We also refuse
1833
+ // to collapse every miss to "not found" — the status distinguishes listed /
1834
+ // subsidiary-of-listed / SEC-registered-but-unlisted / no-US-registrant.
1835
+
1836
+ interface SecTickerRow { cik_str: number; ticker: string; title: string }
1837
+
1838
+ // Generic corporate/industry words that don't distinguish one biotech from
1839
+ // another — excluded when picking a sponsor's "distinctive" token.
1840
+ const GENERIC_NAME_TOKENS = new Set([
1841
+ 'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED',
1842
+ 'LLC', 'LP', 'PLC', 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
1843
+ 'PHARMACEUTICALS', 'PHARMACEUTICAL', 'PHARMA', 'THERAPEUTICS', 'THERAPEUTIC',
1844
+ 'BIOSCIENCES', 'BIOSCIENCE', 'BIO', 'BIOPHARMA', 'BIOPHARMACEUTICALS',
1845
+ 'BIOTECH', 'BIOTECHNOLOGY', 'MEDICAL', 'MEDICINE', 'MEDICINES', 'SCIENCES',
1846
+ 'SCIENCE', 'HEALTH', 'HEALTHCARE', 'GROUP', 'HOLDINGS', 'HOLDING',
1847
+ 'LABORATORIES', 'LABS', 'AND', 'OF', 'US', 'USA', 'INTERNATIONAL', 'GLOBAL',
1848
+ ]);
1849
+
1850
+ const normName = (s: string): string =>
1851
+ s.toUpperCase().replace(/[.,&/()-]/g, ' ').replace(/\s+/g, ' ').trim();
1852
+
1853
+ function significantTokens(name: string): string[] {
1854
+ return normName(name).split(' ').filter((t) => t.length > 1 && !GENERIC_NAME_TOKENS.has(t));
1855
+ }
1856
+
1857
+ function distinctiveToken(name: string): string {
1858
+ return significantTokens(name)[0] ?? normName(name).split(' ')[0] ?? '';
1859
+ }
1860
+
1861
+ // SEC company names use "&", not "and". Produce query variants: &-normalized
1862
+ // full name first, then progressively drop trailing generic words (browse-edgar
1863
+ // matches on a name prefix and a too-specific full name can return nothing).
1864
+ function secQueryVariants(name: string): string[] {
1865
+ const amp = name.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
1866
+ const out = [amp];
1867
+ const words = amp.split(' ');
1868
+ for (let end = words.length; end > 1; end--) {
1869
+ const last = words[end - 1].toUpperCase().replace(/[.,&]/g, '');
1870
+ if (GENERIC_NAME_TOKENS.has(last)) out.push(words.slice(0, end - 1).join(' '));
1871
+ else break;
1872
+ }
1873
+ return [...new Set(out.filter(Boolean))];
1874
+ }
1875
+
1876
+ async function fetchCompanyTickers(): Promise<SecTickerRow[]> {
1877
+ const res = await pwFetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
1878
+ if (!res.ok) throw await httpError(res, 'SEC ticker lookup error');
1879
+ const data = (await res.json()) as Record<string, SecTickerRow>;
1880
+ return Object.values(data);
1881
+ }
1882
+
1883
+ /** Does the sponsor name have its OWN SEC filer CIK (registered entity — a
1884
+ * subsidiary, or a private co that filed a Form D / draft S-1)? browse-edgar
1885
+ * orders by relevance so the leading <cik> tags are the closest matches; each
1886
+ * candidate is validated against its real name from the submissions API
1887
+ * (which doubles as the entity profile). Returns {cik, profile} or null. */
1888
+ async function edgarOwnCik(sponsor: string): Promise<{ cik: string; profile: EntityProfile } | null> {
1889
+ const sig = significantTokens(sponsor);
1890
+ const need = Math.min(2, sig.length || 1);
1891
+ for (const q of secQueryVariants(sponsor)) {
1892
+ const url = `https://www.sec.gov/cgi-bin/browse-edgar?company=${encodeURIComponent(q)}&action=getcompany&type=&dateb=&owner=include&count=10&output=atom`;
1893
+ const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } }).catch(() => null);
1894
+ if (!res || !res.ok) continue;
1895
+ const xml = await res.text();
1896
+ const ciks = [...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1])));
1897
+ for (const cik of [...new Set(ciks)].slice(0, 3)) {
1898
+ const profile = await edgarEntityProfile(cik);
1899
+ if (!profile?.name) continue;
1900
+ const nameToks = new Set(significantTokens(profile.name));
1901
+ const overlap = sig.filter((t) => nameToks.has(t)).length;
1902
+ if (overlap >= need) return { cik, profile };
1903
+ }
1904
+ if (ciks.length) break; // first variant that returned candidates is authoritative
1905
+ }
1906
+ return null;
1907
+ }
1908
+
1909
+ interface ListedFiler { name: string; ticker: string; cik: string; hits: number }
1910
+
1911
+ /** EDGAR full-text: LISTED filers (display_name carries a ticker) whose 10-Ks
1912
+ * mention the sponsor, ranked by mention count. */
1913
+ async function fullTextListedFilers(sponsor: string): Promise<{ candidates: ListedFiler[]; total: number }> {
1914
+ // SEC filings write "&", not "and" — normalize so "Merck Sharp and Dohme"
1915
+ // matches the same volume as "Merck Sharp & Dohme".
1916
+ const phrase = sponsor.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
1917
+ const params = new URLSearchParams({ q: `"${phrase}"`, forms: '10-K', from: '0', size: '100' });
1918
+ const res = await pwFetch(`${EFTS_BASE}/search-index?${params}`, { headers: SEC_HEADERS });
1919
+ if (!res.ok) return { candidates: [], total: 0 };
1920
+ const data = (await res.json()) as {
1921
+ hits?: { total?: { value?: number }; hits?: { _source?: { display_names?: string[] } }[] };
1922
+ };
1923
+ const byCik = new Map<string, ListedFiler>();
1924
+ for (const h of data.hits?.hits ?? []) {
1925
+ for (const dn of h._source?.display_names ?? []) {
1926
+ // "Merck & Co. Inc. (MRK) (CIK 0000310158)" — parse name, ticker?, cik.
1927
+ const parens = [...dn.matchAll(/\(([^)]+)\)/g)].map((m) => m[1].trim());
1928
+ const cikPart = parens.find((p) => /^CIK\s*\d+/i.test(p));
1929
+ const cik = cikPart ? String(Number(cikPart.replace(/CIK\s*/i, ''))) : '';
1930
+ const ticker = parens.find((p) => /^[A-Z][A-Z.]{0,5}$/.test(p) && !/^CIK/i.test(p));
1931
+ if (!cik || !ticker) continue; // listed filers only
1932
+ const name = dn.slice(0, dn.indexOf('(')).trim();
1933
+ const cur = byCik.get(cik);
1934
+ if (cur) cur.hits += 1;
1935
+ else byCik.set(cik, { name, ticker, cik, hits: 1 });
1936
+ }
1937
+ }
1938
+ const candidates = [...byCik.values()].sort((a, b) => b.hits - a.hits);
1939
+ return { candidates, total: data.hits?.total?.value ?? 0 };
1940
+ }
1941
+
1942
+ interface EntityProfile { name?: string; state?: string; recent_forms?: string[] }
1943
+
1944
+ /** Best-effort entity profile for a bare CIK — forms filed + state, to explain
1945
+ * an "unlisted" verdict (e.g. "only Form D" ⇒ private placement). */
1946
+ async function edgarEntityProfile(cik: string): Promise<EntityProfile | null> {
1947
+ try {
1948
+ const padded = cik.padStart(10, '0');
1949
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${padded}.json`, { headers: SEC_HEADERS });
1950
+ if (!res.ok) return null;
1951
+ const d = (await res.json()) as { name?: string; stateOfIncorporation?: string; filings?: { recent?: { form?: string[] } } };
1952
+ const forms = [...new Set(d.filings?.recent?.form ?? [])].slice(0, 8);
1953
+ return { name: d.name, state: d.stateOfIncorporation, recent_forms: forms };
1954
+ } catch {
1955
+ return null;
1956
+ }
1957
+ }
1958
+
1959
+ function filerOut(r: SecTickerRow) {
1960
+ return { company_name: r.title, ticker: r.ticker, cik: String(r.cik_str), cik_padded: String(r.cik_str).padStart(10, '0') };
1961
+ }
1962
+
1963
+ async function sponsorToFiler(sponsor: string): Promise<unknown> {
1964
+ if (typeof sponsor !== 'string' || !sponsor.trim()) {
1965
+ return {
1966
+ error: 'Required argument "sponsor" is missing or empty.',
1967
+ retry_hint: 'Pass an organization name, e.g. sponsor_to_filer({ sponsor: "Merck Sharp and Dohme" }).',
1968
+ };
1969
+ }
1970
+ const name = sponsor.trim();
1971
+ const disc = distinctiveToken(name);
1972
+ const tickers = await fetchCompanyTickers();
1973
+
1974
+ // 1) Direct listed match (exact / prefix / whole-word — not loose substring).
1975
+ const ranked = rankMatches(name, tickers, (r) => r.title);
1976
+ if (ranked.length && ranked[0].score >= 2) {
1977
+ const m = ranked[0].item;
1978
+ return {
1979
+ status: 'resolved',
1980
+ method: 'direct',
1981
+ confidence: ranked[0].score >= 3 ? 'high' : 'medium',
1982
+ sponsor: name,
1983
+ filer: filerOut(m),
1984
+ alternatives: ranked.slice(1, 4).map((x) => filerOut(x.item)),
1985
+ };
1986
+ }
1987
+
1988
+ // Own SEC CIK? (registered filer entity — subsidiary or private that filed)
1989
+ const own = await edgarOwnCik(name).catch(() => null);
1990
+ if (own) {
1991
+ const listedSelf = tickers.find((t) => String(t.cik_str) === own.cik);
1992
+ if (listedSelf) {
1993
+ return { status: 'resolved', method: 'direct_cik', confidence: 'high', sponsor: name, filer: filerOut(listedSelf) };
1994
+ }
1995
+ }
1996
+
1997
+ // 3) Full-text: listed filers referencing the sponsor; confirm a parent by
1998
+ // requiring the sponsor's distinctive token in the filer's name (kills
1999
+ // "merely mentioned by an unrelated filer" false positives).
2000
+ const ft = await fullTextListedFilers(name).catch(() => ({ candidates: [] as ListedFiler[], total: 0 }));
2001
+ const confirmed = ft.candidates.find((c) => normName(c.name).split(' ').includes(disc));
2002
+ if (confirmed) {
2003
+ return {
2004
+ status: 'resolved_via_parent',
2005
+ method: 'subsidiary_fulltext',
2006
+ confidence: confirmed.hits >= 10 ? 'high' : 'medium',
2007
+ sponsor: name,
2008
+ filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
2009
+ evidence: {
2010
+ parent_mention_filings: confirmed.hits,
2011
+ distinctive_token: disc,
2012
+ own_subsidiary_cik: own?.cik ?? null,
2013
+ own_subsidiary_name: own?.profile?.name ?? null,
2014
+ note: 'Sponsor appears to be an operating subsidiary. Resolved to the listed parent whose SEC filings reference it and whose name shares the sponsor\'s distinctive token.',
2015
+ },
2016
+ alternatives: ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits })),
2017
+ };
2018
+ }
2019
+
2020
+ // 4) No confirmed parent — classify the miss honestly.
2021
+ const mentions = ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits }));
2022
+ if (own) {
2023
+ return {
2024
+ status: 'us_registrant_unlisted',
2025
+ confidence: 'high',
2026
+ sponsor: name,
2027
+ own_cik: own.cik,
2028
+ likely: 'private',
2029
+ message: `"${name}" is an SEC-registered entity (CIK ${own.cik}) but is not publicly listed (no ticker) and has no dominant listed parent in EDGAR filings. Most likely a private company (e.g. a Form D private placement or draft/withdrawn registration) or a subsidiary of a private or non-US parent.`,
2030
+ entity: own.profile,
2031
+ full_text_mentions: mentions,
2032
+ };
2033
+ }
2034
+ return {
2035
+ status: 'no_us_registrant',
2036
+ confidence: 'high',
2037
+ sponsor: name,
2038
+ likely: 'non_us_listed_or_private',
2039
+ message: `No US SEC registrant found for "${name}". The sponsor is not US-listed and has no US SEC CIK — most likely a non-US-listed company (foreign exchange) or a private company. Any full-text mentions below are other filers referencing the name, not the sponsor itself.`,
2040
+ full_text_mentions: mentions,
2041
+ };
426
2042
  }
427
2043
 
428
2044
  async function resolveCik(tickerOrCik: string): Promise<string> {
429
2045
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2046
+ // Names EVERY accepted spelling, because `required` is empty on these
2047
+ // schemas (a flat list cannot say "one of these three") so this is the
2048
+ // only place a caller is told what to send. The words "is required" are
2049
+ // load-bearing: error-class.ts books the refusal as user_error rather
2050
+ // than the `error` bucket that feeds Problem Tools (fleet #2069).
2051
+ // No tool name in the example: resolveCik() is shared by four tools, and a
2052
+ // refusal that says `edgar_company_filings(...)` to a caller of
2053
+ // edgar_company_facts hands it the wrong word to copy — which is the exact
2054
+ // failure this whole change exists to stop.
430
2055
  throw new Error(
431
- 'Required argument "ticker_or_cik" is missing or empty. ' +
432
- 'Try one of: edgar_company_filings({ticker_or_cik: "AAPL"}) by ticker, ' +
433
- 'or edgar_company_filings({ticker_or_cik: "320193"}) by CIK number (Apple\'s CIK). ' +
434
- 'Tickers are auto-resolved to CIKs internally.'
2056
+ 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — ' +
2057
+ 'all three take a ticker ("AAPL") or a CIK ("320193"), and tickers are auto-resolved.'
435
2058
  );
436
2059
  }
437
2060
  if (isNumericCik(tickerOrCik)) return tickerOrCik.trim();
@@ -439,62 +2062,110 @@ async function resolveCik(tickerOrCik: string): Promise<string> {
439
2062
  return result.cik;
440
2063
  }
441
2064
 
442
- async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
443
- const cik = await resolveCik(tickerOrCik);
444
- const paddedCik = padCik(cik);
445
- const count = Math.min(40, Math.max(1, limit ?? 20));
446
-
447
- const res = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
448
- if (!res.ok) throw new Error(`SEC EDGAR submissions error: ${res.status}`);
2065
+ /**
2066
+ * A 404 from data.sec.gov/submissions/ means the CIK simply isn't a registrant —
2067
+ * that's a caller mistake, not an EDGAR outage, so say which CIK failed and how to
2068
+ * get a real one instead of surfacing a bare status code.
2069
+ */
2070
+ function submissionsError(status: number, tickerOrCik: string, paddedCik: string): Error {
2071
+ if (status === 404) {
2072
+ return new Error(
2073
+ `No SEC registrant with CIK ${paddedCik} (resolved from "${tickerOrCik}"). ` +
2074
+ 'That CIK does not exist in EDGAR — the company may be private, foreign-listed, or the ' +
2075
+ 'identifier may be wrong. Resolve a real one with edgar_ticker_to_cik({ticker: "…"}), ' +
2076
+ 'which also accepts company names and returns `alternatives` for disambiguation.'
2077
+ );
2078
+ }
2079
+ return new Error(`SEC EDGAR submissions error: ${status}`);
2080
+ }
449
2081
 
450
- const data = (await res.json()) as {
451
- cik: string;
452
- name: string;
453
- sic: string;
454
- sicDescription: string;
455
- stateOfIncorporation: string;
456
- fiscalYearEnd: string;
457
- tickers: string[];
458
- filings: {
459
- recent: {
460
- accessionNumber: string[];
461
- filingDate: string[];
462
- form: string[];
463
- primaryDocument: string[];
464
- items: string[];
465
- size: number[];
466
- };
2082
+ interface SubmissionsPayload {
2083
+ cik: string;
2084
+ name: string;
2085
+ sic: string;
2086
+ sicDescription: string;
2087
+ stateOfIncorporation: string;
2088
+ fiscalYearEnd: string;
2089
+ tickers: string[];
2090
+ filings: {
2091
+ recent: {
2092
+ accessionNumber: string[];
2093
+ filingDate: string[];
2094
+ form: string[];
2095
+ primaryDocument: string[];
2096
+ items: string[];
2097
+ size: number[];
467
2098
  };
468
2099
  };
2100
+ }
469
2101
 
470
- const recent = data.filings.recent;
471
- const filings: {
472
- accession_number: string;
473
- filing_date: string;
474
- form: string;
475
- primary_document: string;
476
- document_url: string;
477
- }[] = [];
2102
+ interface FilingRow {
2103
+ accession_number: string;
2104
+ filing_date: string;
2105
+ form: string;
2106
+ items?: string[];
2107
+ primary_document: string;
2108
+ document_url: string;
2109
+ }
2110
+
2111
+ async function fetchSubmissions(tickerOrCik: string, paddedCik: string): Promise<SubmissionsPayload> {
2112
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2113
+ if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
2114
+ return (await res.json()) as SubmissionsPayload;
2115
+ }
478
2116
 
2117
+ /**
2118
+ * Map the submissions payload's column-oriented `recent` block to filing rows.
2119
+ * `keep` decides per form; `count` caps the output after filtering.
2120
+ */
2121
+ function recentFilingRows(data: SubmissionsPayload, keep: (form: string) => boolean, count: number): FilingRow[] {
2122
+ const recent = data.filings.recent;
2123
+ const filings: FilingRow[] = [];
479
2124
  for (let i = 0; i < recent.accessionNumber.length && filings.length < count; i++) {
480
2125
  const form = recent.form[i];
481
- if (formType && form !== formType) continue;
2126
+ if (!keep(form)) continue;
482
2127
 
483
2128
  const accession = recent.accessionNumber[i];
484
2129
  const accessionPath = accession.replace(/-/g, '');
2130
+ // submissions payload carries `items` as a comma-joined string per filing,
2131
+ // populated for 8-Ks (e.g. "3.01,9.01"). Split to an array; the item code
2132
+ // (3.01 delisting vs 1.01 agreement) is the signal, so no second fetch needed.
2133
+ const rawItems = recent.items?.[i] ?? '';
2134
+ const items = rawItems
2135
+ ? rawItems.split(',').map((s) => s.trim()).filter(Boolean)
2136
+ : undefined;
485
2137
  filings.push({
486
2138
  accession_number: accession,
487
2139
  filing_date: recent.filingDate[i],
488
2140
  form,
2141
+ ...(items && items.length ? { items } : {}),
489
2142
  primary_document: recent.primaryDocument[i],
490
2143
  document_url: `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accessionPath}/${recent.primaryDocument[i]}`,
491
2144
  });
492
2145
  }
2146
+ return filings;
2147
+ }
2148
+
2149
+ async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
2150
+ const cik = await resolveCik(tickerOrCik);
2151
+ const paddedCik = padCik(cik);
2152
+ const count = Math.min(40, Math.max(1, limit ?? 20));
2153
+
2154
+ const data = await fetchSubmissions(tickerOrCik, paddedCik);
2155
+ const filings = recentFilingRows(data, (form) => !formType || form === formType, count);
493
2156
 
494
2157
  return {
495
2158
  cik: data.cik,
496
2159
  company_name: data.name,
497
2160
  tickers: data.tickers ?? [],
2161
+ // The NUMERIC code as well as the prose (fleet #2124). `sic_description`
2162
+ // was already returned but is free text ("Biological Products, (No
2163
+ // Diagnostic Substances)"), so every consumer that wants to branch on
2164
+ // industry has to pattern-match prose that SEC is free to reword. The code
2165
+ // is the fact. entity_profile uses it to decide whether asking the FDA
2166
+ // Purple Book about this filer can possibly return anything; it is already
2167
+ // in the submissions payload, so this costs no extra request.
2168
+ sic: data.sic,
498
2169
  sic_description: data.sicDescription,
499
2170
  state_of_incorporation: data.stateOfIncorporation,
500
2171
  fiscal_year_end: data.fiscalYearEnd,
@@ -503,6 +2174,128 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
503
2174
  };
504
2175
  }
505
2176
 
2177
+ // --- edgar_company_snapshot --------------------------------------------------
2178
+ // The resolve -> list filings -> pull financials chain, collapsed into one call.
2179
+ // Measured in docs/edgar-cohort-retention-2026-09-17.md (fleet #2114 / #2182):
2180
+ // 90%+ of depth EDGAR callers assemble edgar_company_filings +
2181
+ // edgar_company_concept by hand after edgar_ticker_to_cik, three calls every
2182
+ // time; 66% of one-shot callers stop after ticker resolution. Neither
2183
+ // edgar_company_facts (concepts, no filings) nor entity_profile (a cross-source
2184
+ // meta-tool outside this pack, five unfiltered filings, three concepts) gave
2185
+ // a direct-tool caller that result from inside the pack.
2186
+
2187
+ /** Forms listed by default: the substantive periodic/current/proxy reports. Form 4 insider filings dominate a raw feed for any large cap and are what callers filter out first. */
2188
+ const SNAPSHOT_DEFAULT_FORMS = new Set(['10-K', '10-K/A', '10-Q', '10-Q/A', '8-K', '20-F', '40-F', '6-K', 'DEF 14A']);
2189
+
2190
+ async function companySnapshot(tickerOrCik: string, formType?: string, filingsLimit?: number) {
2191
+ const cik = await resolveCik(tickerOrCik);
2192
+ const paddedCik = padCik(cik);
2193
+ const count = Math.min(40, Math.max(1, filingsLimit ?? 10));
2194
+ const filter = (formType ?? '').trim();
2195
+ const keep = !filter
2196
+ ? (form: string) => SNAPSHOT_DEFAULT_FORMS.has(form)
2197
+ : filter.toLowerCase() === 'all'
2198
+ ? () => true
2199
+ : (form: string) => form === filter;
2200
+
2201
+ // Both legs are keyed on the same CIK and independent of each other, so run
2202
+ // them together — this call exists to save round-trips, not to serialize them.
2203
+ const [data, factsRes] = await Promise.all([
2204
+ fetchSubmissions(tickerOrCik, paddedCik),
2205
+ pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS }),
2206
+ ]);
2207
+
2208
+ let filings = recentFilingRows(data, keep, count);
2209
+ let filingsFilter = filter ? filter : 'default (10-K, 10-K/A, 10-Q, 10-Q/A, 8-K, 20-F, 40-F, 6-K, DEF 14A)';
2210
+ // The default set is tuned for operating companies. A fund, trust or ABS
2211
+ // issuer files N-CSR / 485BPOS / 10-D and none of the above, so the default
2212
+ // would hand back an empty list for a registrant with hundreds of filings —
2213
+ // a zero that reads as "nothing filed" (silent-zero policy). Fall back to
2214
+ // every form and SAY so in `filings_filter`, rather than return the empty set.
2215
+ if (!filter && filings.length === 0 && data.filings.recent.accessionNumber.length > 0) {
2216
+ filings = recentFilingRows(data, () => true, count);
2217
+ const forms = [...new Set(filings.map((f) => f.form))].join(', ');
2218
+ filingsFilter = `all — this filer's recent forms (${forms}) include none of the default set, so every form is listed`;
2219
+ }
2220
+
2221
+ // The financials leg degrades on its own: a registrant with no XBRL facts
2222
+ // (funds, trusts, paper-form foreign filers) still has filings worth
2223
+ // returning. The gap is STATED — `financials_status` plus a reason — never
2224
+ // an empty object that reads as "reported nothing" (silent-zero policy).
2225
+ let financials: {
2226
+ financials_status: 'ok' | 'unavailable';
2227
+ financials_note?: string;
2228
+ latest_fiscal_year: number | null;
2229
+ latest_period_end: string | null;
2230
+ key_concepts: Record<string, unknown>;
2231
+ stale_concepts: string[];
2232
+ year_note?: string;
2233
+ };
2234
+ if (factsRes.ok) {
2235
+ const facts = (await factsRes.json()) as {
2236
+ facts: { 'us-gaap'?: Parameters<typeof summarizeAnnualFinancials>[0] };
2237
+ };
2238
+ const usGaap = facts.facts?.['us-gaap'] ?? {};
2239
+ const summary = summarizeAnnualFinancials(usGaap);
2240
+ const reported = Object.values(summary.latest_annual).some((v) => v !== null);
2241
+ financials = reported
2242
+ ? {
2243
+ financials_status: 'ok',
2244
+ latest_fiscal_year: summary.latest_fiscal_year,
2245
+ latest_period_end: summary.latest_period_end,
2246
+ key_concepts: summary.latest_annual,
2247
+ stale_concepts: summary.stale_concepts,
2248
+ year_note: summary.year_note,
2249
+ }
2250
+ : {
2251
+ financials_status: 'unavailable',
2252
+ financials_note: `EDGAR has XBRL facts for CIK ${paddedCik} but none under the us-gaap headline concepts (revenue, net income, assets, ...). Common for filers reporting under IFRS or dei-only tags. edgar_company_facts lists whatever concepts it does report.`,
2253
+ latest_fiscal_year: null,
2254
+ latest_period_end: null,
2255
+ key_concepts: {},
2256
+ stale_concepts: [],
2257
+ };
2258
+ } else {
2259
+ // Drain the body so the connection is released; the status is the fact.
2260
+ await factsRes.text().catch(() => undefined);
2261
+ financials = {
2262
+ financials_status: 'unavailable',
2263
+ financials_note:
2264
+ factsRes.status === 404
2265
+ ? `EDGAR has no XBRL company-facts record for CIK ${paddedCik}. That is normal for funds, trusts, ABS issuers and filers that have never submitted an XBRL-tagged 10-K/10-Q/20-F — the filings above are still complete.`
2266
+ : `SEC EDGAR company-facts returned HTTP ${factsRes.status}; the filings list above is complete, the financials are not. Retry, or call edgar_company_facts directly.`,
2267
+ latest_fiscal_year: null,
2268
+ latest_period_end: null,
2269
+ key_concepts: {},
2270
+ stale_concepts: [],
2271
+ };
2272
+ }
2273
+
2274
+ return {
2275
+ cik: data.cik,
2276
+ cik_padded: paddedCik,
2277
+ company_name: data.name,
2278
+ tickers: data.tickers ?? [],
2279
+ sic: data.sic,
2280
+ sic_description: data.sicDescription,
2281
+ state_of_incorporation: data.stateOfIncorporation,
2282
+ fiscal_year_end: data.fiscalYearEnd,
2283
+ filings_filter: filingsFilter,
2284
+ filings_count: filings.length,
2285
+ filings,
2286
+ ...financials,
2287
+ resources: {
2288
+ edgar_filings: `pipeworx://edgar/company/${paddedCik}/filings`,
2289
+ edgar_facts: `pipeworx://edgar/company/${paddedCik}/facts`,
2290
+ },
2291
+ next: {
2292
+ filing_text: 'edgar_filing_text({accession, section}) — the text of one filing above (section: "risk_factors", "mdna", ...).',
2293
+ concept_history: 'edgar_company_concept({cik, concept: "Revenues"}) — one metric across every reported period.',
2294
+ all_concepts: 'edgar_company_facts({cik}) — every XBRL concept the filer reports.',
2295
+ },
2296
+ };
2297
+ }
2298
+
506
2299
  // ── Filing documents (list docs inside one filing by accession) ─────
507
2300
 
508
2301
  // Normalize an accession number (dashed or undashed) to both forms.
@@ -514,6 +2307,53 @@ function normalizeAccession(raw: string): { dashed: string; nodash: string } | n
514
2307
  return { dashed, nodash: digits };
515
2308
  }
516
2309
 
2310
+ // Read at most maxBytes of a filing document, then stop reading and cancel
2311
+ // the rest of the response body.
2312
+ //
2313
+ // Not a nicety: a SpaceX S-1 primary document is 11.8 MB of raw HTML (fleet
2314
+ // #440 — confirmed by fetching it directly), and SEC's archive server does
2315
+ // not honor Range requests (measured: a `Range: bytes=0-999` GET still comes
2316
+ // back 200 with the full 11.8 MB, not 206). materialising that and then
2317
+ // running htmlToText's regex passes over all of it inside one Worker
2318
+ // invocation is what hits the CPU/isolate ceiling and gets the whole request
2319
+ // killed with a bare Cloudflare 1102 — no JS exception, so a try/catch
2320
+ // around the fetch (see filingDocuments' includePrimaryText path) does NOT
2321
+ // protect against this failure mode. The cap has to be enforced on OUR side
2322
+ // of the socket. Mirrors mcps/legislation-uk/src/index.ts's getTextBounded,
2323
+ // which fixed the identical shape of bug for a 5.9 MB Act.
2324
+ async function fetchTextBounded(url: string, headers: Record<string, string>, maxBytes: number): Promise<{ text: string; truncated: boolean }> {
2325
+ const res = await pwFetch(url, { headers });
2326
+ if (!res.ok) throw new Error(`Failed to fetch primary document (HTTP ${res.status}): ${url}`);
2327
+ if (!res.body) {
2328
+ const whole = await res.text();
2329
+ return { text: whole.length > maxBytes ? whole.slice(0, maxBytes) : whole, truncated: whole.length > maxBytes };
2330
+ }
2331
+ const reader = res.body.getReader();
2332
+ const chunks: Uint8Array[] = [];
2333
+ let total = 0;
2334
+ let truncated = false;
2335
+ for (;;) {
2336
+ const { done, value } = await reader.read();
2337
+ if (done) break;
2338
+ if (value) {
2339
+ total += value.byteLength;
2340
+ chunks.push(value);
2341
+ if (total >= maxBytes) { truncated = true; await reader.cancel(); break; }
2342
+ }
2343
+ }
2344
+ const buf = new Uint8Array(total);
2345
+ let off = 0;
2346
+ for (const c of chunks) { buf.set(c, off); off += c.byteLength; }
2347
+ return { text: new TextDecoder().decode(buf), truncated };
2348
+ }
2349
+ // Cap on RAW HTML bytes read, not plaintext chars — HTML markup overhead in
2350
+ // SEC filings (heavy inline styling/XBRL tagging) means this yields roughly
2351
+ // 1.5-3x fewer plaintext chars, comfortably above FILING_TEXT_CAP for the
2352
+ // front-loaded sections (Business, Risk Factors, MD&A) most questions ask
2353
+ // about. A doc capped here is flagged `raw_truncated: true` — an honest
2354
+ // stated limit, not a silent gap — rather than never answering at all.
2355
+ const FILING_DOC_MAX_BYTES = 4_000_000;
2356
+
517
2357
  // Strip an HTML document to readable plaintext. Workers have no DOMParser;
518
2358
  // drop script/style blocks, remove tags, decode entities, collapse whitespace.
519
2359
  function htmlToText(html: string): string {
@@ -536,7 +2376,7 @@ async function filingDocuments(
536
2376
  ) {
537
2377
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
538
2378
  throw new Error(
539
- 'A ticker or CIK is required. Pass ticker ("AAPL") or cik ("320193"), plus either an accession or a form_type (e.g. edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the latest 10-K).',
2379
+ 'A company is required: pass `ticker` ("AAPL"), `cik` ("320193") or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the latest 10-K).',
540
2380
  );
541
2381
  }
542
2382
  const cik = await resolveCik(tickerOrCik);
@@ -552,7 +2392,7 @@ async function filingDocuments(
552
2392
  }
553
2393
  if (!acc) {
554
2394
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
555
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
2395
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
556
2396
  if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
557
2397
  const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
558
2398
  const r = sub.filings?.recent;
@@ -577,7 +2417,7 @@ async function filingDocuments(
577
2417
  let primaryDocument: string | null = null;
578
2418
  let primaryDocDescription: string | null = null;
579
2419
  try {
580
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2420
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
581
2421
  if (subRes.ok) {
582
2422
  const sub = (await subRes.json()) as {
583
2423
  name?: string;
@@ -608,13 +2448,13 @@ async function filingDocuments(
608
2448
  }
609
2449
 
610
2450
  const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
611
- const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
2451
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
612
2452
  if (idxRes.status === 404) {
613
2453
  throw new Error(
614
2454
  `No filing found for accession ${acc.dashed} — that accession number does not exist in SEC EDGAR (double-check the digits; it may belong to a different filer). Use edgar_company_filings({ticker_or_cik: "${tickerOrCik}"}) to list this company's real accession numbers.`,
615
2455
  );
616
2456
  }
617
- if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
2457
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
618
2458
  const idx = (await idxRes.json()) as {
619
2459
  directory?: { item?: { name: string; type?: string; size?: string | number; 'last-modified'?: string }[] };
620
2460
  };
@@ -640,26 +2480,299 @@ async function filingDocuments(
640
2480
  documents,
641
2481
  };
642
2482
 
643
- if (includePrimaryText && primaryDocument) {
644
- try {
645
- const docUrl = `${folder}/${primaryDocument}`;
646
- const docRes = await fetch(docUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
647
- if (docRes.ok) {
648
- const body = await docRes.text();
649
- const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
650
- const text = isHtml ? htmlToText(body) : body;
651
- result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
2483
+ if (includePrimaryText && primaryDocument) {
2484
+ try {
2485
+ const docUrl = `${folder}/${primaryDocument}`;
2486
+ const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
2487
+ const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
2488
+ const text = isHtml ? htmlToText(body) : body;
2489
+ result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
2490
+ if (rawTruncated) result.primary_text_raw_truncated = true;
2491
+ } catch (e) {
2492
+ result.primary_text = null;
2493
+ result.primary_text_error = String(e);
2494
+ }
2495
+ }
2496
+
2497
+ return result;
2498
+ }
2499
+
2500
+ // Heading patterns for the `section` slice of edgar_filing_text. Each filing's
2501
+ // MD&A / notes carry these standard headings; we locate the LAST occurrence
2502
+ // (the body, not the table-of-contents entry) and return a window from there.
2503
+ // Each section: an anchor regex plus whether to take the FIRST match (the actual
2504
+ // disclosure body — used where the phrase is rare and meaningful) or the LAST
2505
+ // (used where the heading also appears in the table of contents, so the body is
2506
+ // the later occurrence). going_concern anchors on "substantial doubt" — the
2507
+ // ASC 205-40 trigger phrase — because "going concern" alone recurs in boilerplate.
2508
+ const FILING_SECTION_PATTERNS: Record<string, { anchor: RegExp; pick: 'first' | 'last' }> = {
2509
+ going_concern: { anchor: /substantial doubt|going concern/gi, pick: 'first' },
2510
+ liquidity: { anchor: /liquidity and capital resources|liquidity/gi, pick: 'last' },
2511
+ capital_resources: { anchor: /capital resources/gi, pick: 'last' },
2512
+ subsequent_events: { anchor: /subsequent events/gi, pick: 'last' },
2513
+ };
2514
+
2515
+ const FILING_TEXT_DEFAULT_MAX = 50000;
2516
+ const FILING_TEXT_CAP = 100000;
2517
+
2518
+ // fleet #2446: a KPI question ("how many tests did $TICKER process") routed
2519
+ // correctly to edgar_filing_text but the fact sat ~100k characters into a
2520
+ // 10-Q's MD&A, which meant paging max_chars windows by hand to find it. This
2521
+ // is a plain case-insensitive substring scan of the ALREADY-EXTRACTED plain-
2522
+ // text document (not a second fetch) — cheap, and it runs before the normal
2523
+ // offset/max_chars windowing so it sees the whole doc (or the whole `section`
2524
+ // slice) rather than one page of it.
2525
+ const SEARCH_CONTEXT_CHARS = 400;
2526
+ const SEARCH_MAX_PASSAGES = 10;
2527
+
2528
+ // A KPI figure looks like "2,056,800" or "1,708,200" — thousands-grouped, or
2529
+ // otherwise a bare run of 4+ digits (a raw count with no separator).
2530
+ const NEARBY_NUMBER_RE = /\d{1,3}(?:,\d{3})+|\d{4,}/;
2531
+
2532
+ function findPassages(
2533
+ text: string,
2534
+ term: string,
2535
+ contextChars = SEARCH_CONTEXT_CHARS,
2536
+ maxPassages = SEARCH_MAX_PASSAGES,
2537
+ ): { total: number; distinct_locations: number; passages: Array<{ offset: number; text: string }> } {
2538
+ const needle = term.toLowerCase();
2539
+ if (!needle) return { total: 0, distinct_locations: 0, passages: [] };
2540
+ const hay = text.toLowerCase();
2541
+ const offsets: number[] = [];
2542
+ let from = 0;
2543
+ for (;;) {
2544
+ const idx = hay.indexOf(needle, from);
2545
+ if (idx === -1) break;
2546
+ offsets.push(idx);
2547
+ from = idx + needle.length;
2548
+ }
2549
+
2550
+ // Merge occurrences close enough that their context windows would overlap
2551
+ // into ONE passage — a KPI sentence ("we processed approximately X tests,
2552
+ // compared to Y tests processed") can repeat the search term 2-3 times a
2553
+ // few words apart, and without this a common word burns most of the
2554
+ // (capped) passage budget on one sentence instead of covering the document.
2555
+ const groups: Array<{ first: number; last: number }> = [];
2556
+ for (const idx of offsets) {
2557
+ const g = groups[groups.length - 1];
2558
+ if (g && idx - g.last <= contextChars * 2) g.last = idx;
2559
+ else groups.push({ first: idx, last: idx });
2560
+ }
2561
+
2562
+ // Rank groups whose window carries a nearby NUMBER first. A KPI question
2563
+ // ("how many tests/units/subscribers") is answered by a figure next to the
2564
+ // word, not by every mention of the word — measured live (fleet #2446):
2565
+ // searching "tests" in Natera's Q2 2026 10-Q hits 78 times, mostly
2566
+ // forward-looking-statements boilerplate ("expectations... for our
2567
+ // tests"), with the actual "processed approximately 2,056,800 tests" KPI
2568
+ // buried past the first ~80 of them by document order. Ranking by nearby
2569
+ // digits (no second fetch — same text already in hand) reliably surfaces
2570
+ // it inside a small `maxPassages` cap instead of requiring the caller to
2571
+ // already know the more distinctive phrase to search for. Groups without a
2572
+ // nearby number keep DOCUMENT ORDER among themselves (stable sort).
2573
+ const scored = groups.map((g) => {
2574
+ const start = Math.max(0, g.first - contextChars);
2575
+ const end = Math.min(text.length, g.last + needle.length + contextChars);
2576
+ return { start, end, hasNumber: NEARBY_NUMBER_RE.test(text.slice(start, end)) ? 1 : 0 };
2577
+ });
2578
+ scored.sort((a, b) => b.hasNumber - a.hasNumber);
2579
+
2580
+ const passages = scored.slice(0, maxPassages).map((s) => ({ offset: s.start, text: text.slice(s.start, s.end) }));
2581
+ return { total: offsets.length, distinct_locations: groups.length, passages };
2582
+ }
2583
+
2584
+ // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
2585
+ // out (rather than piggybacking edgar_filing_documents' include_primary_text,
2586
+ // which caps at 40k and can't page) lets the register pull deep disclosures —
2587
+ // going-concern language, ATM facilities, share caps — that live past 40k.
2588
+ async function filingText(
2589
+ accession: string | undefined,
2590
+ tickerOrCik: string,
2591
+ section?: string,
2592
+ maxChars?: number,
2593
+ offset?: number,
2594
+ wantFormTypeArg?: string,
2595
+ searchTerm?: string,
2596
+ ) {
2597
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2598
+ throw new Error('A company is required: pass `ticker` ("ACTU"), `cik` or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_text({ticker:"ACTU", form_type:"10-Q"})).');
2599
+ }
2600
+ const cik = await resolveCik(tickerOrCik);
2601
+ const paddedCik = padCik(cik);
2602
+ const cikNoZeros = String(Number(cik.replace(/\D/g, '')));
2603
+
2604
+ let acc = accession ? normalizeAccession(accession) : null;
2605
+ if (accession && !acc) {
2606
+ throw new Error(`Invalid accession "${accession}". A SEC accession is 18 digits shaped 10-2-6 (e.g. "0001683168-26-003909"). Get one from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`);
2607
+ }
2608
+
2609
+ // One submissions fetch serves both auto-resolution and metadata enrichment.
2610
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
2611
+ if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) for ${tickerOrCik}.`);
2612
+ const sub = (await subRes.json()) as {
2613
+ name?: string;
2614
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[]; primaryDocument?: string[] } };
2615
+ };
2616
+ const r = sub.filings?.recent;
2617
+ const accs = r?.accessionNumber ?? [];
2618
+ const forms = r?.form ?? [];
2619
+
2620
+ if (!acc) {
2621
+ const wantForm = wantFormTypeArg?.trim().toUpperCase();
2622
+ const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
2623
+ if (idx < 0 || !accs[idx]) {
2624
+ throw new Error(`No ${wantForm ?? 'recent'} filing found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
2625
+ }
2626
+ acc = normalizeAccession(accs[idx]);
2627
+ if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
2628
+ }
2629
+
2630
+ const i = accs.indexOf(acc.dashed);
2631
+ const formType = i >= 0 ? forms[i] ?? null : null;
2632
+ const filingDate = i >= 0 ? r?.filingDate?.[i] ?? null : null;
2633
+ let primaryDocument = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
2634
+
2635
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
2636
+ // If the accession wasn't in the recent index (older filing), fall back to the
2637
+ // folder index to find the primary document.
2638
+ if (!primaryDocument) {
2639
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
2640
+ if (idxRes.status === 404) throw new Error(`No filing found for accession ${acc.dashed} — it does not exist in SEC EDGAR for this filer. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"}) to list real accessions.`);
2641
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
2642
+ const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
2643
+ const docs = idxJson.directory?.item ?? [];
2644
+ // Prefer the main htm document that isn't an exhibit/XBRL artifact.
2645
+ primaryDocument =
2646
+ docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
2647
+ if (!primaryDocument) throw new Error(`Could not identify the primary document for accession ${acc.dashed}. Use edgar_filing_documents to list the filing's files and fetch one by URL.`);
2648
+ }
2649
+
2650
+ const docUrl = `${folder}/${primaryDocument}`;
2651
+ const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
2652
+ const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
2653
+ let fullText = isHtml ? htmlToText(body) : body;
2654
+
2655
+ // Optional section slice: locate the section heading (last occurrence = the
2656
+ // real body, past any table-of-contents mention) and reframe the text to start
2657
+ // there. `section` is best-effort — an unmatched section returns the whole doc.
2658
+ let sectionApplied: string | null = null;
2659
+ let sectionFound: string | boolean | null = null;
2660
+ const sec = (section ?? '').trim().toLowerCase();
2661
+ if (sec) {
2662
+ const conf = FILING_SECTION_PATTERNS[sec];
2663
+ if (!conf) {
2664
+ sectionFound = `unknown_section:${sec}`;
2665
+ } else {
2666
+ const idxs: number[] = [];
2667
+ for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
2668
+ const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
2669
+ if (chosen >= 0) {
2670
+ // Back up a little so the section heading itself is included, not clipped mid-phrase.
2671
+ fullText = fullText.slice(Math.max(0, chosen - 120));
2672
+ sectionApplied = sec;
2673
+ sectionFound = true;
652
2674
  } else {
653
- result.primary_text = null;
654
- result.primary_text_error = `fetch ${docRes.status}`;
2675
+ sectionFound = false;
655
2676
  }
656
- } catch (e) {
657
- result.primary_text = null;
658
- result.primary_text_error = String(e);
659
2677
  }
660
2678
  }
661
2679
 
662
- return result;
2680
+ // `search` short-circuits the normal offset/max_chars windowing: it scans
2681
+ // the whole (possibly section-sliced) document and returns matching
2682
+ // passages with their own offsets, rather than one page of raw text the
2683
+ // caller has to eyeball for the fact they wanted (fleet #2446).
2684
+ const search = (searchTerm ?? '').trim();
2685
+ if (search) {
2686
+ const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, search);
2687
+ return {
2688
+ accession: acc.dashed,
2689
+ cik: cikNoZeros,
2690
+ company: sub.name ?? null,
2691
+ form: formType,
2692
+ filed: filingDate,
2693
+ document: primaryDocument,
2694
+ document_url: docUrl,
2695
+ section: sectionApplied,
2696
+ ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2697
+ total_chars: fullText.length,
2698
+ search: {
2699
+ term: search,
2700
+ total_matches: totalMatches,
2701
+ distinct_locations: distinctLocations,
2702
+ returned: passages.length,
2703
+ truncated: distinctLocations > passages.length,
2704
+ passages,
2705
+ note: totalMatches === 0
2706
+ ? 'No case-insensitive match for this exact wording in the document (or section, if one was given). That is a real answer, not a failure — retry with a shorter phrase or drop `section` to search the whole document.'
2707
+ : `Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order. Each passage's offset is a position in the document text — pass it as \`offset\` (without \`search\`) to read more surrounding text via the normal paged text field.`,
2708
+ },
2709
+ ...(rawTruncated
2710
+ ? {
2711
+ raw_truncated: true,
2712
+ raw_truncated_note: `This document's raw HTML exceeds the ${(FILING_DOC_MAX_BYTES / 1_000_000).toFixed(0)}MB this tool reads in one call, so the search above only scanned the portion read — NOT the whole filing. Treat total_matches:0 as inconclusive, not "absent", for a raw_truncated document.`,
2713
+ }
2714
+ : {}),
2715
+ };
2716
+ }
2717
+
2718
+ const total = fullText.length;
2719
+ const off = Math.max(0, Math.floor(Number(offset) || 0));
2720
+ const cap = Math.min(FILING_TEXT_CAP, Math.max(1000, Math.floor(Number(maxChars) || FILING_TEXT_DEFAULT_MAX)));
2721
+ const slice = fullText.slice(off, off + cap);
2722
+ const end = off + slice.length;
2723
+ const truncated = end < total;
2724
+
2725
+ return {
2726
+ accession: acc.dashed,
2727
+ cik: cikNoZeros,
2728
+ company: sub.name ?? null,
2729
+ form: formType,
2730
+ filed: filingDate,
2731
+ document: primaryDocument,
2732
+ document_url: docUrl,
2733
+ section: sectionApplied,
2734
+ ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2735
+ total_chars: total,
2736
+ offset: off,
2737
+ returned_chars: slice.length,
2738
+ truncated,
2739
+ // When truncated, pass this back as `offset` to page forward; do NOT spill the
2740
+ // whole doc to disk — request the next window instead.
2741
+ next_offset: truncated ? end : null,
2742
+ ...(rawTruncated
2743
+ ? {
2744
+ raw_truncated: true,
2745
+ raw_truncated_note: `This document's raw HTML exceeds the ${(FILING_DOC_MAX_BYTES / 1_000_000).toFixed(0)}MB this tool reads in one call, so total_chars/text above reflect only the portion read — NOT the whole filing. A section search (going_concern/liquidity/capital_resources/subsequent_events) that picks the LAST occurrence may miss content past this cutoff, since large filings' later sections (financial statement notes, subsequent events) can fall beyond it. Treat section_found:false as inconclusive, not "absent", for a raw_truncated document.`,
2746
+ }
2747
+ : {}),
2748
+ text: slice,
2749
+ };
2750
+ }
2751
+
2752
+ // data.sec.gov answers a bare 404 both when no filer has the CIK at all and when
2753
+ // the filer exists but has never submitted XBRL financial data (investment funds,
2754
+ // trusts, individuals, most pre-2011 foreign private issuers). "SEC EDGAR company
2755
+ // facts error: 404" told the caller neither, and booked as `error` (the tier
2756
+ // meaning a Pipeworx defect) instead of the caller's CIK being the problem. The
2757
+ // sibling `sec` pack fixed this same shape (fleet #586); ported here — the two
2758
+ // packs cover the same upstream and had silently drifted. `not_found:` is the
2759
+ // classifier token: the gateway books it as user_error and strips it before the
2760
+ // caller sees it.
2761
+ async function companyFactsNotFound(input: string, paddedCik: string): Promise<Error> {
2762
+ const lookup = 'Resolve a real one with edgar_ticker_to_cik({ticker: "…"}) — it accepts a ticker or a company name.';
2763
+ const sub = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS }).catch(
2764
+ () => null,
2765
+ );
2766
+ if (sub?.ok) {
2767
+ const filer = (await sub.json().catch(() => null)) as { name?: string } | null;
2768
+ const name = filer?.name ? `"${filer.name}"` : 'a filer';
2769
+ return new Error(
2770
+ `not_found: SEC EDGAR knows CIK ${paddedCik} as ${name}, but it has no XBRL financial facts — this filer does not submit XBRL financial statements (typical for investment funds, trusts and individuals). If you meant an operating company, look up its CIK first: ${lookup}`,
2771
+ );
2772
+ }
2773
+ return new Error(
2774
+ `not_found: SEC EDGAR has no company at CIK ${paddedCik} (resolved from "${input}"). EDGAR covers companies registered with the US SEC only. ${lookup}`,
2775
+ );
663
2776
  }
664
2777
 
665
2778
  async function companyFacts(cikOrTicker: string) {
@@ -669,10 +2782,11 @@ async function companyFacts(cikOrTicker: string) {
669
2782
  // resolveCik like every sibling: validates the arg and resolves tickers.
670
2783
  const cik = await resolveCik(cikOrTicker);
671
2784
  const paddedCik = padCik(cik);
672
- const res = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
2785
+ const res = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
673
2786
  headers: SEC_HEADERS,
674
2787
  });
675
- if (!res.ok) throw new Error(`SEC EDGAR company facts error: ${res.status}`);
2788
+ if (res.status === 404) throw await companyFactsNotFound(cikOrTicker, paddedCik);
2789
+ if (!res.ok) throw await httpError(res, 'SEC EDGAR company facts error');
676
2790
 
677
2791
  const data = (await res.json()) as {
678
2792
  cik: number;
@@ -692,52 +2806,22 @@ async function companyFacts(cikOrTicker: string) {
692
2806
  };
693
2807
  };
694
2808
 
2809
+ // Most-recent annual value per concept, with the filer's CURRENT concept
2810
+ // leading and retired concepts flagged stale — see shared/src/xbrl.ts for the
2811
+ // three traps this replaces (fy = filing year, frame on the last filer, and
2812
+ // Microsoft's FY2010 `Revenues` presented as "most recent"). Fleet #594.
695
2813
  const usGaap = data.facts?.['us-gaap'] ?? {};
696
- const KEY_METRICS = [
697
- 'Revenues',
698
- 'RevenueFromContractWithCustomerExcludingAssessedTax',
699
- 'NetIncomeLoss',
700
- 'Assets',
701
- 'Liabilities',
702
- 'StockholdersEquity',
703
- 'CashAndCashEquivalentsAtCarryingValue',
704
- 'EarningsPerShareBasic',
705
- 'EarningsPerShareDiluted',
706
- 'CommonStockSharesOutstanding',
707
- 'OperatingIncomeLoss',
708
- 'GrossProfit',
709
- 'ResearchAndDevelopmentExpense',
710
- ];
711
-
712
- const metrics: Record<
713
- string,
714
- { label: string; most_recent_annual: { year: number; value: number; filed: string } | null }
715
- > = {};
716
-
717
- for (const key of KEY_METRICS) {
718
- const fact = usGaap[key];
719
- if (!fact) continue;
720
-
721
- const usdEntries = fact.units['USD'] ?? fact.units['shares'] ?? [];
722
- // Same fix as companyConcept: don't gate on `frame !== undefined`;
723
- // off-calendar filers (NVDA, AAPL) get their modern annuals dropped.
724
- // Sort by period end (desc) to surface the actual most-recent 10-K.
725
- const annual = usdEntries
726
- .filter((e) => e.form === '10-K' || e.form === '10-K/A')
727
- .sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
728
-
729
- metrics[key] = {
730
- label: fact.label,
731
- most_recent_annual: annual[0]
732
- ? { year: annual[0].fy, value: annual[0].val, filed: annual[0].filed }
733
- : null,
734
- };
735
- }
2814
+ const summary = summarizeAnnualFinancials(usGaap);
736
2815
 
737
2816
  return {
738
2817
  cik: String(data.cik),
739
2818
  company_name: data.entityName,
740
- key_financials: metrics,
2819
+ latest_fiscal_year: summary.latest_fiscal_year,
2820
+ latest_period_end: summary.latest_period_end,
2821
+ latest_annual: summary.latest_annual,
2822
+ key_financials: summary.key_financials,
2823
+ stale_concepts: summary.stale_concepts,
2824
+ year_note: summary.year_note,
741
2825
  available_concepts: Object.keys(usGaap).length,
742
2826
  };
743
2827
  }
@@ -770,12 +2854,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
770
2854
  revenues: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
771
2855
  netincome: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
772
2856
  netincomeloss: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
2857
+ // Without these, "shares outstanding" fell through to the fuzzy name scan,
2858
+ // which matched PreferredStockSharesOutstanding first — and most filers
2859
+ // report 0 preferred. Asking a plain question about Snowflake returned
2860
+ // "0 shares outstanding" for a company with ~330M. A confident zero about a
2861
+ // public company is the worst shape of wrong answer we produce, so the
2862
+ // common-stock tags are named explicitly and ordered.
2863
+ // EntityCommonStockSharesOutstanding is the dei cover-page tag, which is
2864
+ // often fresher than the us-gaap balance-sheet one.
2865
+ sharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
2866
+ commonsharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
2867
+ commonstocksharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding'],
773
2868
  cash: [
774
2869
  'CashAndCashEquivalentsAtCarryingValue',
775
2870
  'Cash',
776
2871
  'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
777
2872
  ],
778
- longtermdebt: ['LongTermDebt', 'LongTermDebtNoncurrent'],
2873
+ longtermdebt: [
2874
+ 'LongTermDebt', 'LongTermDebtNoncurrent',
2875
+ 'LongTermDebtAndCapitalLeaseObligations', 'DebtAndCapitalLeaseObligations',
2876
+ // Post-ASC-842 (2019+) filers and combined-debt filers use different tags.
2877
+ // Also adds debtlongterm* to the company-facts scan token set so the name
2878
+ // scan catches tags where "Debt" precedes "Longterm" (e.g. Ford CIK 37996).
2879
+ 'LongTermDebtAndFinanceLeaseLiability',
2880
+ 'DebtLongtermAndShorttermCombinedAmount',
2881
+ ],
779
2882
  // Common concepts an LLM asks for that were MISSING → 404 (net income was
780
2883
  // covered but these weren't). Keys are bare-alphanumeric (the lookup strips
781
2884
  // spaces/punctuation), so "total assets"/"earnings per share" etc. all match.
@@ -793,7 +2896,9 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
793
2896
  operatingincome: ['OperatingIncomeLoss'],
794
2897
  operatingincomeloss: ['OperatingIncomeLoss'],
795
2898
  // ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
796
- longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
2899
+ longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt', 'LongTermDebtAndFinanceLeaseLiability', 'DebtLongtermAndShorttermCombinedAmount'],
2900
+ longtermdebtandfinanceleaseliability: ['LongTermDebtAndFinanceLeaseLiability', 'LongTermDebt', 'LongTermDebtAndCapitalLeaseObligations'],
2901
+ debtlongtermandshorttermcombinedamount: ['DebtLongtermAndShorttermCombinedAmount', 'LongTermDebt', 'LongTermDebtNoncurrent'],
797
2902
  salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
798
2903
  revenuefromcontractwithcustomerexcludingassessedtax: [
799
2904
  'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
@@ -825,6 +2930,19 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
825
2930
  // only, which silently dropped every foreign filer's data even when the tag
826
2931
  // existed → 404s / empty results.
827
2932
  const ANNUAL_FORMS = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
2933
+ const QUARTERLY_FORMS = new Set(['10-Q', '10-Q/A']);
2934
+ const ALL_PERIODIC_FORMS = new Set([...ANNUAL_FORMS, ...QUARTERLY_FORMS]);
2935
+
2936
+ /** Forms to include for a requested reporting period. Default "all" surfaces
2937
+ * quarterly (10-Q) alongside annual — a metric like Cash moves every quarter,
2938
+ * and returning only the annual 10-K value hid the fresher 10-Q figure
2939
+ * (published an 8-month runway off a 10-K when the 10-Q implied ~5). */
2940
+ function formsForPeriod(period: string | undefined): Set<string> {
2941
+ const p = (period ?? 'all').trim().toLowerCase();
2942
+ if (p === 'annual' || p === 'yearly' || p === 'fy') return ANNUAL_FORMS;
2943
+ if (p === 'quarterly' || p === 'quarter' || p === 'q') return QUARTERLY_FORMS;
2944
+ return ALL_PERIODIC_FORMS;
2945
+ }
828
2946
 
829
2947
  // ifrs-full taxonomy fallback for foreign private issuers (they report under
830
2948
  // ifrs-full, not us-gaap). Keyed by the same normalized metric name; tried only
@@ -845,12 +2963,36 @@ const IFRS_FALLBACK: Record<string, string[]> = {
845
2963
  equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
846
2964
  };
847
2965
 
848
- async function companyConcept(cikOrTicker: string, concept: string) {
2966
+ async function companyConcept(
2967
+ cikOrTicker: string,
2968
+ concept: string,
2969
+ period?: string,
2970
+ fiscalYearArg?: string | number,
2971
+ fiscalPeriodArg?: string,
2972
+ ) {
2973
+ // Checked here rather than by the gateway's declared-`required` pre-flight,
2974
+ // because `cik` accepts three spellings and a flat required list cannot say
2975
+ // "one of these" (see the inputSchema note). Both messages say "is required"
2976
+ // so they classify as a caller error, not as our defect, and both name every
2977
+ // spelling that works — the old gateway refusal named only `cik`, which is
2978
+ // the word the caller had already decided not to use.
2979
+ if (typeof cikOrTicker !== 'string' || !cikOrTicker.trim()) {
2980
+ throw new Error(
2981
+ 'A company is required: pass `cik` (it takes a ticker like "AAPL" or a CIK like "320193"), or its aliases `ticker` / `ticker_or_cik`. Example: edgar_company_concept({cik:"DOV", concept:"NetIncomeLoss"}).',
2982
+ );
2983
+ }
2984
+ if (typeof concept !== 'string' || !concept.trim()) {
2985
+ throw new Error(
2986
+ 'A metric is required: pass `concept` (alias `metric`) — a friendly name like "Revenue", "NetIncomeLoss", "Cash", "LongTermDebt" or "EarningsPerShareDiluted". Example: edgar_company_concept({cik:"DOV", concept:"NetIncomeLoss"}).',
2987
+ );
2988
+ }
2989
+ const forms = formsForPeriod(period);
849
2990
  // Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
850
2991
  // Production analytics: 11% errors on edgar largely from LLMs passing
851
2992
  // tickers as cik (then padCik strips letters → 0000000000 → 404).
852
2993
  const cik = await resolveCik(cikOrTicker);
853
2994
  const paddedCik = padCik(cik);
2995
+ let resolvedEntityName = '';
854
2996
 
855
2997
  // Normalize the lookup key to bare alphanumerics so natural-language input
856
2998
  // ("net income", "Long-Term Debt", "earnings per share") matches the no-space
@@ -870,6 +3012,24 @@ async function companyConcept(cikOrTicker: string, concept: string) {
870
3012
  { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string }[]
871
3013
  >;
872
3014
  };
3015
+ type ConceptFact = ConceptDoc['units'][string][number];
3016
+
3017
+ // SEC's XBRL JSON is normally { units: { "USD": [ ...facts ] } }, but a small
3018
+ // fraction of documents carry a `units` bucket that is not an array (and a
3019
+ // handful omit `units` entirely). `for (const e of values)` on one of those
3020
+ // throws "values is not iterable" — an unguarded internal TypeError, so it
3021
+ // books as OUR defect rather than an upstream data problem, and the caller
3022
+ // gets a message that names a variable in our source and tells them nothing
3023
+ // they can act on. It was firing on roughly 1 call in 318 of
3024
+ // edgar_company_concept, the highest-volume tool on the platform, which is
3025
+ // exactly the kind of rate that never trips an alarm and never gets fixed.
3026
+ //
3027
+ // Skip the malformed bucket rather than failing the whole lookup: a company's
3028
+ // other units almost always still carry the answer, so a partial answer beats
3029
+ // a crash. If every bucket is malformed the caller falls through to the normal
3030
+ // no-data path, which already says something useful.
3031
+ const unitEntries = (units: ConceptDoc['units'] | undefined): [string, ConceptFact[]][] =>
3032
+ Object.entries(units ?? {}).filter((e): e is [string, ConceptFact[]] => Array.isArray(e[1]));
873
3033
 
874
3034
  // Fetch EVERY candidate that exists, then pick the one with the most recent
875
3035
  // 10-K data — not the first non-empty one. Filers switch revenue tags over
@@ -881,16 +3041,16 @@ async function companyConcept(cikOrTicker: string, concept: string) {
881
3041
  const found: { doc: ConceptDoc; latestEnd: string }[] = [];
882
3042
  const tryNamespace = async (ns: string, tags: string[]) => {
883
3043
  for (const candidate of tags) {
884
- const r = await fetch(
3044
+ const r = await pwFetch(
885
3045
  `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
886
3046
  { headers: SEC_HEADERS },
887
3047
  );
888
3048
  if (!r.ok) { lastStatus = r.status; continue; }
889
3049
  const doc = (await r.json()) as ConceptDoc;
890
3050
  let latestEnd = '';
891
- for (const values of Object.values(doc.units)) {
3051
+ for (const [, values] of unitEntries(doc.units)) {
892
3052
  for (const e of values) {
893
- if (!ANNUAL_FORMS.has(e.form)) continue;
3053
+ if (!ALL_PERIODIC_FORMS.has(e.form)) continue;
894
3054
  if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
895
3055
  }
896
3056
  }
@@ -910,19 +3070,26 @@ async function companyConcept(cikOrTicker: string, concept: string) {
910
3070
  // LongTermDebtNoncurrent, many filers use LongTermDebtAndCapitalLease...). Pull
911
3071
  // the full facts once and find the freshest us-gaap tag whose NAME matches the
912
3072
  // concept keywords, preferring balance-sheet stock tags over cash-flow tags.
913
- if (found.length === 0) {
3073
+ // Also trigger when ALL found data is >3 years stale — filer changed tag.
3074
+ const THREE_YEARS_MS = 3 * 365 * 24 * 3600 * 1000;
3075
+ const bestFoundMs = found.length > 0 ? Math.max(...found.map(f => f.latestEnd ? new Date(f.latestEnd).getTime() : 0)) : 0;
3076
+ const foundIsStale = found.length > 0 && bestFoundMs > 0 && (Date.now() - bestFoundMs) > THREE_YEARS_MS;
3077
+ if (found.length === 0 || foundIsStale) {
914
3078
  const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
915
3079
  const tokens = candidates.map(norm).filter((t) => t.length >= 3);
916
- const factsRes = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
917
- if (factsRes.ok && tokens.length) {
3080
+ const factsRes = await pwFetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3081
+ if (!factsRes.ok) {
3082
+ lastStatus = factsRes.status;
3083
+ } else if (tokens.length) {
918
3084
  const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }> } };
3085
+ resolvedEntityName = facts.entityName ?? '';
919
3086
  const gaap = facts.facts?.['us-gaap'] ?? {};
920
3087
  const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
921
3088
  const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
922
3089
  for (const [tag, entry] of Object.entries(gaap)) {
923
3090
  if (!tokens.some((t) => norm(tag).includes(t))) continue;
924
3091
  let latestEnd = '';
925
- for (const values of Object.values(entry.units)) {
3092
+ for (const [, values] of unitEntries(entry.units)) {
926
3093
  for (const e of values) {
927
3094
  if (e.form !== '10-K' && e.form !== '10-K/A') continue;
928
3095
  if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
@@ -938,10 +3105,82 @@ async function companyConcept(cikOrTicker: string, concept: string) {
938
3105
  if (matches.length) found.push({ doc: matches[0].doc, latestEnd: matches[0].latestEnd });
939
3106
  }
940
3107
  }
3108
+ // Still stale after the name scan — every tag we can see is >3y old and the
3109
+ // scan found nothing fresher. Don't hand back the old number. It is the worst
3110
+ // failure shape we ship: a well-formed, plausible, five-year-old answer with
3111
+ // no marker on it, so neither the caller nor any automatic check can tell it
3112
+ // apart from a current one.
3113
+ //
3114
+ // Ford is the reference case, measured 2026-08-01. companyconcept for
3115
+ // LongTermDebtNoncurrent serves 4 facts ending at end=2020-12-31 /
3116
+ // filed=2021-02-05, and LongTermDebt, LongTermDebtAndCapitalLeaseObligations
3117
+ // and DebtLongtermAndShorttermCombinedAmount all 404 — yet Ford filed a 10-Q
3118
+ // on 2026-07-29. The debt is right there in that filing, tagged
3119
+ // LongTermDebtAndCapitalLeaseObligations, but reported ONLY under
3120
+ // StatementBusinessSegmentsAxis (Company-excluding-Ford-Credit vs Ford
3121
+ // Credit) with no undimensioned consolidated total. companyfacts and
3122
+ // companyconcept expose dimensionless facts only, so the live value is
3123
+ // invisible to this API no matter which tag we ask for. Any filer that splits
3124
+ // a balance-sheet line across segments and never totals it has this hole; the
3125
+ // only real fix is a second ingestion path (inline XBRL / SEC's Financial
3126
+ // Statement Data Sets, whose num.txt carries the segments column).
3127
+ //
3128
+ // We still name the stale tag we found, so a caller who genuinely wants the
3129
+ // historical figure knows exactly where it is rather than being told nothing.
3130
+ if (found.length > 0) {
3131
+ const freshest = found.reduce((a, b) => ((b.latestEnd ?? '') > (a.latestEnd ?? '') ? b : a));
3132
+ const freshestMs = freshest.latestEnd ? new Date(freshest.latestEnd).getTime() : 0;
3133
+ if (freshestMs > 0 && Date.now() - freshestMs > THREE_YEARS_MS) {
3134
+ const years = Math.floor((Date.now() - freshestMs) / (365 * 24 * 3600 * 1000));
3135
+ return {
3136
+ cik: String(cik),
3137
+ company_name: resolvedEntityName || freshest.doc.entityName || null,
3138
+ concept,
3139
+ candidates_tried: candidates,
3140
+ period: (period ?? 'all').trim().toLowerCase(),
3141
+ reported: false,
3142
+ latest: null,
3143
+ values: [],
3144
+ stale_tag: { tag: freshest.doc.tag, latest_period_end: freshest.latestEnd, years_old: years },
3145
+ note: `No CURRENT value for "${concept}" is available from SEC's XBRL API for this filer. The freshest tag we can see, ${freshest.doc.tag}, stops at ${freshest.latestEnd} (~${years}y old) and every alternative name 404s — but the company may well still be filing. The usual cause is that the filer reports this line only under a dimension (most often segment breakdowns, e.g. Ford splits long-term debt into Company-vs-Ford-Credit and publishes no consolidated total); companyfacts and companyconcept expose UNDIMENSIONED facts only, so a dimension-only line is invisible here regardless of which tag is requested. latest is null, not zero, and deliberately not the ${freshest.latestEnd} figure — that number is real history but is NOT this company's current ${concept}. To get the current value, read the segment table in the latest filing directly: edgar_filings({cik:"${cikOrTicker}", form:"10-Q"}). To see every tag this filer does publish undimensioned, call edgar_company_facts({cik:"${cikOrTicker}"}).`,
3146
+ };
3147
+ }
3148
+ }
941
3149
  if (found.length === 0) {
942
- throw new Error(
943
- `SEC EDGAR company concept error: ${lastStatus} — none of the candidate concepts ${JSON.stringify(candidates)} exist for CIK ${paddedCik}, and no us-gaap tag matched. Use edgar_company_facts({cik: "${cikOrTicker}"}) to see every concept this filer reports.`,
944
- );
3150
+ // Distinguish a genuine "this filer doesn't report the concept" (every
3151
+ // lookup 404'd) from an upstream failure (SEC throttle / 5xx). Only the
3152
+ // latter should surface as an error the caller retries. A 404 after we've
3153
+ // exhausted us-gaap candidates + IFRS + a full company-facts name scan
3154
+ // means the concept is genuinely absent — for a balance-sheet item like
3155
+ // long-term debt on an equity-funded filer, that's the FACT "reports none",
3156
+ // not a failure. Return it as a clean not-reported result (usable answer,
3157
+ // and it stops mis-classifying as an error). Was the single largest edgar
3158
+ // error class (LongTermDebt on clinical-stage biotechs, 2026-07).
3159
+ if (lastStatus === 403 || lastStatus === 429 || lastStatus >= 500) {
3160
+ throw new Error(
3161
+ `SEC EDGAR company concept error: ${lastStatus} — SEC upstream ${lastStatus === 403 || lastStatus === 429 ? 'rate-limited' : 'unavailable'} while resolving "${concept}" for CIK ${paddedCik}. Retry shortly.`,
3162
+ );
3163
+ }
3164
+ // Nothing resolved the entity's NAME either, and companyfacts 404'd — so
3165
+ // this CIK is not a registrant, and the not-reported answer below would be
3166
+ // a statement about a company that does not exist ("this filer reports no
3167
+ // tag" implies a filer). Requires BOTH conditions: a real filer whose
3168
+ // companyfacts merely 404s still has resolvedEntityName from elsewhere and
3169
+ // keeps the deliberate not-reported behaviour.
3170
+ if (!resolvedEntityName && lastStatus === 404) {
3171
+ throw submissionsError(404, cikOrTicker, paddedCik);
3172
+ }
3173
+ return {
3174
+ cik: String(cik),
3175
+ company_name: resolvedEntityName || null,
3176
+ concept,
3177
+ candidates_tried: candidates,
3178
+ period: (period ?? 'all').trim().toLowerCase(),
3179
+ reported: false,
3180
+ latest: null,
3181
+ values: [],
3182
+ note: `This filer reports no XBRL tag matching "${concept}" under any recognized name — checked us-gaap candidates ${JSON.stringify(candidates)}, IFRS, and a full company-facts name scan. This means EITHER the company genuinely has none (common for e.g. long-term debt at equity-funded filers) OR it reports the item under a tag we didn't match (e.g. convertible/other debt lines). value is null, not zero — call edgar_company_facts({cik:"${cikOrTicker}"}) to see the exact tags this filer uses before treating it as absent.`,
3183
+ };
945
3184
  }
946
3185
  found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
947
3186
  const data = found[0].doc;
@@ -959,14 +3198,29 @@ async function companyConcept(cikOrTicker: string, concept: string) {
959
3198
  // caught this.
960
3199
  // - DO dedupe: a 10-K and its 10-K/A amendments both report the same
961
3200
  // (fy, fp, end) tuple. Keep the most-recently-filed version.
962
- type Raw = { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
3201
+ type Raw = { start?: string; end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
3202
+ const spanDays = (a?: string, b?: string) => (a && b) ? Math.round((Date.parse(b) - Date.parse(a)) / 86400000) : NaN;
3203
+ // Does this fact's DURATION match its period label? A 10-Q files BOTH the
3204
+ // discrete 3-month quarter AND the year-to-date figure under the same
3205
+ // (fy, fp, end) tuple, filed the same day — so "most recently filed" was a
3206
+ // coin toss between them, and the YTD often won: "AAPL revenue Q3" answered
3207
+ // with NINE MONTHS of revenue labeled Q3 (found via fleet #250). Prefer the
3208
+ // fact whose span matches the label; fall back to filing recency only
3209
+ // between facts of the same shape.
3210
+ const labelFits = (e: Raw): boolean => {
3211
+ const span = spanDays(e.start, e.end);
3212
+ if (Number.isNaN(span)) return false; // instant concepts have no start; nothing to prefer
3213
+ return /^Q[123]$/.test(e.fp ?? '') ? (span >= 80 && span <= 100) : (span >= 330 && span <= 400);
3214
+ };
963
3215
  const dedup = new Map<string, { entry: Raw; unit: string }>();
964
- for (const [unit, values] of Object.entries(data.units)) {
3216
+ for (const [unit, values] of unitEntries(data.units)) {
965
3217
  for (const e of values) {
966
- if (!ANNUAL_FORMS.has(e.form)) continue; // 10-K (+/A) domestic, 20-F/40-F (+/A) foreign
3218
+ if (!forms.has(e.form)) continue; // period-scoped: annual (10-K/20-F/40-F), quarterly (10-Q), or all
967
3219
  const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
968
3220
  const prior = dedup.get(key);
969
- if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
3221
+ if (!prior
3222
+ || (labelFits(e) && !labelFits(prior.entry))
3223
+ || (labelFits(e) === labelFits(prior.entry) && (e.filed ?? '') > (prior.entry.filed ?? ''))) {
970
3224
  dedup.set(key, { entry: e, unit });
971
3225
  }
972
3226
  }
@@ -974,20 +3228,235 @@ async function companyConcept(cikOrTicker: string, concept: string) {
974
3228
  const entries = [...dedup.values()]
975
3229
  .map(({ entry, unit }) => ({
976
3230
  fiscal_year: entry.fy,
3231
+ fiscal_period: entry.fp ?? 'FY', // FY (annual) | Q1 | Q2 | Q3
3232
+ period_start: entry.start,
977
3233
  period_end: entry.end,
978
3234
  value: entry.val,
3235
+ form: entry.form, // 10-K / 10-Q / 20-F / 40-F (+ /A amendments)
979
3236
  filed: entry.filed,
980
3237
  unit,
3238
+ derived: false as boolean | undefined,
3239
+ note: undefined as string | undefined,
981
3240
  }))
982
3241
  .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
983
3242
 
3243
+ // Derive standalone Q4 rows. SEC filers report Q1-Q3 in 10-Qs and the full
3244
+ // year in the 10-K — a standalone Q4 fact does not exist in XBRL, so a
3245
+ // question like "Apple revenue Q4 2024" was structurally unanswerable from
3246
+ // this payload, and callers reached for `latest` instead (a confidently
3247
+ // wrong period — fleet #250). Q4 = FY − Q1 − Q2 − Q3, computed only when
3248
+ // the arithmetic is actually valid:
3249
+ // - duration concepts only (rows carry period_start). Instant concepts
3250
+ // (cash, assets) are point-in-time — the FY value IS the year-end
3251
+ // reading and subtraction is meaningless.
3252
+ // - not per-share/ratio units ("USD/shares"): EPS does not sum across
3253
+ // quarters when the share count moves.
3254
+ // - the FY row must span 330-400 days. 10-K comparative periods are
3255
+ // reported under the FILING's fiscal year (a fy-2019 row can carry
3256
+ // fiscal 2017 dates — the known contamination trap), so trusting `fy`
3257
+ // alone subtracts quarters from the wrong year; the date window is
3258
+ // what makes the row the year it claims to be.
3259
+ // - exactly 3 distinct ~quarter-length (80-100d) windows inside the FY
3260
+ // window. Fewer means a quarter is missing; more means duplicate or
3261
+ // YTD rows slipped in, and either way the subtraction is garbage.
3262
+ const derivedQ4: typeof entries = [];
3263
+ for (const fyRow of entries) {
3264
+ if (fyRow.fiscal_period !== 'FY' || fyRow.unit.includes('/')) continue;
3265
+ const span = spanDays(fyRow.period_start, fyRow.period_end);
3266
+ if (!(span >= 330 && span <= 400)) continue;
3267
+ const quarters = new Map<string, typeof entries[number]>();
3268
+ for (const q of entries) {
3269
+ if (!/^Q[123]$/.test(q.fiscal_period) || q.unit !== fyRow.unit) continue;
3270
+ const qSpan = spanDays(q.period_start, q.period_end);
3271
+ if (!(qSpan >= 80 && qSpan <= 100)) continue;
3272
+ if (!q.period_start || !fyRow.period_start || q.period_start < fyRow.period_start || q.period_end > fyRow.period_end) continue;
3273
+ quarters.set(`${q.period_start}|${q.period_end}`, q);
3274
+ }
3275
+ if (quarters.size !== 3) continue;
3276
+ const qSum = [...quarters.values()].reduce((s, q) => s + q.value, 0);
3277
+ derivedQ4.push({
3278
+ fiscal_year: fyRow.fiscal_year,
3279
+ fiscal_period: 'Q4',
3280
+ period_start: undefined,
3281
+ period_end: fyRow.period_end,
3282
+ value: fyRow.value - qSum,
3283
+ form: 'derived',
3284
+ filed: fyRow.filed,
3285
+ unit: fyRow.unit,
3286
+ derived: true,
3287
+ note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
3288
+ });
3289
+ }
3290
+ if (derivedQ4.length > 0) {
3291
+ // A fiscal year re-reported as a 10-K comparative period spans the same
3292
+ // dates as the original FY row and would mint a duplicate Q4 — keep one
3293
+ // derivation per (period_end, unit). Prefer the LOWEST fiscal_year label:
3294
+ // comparatives carry the FILING's fy over earlier dates (a fy-2026 row
3295
+ // spanning fiscal 2024), so the lowest label per window is the original
3296
+ // 10-K's, the one whose year actually names the window.
3297
+ const byWindow = new Map<string, typeof entries[number]>();
3298
+ for (const d of derivedQ4) {
3299
+ const k = `${d.period_end}|${d.unit}`;
3300
+ const prior = byWindow.get(k);
3301
+ if (!prior || (d.fiscal_year ?? Infinity) < (prior.fiscal_year ?? Infinity)) byWindow.set(k, d);
3302
+ }
3303
+ entries.push(...byWindow.values());
3304
+ entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
3305
+ }
3306
+
3307
+ // PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
3308
+ // names of two fields on every row below, and callers asking a one-period
3309
+ // question ("net income fiscal 2024") send them as ARGUMENTS — measured on
3310
+ // every partial-outcome call this tool logged over three days. They used to
3311
+ // be undeclared, so the gateway dropped them and the caller got all 89-230
3312
+ // periods back with no indication its filter had been ignored: the
3313
+ // dropped-filter-returns-too-much shape, where whichever period the
3314
+ // synthesizer happens to read becomes the answer. Honor them instead.
3315
+ //
3316
+ // An unmatched filter returns NO rows and says which periods exist, rather
3317
+ // than falling back to every period — a filter that silently widens is how a
3318
+ // confidently-wrong year gets reported, and `latest` is deliberately null so
3319
+ // there is no plausible number sitting there to be mistaken for the one
3320
+ // asked for.
3321
+ const fyWanted = fiscalYearArg === undefined || fiscalYearArg === null || String(fiscalYearArg).trim() === ''
3322
+ ? null
3323
+ : String(fiscalYearArg).trim();
3324
+ const fpWanted = typeof fiscalPeriodArg === 'string' && fiscalPeriodArg.trim()
3325
+ ? fiscalPeriodArg.trim().toUpperCase()
3326
+ : null;
3327
+ let periodFilter: Record<string, unknown> | undefined;
3328
+ let rows = entries;
3329
+ if (fyWanted || fpWanted) {
3330
+ const matched = entries.filter((e) =>
3331
+ (!fyWanted || String(e.fiscal_year) === fyWanted)
3332
+ && (!fpWanted || String(e.fiscal_period).toUpperCase() === fpWanted));
3333
+ const availableYears = [...new Set(entries.map((e) => e.fiscal_year).filter((y) => y != null))].sort((a, b) => Number(b) - Number(a));
3334
+ const availablePeriods = [...new Set(entries.map((e) => String(e.fiscal_period)))].sort();
3335
+ rows = matched;
3336
+ periodFilter = {
3337
+ fiscal_year: fyWanted,
3338
+ fiscal_period: fpWanted,
3339
+ matched: matched.length,
3340
+ of_periods_reported: entries.length,
3341
+ ...(matched.length === 0
3342
+ ? {
3343
+ available_fiscal_years: availableYears,
3344
+ available_fiscal_periods: availablePeriods,
3345
+ note:
3346
+ `No row matches ${[fyWanted ? `fiscal_year ${fyWanted}` : null, fpWanted ? `fiscal_period ${fpWanted}` : null].filter(Boolean).join(' + ')} for "${data.tag}". `
3347
+ + `values is EMPTY and latest is null on purpose — this filer's reported periods are ${availableYears.length ? `fiscal years ${availableYears.join(', ')}` : '(none)'}`
3348
+ + `${availablePeriods.length ? ` and periods ${availablePeriods.join(', ')}` : ''}, so no number here is the one you asked for. `
3349
+ + `fiscal_year is the FILER'S OWN label, not a calendar year (NVDA's FY2024 ended January 2024), and on a 10-K's comparative rows it carries the FILING's fiscal year rather than the year the dates fall in — if you need an exact window, drop the filter and match on period_end instead.`,
3350
+ }
3351
+ : {}),
3352
+ // A matched filter is not automatically an unambiguous one. A 10-K tags
3353
+ // its COMPARATIVE periods under the filing's own fiscal year, so
3354
+ // fiscal_year 2023 on ITW returns three rows all labeled 2023 with
3355
+ // period_end 2023-12-31, 2022-12-31 and 2021-12-31 (verified live).
3356
+ // Every one is a real filed fact; only the first is the year the label
3357
+ // names. `values` is sorted period_end DESC and `latest` is already that
3358
+ // first row, so the right answer is what a caller gets by default — but
3359
+ // say so here rather than leaving three same-labeled numbers to be
3360
+ // picked between, which is the confidently-wrong-period failure this
3361
+ // filter exists to prevent.
3362
+ ...((() => {
3363
+ if (matched.length < 2) return {};
3364
+ const ends = [...new Set(matched.map((e) => e.period_end))];
3365
+ if (ends.length < 2) return {};
3366
+ return {
3367
+ ambiguity_note:
3368
+ `${matched.length} rows carry the label fiscal_year ${matched[0].fiscal_year}, covering ${ends.length} different periods (${ends.join(', ')}). `
3369
+ + `That is a 10-K reporting its comparative years under the FILING's fiscal year, not ${matched[0].fiscal_year} being reported ${matched.length} times. `
3370
+ + `values[0] and latest are the row whose dates actually fall in the labeled year (period_end ${ends[0]}) — use those, and read period_end rather than fiscal_year if you need to be certain which window a figure covers.`,
3371
+ };
3372
+ })()),
3373
+ };
3374
+ }
3375
+
3376
+ // SUBSTITUTION DISCLOSURE (fleet #219).
3377
+ //
3378
+ // The name-scan fallback above matches any us-gaap tag CONTAINING a candidate
3379
+ // token, which is what makes it useful — filers rename things constantly, and
3380
+ // finding LongTermDebtAndCapitalLeaseObligations when you asked for
3381
+ // LongTermDebt is the whole point. But containment cuts both ways: asking for
3382
+ // InterestExpense matches FinanceLeaseInterestExpense, and Delta then returns
3383
+ // $37M of finance-lease interest as though it were total interest expense —
3384
+ // off by an order of magnitude, well-formed, and with nothing on it to say so.
3385
+ // A credit analyst reads that number and misjudges the debt burden.
3386
+ //
3387
+ // `concept` already carries the tag actually served, so a careful caller
3388
+ // COULD notice. Nobody does: the synthesizing model reads a plausible number
3389
+ // under a plausible label and reports it. This file already refuses to hand
3390
+ // back a stale figure without a marker, for exactly the reason stated in the
3391
+ // comment above — "a well-formed, plausible answer with no marker on it, so
3392
+ // neither the caller nor any automatic check can tell it apart". A
3393
+ // substituted tag is the same shape, and gets the same treatment.
3394
+ const requested = concept.trim();
3395
+ const substituted = requested.length > 0
3396
+ && data.tag.toLowerCase().replace(/[^a-z0-9]/g, '') !== requested.toLowerCase().replace(/[^a-z0-9]/g, '');
3397
+
3398
+ // Coverage-bound disclosure (fleet #693). A returned value can be genuinely
3399
+ // CURRENT for this exact XBRL tag while a newer periodic filing exists that
3400
+ // simply doesn't reuse the tag — verified live on Brown-Forman (CIK 14693):
3401
+ // its FY2026 10-K (filed 2026-06-12) tags dozens of concepts but never
3402
+ // us-gaap:CommonStockSharesOutstanding, and dei:EntityCommonStockSharesOutstanding
3403
+ // is 404 across BF's ENTIRE XBRL history (confirmed via companyfacts — not a
3404
+ // one-year gap, this filer has never used that dei tag). SEC's own
3405
+ // companyconcept API is correct as far as it goes; the gap is upstream, in
3406
+ // what the filer chose to tag. That's invisible to the 3-year stale-tag
3407
+ // guard above (BF is ~15mo, not ~3y) and reads exactly like a current
3408
+ // answer with nothing on it to say otherwise — the same failure shape that
3409
+ // guard exists to prevent, just under its threshold. Flag it whenever the
3410
+ // freshest fact is older than one normal filing cycle for its own form
3411
+ // type. No extra fetch: compare the fact's own `filed` date to today.
3412
+ // Filtered rows, not every row: with fiscal_year/fiscal_period set, `latest`
3413
+ // must be the freshest fact WITHIN the period asked for, or the one field a
3414
+ // synthesizer reaches for first would answer a different year than the
3415
+ // question (fleet #2058).
3416
+ const latestFact = rows.find((e) => !e.derived) ?? null;
3417
+ let coverageBound: string | undefined;
3418
+ if (latestFact?.filed) {
3419
+ const daysSinceFiled = (Date.now() - Date.parse(latestFact.filed)) / 86400000;
3420
+ const isAnnual = ANNUAL_FORMS.has(latestFact.form);
3421
+ const cycleDays = isAnnual ? 400 : 120; // ~13mo (annual cadence + filing-lag grace) / ~4mo (quarterly + grace)
3422
+ if (Number.isFinite(daysSinceFiled) && daysSinceFiled > cycleDays) {
3423
+ coverageBound =
3424
+ `This is the freshest value SEC's XBRL API has for "${data.tag}" — from a ${latestFact.form} filed ` +
3425
+ `${latestFact.filed}, ${Math.floor(daysSinceFiled)} days ago, past the normal ${isAnnual ? 'annual' : 'quarterly'} filing cycle. ` +
3426
+ `If ${data.entityName} has filed a newer ${isAnnual ? '10-K' : '10-Q'} since then, it may simply not tag this specific concept in it ` +
3427
+ `(confirmed on Brown-Forman's FY2026 10-K: it tags hundreds of other concepts, not this one, and never tags the dei cover-page ` +
3428
+ `share count either) — this is not necessarily a gap in our data. Check edgar_company_filings({ticker_or_cik:"${cikOrTicker}"}) ` +
3429
+ `for filings newer than ${latestFact.filed}, and edgar_company_facts({cik:"${String(data.cik)}"}) for every tag the filer currently uses.`;
3430
+ }
3431
+ }
3432
+
984
3433
  return {
985
3434
  cik: String(data.cik),
986
3435
  company_name: data.entityName,
987
3436
  concept: data.tag,
3437
+ ...(substituted
3438
+ ? {
3439
+ requested_concept: requested,
3440
+ concept_substituted: true,
3441
+ substitution_note:
3442
+ `You asked for "${requested}"; this filer does not report that exact tag, so the closest matching tag ` +
3443
+ `"${data.tag}" (${data.label}) is returned instead. These are NOT interchangeable — a narrower tag can be ` +
3444
+ `a small fraction of the concept you asked for. Do NOT present this as "${requested}" without saying which ` +
3445
+ `tag it came from. Call edgar_company_facts({cik:"${String(data.cik)}"}) to see every tag this filer uses.`,
3446
+ }
3447
+ : {}),
988
3448
  label: data.label,
989
3449
  description: data.description,
990
- annual_values: entries,
3450
+ period: (period ?? 'all').trim().toLowerCase(),
3451
+ // Freshest data point across the requested period — use THIS for
3452
+ // point-in-time metrics (cash, runway, debt); it is the latest 10-Q when
3453
+ // one is more recent than the last 10-K, so an annual figure never masks a
3454
+ // newer quarter. Always a REPORTED fact, never a derived Q4.
3455
+ latest: latestFact,
3456
+ ...(coverageBound ? { coverage_bound: coverageBound } : {}),
3457
+ ...(periodFilter ? { period_filter: periodFilter } : {}),
3458
+ values: rows,
3459
+ annual_values: rows, // deprecated alias; prefer `values` (now period-scoped)
991
3460
  };
992
3461
  }
993
3462
 
@@ -1019,9 +3488,13 @@ function xmlVal(block: string, tag: string): string | null {
1019
3488
  return plain ? plain[1].trim() : null;
1020
3489
  }
1021
3490
 
3491
+ // Tolerates an attribute-bearing opening tag (`<tr class="ro">`, `<Report
3492
+ // instance="...">`) as well as a bare one — a strict superset of matching
3493
+ // `<tag>` only, so every existing plain-XML caller (Form 4/N-PORT, no
3494
+ // attributes) still matches exactly the same blocks it always did.
1022
3495
  function xmlBlocks(xml: string, tag: string): string[] {
1023
3496
  const out: string[] = [];
1024
- const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'gi');
3497
+ const re = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'gi');
1025
3498
  let m: RegExpExecArray | null;
1026
3499
  while ((m = re.exec(xml)) !== null) out.push(m[1]);
1027
3500
  return out;
@@ -1089,8 +3562,8 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
1089
3562
  const paddedCik = padCik(cik);
1090
3563
  const count = Math.min(25, Math.max(1, limit ?? 10));
1091
3564
 
1092
- const res = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
1093
- if (!res.ok) throw new Error(`SEC EDGAR submissions error: ${res.status}`);
3565
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3566
+ if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
1094
3567
  const data = (await res.json()) as {
1095
3568
  cik: string;
1096
3569
  name: string;
@@ -1120,7 +3593,7 @@ async function insiderTransactions(tickerOrCik: string, limit?: number, includeD
1120
3593
  const rawDoc = t.doc.replace(/^xsl[^/]*\//i, '');
1121
3594
  const url = `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accPath}/${rawDoc}`;
1122
3595
  try {
1123
- const r = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3596
+ const r = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
1124
3597
  if (!r.ok) return { accession_number: t.accession, filing_date: t.date, form: t.form, error: `fetch ${r.status}`, filing_url: url };
1125
3598
  const xml = await r.text();
1126
3599
  const parsed = parseForm4Transactions(xml, includeDerivatives ?? false);
@@ -1162,8 +3635,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1162
3635
  const paddedCik = padCik(cik);
1163
3636
  const topN = Math.min(100, Math.max(1, limit ?? 25));
1164
3637
 
1165
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
1166
- if (!subRes.ok) throw new Error(`SEC EDGAR submissions error: ${subRes.status}`);
3638
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3639
+ if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
1167
3640
  const sub = (await subRes.json()) as {
1168
3641
  cik: string;
1169
3642
  name: string;
@@ -1188,8 +3661,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1188
3661
  // is neither primary_doc.xml (cover page) nor an xsl-rendered viewer copy.
1189
3662
  const accPath = target.accession.replace(/-/g, '');
1190
3663
  const folder = `https://www.sec.gov/Archives/edgar/data/${sub.cik}/${accPath}`;
1191
- const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
1192
- if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
3664
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
3665
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
1193
3666
  const idx = (await idxRes.json()) as { directory: { item: { name: string }[] } };
1194
3667
  const infoFile = idx.directory.item.find(
1195
3668
  (it) => it.name.toLowerCase().endsWith('.xml') && it.name !== 'primary_doc.xml' && !/^xsl/i.test(it.name),
@@ -1198,8 +3671,8 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1198
3671
  throw new Error(`13F information table not found in filing ${target.accession}.`);
1199
3672
  }
1200
3673
 
1201
- const tableRes = await fetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
1202
- if (!tableRes.ok) throw new Error(`SEC EDGAR 13F table error: ${tableRes.status}`);
3674
+ const tableRes = await pwFetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3675
+ if (!tableRes.ok) throw await httpError(tableRes, 'SEC EDGAR 13F table error');
1203
3676
  const xml = stripNs(await tableRes.text());
1204
3677
 
1205
3678
  // Aggregate rows by issuer+cusip+putCall (managers file multiple sub-portfolio
@@ -1224,15 +3697,44 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1224
3697
 
1225
3698
  const all = [...byKey.values()].sort((a, b) => b.value_usd - a.value_usd);
1226
3699
  const totalValue = all.reduce((s, h) => s + h.value_usd, 0);
3700
+
3701
+ // A 13F row for an OPTION is not ownership of the underlying, and a PUT is a
3702
+ // BEARISH position. Both facts are carried only by the `putCall` tag, which a
3703
+ // reader can easily skip — so a value-ranked list leads with "PALANTIR ...
3704
+ // 66.04%" for a manager who is in fact positioned AGAINST Palantir via puts.
3705
+ // Misreporting Scion's puts as top holdings is a well-known failure mode. We
3706
+ // already parse putCall; the job here is to make it impossible to miss —
3707
+ // label direction per row and total the legs separately so nothing sums
3708
+ // bullish and bearish notional into one "portfolio".
3709
+ const isPut = (pc: string | null) => (pc ?? '').toLowerCase() === 'put';
3710
+ const isCall = (pc: string | null) => (pc ?? '').toLowerCase() === 'call';
3711
+ const directionOf = (pc: string | null) =>
3712
+ isPut(pc) ? 'bearish (put option)' : isCall(pc) ? 'bullish (call option)' : 'long equity';
3713
+
3714
+ const sumWhere = (pred: (h: Agg) => boolean) =>
3715
+ all.filter(pred).reduce((s, h) => s + h.value_usd, 0);
3716
+ const longEquityValue = sumWhere((h) => !isPut(h.put_call) && !isCall(h.put_call));
3717
+ const putValue = sumWhere((h) => isPut(h.put_call));
3718
+ const callValue = sumWhere((h) => isCall(h.put_call));
3719
+
1227
3720
  const holdings = all.slice(0, topN).map((h) => ({
1228
3721
  issuer: h.issuer,
1229
3722
  cusip: h.cusip,
1230
3723
  put_call: h.put_call,
3724
+ direction: directionOf(h.put_call),
1231
3725
  value_usd: h.value_usd,
1232
3726
  shares: h.shares,
3727
+ // Retained for compatibility, but it divides by a total that mixes option
3728
+ // notional with equity; pct_of_long_equity is the one to rank holdings by.
1233
3729
  pct_of_portfolio: totalValue > 0 ? Math.round((h.value_usd / totalValue) * 10000) / 100 : null,
3730
+ pct_of_long_equity:
3731
+ !isPut(h.put_call) && !isCall(h.put_call) && longEquityValue > 0
3732
+ ? Math.round((h.value_usd / longEquityValue) * 10000) / 100
3733
+ : null,
1234
3734
  }));
1235
3735
 
3736
+ const hasOptions = putValue > 0 || callValue > 0;
3737
+
1236
3738
  return {
1237
3739
  cik: sub.cik,
1238
3740
  manager_name: sub.name,
@@ -1240,6 +3742,20 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1240
3742
  report_period: target.period,
1241
3743
  filed_date: target.filed,
1242
3744
  total_portfolio_value_usd: totalValue,
3745
+ // Split the single "portfolio value" into legs that mean different things.
3746
+ // For options these are the NOTIONAL value of the underlying shares, not
3747
+ // premium paid — a $912M put line is not $912M of capital at risk.
3748
+ position_summary: {
3749
+ long_equity_value_usd: longEquityValue,
3750
+ put_option_notional_usd: putValue,
3751
+ call_option_notional_usd: callValue,
3752
+ long_equity_positions: all.filter((h) => !isPut(h.put_call) && !isCall(h.put_call)).length,
3753
+ put_positions: all.filter((h) => isPut(h.put_call)).length,
3754
+ call_positions: all.filter((h) => isCall(h.put_call)).length,
3755
+ },
3756
+ interpretation_note: hasOptions
3757
+ ? 'This filing contains OPTION positions. A `put` row is a BEARISH bet against the issuer, not a holding of it — do not describe it as something the manager owns. For option rows, value_usd is the notional value of the underlying shares, not premium paid or capital at risk, so it is not comparable to an equity position of the same dollar size. Rank actual holdings by pct_of_long_equity; total_portfolio_value_usd sums all legs and is not a meaningful portfolio size.'
3758
+ : 'All positions are long equity (no options in this filing).',
1243
3759
  total_positions: all.length,
1244
3760
  holdings_returned: holdings.length,
1245
3761
  holdings,
@@ -1251,6 +3767,9 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
1251
3767
  function decodeEntities(s: string | null): string | null {
1252
3768
  if (s == null) return null;
1253
3769
  return s
3770
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
3771
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
3772
+ .replace(/&nbsp;/gi, ' ')
1254
3773
  .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
1255
3774
  .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'");
1256
3775
  }
@@ -1260,8 +3779,8 @@ function decodeEntities(s: string | null): string | null {
1260
3779
  let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
1261
3780
  async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
1262
3781
  if (!mfTickerCache) {
1263
- const res = await fetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
1264
- if (!res.ok) throw new Error(`SEC fund-ticker lookup error: ${res.status}`);
3782
+ const res = await pwFetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
3783
+ if (!res.ok) throw await httpError(res, 'SEC fund-ticker lookup error');
1265
3784
  const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
1266
3785
  // fields: [cik, seriesId, classId, symbol]
1267
3786
  mfTickerCache = new Map();
@@ -1274,7 +3793,11 @@ async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesI
1274
3793
 
1275
3794
  async function fundHoldings(ticker: string, limit?: number) {
1276
3795
  const t = String(ticker ?? '').trim();
1277
- if (!t) throw new Error('Required argument "ticker" is missing. Pass an ETF/fund ticker like "ARKK" or "SPY".');
3796
+ if (!t) throw new Error(
3797
+ 'A fund ticker is required: pass `ticker`, or its alias `ticker_or_cik` — an ETF or ' +
3798
+ 'mutual-fund ticker like "ARKK", "QQQ" or "VOO". N-PORT funds are keyed by ticker, so a ' +
3799
+ 'bare CIK has nothing to match here. Example: edgar_fund_holdings({ticker: "ARKK"}).'
3800
+ );
1278
3801
  const topN = Math.min(100, Math.max(1, limit ?? 25));
1279
3802
 
1280
3803
  const resolved = await resolveFundTicker(t);
@@ -1284,8 +3807,8 @@ async function fundHoldings(ticker: string, limit?: number) {
1284
3807
  const { cik, seriesId } = resolved;
1285
3808
  const paddedCik = padCik(cik);
1286
3809
 
1287
- const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
1288
- if (!subRes.ok) throw new Error(`SEC submissions error: ${subRes.status}`);
3810
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3811
+ if (!subRes.ok) throw submissionsError(subRes.status, t, paddedCik);
1289
3812
  const sub = (await subRes.json()) as {
1290
3813
  cik: string; name: string;
1291
3814
  filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
@@ -1306,7 +3829,7 @@ async function fundHoldings(ticker: string, limit?: number) {
1306
3829
  const accPath = f.accession.replace(/-/g, '');
1307
3830
  const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
1308
3831
  try {
1309
- const res = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3832
+ const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
1310
3833
  if (!res.ok) return null;
1311
3834
  return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
1312
3835
  } catch {
@@ -1345,8 +3868,657 @@ async function fundHoldings(ticker: string, limit?: number) {
1345
3868
  };
1346
3869
  }
1347
3870
 
3871
+ // ── Companies by SIC (peer / competitor lookup) ─────────────────────
3872
+ //
3873
+ // EDGAR exposes an SIC (Standard Industrial Classification) code on every
3874
+ // filer's submissions record, but nothing in this pack (or the sibling `sec`
3875
+ // pack) could go the other direction — SIC -> list of filers. compare_entities
3876
+ // only compares companies already named; there was no way to FIND the peer
3877
+ // set. Fleet #689.
3878
+ //
3879
+ // Upstream: browse-edgar's `output=atom` company-search. Two upstream quirks,
3880
+ // both verified live (curl, 2026-08-30):
3881
+ // 1. Leaving `type` blank (or omitted) makes the endpoint hang/stall rather
3882
+ // than erroring — confirmed by a request that never returned inside 120s.
3883
+ // Always pass a non-empty `type`; default it to "10-K" (active reporters).
3884
+ // 2. The atom feed's <entry title> and <company-info name> are both the
3885
+ // literal string "ARRAY(0x...)" — a long-standing bug on SEC's side where
3886
+ // the feed serializes a PHP/Perl array ref instead of the company name.
3887
+ // Only <cik> is usable from the feed itself, so names/tickers are joined
3888
+ // in afterward from the company_tickers.json bulk file (already used by
3889
+ // sponsor_to_filer) — which only covers filers with a listed ticker.
3890
+ // Filers without one still appear, with ticker/company_name left null
3891
+ // rather than silently dropped (SEC registrants that aren't equity-listed
3892
+ // are still real peers for some questions, e.g. "who else files in SIC X").
3893
+
3894
+ interface SicPeer { cik: string; ticker: string | null; company_name: string | null }
3895
+
3896
+ async function resolveEntitySic(tickerOrCik: string): Promise<{ cik: string; name: string | null; sic: string | null; sic_description: string | null }> {
3897
+ const cik = await resolveCik(tickerOrCik);
3898
+ const paddedCik = padCik(cik);
3899
+ const res = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
3900
+ if (!res.ok) throw submissionsError(res.status, tickerOrCik, paddedCik);
3901
+ const data = (await res.json()) as { name?: string; sic?: string; sicDescription?: string };
3902
+ return {
3903
+ cik: String(Number(cik)),
3904
+ name: data.name ?? null,
3905
+ sic: data.sic && data.sic.trim() ? data.sic.trim() : null,
3906
+ sic_description: data.sicDescription ?? null,
3907
+ };
3908
+ }
3909
+
3910
+ async function companiesBySic(
3911
+ sicArg: string | undefined,
3912
+ tickerOrCik: string | undefined,
3913
+ formTypeArg: string | undefined,
3914
+ excludeSelfArg: boolean | undefined,
3915
+ limitArg: number | undefined,
3916
+ ): Promise<unknown> {
3917
+ let sic = sicArg?.trim();
3918
+ let sicDescription: string | null = null;
3919
+ let selfCik: string | null = null;
3920
+ let selfName: string | null = null;
3921
+
3922
+ if (!sic) {
3923
+ if (!tickerOrCik || !tickerOrCik.trim()) {
3924
+ return {
3925
+ error: 'An argument is required: pass either `sic` (a SIC code, e.g. "3571") or a company as `ticker_or_cik` — aliases `cik` / `ticker` — whose own SIC should be resolved first.',
3926
+ retry_hint: 'edgar_companies_by_sic({ticker_or_cik: "AAPL"}) or edgar_companies_by_sic({sic: "3571"}).',
3927
+ };
3928
+ }
3929
+ const resolved = await resolveEntitySic(tickerOrCik);
3930
+ if (!resolved.sic) {
3931
+ return {
3932
+ status: 'no_sic',
3933
+ cik: resolved.cik,
3934
+ company_name: resolved.name,
3935
+ message: `SEC EDGAR has no SIC code on file for CIK ${resolved.cik} (${resolved.name ?? tickerOrCik}) — cannot find peers by industry code. Try edgar_company_filings for this filer's own history instead.`,
3936
+ };
3937
+ }
3938
+ sic = resolved.sic;
3939
+ sicDescription = resolved.sic_description;
3940
+ selfCik = resolved.cik;
3941
+ selfName = resolved.name;
3942
+ }
3943
+
3944
+ if (!/^\d{1,4}$/.test(sic)) {
3945
+ return {
3946
+ error: `Invalid SIC code "${sic}" — SEC SIC codes are 1-4 digits (e.g. "3571", "2836", "6021").`,
3947
+ retry_hint: 'Pass a numeric SIC code, or ticker_or_cik to resolve one from a company.',
3948
+ };
3949
+ }
3950
+
3951
+ const formType = (formTypeArg?.trim() || '10-K').toUpperCase();
3952
+ const excludeSelf = excludeSelfArg !== false; // default true
3953
+ const limit = Math.min(100, Math.max(1, Math.floor(Number(limitArg) || 25)));
3954
+
3955
+ // count=100 is SEC's max per page for this endpoint; no pagination beyond
3956
+ // that yet — an SIC bucket with >100 active 10-K filers returns only the
3957
+ // first 100 (flagged via `possibly_more`, not silently).
3958
+ const url = `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&SIC=${encodeURIComponent(sic)}&type=${encodeURIComponent(formType)}&dateb=&owner=include&count=100&output=atom`;
3959
+ const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
3960
+ if (!res.ok) throw await httpError(res, 'SEC EDGAR company-search error');
3961
+ const xml = await res.text();
3962
+ const ciks = [...new Set([...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1]))))];
3963
+
3964
+ if (ciks.length === 0) {
3965
+ return {
3966
+ sic,
3967
+ sic_description: sicDescription,
3968
+ form_type: formType,
3969
+ total_found: 0,
3970
+ peers: [],
3971
+ message: `No SEC filers found under SIC ${sic} with form type ${formType}. The code may be unused/retired, or try form_type:"" — though note the upstream search can stall with no type filter; retry rather than omitting it.`,
3972
+ };
3973
+ }
3974
+
3975
+ const tickerRows = await fetchCompanyTickers().catch(() => [] as SecTickerRow[]);
3976
+ const byTickerCik = new Map<string, SecTickerRow>();
3977
+ for (const r of tickerRows) byTickerCik.set(String(r.cik_str), r);
3978
+
3979
+ // Best-effort sic_description when we didn't already have it (direct `sic`
3980
+ // input, not resolved via a company) — pull it off the first listed peer's
3981
+ // submissions record rather than leaving it null.
3982
+ if (!sicDescription) {
3983
+ const firstListed = ciks.find((c) => byTickerCik.has(c)) ?? ciks[0];
3984
+ const one = await resolveEntitySic(firstListed).catch(() => null);
3985
+ if (one?.sic === sic) sicDescription = one.sic_description;
3986
+ }
3987
+
3988
+ let peerCiks = ciks;
3989
+ if (excludeSelf && selfCik) peerCiks = peerCiks.filter((c) => c !== selfCik);
3990
+
3991
+ // browse-edgar's own ordering mixes long-defunct/delisted filers in with
3992
+ // current ones (SIC covers everyone who EVER filed the form, not just
3993
+ // active reporters) — verified live on SIC 3571: 100 filers returned, only
3994
+ // 5 (AAPL, DELL, OMCL, OSS, SCKT) still carry a listed ticker. Sorting
3995
+ // ticker-matched filers first means `limit` actually returns the
3996
+ // currently-tradable peers a "who competes with X" question wants, instead
3997
+ // of truncating to whichever 25 happened to sort first upstream (mostly
3998
+ // nulls). Unlisted registrants are still real filers, just pushed after.
3999
+ const sortedCiks = [...peerCiks].sort((a, b) => Number(byTickerCik.has(b)) - Number(byTickerCik.has(a)));
4000
+
4001
+ const peers: SicPeer[] = sortedCiks.slice(0, limit).map((c) => {
4002
+ const t = byTickerCik.get(c);
4003
+ return { cik: c, ticker: t?.ticker ?? null, company_name: t?.title ?? null };
4004
+ });
4005
+
4006
+ return {
4007
+ sic,
4008
+ sic_description: sicDescription,
4009
+ ...(selfCik ? { resolved_from: { ticker_or_cik: tickerOrCik, cik: selfCik, company_name: selfName } } : {}),
4010
+ form_type: formType,
4011
+ exclude_self: excludeSelf,
4012
+ total_found: peerCiks.length,
4013
+ returned: peers.length,
4014
+ possibly_more: ciks.length >= 100,
4015
+ source: 'SEC EDGAR company search (browse-edgar), joined with company_tickers.json for name/ticker',
4016
+ note: 'SIC is a broad industry bucket assigned once at registration, not a live competitor analysis — some peers may be inactive or only loosely comparable. Filers with no listed ticker still appear (ticker/company_name: null) — they are real SEC registrants, just not equity-listed.',
4017
+ peers,
4018
+ };
4019
+ }
4020
+
4021
+ // ── Filer subsidiaries (Exhibit 21) ─────────────────────────────────
4022
+ // The reverse of sponsor_to_filer: given a PARENT filer, list its operating
4023
+ // subsidiaries as filed in Exhibit 21 (Item 601(b)(21)) of its most recent
4024
+ // 10-K. Fleet #690 — ZT was rebuilding this per-company via a chain of ~10
4025
+ // trial-registry-candidate + sponsor_to_filer confirmation calls; this
4026
+ // replaces that chain with a single authoritative list plus provenance
4027
+ // (which filing, which exhibit, straight from SEC).
4028
+
4029
+ interface Subsidiary { name: string; jurisdiction: string | null }
4030
+
4031
+ const EX21_TYPE_RE = /^EX-?21(\.\d+)?$/i;
4032
+
4033
+ // Given a filing's -index.html (the human filing-summary page, which —
4034
+ // unlike index.json — carries a "Type" column such as "EX-21"), find the
4035
+ // Exhibit 21 document's URL. index.json's `type` field is a generic
4036
+ // MIME-icon class (e.g. "text.gif" for every .htm file in the filing, exhibit
4037
+ // 21 included), not the SEC exhibit type, so it can't be used to identify
4038
+ // EX-21 among a filing's other .htm exhibits — verified live on Merck's
4039
+ // 2026-02-24 10-K (accession 0000310158-26-000063).
4040
+ function findExhibit21Url(indexHtml: string): string | null {
4041
+ for (const tr of xmlBlocks(indexHtml, 'tr')) {
4042
+ const href = /<a[^>]+href="([^"]+\.(?:htm|html|txt))"/i.exec(tr)?.[1];
4043
+ if (!href) continue;
4044
+ const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => (htmlToText(m[1]) ?? '').trim());
4045
+ if (cells.some((c) => EX21_TYPE_RE.test(c))) {
4046
+ return href.startsWith('http') ? href : `https://www.sec.gov${href}`;
4047
+ }
4048
+ }
4049
+ return null;
4050
+ }
4051
+
4052
+ // Parse the EX-21 document into structured rows. SEC's typical layout is a
4053
+ // 2-column HTML table (Name | Country or State of Incorporation) — verified
4054
+ // live on Merck. Not every filer's EX-21 uses a table (smaller filers
4055
+ // sometimes file a plain paragraph list), so this returns [] rather than
4056
+ // throwing when no table rows parse; the caller falls back to the raw text.
4057
+ function parseSubsidiaryTable(html: string): Subsidiary[] {
4058
+ const out: Subsidiary[] = [];
4059
+ for (const tr of xmlBlocks(html, 'tr')) {
4060
+ const cells = [...tr.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
4061
+ .map((m) => (decodeEntities(htmlToText(m[1]))?.trim()) ?? '')
4062
+ .filter((c) => c.length > 0);
4063
+ if (cells.length < 2) continue;
4064
+ const [name, jurisdiction] = cells;
4065
+ if (/^name$/i.test(name) || /^(country|state|jurisdiction)/i.test(jurisdiction)) continue; // header row
4066
+ if (name.length > 200) continue; // guard against a non-table paragraph blob matching by accident
4067
+ out.push({ name, jurisdiction: jurisdiction || null });
4068
+ }
4069
+ return out;
4070
+ }
4071
+
4072
+ async function filerToSponsors(
4073
+ tickerOrCik: string | undefined,
4074
+ nameFilterArg: string | undefined,
4075
+ limitArg: number | undefined,
4076
+ ): Promise<unknown> {
4077
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
4078
+ return {
4079
+ error: 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — all three take a ticker ("MRK") or a CIK ("310158").',
4080
+ retry_hint: 'filer_to_sponsors({ticker_or_cik: "MRK"}) or pass a CIK, e.g. "310158".',
4081
+ };
4082
+ }
4083
+ const cik = await resolveCik(tickerOrCik);
4084
+ const paddedCik = padCik(cik);
4085
+ const limit = Math.min(500, Math.max(1, Math.floor(Number(limitArg) || 200)));
4086
+ const nameFilter = nameFilterArg?.trim().toLowerCase();
4087
+
4088
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
4089
+ if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
4090
+ const sub = (await subRes.json()) as {
4091
+ name?: string;
4092
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } };
4093
+ };
4094
+ const r = sub.filings?.recent;
4095
+ const forms = r?.form ?? [];
4096
+ const accs = r?.accessionNumber ?? [];
4097
+ const dates = r?.filingDate ?? [];
4098
+ const cikNoZeros = String(Number(cik));
4099
+
4100
+ // Walk the most recent annual filings looking for one that carries an
4101
+ // Exhibit 21 — almost always the latest 10-K, but Item 601(b)(21) only
4102
+ // requires listing SIGNIFICANT subsidiaries, so a small filer's most recent
4103
+ // 10-K can genuinely omit it even when an older one had it.
4104
+ const candidates: { idx: number; form: string }[] = [];
4105
+ for (let i = 0; i < forms.length && candidates.length < 5; i++) {
4106
+ if (forms[i] === '10-K' || forms[i] === '10-K/A') candidates.push({ idx: i, form: forms[i] });
4107
+ }
4108
+ if (candidates.length === 0) {
4109
+ return {
4110
+ cik: cikNoZeros,
4111
+ company_name: sub.name ?? null,
4112
+ status: 'no_10k',
4113
+ message: `No 10-K on file for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) in the recent filings index — Exhibit 21 is filed as part of the annual report. Foreign private issuers file 20-F instead, which is not yet covered by this tool.`,
4114
+ subsidiaries: [],
4115
+ };
4116
+ }
4117
+
4118
+ for (const c of candidates) {
4119
+ const accession = accs[c.idx];
4120
+ const accessionPath = accession.replace(/-/g, '');
4121
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
4122
+ const idxRes = await pwFetch(`${folder}/${accession}-index.html`, { headers: SEC_HEADERS });
4123
+ if (!idxRes.ok) continue;
4124
+ const idxHtml = await idxRes.text();
4125
+ const exUrl = findExhibit21Url(idxHtml);
4126
+ if (!exUrl) continue;
4127
+
4128
+ const exRes = await pwFetch(exUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
4129
+ if (!exRes.ok) continue;
4130
+ const exHtml = await exRes.text();
4131
+ const parsed = parseSubsidiaryTable(exHtml);
4132
+
4133
+ const provenance = {
4134
+ source: 'SEC EDGAR Exhibit 21 (Item 601(b)(21))',
4135
+ cik: cikNoZeros,
4136
+ company_name: sub.name ?? null,
4137
+ form: c.form,
4138
+ accession,
4139
+ filing_date: dates[c.idx] ?? null,
4140
+ exhibit_url: exUrl,
4141
+ };
4142
+
4143
+ if (parsed.length === 0) {
4144
+ // Exhibit exists but didn't parse as a table — return the raw text so
4145
+ // the caller isn't left with nothing, flagged as unstructured rather
4146
+ // than silently empty.
4147
+ const raw = htmlToText(exHtml);
4148
+ return {
4149
+ ...provenance,
4150
+ status: 'unstructured',
4151
+ message: 'Exhibit 21 was found but is not in the standard 2-column table format this parser expects — returning raw text instead of structured rows.',
4152
+ raw_text: raw.length > 20000 ? raw.slice(0, 20000) + '\n…[truncated]' : raw,
4153
+ subsidiaries: [],
4154
+ };
4155
+ }
4156
+
4157
+ const filtered = nameFilter ? parsed.filter((s) => s.name.toLowerCase().includes(nameFilter)) : parsed;
4158
+ return {
4159
+ ...provenance,
4160
+ status: 'ok',
4161
+ total_subsidiaries: parsed.length,
4162
+ returned: Math.min(filtered.length, limit),
4163
+ ...(nameFilter ? { name_filter: nameFilter, name_filter_matches: filtered.length } : {}),
4164
+ subsidiaries: filtered.slice(0, limit),
4165
+ };
4166
+ }
4167
+
4168
+ return {
4169
+ cik: cikNoZeros,
4170
+ company_name: sub.name ?? null,
4171
+ status: 'no_exhibit_21',
4172
+ message: `Checked the ${candidates.length} most recent 10-K/10-K/A filing(s) for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) and none carried an Exhibit 21 — the filer may have no significant subsidiaries to disclose (common for smaller/single-entity companies), or files under a different form (foreign private issuers use 20-F, not covered here).`,
4173
+ checked_filings: candidates.map((c) => ({ form: c.form, accession: accs[c.idx], filing_date: dates[c.idx] ?? null })),
4174
+ subsidiaries: [],
4175
+ };
4176
+ }
4177
+
4178
+ // ── Product-level / disaggregated revenue (10-K segment notes) ─────
4179
+ // Fleet #691. XBRL companyfacts/companyconcept expose only UNDIMENSIONED
4180
+ // facts (see the LongTermDebt/Ford note on companyConcept above) — product-
4181
+ // level revenue is almost always tagged WITH a dimension (a custom segment
4182
+ // axis with a "Keytruda [Member]", or srt:ProductOrServiceAxis), so it is
4183
+ // invisible to those APIs no matter which tag is requested. It IS visible in
4184
+ // SEC's own "Financial Report" R*.htm viewer pages, which render every
4185
+ // dimensional breakdown as a standardized Arelle-generated HTML table — the
4186
+ // SAME table markup across virtually every modern XBRL filer, because SEC's
4187
+ // own renderer produces it, not the filer. Verified live on Merck's FY2025
4188
+ // 10-K (accession 0000310158-26-000063): FilingSummary.xml names report
4189
+ // "9955598 - Disclosure - Segment Reporting - Schedule of Sales of Company's
4190
+ // Products (Details)" -> R119.htm -> a "Sales" row under the
4191
+ // "Operating Segments | Pharmaceutical | Keytruda" dimension = $31,641M.
4192
+
4193
+ interface DisaggRow { dimension: string | null; concept: string; values: { period: string; value: string }[] }
4194
+
4195
+ // Ordered by specificity — the first PATTERN that matches ANY report wins,
4196
+ // not the first report matching ANY pattern, so a precise "Sales of
4197
+ // Products" report outranks a looser "revenue by segment" one when a filer
4198
+ // has both. Geographic-only breakdowns are excluded explicitly: they use the
4199
+ // same report-naming conventions but answer a different question.
4200
+ // Report titles in FilingSummary.xml are structured `<num> - Disclosure - <Note>
4201
+ // - <Subject> (Details)`. Matching on the SUBJECT (the text after the last
4202
+ // " - ") rather than the whole string is what makes the geographic exclusion
4203
+ // safe: ABBV files its product table as "Segment and Geographic Area
4204
+ // Information - Disaggregation of Revenue (Details)", so a blanket
4205
+ // /geographic/ skip on the full title threw away the very report we wanted,
4206
+ // while PFE's genuinely geo-only "Revenues by Geographic Area (Detail)" must
4207
+ // still be skipped. Both are decided correctly on the subject.
4208
+ function reportSubject(longName: string): string {
4209
+ const parts = longName.split(' - ');
4210
+ return (parts.length > 1 ? parts[parts.length - 1] : longName).trim();
4211
+ }
4212
+
4213
+ // Tier 1: the filer names a product/turnover breakdown outright.
4214
+ const REPORT_NAME_PATTERNS: RegExp[] = [
4215
+ /sales of .*compan.*products/i,
4216
+ /revenues? by [\w\s,&]{0,40}product/i,
4217
+ /net sales by product/i,
4218
+ /product (net )?(sales|revenues?)/i,
4219
+ /(net |gross )?(sales|revenues?)[\w\s,&]{0,30}by product/i,
4220
+ /turnover by product/i,
4221
+ /major product/i,
4222
+ /disaggregat(ed|ion) of (net )?revenues?/i,
4223
+ /revenue.*by segment and product/i,
4224
+ /\btop \d+\b/i, // Novartis titles its per-product table "… - Top 20 (Details 5)"
4225
+ ];
4226
+
4227
+ // Tier 2: no product wording, but the report is still the revenue
4228
+ // disaggregation note — its XBRL dimensions carry the products (verified:
4229
+ // Novo Nordisk's "Business segments and geographical areas" rows are Ozempic /
4230
+ // Rybelsus; Sanofi's are Dupixent / Aubagio; Incyte's bare "Revenues
4231
+ // (Details)" is JAKAFI / OPZELURA). Only tried when tier 1 finds nothing.
4232
+ const REPORT_NAME_PATTERNS_FALLBACK: RegExp[] = [
4233
+ /\bnet sales\b/i,
4234
+ /sales by segment/i,
4235
+ /\brevenues?\s*\(detail/i,
4236
+ ];
4237
+
4238
+ // Subjects that are about the mechanics of revenue (reserves, rebates,
4239
+ // concentration) rather than the revenue breakdown itself. Without this,
4240
+ // SRPT's "Reserves for Product Revenues" outranks its "Summary of Product
4241
+ // Revenues", and UTHR's "Revenue Recognition" would match tier 2.
4242
+ const REPORT_NOISE = /footnote|narrative|parenthetical|additional information|concentration|reconciliation|recognition|\breserves?\b|allowance|rebate|discount|deduction|milestone|receivable|contract balance|performance obligation|remaining|returns/i;
4243
+
4244
+ const REPORT_GEO_ONLY = /\bgeograph/i;
4245
+ const REPORT_PRODUCT_SIGNAL = /product|segment|top \d+/i;
4246
+
4247
+ async function findRevenueReport(folder: string): Promise<{ url: string; report_name: string } | null> {
4248
+ const res = await pwFetch(`${folder}/FilingSummary.xml`, { headers: SEC_HEADERS });
4249
+ if (!res.ok) return null;
4250
+ const xml = await res.text();
4251
+ // FilingSummary.xml's <Report> elements carry an `instance="..."`
4252
+ // attribute (e.g. <Report instance="mrk-20251231.htm">) — xmlBlocks
4253
+ // tolerates that.
4254
+ const reports = xmlBlocks(xml, 'Report');
4255
+
4256
+ const candidates = reports
4257
+ .map((rep) => {
4258
+ const longName = xmlVal(rep, 'LongName') ?? '';
4259
+ const html = xmlVal(rep, 'HtmlFileName');
4260
+ return { longName, subject: reportSubject(longName), html };
4261
+ })
4262
+ .filter((c) => {
4263
+ if (!c.html || !c.longName) return false;
4264
+ if (REPORT_NOISE.test(c.subject)) return false;
4265
+ // Geographic-only reports are the wrong axis — but a subject that also
4266
+ // names products or segments is the right one wearing a geo label.
4267
+ if (REPORT_GEO_ONLY.test(c.subject) && !REPORT_PRODUCT_SIGNAL.test(c.subject)) return false;
4268
+ return true;
4269
+ });
4270
+
4271
+ // A filer publishes both the section-level report ("NET PRODUCT REVENUES")
4272
+ // and the rendered table ("… - Schedule of Net Product Revenues (Detail)").
4273
+ // Only the latter parses into rows, so it is tried first — ALNY and SRPT
4274
+ // both returned status "unparsed" because the section-level one won.
4275
+ const isDetails = (n: string) => /\(detail(s)?(\s+\d+)?\)\s*$/i.test(n.trim());
4276
+ for (const tier of [REPORT_NAME_PATTERNS, REPORT_NAME_PATTERNS_FALLBACK]) {
4277
+ for (const detailsOnly of [true, false]) {
4278
+ for (const pat of tier) {
4279
+ for (const c of candidates) {
4280
+ if (detailsOnly !== isDetails(c.longName)) continue;
4281
+ if (!pat.test(c.longName)) continue;
4282
+ return { url: `${folder}/${c.html}`, report_name: decodeEntities(c.longName) ?? c.longName };
4283
+ }
4284
+ }
4285
+ }
4286
+ }
4287
+ return null;
4288
+ }
4289
+
4290
+ // Parse an SEC Financial-Report R*.htm page into dimensional rows. Standard
4291
+ // Arelle-rendered structure (SEC's own viewer, consistent across filers):
4292
+ // - Header <tr>s carry <th class="th"><div>period label</div></th> cells
4293
+ // (e.g. "Dec. 31, 2025").
4294
+ // - A "context" row sets the dimension breadcrumb (e.g. "Operating
4295
+ // Segments | Pharmaceutical | Keytruda") for the data rows that follow
4296
+ // it, until the next context row — identified as a row whose concept
4297
+ // link references an XBRL Axis (`Axis=` in the href) and which carries
4298
+ // no numeric values itself.
4299
+ // - "[Line Items]" / "[Table]" / "[Abstract]" / "[Domain]" label rows are
4300
+ // structural noise (bolded headers with blank values) — skipped, never
4301
+ // treated as a context or a data row.
4302
+ // - A data row carries one value cell (`class="num"`/`"nump"`) per period
4303
+ // column alongside its own concept label (e.g. "Sales").
4304
+ // Best-effort: filers occasionally deviate from this shape (older filings,
4305
+ // non-Arelle renders) — an empty `rows` array means "didn't parse", not
4306
+ // "reports zero", and is flagged as such by the caller.
4307
+ function parseDisaggregatedRevenue(html: string): { periods: string[]; rows: DisaggRow[]; units_note: string | null } {
4308
+ const titleMatch = /<th[^>]*>\s*<div[^>]*>\s*<strong>([\s\S]*?)<\/strong>/i.exec(html);
4309
+ const titleText = titleMatch ? htmlToText(titleMatch[1]) : '';
4310
+ const unitsMatch = /\$\s*in\s*(Thousands|Millions|Billions)/i.exec(titleText);
4311
+ const unitsNote = unitsMatch ? `$ in ${unitsMatch[1]}` : null;
4312
+
4313
+ // Period columns come from two header rows: an optional duration row
4314
+ // (<th class="th" colspan="3">12 Months Ended</th>, carrying no <div>) and
4315
+ // the date row. ABBV writes its date row as
4316
+ // <th class="th">\n<div>Dec. 31, 2025 </div>\n<div>USD ($)</div>\n</th>
4317
+ // — a newline after the tag and several <div>s — which a single-line
4318
+ // "<th class=\"th\"><div>…</div></th>" pattern misses completely. That left
4319
+ // `periods` empty, so every row's `values` filtered down to nothing and the
4320
+ // tool returned status "ok" with 76 rows and not one dollar figure.
4321
+ const durations: string[] = [];
4322
+ const dateLabels: string[] = [];
4323
+ for (const m of html.matchAll(/<th class="th"([^>]*)>([\s\S]*?)<\/th>/gi)) {
4324
+ const divs = [...(m[2] ?? '').matchAll(/<div[^>]*>([\s\S]*?)<\/div>/gi)].map((d) => htmlToText(d[1]).trim());
4325
+ if (divs.length === 0) {
4326
+ const span = Math.max(1, Number(/colspan="(\d+)"/i.exec(m[1] ?? '')?.[1] ?? 1));
4327
+ const label = htmlToText(m[2] ?? '').trim();
4328
+ for (let i = 0; i < span; i++) durations.push(label);
4329
+ continue;
4330
+ }
4331
+ if (divs[0]) dateLabels.push(divs[0]); // later <div>s are the unit ("USD ($)") and axis qualifiers
4332
+ }
4333
+ // Two columns can carry the SAME date over different durations — ABBV reports
4334
+ // both "3 Months Ended Dec. 31, 2025" and "12 Months Ended Dec. 31, 2025", and
4335
+ // reading a quarter as the annual figure is the expensive mistake here. Qualify
4336
+ // only when the bare date is ambiguous, so single-duration filers keep the
4337
+ // period labels they have always returned.
4338
+ const ambiguous = new Set(dateLabels.filter((d, i) => dateLabels.indexOf(d) !== i));
4339
+ const periods = dateLabels.map((d, i) => (ambiguous.has(d) && durations[i] ? `${durations[i]} ${d}` : d));
4340
+
4341
+ const rows: DisaggRow[] = [];
4342
+ let context: string | null = null;
4343
+ for (const tr of xmlBlocks(html, 'tr')) {
4344
+ const labelMatch = /<td[^>]*class="pl[^"]*"[^>]*>([\s\S]*?)<\/td>/i.exec(tr);
4345
+ if (!labelMatch) continue; // header/period row, no row label
4346
+ const label = htmlToText(labelMatch[1]).trim();
4347
+ if (!label) continue;
4348
+ if (/\[(Line Items|Table|Abstract|Domain|Axis)\]/i.test(label)) continue; // structural noise
4349
+
4350
+ // Include the blank <td class="text"> cells: they hold a column's POSITION
4351
+ // when a row has no figure for that period. Collecting only the numeric
4352
+ // cells and zipping them against `periods` shifts every value left past a
4353
+ // gap, which silently files an annual figure under the quarterly column.
4354
+ const valueCells = [...tr.matchAll(/<td class="(num[a-z]*|text)"[^>]*>([\s\S]*?)<\/td>/gi)]
4355
+ .map((m) => (m[1].toLowerCase() === 'text' ? '' : htmlToText(m[2]).trim()));
4356
+ const hasValues = valueCells.some((v) => v.length > 0);
4357
+ const referencesAxis = /Axis=/.test(tr);
4358
+
4359
+ if (referencesAxis && !hasValues) {
4360
+ context = label;
4361
+ continue;
4362
+ }
4363
+ if (!hasValues) continue; // blank-value row that isn't a recognized axis header — skip
4364
+
4365
+ rows.push({
4366
+ dimension: context,
4367
+ concept: label,
4368
+ values: periods.map((p, i) => ({ period: p, value: valueCells[i] ?? '' })).filter((v) => v.value !== ''),
4369
+ });
4370
+ }
4371
+ return { periods, rows, units_note: unitsNote };
4372
+ }
4373
+
4374
+ async function productRevenue(
4375
+ tickerOrCik: string | undefined,
4376
+ productFilterArg: string | undefined,
4377
+ formTypeArg: string | undefined,
4378
+ accessionArg: string | undefined,
4379
+ limitArg: number | undefined,
4380
+ ): Promise<unknown> {
4381
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
4382
+ return {
4383
+ error: 'A company is required: pass `ticker_or_cik`, or its aliases `cik` / `ticker` — all three take a ticker ("MRK") or a CIK ("310158").',
4384
+ retry_hint: 'edgar_product_revenue({ticker_or_cik: "MRK", product_filter: "Keytruda"}).',
4385
+ };
4386
+ }
4387
+ const cik = await resolveCik(tickerOrCik);
4388
+ const paddedCik = padCik(cik);
4389
+ const cikNoZeros = String(Number(cik));
4390
+ // Foreign private issuers file 20-F and Canadian MJDS filers 40-F instead of
4391
+ // a 10-K, and their rendered R*.htm reports exist just the same. With no
4392
+ // form_type from the caller, walk 10-K -> 20-F -> 40-F rather than reporting
4393
+ // "no filing" for every non-US-domestic pharma (NVS, AZN, GSK, SNY, NVO, TAK).
4394
+ const explicitForm = formTypeArg?.trim() ? formTypeArg.trim().toUpperCase() : null;
4395
+ const FORM_FALLBACK_ORDER = ['10-K', '20-F', '40-F'];
4396
+ const formCandidates = explicitForm ? [explicitForm] : FORM_FALLBACK_ORDER;
4397
+ const productFilter = productFilterArg?.trim().toLowerCase();
4398
+ const limit = Math.min(200, Math.max(1, Math.floor(Number(limitArg) || 100)));
4399
+
4400
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
4401
+ if (!subRes.ok) throw submissionsError(subRes.status, tickerOrCik, paddedCik);
4402
+ const sub = (await subRes.json()) as {
4403
+ name?: string;
4404
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } };
4405
+ };
4406
+ const r = sub.filings?.recent;
4407
+ const forms = r?.form ?? [];
4408
+ const accs = r?.accessionNumber ?? [];
4409
+ const dates = r?.filingDate ?? [];
4410
+
4411
+ let acc: string | undefined;
4412
+ let filingDate: string | null = null;
4413
+ let formType: string;
4414
+ if (accessionArg) {
4415
+ const norm = normalizeAccession(accessionArg);
4416
+ if (!norm) throw new Error(`Invalid accession "${accessionArg}" — expected 18 digits shaped 10-2-6.`);
4417
+ acc = norm.dashed;
4418
+ const i = accs.indexOf(acc);
4419
+ filingDate = i >= 0 ? dates[i] ?? null : null;
4420
+ formType = (i >= 0 ? forms[i] : null) ?? explicitForm ?? 'unknown';
4421
+ } else {
4422
+ let idx = -1;
4423
+ let picked: string | null = null;
4424
+ for (const want of formCandidates) {
4425
+ idx = forms.findIndex((f) => f.toUpperCase() === want);
4426
+ if (idx >= 0) { picked = want; break; }
4427
+ }
4428
+ if (idx < 0 || !picked) {
4429
+ return {
4430
+ cik: cikNoZeros,
4431
+ company_name: sub.name ?? null,
4432
+ status: 'no_filing',
4433
+ resolved_form: null,
4434
+ forms_checked: formCandidates,
4435
+ message: `No ${formCandidates.join(' / ')} on file for CIK ${cikNoZeros} (${sub.name ?? tickerOrCik}) in the recent filings index.`,
4436
+ rows: [],
4437
+ };
4438
+ }
4439
+ formType = picked;
4440
+ acc = accs[idx];
4441
+ filingDate = dates[idx] ?? null;
4442
+ }
4443
+
4444
+ const accessionPath = acc!.replace(/-/g, '');
4445
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
4446
+ const report = await findRevenueReport(folder);
4447
+ if (!report) {
4448
+ return {
4449
+ cik: cikNoZeros,
4450
+ company_name: sub.name ?? null,
4451
+ form: formType,
4452
+ resolved_form: formType,
4453
+ accession: acc,
4454
+ filing_date: filingDate,
4455
+ status: 'not_found',
4456
+ message: `No product/segment revenue disaggregation report was found in this filing's FilingSummary.xml (checked report titles for "sales of products", "disaggregation of revenue", "revenue by product", etc.). Either this filer doesn't disaggregate revenue by product in XBRL, or it uses a title this heuristic doesn't recognize — try edgar_filing_text({ticker_or_cik:"${tickerOrCik}", form_type:"${formType}"}) and read the segment/revenue footnote directly.`,
4457
+ rows: [],
4458
+ };
4459
+ }
4460
+
4461
+ const repRes = await pwFetch(report.url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
4462
+ if (!repRes.ok) throw await httpError(repRes, 'SEC EDGAR financial report error');
4463
+ const repHtml = await repRes.text();
4464
+ const parsed = parseDisaggregatedRevenue(repHtml);
4465
+
4466
+ const citation = {
4467
+ source: 'SEC EDGAR XBRL Financial Report (Arelle-rendered R.htm viewer)',
4468
+ cik: cikNoZeros,
4469
+ company_name: sub.name ?? null,
4470
+ form: formType,
4471
+ resolved_form: formType,
4472
+ accession: acc,
4473
+ filing_date: filingDate,
4474
+ report_name: report.report_name,
4475
+ report_url: report.url,
4476
+ };
4477
+
4478
+ if (parsed.rows.length === 0) {
4479
+ return {
4480
+ ...citation,
4481
+ status: 'unparsed',
4482
+ message: 'Found a matching revenue-disaggregation report but could not parse it into rows with this parser (the filer\'s table structure deviates from the standard Arelle layout). Read report_url directly, or use edgar_filing_text for the prose segment note.',
4483
+ periods: parsed.periods,
4484
+ rows: [],
4485
+ };
4486
+ }
4487
+
4488
+ const filtered = productFilter
4489
+ ? parsed.rows.filter((row) => (row.dimension ?? row.concept).toLowerCase().includes(productFilter))
4490
+ : parsed.rows;
4491
+
4492
+ return {
4493
+ ...citation,
4494
+ status: 'ok',
4495
+ units_note: parsed.units_note,
4496
+ periods: parsed.periods,
4497
+ total_rows: parsed.rows.length,
4498
+ returned: Math.min(filtered.length, limit),
4499
+ ...(productFilter ? { product_filter: productFilter, product_filter_matches: filtered.length } : {}),
4500
+ note: 'Rows come from the filer\'s own disaggregated-revenue XBRL dimension (e.g. product/segment breakdown), rendered by SEC\'s standard viewer — not free-text extraction. `dimension` is the full breadcrumb (e.g. "Operating Segments | Pharmaceutical | Keytruda"); `concept` is the line-item label (usually "Sales" or "Revenues").',
4501
+ rows: filtered.slice(0, limit),
4502
+ };
4503
+ }
4504
+
1348
4505
  // ── callTool router ─────────────────────────────────────────────────
1349
4506
 
4507
+ /**
4508
+ * The one company argument, under any of the three names this pack uses for it.
4509
+ * `ticker_or_cik`, `cik` and `ticker` all mean "a ticker OR a CIK" here —
4510
+ * resolveCik() accepts either — so which word a caller reached for is not
4511
+ * information, it is noise. Coalesce rather than branch, first non-empty wins.
4512
+ */
4513
+ function companyArg(args: Record<string, unknown>): string {
4514
+ for (const key of ['ticker_or_cik', 'cik', 'ticker'] as const) {
4515
+ const v = args[key];
4516
+ if (typeof v === 'string' && v.trim()) return v;
4517
+ if (typeof v === 'number') return String(v);
4518
+ }
4519
+ return args.ticker_or_cik as string;
4520
+ }
4521
+
1350
4522
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
1351
4523
  switch (name) {
1352
4524
  case 'edgar_search_filings':
@@ -1357,32 +4529,57 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1357
4529
  args.end_date as string | undefined,
1358
4530
  args.limit as number | undefined,
1359
4531
  );
4532
+ // Every tool below takes the same thing — a company — and this pack spells
4533
+ // it three ways across its own tools (`ticker_or_cik`, `cik`, `ticker`), so
4534
+ // a model filling arguments from prose reaches for whichever word it read
4535
+ // last. All three are DECLARED in each inputSchema and coalesced here; an
4536
+ // undeclared argument is dropped today and would be rejected once
4537
+ // REJECT_UNKNOWN_ARGS is enforced (#2013), so declaring is what makes the
4538
+ // alias real. `required` is empty on those schemas for the same reason —
4539
+ // a flat list cannot say "one of these three" (fleet #2058, swept in #2069).
1360
4540
  case 'edgar_company_filings':
1361
4541
  return companyFilings(
1362
- args.ticker_or_cik as string,
4542
+ companyArg(args),
1363
4543
  args.form_type as string | undefined,
1364
4544
  args.limit as number | undefined,
1365
4545
  );
1366
4546
  case 'edgar_company_facts':
1367
- return companyFacts(args.cik as string);
4547
+ return companyFacts(companyArg(args));
4548
+ case 'edgar_company_snapshot':
4549
+ return companySnapshot(
4550
+ companyArg(args),
4551
+ args.form_type as string | undefined,
4552
+ args.filings_limit as number | undefined,
4553
+ );
1368
4554
  case 'edgar_insider_transactions':
1369
4555
  return insiderTransactions(
1370
- args.ticker_or_cik as string,
4556
+ companyArg(args),
1371
4557
  args.limit as number | undefined,
1372
4558
  args.include_derivatives as boolean | undefined,
1373
4559
  );
1374
4560
  case 'edgar_institutional_holdings':
1375
4561
  return institutionalHoldings(
1376
- args.ticker_or_cik as string,
4562
+ companyArg(args),
1377
4563
  args.limit as number | undefined,
1378
4564
  );
1379
4565
  case 'edgar_fund_holdings':
1380
- return fundHoldings(args.ticker as string, args.limit as number | undefined);
4566
+ return fundHoldings((args.ticker ?? args.ticker_or_cik) as string, args.limit as number | undefined);
1381
4567
  case 'edgar_company_concept':
1382
- // Accept `ticker_or_cik` too: the schema/handler use `cik` (which takes a
1383
- // ticker or CIK) but the sibling tools use `ticker_or_cik` and the error
1384
- // message names it — agents naturally pass it and were rejected.
1385
- return companyConcept((args.cik ?? args.ticker_or_cik) as string, args.concept as string);
4568
+ // Accept `ticker` / `ticker_or_cik` / `metric` too. The canonical names
4569
+ // are `cik` (which takes a ticker OR a CIK) and `concept`, but this
4570
+ // pack's own sibling tools spell the company argument three different
4571
+ // ways and the description calls the metric a "metric" — so a caller
4572
+ // filling arguments from prose reaches for whichever word it just read.
4573
+ // Measured: every `partial` outcome recorded for this tool over three
4574
+ // days (12/12) sent `ticker`, and a third of those sent `metric` as
4575
+ // well; all were rejected before the pack ran (fleet #2058).
4576
+ return companyConcept(
4577
+ (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
4578
+ (args.concept ?? args.metric) as string,
4579
+ args.period as string | undefined,
4580
+ args.fiscal_year as string | number | undefined,
4581
+ args.fiscal_period as string | undefined,
4582
+ );
1386
4583
  case 'edgar_filing_documents':
1387
4584
  return filingDocuments(
1388
4585
  args.accession as string | undefined,
@@ -1390,10 +4587,44 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1390
4587
  args.include_primary_text as boolean | undefined,
1391
4588
  args.form_type as string | undefined,
1392
4589
  );
4590
+ case 'edgar_filing_text':
4591
+ return filingText(
4592
+ args.accession as string | undefined,
4593
+ (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
4594
+ args.section as string | undefined,
4595
+ args.max_chars as number | undefined,
4596
+ args.offset as number | undefined,
4597
+ args.form_type as string | undefined,
4598
+ (args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
4599
+ );
1393
4600
  case 'edgar_ticker_to_cik':
1394
- return tickerToCik(args.ticker as string);
4601
+ return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
4602
+ case 'sponsor_to_filer':
4603
+ return sponsorToFiler((args.sponsor ?? args.name ?? args.ticker) as string);
4604
+ case 'filer_to_sponsors':
4605
+ return filerToSponsors(
4606
+ companyArg(args) as string | undefined,
4607
+ args.name_filter as string | undefined,
4608
+ args.limit as number | undefined,
4609
+ );
1395
4610
  case 'edgar_xbrl_frames':
1396
4611
  return xbrlFrames(args);
4612
+ case 'edgar_companies_by_sic':
4613
+ return companiesBySic(
4614
+ args.sic as string | undefined,
4615
+ companyArg(args) as string | undefined,
4616
+ args.form_type as string | undefined,
4617
+ args.exclude_self as boolean | undefined,
4618
+ args.limit as number | undefined,
4619
+ );
4620
+ case 'edgar_product_revenue':
4621
+ return productRevenue(
4622
+ companyArg(args) as string | undefined,
4623
+ args.product_filter as string | undefined,
4624
+ args.form_type as string | undefined,
4625
+ args.accession as string | undefined,
4626
+ args.limit as number | undefined,
4627
+ );
1397
4628
  default:
1398
4629
  throw new Error(`Unknown tool: ${name}`);
1399
4630
  }
@@ -1414,11 +4645,11 @@ async function xbrlFrames(args: Record<string, unknown>) {
1414
4645
  const limit = Math.min(200, Math.max(1, Number(args.limit) || 25));
1415
4646
 
1416
4647
  const url = `${DATA_BASE}/api/xbrl/frames/${taxonomy}/${encodeURIComponent(concept)}/${encodeURIComponent(unit)}/${period}.json`;
1417
- const res = await fetch(url, { headers: SEC_HEADERS });
4648
+ const res = await pwFetch(url, { headers: SEC_HEADERS });
1418
4649
  if (res.status === 404) {
1419
4650
  return { error: 'not_found', concept, period, unit, taxonomy, message: `No SEC frame for ${taxonomy}/${concept}/${unit}/${period}. Check the tag spelling/casing, unit (USD vs shares), and period type (add "I" for balance-sheet/instant items like Assets).` };
1420
4651
  }
1421
- if (!res.ok) throw new Error(`SEC frames error: ${res.status}`);
4652
+ if (!res.ok) throw await httpError(res, 'SEC frames error');
1422
4653
  const data = (await res.json()) as { taxonomy?: string; tag?: string; uom?: string; label?: string; description?: string; data?: FrameDatum[] };
1423
4654
  const rows = (data.data ?? []).slice().sort((a, b) => sortDesc ? (b.val ?? 0) - (a.val ?? 0) : (a.val ?? 0) - (b.val ?? 0));
1424
4655