@pipeworx/mcp-edgar 0.3.13 → 0.3.14

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Securities and Exchange Commission's filings database. Every public company in the US files here, every filing is timestamped and immutable, and every disclosure (revenue, debt, risk factors, executive compensation, M&A activity, insider trading) is structured for machine retrieval. Free and authoritative.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1855+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1878+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
6
6
 
7
7
  ## Why this matters for AI agents
8
8
 
@@ -111,7 +111,7 @@ directly, instead of just this one's:
111
111
  }
112
112
  ```
113
113
 
114
- Both URLs reach the same gateway and the same 1855+ data sources. The
114
+ Both URLs reach the same gateway and the same 1878+ data sources. The
115
115
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
116
116
  reaches all of them from either one.
117
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-8dcbabc71f11a133be24546b4546bf7ee8497587841c394b19f76a770375639f",
30
- "sourceCommit": "5749f83c0ea115fab4c0c0ef04b66b05f2507d67"
29
+ "sourceHash": "v1-22732e078cc175e931b642bd5804d7ba1b2610fd58415c9f8811ec32ff5939f5",
30
+ "sourceCommit": "29fb434ca65fe0f61191f19c3502f583f5fb99d7"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/edgar",
4
4
  "title": "Edgar",
5
5
  "description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
6
- "version": "0.3.13",
6
+ "version": "0.3.14",
7
7
  "websiteUrl": "https://pipeworx.io/packs/edgar",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-edgar",
package/src/index.ts CHANGED
@@ -382,12 +382,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
382
382
  // internal-host-class.ts; no-op for every third-party upstream, which is why
383
383
  // this touches 481 packs' error text and changes none of it.
384
384
  return markInternalOrigin(
385
- `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
385
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
386
386
  res.url,
387
387
  res.status,
388
388
  );
389
389
  }
390
390
 
391
+ /**
392
+ * Facts about a FAILED response that live in its headers, not its body, appended
393
+ * so the gateway's classifier (which only ever sees this string) can tell a limit
394
+ * from a wall from a refusal (fleet #3477). Both are standard, both are absent on
395
+ * almost every response, and neither is invented: `Retry-After` is the server
396
+ * saying "come back later" (the one thing that makes a 403 a throttle), and
397
+ * Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
398
+ */
399
+ function responseHints(res: Response): string {
400
+ const hints: string[] = [];
401
+ const retryAfter = res.headers.get('retry-after');
402
+ if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
403
+ if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
404
+ return hints.length ? ` (${hints.join('; ')})` : '';
405
+ }
406
+
391
407
  /**
392
408
  * Just the upstream's own explanation — no name, no status.
393
409
  *
@@ -1967,7 +1983,7 @@ const tools: McpToolExport['tools'] = [
1967
1983
  {
1968
1984
  name: 'edgar_filing_text',
1969
1985
  description:
1970
- '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. A `search` response carries ONLY the matched passages, so it reports `returned_chars` against `total_chars` and sets `truncated:true` whenever those differ — read a figure\'s absence from it as "the phrase did not match there", never as "the filing does not say it", and re-read without `search` (or at a passage `offset`) to confirm. (Do not read `search.truncated` as a completeness signal: it is about the SEARCH — false means every distinct match location was returned — and is routinely false on a response holding a third of the document.) **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). 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.',
1986
+ '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` on a fully-read document (`status: "not_found"`) 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. On a `raw_truncated` document a zero is `status: "not_found_within_scanned_portion"`: the phrase was not found in the first `scanned_chars` characters, which is NOT evidence the filing lacks it. A `search` response carries ONLY the matched passages, so it reports `returned_chars` against `total_chars` and sets `truncated:true` whenever those differ — read a figure\'s absence from it as "the phrase did not match there", never as "the filing does not say it", and re-read without `search` (or at a passage `offset`) to confirm. (Do not read `search.truncated` as a completeness signal: it is about the SEARCH — false means every distinct match location was returned — and is routinely false on a response holding a third of the document.) **For a question asking two facts from the same filing** ("revenue and test volume in the latest 10-Q"), pass `search` as `|`-separated phrases (e.g. "revenue|tests processed") to get both in this one call under `searches[]` — and when a phrase names a financial total (revenue, net income, EPS, ...), the matching XBRL figure for this exact accession comes back under `xbrl` even if that total never appears as an absolute number in the prose (common: filings often state only the growth delta/percentage in text). Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events | risk_factors — 10-K Item 1A, bounded from its heading to the next Item heading, table-of-contents hits and cross-references skipped; the response carries `section_diagnostics` with its length and where it ended); `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.',
1971
1987
  summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1972
1988
  inputSchema: {
1973
1989
  type: 'object' as const,
@@ -1995,8 +2011,8 @@ const tools: McpToolExport['tools'] = [
1995
2011
  },
1996
2012
  section: {
1997
2013
  type: 'string',
1998
- enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events'],
1999
- description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false).',
2014
+ enum: ['going_concern', 'liquidity', 'capital_resources', 'subsequent_events', 'risk_factors'],
2015
+ description: 'Return only this section (located by heading). Omit for the whole document. Unmatched sections fall back to the whole document (section_found:false). risk_factors applies to 10-K text only and is the filer-written wording, untagged: it is not a classification of the risks.',
2000
2016
  },
2001
2017
  max_chars: {
2002
2018
  type: 'number',
@@ -2033,7 +2049,7 @@ const tools: McpToolExport['tools'] = [
2033
2049
  {
2034
2050
  name: 'edgar_filing_exhibit_text',
2035
2051
  description:
2036
- 'Full text of an EXHIBIT attached to a filing — not the filing\'s own primary document (use edgar_filing_text for that). This is the only way to read an 8-K\'s Exhibit 99.1/99.2 press release — a quarterly production-and-delivery report, an earnings release, a KPI update — because the 8-K body itself routinely just says "see the attached exhibit" and carries none of the numbers; the figures live in the exhibit document. Pass the filer\'s `ticker` or `cik` plus either an `accession` (from edgar_company_filings) or a `form_type` to auto-resolve the latest matching filing (defaults to "8-K" when both accession and form_type are omitted, since that is overwhelmingly when an exhibit holds the operative content). Set `exhibit` to pick a specific attachment by its SEC-assigned type — "99.1", "99", "10.1" — read off the filing\'s own document index (case-insensitive, the "EX-" prefix optional); omit it to default to the first EX-99.* exhibit, which is the convention Item 2.02 (earnings) and Item 7.01 (Reg FD) 8-Ks use for a press release. When the requested exhibit is not found (or none exists), the response lists every exhibit actually in the filing under `available_exhibits` rather than failing silently. **For a specific number inside the exhibit, pass `search`** (a short phrase likely to appear verbatim, e.g. "vehicle deliveries", "Megapack", "energy storage") the same way edgar_filing_text does — it scans the whole exhibit and returns matching passages ranked by a nearby figure, with `|`-separated phrases for more than one fact in one call; a zero-match search is a real answer (try different wording) and falls back to a word-level match when the exact phrase misses. Without `search`, the exhibit text is returned paged (`max_chars`/`offset`, same convention as edgar_filing_text) since a press release can run several thousand words. Use for "Tesla\'s Q3 2026 vehicle deliveries and production", "what did $TICKER\'s earnings press release say about [KPI]", "read the exhibit attached to $TICKER\'s latest 8-K". Source: U.S. Securities and Exchange Commission, EDGAR filing exhibits (the same primary-source filings edgar_filing_text and edgar_filing_documents read).',
2052
+ 'Full text of an EXHIBIT attached to a filing — not the filing\'s own primary document (use edgar_filing_text for that). This is the only way to read an 8-K\'s Exhibit 99.1/99.2 press release — a quarterly production-and-delivery report, an earnings release, a KPI update — because the 8-K body itself routinely just says "see the attached exhibit" and carries none of the numbers; the figures live in the exhibit document. Pass the filer\'s `ticker` or `cik` plus either an `accession` (from edgar_company_filings) or a `form_type` to auto-resolve the latest matching filing (defaults to "8-K" when both accession and form_type are omitted, since that is overwhelmingly when an exhibit holds the operative content). Set `exhibit` to pick a specific attachment by its SEC-assigned type — "99.1", "99", "10.1" — read off the filing\'s own document index (case-insensitive, the "EX-" prefix optional); omit it to default to the first EX-99.* exhibit, which is the convention Item 2.02 (earnings) and Item 7.01 (Reg FD) 8-Ks use for a press release. When the requested exhibit is not found (or none exists), the response lists every exhibit actually in the filing under `available_exhibits` rather than failing silently. **For a specific number inside the exhibit, pass `search`** (a short phrase likely to appear verbatim, e.g. "vehicle deliveries", "Megapack", "energy storage") the same way edgar_filing_text does — it scans the whole exhibit and returns matching passages ranked by a nearby figure, with `|`-separated phrases for more than one fact in one call; a zero-match search is a real answer when `raw_truncated` is absent (try different wording); with `raw_truncated: true` it is `status: "not_found_within_scanned_portion"` and inconclusive and falls back to a word-level match when the exact phrase misses. Without `search`, the exhibit text is returned paged (`max_chars`/`offset`, same convention as edgar_filing_text) since a press release can run several thousand words. Use for "Tesla\'s Q3 2026 vehicle deliveries and production", "what did $TICKER\'s earnings press release say about [KPI]", "read the exhibit attached to $TICKER\'s latest 8-K". Source: U.S. Securities and Exchange Commission, EDGAR filing exhibits (the same primary-source filings edgar_filing_text and edgar_filing_documents read).',
2037
2053
  summary: 'Full text of an exhibit (e.g. a press release) attached to an SEC filing, such as an 8-K\'s EX-99.1.',
2038
2054
  inputSchema: {
2039
2055
  type: 'object' as const,
@@ -3133,6 +3149,30 @@ async function fetchTextBounded(url: string, headers: Record<string, string>, ma
3133
3149
  // stated limit, not a silent gap — rather than never answering at all.
3134
3150
  const FILING_DOC_MAX_BYTES = 4_000_000;
3135
3151
 
3152
+ /**
3153
+ * What a search result MEANS given how much of the document was read (fleet #3562).
3154
+ * A zero-match search over a document whose raw HTML hit the read cap
3155
+ * (`raw_truncated`) only says the phrase is absent from the portion scanned.
3156
+ * Before this, such a zero carried the same `status`-less empty array and a note
3157
+ * calling it "a real answer", so a model read "no going-concern language" off a
3158
+ * scan that never reached the notes. `status` is the distinct, checkable field:
3159
+ * found | not_found | not_found_within_scanned_portion (zero over a capped read)
3160
+ * | word_lead_only | word_lead_only_within_scanned_portion.
3161
+ */
3162
+ function searchStatus(
3163
+ found: boolean,
3164
+ wordMode: boolean,
3165
+ rawTruncated: boolean,
3166
+ ): { status: string; scan_note?: string } {
3167
+ if (found) return { status: 'found' };
3168
+ if (!rawTruncated) return { status: wordMode ? 'word_lead_only' : 'not_found' };
3169
+ return {
3170
+ status: wordMode ? 'word_lead_only_within_scanned_portion' : 'not_found_within_scanned_portion',
3171
+ scan_note:
3172
+ 'INCONCLUSIVE: the document was only PARTLY read (raw_truncated: true) — see `scanned_chars` on the response for how far this scan reached. The phrase was not found WITHIN that portion; it may well appear in the part that was never read. Do NOT conclude the filing omits this wording. Retry with a different phrase, use `section` (it reads from the same capped text), or open the filing at document_url.',
3173
+ };
3174
+ }
3175
+
3136
3176
  // Strip an HTML document to readable plaintext. Workers have no DOMParser;
3137
3177
  // drop script/style blocks, remove tags, decode entities, collapse whitespace.
3138
3178
  function htmlToText(html: string): string {
@@ -3258,6 +3298,117 @@ export function findFormIndices(
3258
3298
  const AUTO_SELECT_SCAN = 4;
3259
3299
  const AUTO_SELECT_LIST = 8;
3260
3300
 
3301
+ /**
3302
+ * Item 1A (Risk Factors) of a 10-K, bounded at BOTH ends. Unlike the other
3303
+ * sections (anchor to end-of-document), a risk-factors slice must stop at the
3304
+ * next Item heading, or "Item 1A text" would silently be 1A + the rest of the
3305
+ * filing.
3306
+ *
3307
+ * The failure this is built around is returning a plausible-looking wrong
3308
+ * chunk: the table-of-contents line ("Item 1A. Risk Factors 5 Item 1B ..."),
3309
+ * a cross-reference ("see Item 1A. Risk Factors"), a running page header
3310
+ * ("Item 1A. Risk Factors (Continued)"), or a section that stops early at one
3311
+ * of those. So: collect every heading-shaped start, drop the ones that read
3312
+ * as references or running headers, pair each start with the next Item
3313
+ * 1B/1C/2 heading, and keep the start whose slice is LONGEST. A TOC entry's
3314
+ * slice runs to the next TOC line (tens of characters); the real section is
3315
+ * thousands. Pure text, deterministic, no model.
3316
+ */
3317
+ // Some filers' HTML-to-text leaves ONE stray space inside a heading word
3318
+ // ("RIS K FACTORS" Microsoft, "R isk Factors" Oracle, "Item 1 A" Prdo): the
3319
+ // literal heading then never matches and only the contents line is found.
3320
+ // tol(word) accepts the word whole or with a single space at any inner
3321
+ // position. One space per word, never a wildcard run, so it cannot widen into
3322
+ // unrelated text; the RF_MIN_PLAUSIBLE_CHARS floor below stays the backstop.
3323
+ function tol(word: string): string {
3324
+ const alts = [word];
3325
+ for (let i = 1; i < word.length; i++) alts.push(`${word.slice(0, i)}\\s${word.slice(i)}`);
3326
+ return `(?:${alts.join('|')})`;
3327
+ }
3328
+ const RF_PUNCT = '[.:\\-\\u2013\\u2014]?';
3329
+ const RF_START = new RegExp(`\\b${tol('item')}\\s*${tol('1a')}\\s*${RF_PUNCT}\\s*${tol('risk')}\\s+${tol('factors')}`, 'gi');
3330
+ const RF_END = new RegExp(
3331
+ `\\b${tol('item')}\\s*(?:${tol('1b')}\\s*${RF_PUNCT}\\s*${tol('unresolved')}\\s+${tol('staff')}|${tol('1c')}\\s*${RF_PUNCT}\\s*${tol('cybersecurity')}|2\\s*${RF_PUNCT}\\s*(?:${tol('description')}\\s+${tol('of')}\\s+)?${tol('propert')})`,
3332
+ 'gi',
3333
+ );
3334
+ const RF_REF_WORDS = /(?:\b(?:see|in|under|and|to|of|at|with|within|also|by|from|the|this|our|its|entitled|captioned|heading)\s*|[“"'‘(])$/i;
3335
+
3336
+ function isHeadingShaped(text: string, idx: number, len: number): boolean {
3337
+ const before = text.slice(Math.max(0, idx - 24), idx).replace(/[\s\u00a0]+$/, '');
3338
+ if (RF_REF_WORDS.test(before)) return false;
3339
+ const after = text.slice(idx + len, idx + len + 24).replace(/^[\s\u00a0.]+/, '');
3340
+ if (/^[”"’')]/.test(after)) return false; // ...“Item 1A. Risk Factors” cross-reference
3341
+ if (/^\(?\s*continued\b/i.test(after)) return false; // running page header
3342
+ if (/^(?:of|in|above|below|under|for|and|section|herein|generally)\b/.test(after)) return false;
3343
+ return true;
3344
+ }
3345
+
3346
+ // Below this a slice is not a risk-factors section, it is an index/cross-reference
3347
+ // line that won the longest-slice rule unopposed (Intel FY2025: 32 chars, a
3348
+ // "Pages 37 - 48" index entry, one heading candidate). Chosen from the
3349
+ // measured gap: the failure is tens of characters and the smallest genuine
3350
+ // section measured is 31,270 (Intrusion FY2025); 1,000 sits ~30x above the
3351
+ // failure and ~30x below the smallest true section. The smaller-reporting-
3352
+ // company "not required" statement is exempt: it is legitimately tiny and has
3353
+ // its own not_required flag.
3354
+ export const RF_MIN_PLAUSIBLE_CHARS = 1000;
3355
+
3356
+ export function extractRiskFactors(
3357
+ fullText: string,
3358
+ ): { text: string; implausible: boolean; diagnostics: Record<string, unknown> } | null {
3359
+ const starts: number[] = [];
3360
+ let skippedRefs = 0;
3361
+ for (const m of fullText.matchAll(RF_START)) {
3362
+ if (m.index === undefined) continue;
3363
+ if (isHeadingShaped(fullText, m.index, m[0].length)) starts.push(m.index);
3364
+ else skippedRefs++;
3365
+ }
3366
+ if (starts.length === 0) return null;
3367
+ const ends: Array<{ idx: number; label: string }> = [];
3368
+ for (const m of fullText.matchAll(RF_END)) {
3369
+ if (m.index === undefined) continue;
3370
+ if (!isHeadingShaped(fullText, m.index, m[0].length)) continue;
3371
+ const t = m[0].toLowerCase().replace(/\s+/g, '');
3372
+ ends.push({ idx: m.index, label: t.includes('1b') ? 'Item 1B' : t.includes('1c') ? 'Item 1C' : 'Item 2' });
3373
+ }
3374
+ let best: { start: number; end: number; label: string | null } | null = null;
3375
+ for (const st of starts) {
3376
+ const e = ends.find((x) => x.idx > st);
3377
+ const end = e ? e.idx : fullText.length;
3378
+ if (!best || end - st > best.end - best.start) best = { start: st, end, label: e ? e.label : null };
3379
+ }
3380
+ const b = best!;
3381
+ // Drop the page number the stripped HTML leaves glued to the section's last line ("... retention. 32").
3382
+ const text = fullText.slice(b.start, b.end).trim().replace(/\s+\d{1,3}$/, '');
3383
+ // A real Item 1A is thousands of characters; the exceptions are filers who
3384
+ // say "not required for smaller reporting companies" — report that as what
3385
+ // it is rather than a bare short string that reads like a failed parse.
3386
+ const notRequired = text.length < 2500 && /not\s+required|smaller\s+reporting\s+compan/i.test(text);
3387
+ const implausible = !notRequired && text.length < RF_MIN_PLAUSIBLE_CHARS;
3388
+ return {
3389
+ text,
3390
+ implausible,
3391
+ diagnostics: {
3392
+ start_offset: b.start,
3393
+ end_offset: b.end,
3394
+ section_chars: text.length,
3395
+ ended_at: b.label ?? 'end_of_document',
3396
+ heading_candidates: starts.length,
3397
+ references_skipped: skippedRefs,
3398
+ ...(b.label === null
3399
+ ? { warning: 'No Item 1B/1C/2 heading followed the start, so this runs to the end of the text read: the section may be longer than the real Item 1A.' }
3400
+ : {}),
3401
+ ...(notRequired
3402
+ ? { not_required: true, note: 'This filer states Item 1A is not required (smaller reporting company); the text is that statement, not omitted risk factors.' }
3403
+ : implausible
3404
+ ? { warning: `Only ${text.length} characters, which is an index or cross-reference line rather than a risk-factors section (some filers place Item 1A in a cross-reference index and write the risks under an unnumbered heading). Not returned as the section: section_found is false and the whole document is returned.` }
3405
+ : text.length < 2500
3406
+ ? { warning: `Only ${text.length} characters — shorter than a real risk-factors section normally is. Treat as suspect (possible table-of-contents hit or a filer that incorporates risk factors by reference).` }
3407
+ : {}),
3408
+ },
3409
+ };
3410
+ }
3411
+
3261
3412
  /**
3262
3413
  * The `section` slice of a filing's text, as a function rather than inline, so
3263
3414
  * the auto-selection walk below can score a candidate against the SAME text
@@ -3267,9 +3418,22 @@ const AUTO_SELECT_LIST = 8;
3267
3418
  export function sliceFilingSection(
3268
3419
  fullText: string,
3269
3420
  section: string | undefined,
3270
- ): { text: string; applied: string | null; found: string | boolean | null } {
3271
- const sec = (section ?? '').trim().toLowerCase();
3421
+ ): { text: string; applied: string | null; found: string | boolean | null; diagnostics?: Record<string, unknown> } {
3422
+ const sec = (section ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
3272
3423
  if (!sec) return { text: fullText, applied: null, found: null };
3424
+ if (sec === 'risk_factors' || sec === 'item_1a') {
3425
+ const r = extractRiskFactors(fullText);
3426
+ if (!r) return { text: fullText, applied: null, found: false };
3427
+ if (r.implausible) {
3428
+ return {
3429
+ text: fullText,
3430
+ applied: null,
3431
+ found: false,
3432
+ diagnostics: { ...r.diagnostics, rejected_slice_chars: r.text.length, rejected_slice_text: r.text.slice(0, 120) },
3433
+ };
3434
+ }
3435
+ return { text: r.text, applied: 'risk_factors', found: true, diagnostics: r.diagnostics };
3436
+ }
3273
3437
  const conf = FILING_SECTION_PATTERNS[sec];
3274
3438
  if (!conf) return { text: fullText, applied: null, found: `unknown_section:${sec}` };
3275
3439
  const idxs: number[] = [];
@@ -3592,6 +3756,82 @@ export function findWordPassages(
3592
3756
  };
3593
3757
  }
3594
3758
 
3759
+ // --- holding-company reorganisations (fleet #3563) -------------------------
3760
+ // SEC's ticker map gives each ticker ONE cik. When a company reorganises under
3761
+ // a new holding company the ticker moves to the new registrant (XOM ->
3762
+ // ExxonMobil Holdings Corp, cik 2115436, proxy material only) while the 10-K
3763
+ // history stays under the old one (EXXON MOBIL CORP, cik 34088). By ticker the
3764
+ // caller then got "no 10-K found" about one of the largest filers in the
3765
+ // market. A sibling is accepted ONLY when its name, stripped of corporate
3766
+ // boilerplate and spacing, EQUALS the resolved registrant's AND it really holds
3767
+ // the requested form: "ExxonMobil Holdings Corp" and "EXXON MOBIL CORP" both
3768
+ // reduce to "EXXONMOBIL"; "ExxonMobil Renewables LLC" does not.
3769
+ const REGISTRANT_BOILERPLATE = new Set([
3770
+ 'THE', 'A', 'INC', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED', 'LLC', 'LP', 'PLC',
3771
+ 'SA', 'AG', 'NV', 'GMBH', 'AB', 'AS', 'OY', 'SPA', 'GROUP', 'HOLDINGS', 'HOLDING', 'AND', 'OF',
3772
+ 'US', 'USA',
3773
+ ]);
3774
+ // Words like INTERNATIONAL / GLOBAL / NEW / TRUST / FUND are deliberately NOT
3775
+ // stripped: "Eastern International Ltd" and "The Eastern Company" are different
3776
+ // registrants (measured in the fleet #3563 survey), and a wrong redirect is worse
3777
+ // than the not-found it replaces. SIC is compared as a second guard.
3778
+
3779
+ const SIBLING_MAX_AGE_DAYS = 800;
3780
+
3781
+ export function registrantCore(name: string): string {
3782
+ return name
3783
+ .replace(/\([^)]*\)/g, ' ')
3784
+ .toUpperCase()
3785
+ .replace(/[^A-Z0-9 ]/g, ' ')
3786
+ .split(/\s+/)
3787
+ .filter((t) => t && !REGISTRANT_BOILERPLATE.has(t))
3788
+ .join('');
3789
+ }
3790
+
3791
+ async function findSiblingFilers(
3792
+ ownName: string,
3793
+ ownCik: string,
3794
+ wantFormTypeArg: string,
3795
+ ownSic?: string,
3796
+ ): Promise<{ cik: string; name: string; accession: string; filed: string | null }[]> {
3797
+ const core = registrantCore(ownName);
3798
+ if (core.length < 5) return [];
3799
+ const res = await pwFetch(
3800
+ `https://efts.sec.gov/LATEST/search-index?keysTyped=${encodeURIComponent(core.slice(0, 5).toLowerCase())}`,
3801
+ { headers: SEC_HEADERS },
3802
+ ).catch(() => null);
3803
+ if (!res || !res.ok) return [];
3804
+ const data = (await res.json().catch(() => null)) as
3805
+ | { hits?: { hits?: { _id: string; _source?: { entity?: string } }[] } }
3806
+ | null;
3807
+ const hits = (data?.hits?.hits ?? [])
3808
+ .filter((h) => /^\d+$/.test(h._id) && h._source?.entity)
3809
+ .map((h) => ({ cik: String(Number(h._id)), name: h._source!.entity! }))
3810
+ .filter((h) => h.cik !== ownCik && registrantCore(h.name) === core)
3811
+ .slice(0, 4);
3812
+ const found: { cik: string; name: string; accession: string; filed: string | null }[] = [];
3813
+ for (const h of hits) {
3814
+ const r = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(h.cik)}.json`, { headers: SEC_HEADERS }).catch(() => null);
3815
+ if (!r || !r.ok) continue;
3816
+ const sub = (await r.json().catch(() => null)) as
3817
+ | { sic?: string; filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } } }
3818
+ | null;
3819
+ const rec = sub?.filings?.recent;
3820
+ if (!rec) continue;
3821
+ if (ownSic && sub?.sic && sub.sic !== ownSic) continue; // same name, different industry: not the same company
3822
+ const idx = findFormIndices(rec.form ?? [], wantFormTypeArg, rec.filingDate ?? [], null, 1)[0];
3823
+ // A registrant that last filed this form years ago is a dead namesake
3824
+ // (measured: DMRC's same-named cik last filed a 10-K in 2008), not the
3825
+ // predecessor of a fresh reorganisation.
3826
+ const filed = rec.filingDate?.[idx ?? -1] ?? '';
3827
+ if (!filed || Date.parse(filed) < Date.now() - SIBLING_MAX_AGE_DAYS * 86_400_000) continue;
3828
+ if (idx !== undefined && rec.accessionNumber?.[idx]) {
3829
+ found.push({ cik: h.cik, name: h.name, accession: rec.accessionNumber[idx], filed: rec.filingDate?.[idx] ?? null });
3830
+ }
3831
+ }
3832
+ return found;
3833
+ }
3834
+
3595
3835
  // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
3596
3836
  // out (rather than piggybacking edgar_filing_documents' include_primary_text,
3597
3837
  // which caps at 40k and can't page) lets the register pull deep disclosures —
@@ -3605,7 +3845,8 @@ async function filingText(
3605
3845
  wantFormTypeArg?: string,
3606
3846
  searchTerm?: string,
3607
3847
  filedArg?: string,
3608
- ) {
3848
+ siblingOf?: string,
3849
+ ): Promise<any> {
3609
3850
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
3610
3851
  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"})).');
3611
3852
  }
@@ -3623,6 +3864,7 @@ async function filingText(
3623
3864
  if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) for ${tickerOrCik}.`);
3624
3865
  const sub = (await subRes.json()) as {
3625
3866
  name?: string;
3867
+ sic?: string;
3626
3868
  filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[]; primaryDocument?: string[] } };
3627
3869
  };
3628
3870
  const r = sub.filings?.recent;
@@ -3714,6 +3956,26 @@ async function filingText(
3714
3956
  }
3715
3957
  const candidateIdx = findFormIndices(forms, wantFormTypeArg, filingDates, filedPrefix, AUTO_SELECT_LIST)
3716
3958
  .filter((idx) => !!accs[idx] && !!normalizeAccession(accs[idx]));
3959
+ if (!candidateIdx.length && wantForm && !siblingOf && !isNumericCik(tickerOrCik) && sub.name) {
3960
+ // Ticker resolved to a registrant that has never filed this form. Look
3961
+ // for the sibling registrant that carries the history (holdco reorg).
3962
+ const sibs = await findSiblingFilers(sub.name, cikNoZeros, wantForm, sub.sic);
3963
+ if (sibs.length === 1) {
3964
+ const redirected = await filingText(undefined, sibs[0].cik, section, maxChars, offset, wantFormTypeArg, searchTerm, filedArg, cikNoZeros);
3965
+ return {
3966
+ ...redirected,
3967
+ registrant_redirect: {
3968
+ asked_for: tickerOrCik,
3969
+ resolved_to: { cik: cikNoZeros, company: sub.name, note: `has no ${wantForm} on file` },
3970
+ read_instead: { cik: sibs[0].cik, company: sibs[0].name },
3971
+ why: `"${tickerOrCik}" resolves to ${sub.name} (CIK ${cikNoZeros}), which has filed no ${wantForm}. ${sibs[0].name} (CIK ${sibs[0].cik}) has the same name and does file one — typical after a holding-company reorganisation, where the ticker moves to the new registrant and the filing history stays with the old one. This response reads ${sibs[0].name}'s filing.`,
3972
+ },
3973
+ };
3974
+ }
3975
+ if (sibs.length > 1) {
3976
+ throw new Error(`No ${wantForm} filing found for ${tickerOrCik}: it resolves to ${sub.name} (CIK ${cikNoZeros}), which has filed none. Same-named registrants that do file one: ${sibs.map((x) => `${x.name} (CIK ${x.cik})`).join('; ')}. Ask again with one of those CIKs as ticker_or_cik.`);
3977
+ }
3978
+ }
3717
3979
  if (!candidateIdx.length) {
3718
3980
  const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
3719
3981
  throw new Error(`No ${wantForm ?? 'recent'} filing${dateClause} found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
@@ -3790,6 +4052,7 @@ async function filingText(
3790
4052
  fullText = sliced.text;
3791
4053
  const sectionApplied = sliced.applied;
3792
4054
  const sectionFound = sliced.found;
4055
+ const sectionDiagnostics = sliced.diagnostics;
3793
4056
 
3794
4057
  // `search` short-circuits the normal offset/max_chars windowing: it scans
3795
4058
  // the whole (possibly section-sliced) document and returns matching
@@ -3835,7 +4098,13 @@ async function filingText(
3835
4098
  ? 'No case-insensitive match for this wording (nor, for a multi-word phrase, for any of its individual words) in the document (or section, if one was given). That is a real answer, not a failure — retry with different wording or drop `section` to search the whole document.'
3836
4099
  : `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.`,
3837
4100
  };
3838
- return xbrl ? { ...base, xbrl } : base;
4101
+ const st = searchStatus(totalMatches > 0, wordHit !== null, rawTruncated);
4102
+ const out = {
4103
+ ...base,
4104
+ status: st.status,
4105
+ ...(st.scan_note ? { note: st.scan_note + (wordHit ? ' ' + base.note : ''), scanned_chars: documentChars } : {}),
4106
+ };
4107
+ return xbrl ? { ...out, xbrl } : out;
3839
4108
  };
3840
4109
 
3841
4110
  const results = await Promise.all(searchPhrases.map(buildOne));
@@ -3881,6 +4150,7 @@ async function filingText(
3881
4150
  document_url: docUrl,
3882
4151
  section: sectionApplied,
3883
4152
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
4153
+ ...(sectionDiagnostics ? { section_diagnostics: sectionDiagnostics } : {}),
3884
4154
  total_chars: fullText.length,
3885
4155
  ...(fullText.length !== documentChars ? { document_chars: documentChars } : {}),
3886
4156
  returned_chars: returnedChars,
@@ -3899,7 +4169,8 @@ async function filingText(
3899
4169
  ...(rawTruncated
3900
4170
  ? {
3901
4171
  raw_truncated: true,
3902
- 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.`,
4172
+ scanned_chars: documentChars,
4173
+ 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 first ${documentChars} characters of text — NOT the whole filing. A phrase with status "not_found_within_scanned_portion" is inconclusive, not "absent".`,
3903
4174
  }
3904
4175
  : {}),
3905
4176
  };
@@ -3924,6 +4195,7 @@ async function filingText(
3924
4195
  document_url: docUrl,
3925
4196
  section: sectionApplied,
3926
4197
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
4198
+ ...(sectionDiagnostics ? { section_diagnostics: sectionDiagnostics } : {}),
3927
4199
  total_chars: total,
3928
4200
  // A section slice reframed `total_chars` to the slice (fleet #3316): say so,
3929
4201
  // or a 2,000-char section of a 400,000-char 10-K reads as a whole document.
@@ -3937,7 +4209,7 @@ async function filingText(
3937
4209
  ...(rawTruncated
3938
4210
  ? {
3939
4211
  raw_truncated: true,
3940
- 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.`,
4212
+ 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; risk_factors instead runs to the end of the text read, with a warning) 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.`,
3941
4213
  }
3942
4214
  : {}),
3943
4215
  text: slice,
@@ -4097,7 +4369,8 @@ async function filingExhibitText(
4097
4369
  const buildOne = (term: string) => {
4098
4370
  const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
4099
4371
  const wordHit = totalMatches === 0 ? findWordPassages(fullText, term) : null;
4100
- return wordHit
4372
+ const st = searchStatus(totalMatches > 0, wordHit !== null, rawTruncated);
4373
+ const r = wordHit
4101
4374
  ? {
4102
4375
  term,
4103
4376
  match_mode: 'words' as const,
@@ -4121,6 +4394,11 @@ async function filingExhibitText(
4121
4394
  ? 'No case-insensitive match for this wording (nor any of its individual words) in this exhibit. That is a real answer, not a failure — retry with different wording, or this fact may be in a different exhibit (see `available_exhibits`).'
4122
4395
  : 'Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order.',
4123
4396
  };
4397
+ return {
4398
+ ...r,
4399
+ status: st.status,
4400
+ ...(st.scan_note ? { note: st.scan_note + (wordHit ? ' ' + r.note : ''), scanned_chars: documentChars } : {}),
4401
+ };
4124
4402
  };
4125
4403
  const results = searchPhrases.map(buildOne);
4126
4404
  const returnedChars = results.reduce((n, r) => n + r.passages.reduce((m, p) => m + p.text.length, 0), 0);
@@ -4135,7 +4413,7 @@ async function filingExhibitText(
4135
4413
  ? { truncated_note: `These are matched passages, not the exhibit: ${returnedChars} of ${documentChars} characters. Call again without \`search\` (or with a passage's \`offset\`) to read the surrounding text.` }
4136
4414
  : {}),
4137
4415
  ...(searchPhrases.length === 1 ? { search: results[0] } : { searches: searchPhrases.map((phrase, i) => ({ phrase, ...results[i] })) }),
4138
- ...(rawTruncated ? { raw_truncated: true } : {}),
4416
+ ...(rawTruncated ? { raw_truncated: true, scanned_chars: documentChars } : {}),
4139
4417
  };
4140
4418
  }
4141
4419
 
@@ -4727,6 +5005,13 @@ async function companyConcept(
4727
5005
  // (fy, fp, end) tuple. Keep the most-recently-filed version.
4728
5006
  type Raw = { start?: string; end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
4729
5007
  const spanDays = (a?: string, b?: string) => (a && b) ? Math.round((Date.parse(b) - Date.parse(a)) / 86400000) : NaN;
5008
+ /** Distinct accession numbers of the facts a derived row was computed from
5009
+ * (fleet #3179), in the order given; undefined when none carries one. */
5010
+ const uniqueAccessions = (rows: Array<{ accession?: string }>): string[] | undefined => {
5011
+ const out: string[] = [];
5012
+ for (const r of rows) if (r.accession && !out.includes(r.accession)) out.push(r.accession);
5013
+ return out.length > 0 ? out : undefined;
5014
+ };
4730
5015
  // Does this fact's DURATION match its period label? A 10-Q files BOTH the
4731
5016
  // discrete 3-month quarter AND the year-to-date figure under the same
4732
5017
  // (fy, fp, end) tuple, filed the same day — so "most recently filed" was a
@@ -4782,6 +5067,10 @@ async function companyConcept(
4782
5067
  ytd: false as boolean,
4783
5068
  note: undefined as string | undefined,
4784
5069
  total_liquidity: undefined as LiquidityBreakdown | undefined,
5070
+ // Derived rows only (fleet #3179): the filings the arithmetic read, so a
5071
+ // computed Q2/Q3/Q4 figure is still traceable to documents. `accession`
5072
+ // stays undefined on those rows -- no ONE filing states the number.
5073
+ derived_from_accessions: undefined as string[] | undefined,
4785
5074
  }))
4786
5075
  .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
4787
5076
 
@@ -4828,6 +5117,7 @@ async function companyConcept(
4828
5117
  form: 'derived',
4829
5118
  filed: fyRow.filed,
4830
5119
  accession: undefined, // arithmetic over FY + Q1-Q3, not a fact one filing states
5120
+ derived_from_accessions: uniqueAccessions([fyRow, ...quarters.values()]),
4831
5121
  unit: fyRow.unit,
4832
5122
  derived: true,
4833
5123
  duration_days: undefined, // no period_start to compute a span from
@@ -4895,6 +5185,7 @@ async function companyConcept(
4895
5185
  form: 'derived',
4896
5186
  filed: q2ytd.filed,
4897
5187
  accession: undefined, // YTD(Q2) minus Q1 — spans two filings
5188
+ derived_from_accessions: uniqueAccessions([q2ytd, q1]),
4898
5189
  unit: q2ytd.unit,
4899
5190
  derived: true,
4900
5191
  duration_days: spanDays(q1.period_end, q2ytd.period_end),
@@ -4917,6 +5208,7 @@ async function companyConcept(
4917
5208
  form: 'derived',
4918
5209
  filed: q3ytd.filed,
4919
5210
  accession: undefined, // YTD(Q3) minus YTD(Q2) — spans two filings
5211
+ derived_from_accessions: uniqueAccessions([q3ytd, q2ytd]),
4920
5212
  unit: q3ytd.unit,
4921
5213
  derived: true,
4922
5214
  duration_days: spanDays(q2ytd.period_end, q3ytd.period_end),
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@ import pack from './index.js';
10
10
 
11
11
 
12
12
  const server = new Server(
13
- { name: '@pipeworx/mcp-edgar', version: '0.3.13' },
13
+ { name: '@pipeworx/mcp-edgar', version: '0.3.14' },
14
14
  { capabilities: { tools: {} } },
15
15
  );
16
16