@pipeworx/mcp-edgar 0.3.10 → 0.3.12

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 1807+ 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 1837+ 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
 
@@ -60,6 +60,8 @@ For insider trades specifically, see the dedicated `insider-trading` pack — it
60
60
  - **Real-time-ish, not real-time.** Filings appear on EDGAR within minutes of submission, but Pipeworx caches results. For breaking-news-grade timeliness, set `Cache-Control: no-cache` (anonymous limit applies) or check the `_meta.cache.fresh_until` field.
61
61
  - **Concept availability differs.** Smaller filers tag fewer XBRL concepts than large ones. `edgar_company_concept` may return empty arrays for valid concepts that the company simply doesn't report. Use `edgar_company_facts` to see which concepts a company DOES report.
62
62
  - **The company argument is spelled three different ways across this pack, and `edgar_company_concept` accepts all of them.** `edgar_company_filings`/`edgar_insider_transactions`/`edgar_product_revenue` name it `ticker_or_cik`, `edgar_fund_holdings`/`edgar_ticker_to_cik` name it `ticker`, and `edgar_company_concept`/`edgar_company_facts` name it `cik` (which takes a ticker too). A model filling arguments reaches for whichever word it read last, so `edgar_company_concept` declares `ticker` and `ticker_or_cik` as aliases of `cik`, and `metric` as an alias of `concept`. Measured before that change (fleet #2058): of 11 times the ask_pipeworx adjudicator selected this tool over 16 never-before-asked questions, 9 calls were rejected by the gateway's required-argument pre-flight — every one of them because it had sent `ticker` or `ticker_or_cik` rather than `cik`, and a third of those sent `metric` rather than `concept` as well. Both calls that used `cik`+`concept` succeeded. The outcome recorded for the rejected ones was `partial`, not an error, because a single-tool selection that arrives as a one-element `tools` array runs the fan-out branch — so the word for "the only lookup never ran" was the same word used for "some of several lookups ran".
63
+ - **A filing you did not NAME is a filing we CHOSE, and the envelope now says which (fleet #3319).** `edgar_filing_text` and `edgar_filing_documents` auto-resolve the LATEST filing of `form_type` when `accession` is omitted, and every field then describes that filing as though it had been asked for. Measured live 2026-10-18: a grounded question about "Tesla's September 29 2026 8-K" reached `edgar_filing_text({ticker:"TSLA", form_type:"8-K", search:"credit facility"})`, resolved to the 2026-10-02 8-K — a different filing, 3,061 chars, zero matches — and refused `not_in_source` 5 tries out of 5, while the filing asked about sat one row further down the same index with all three credit tranches in it. So every response carries `accession_source` (`"caller"` | `"auto_selected"`) plus, when we chose, an `auto_selected` block naming what was requested, what was picked, what was read with its exact-match count, and the candidates that were not read. **Pass `filed`** ("2026-09-29", "2026-09", "September 29, 2026") whenever the question names a date; for an 8-K the newest filing is routinely not the one meant. When only a `search` is available, `edgar_filing_text` reads on past a candidate whose text contains no exact match (bounded to 4 documents, exact matches only), and if none match it falls back to the newest WITH a warning that a zero-match result there is inconclusive about the company rather than about that filing.
64
+ - **`edgar_company_filings` used to drop a named `accession` on the floor.** It declared neither `accession` nor `include_primary_text`, so a caller that named one filing got the same unnarrowed list as one that named nothing — HTTP 200, reads as a clean success. That is how the fleet #3319 question finally died: the grounded ladder's last attempt routed here with `{accession:"0001628280-26-063820", ticker:"TSLA", include_primary_text:true}` (AE `blob12` — the router had already resolved the right 8-K) and the extraction model received a generic 10-row filings list carrying no facility amounts. Both arguments are real now, plus `filed`; a one-row result says so in `narrowed_note`, and an accession the filer never filed is a loud `not_found:` error rather than an unfiltered list.
63
65
  - **`fiscal_year` and `fiscal_period` are filters AND response fields.** They are the names of two fields on every row in `values`, and the description's advice to "match the requested fiscal_year and fiscal_period" used to make argument-fillers send them as arguments, where they were undeclared and silently dropped — so the caller got all 89-230 reported periods back with nothing saying its filter had been ignored. They are now real optional filters: pass either or both and `values`/`latest` come back scoped to it, with a `period_filter` block echoing what was applied. An unmatched filter returns NO rows plus the list of fiscal years and periods the filer actually reports, rather than falling back to every period — `fiscal_year` is the filer's OWN label (NVDA's FY2024 ended January 2024), so a caller thinking in calendar years needs to see the labels rather than be handed a neighbouring year's number.
64
66
 
65
67
  ## Quick Start
@@ -106,7 +108,7 @@ directly, instead of just this one's:
106
108
  }
107
109
  ```
108
110
 
109
- Both URLs reach the same gateway and the same 1807+ data sources. The
111
+ Both URLs reach the same gateway and the same 1837+ data sources. The
110
112
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
111
113
  reaches all of them from either one.
112
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
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-c6ffe46614ee14db9190ceb27485537bf8164b74f6f5768addd234dad2987dff",
30
- "sourceCommit": "8dbe088fb6f9d2c88f934173742329b4e20a67d6"
29
+ "sourceHash": "v1-91337e87e2be0647d629be7b6c821ab5c696b671f31a18ead8dedd775cd40966",
30
+ "sourceCommit": "0f33463aa47677f38e4f177a4b4a7160dd80819f"
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.10",
6
+ "version": "0.3.12",
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
@@ -24,6 +24,93 @@ interface McpToolExport {
24
24
  provider?: string;
25
25
  }
26
26
 
27
+ /**
28
+ * The class routing tokens, and the two safe ways to wrap a message carrying one.
29
+ *
30
+ * A pack signals an error's class with a leading token — `user_error:`,
31
+ * `upstream_down:`, `upstream_throttled:`, `not_found:`, `blocked_host:`. The
32
+ * gateway's classifier anchors on `^`, and `stripClassPrefix` (which hides the
33
+ * token from the caller) anchors on `^` too. So the convention has one failure
34
+ * mode, and it is silent: a catch block that wraps the message —
35
+ * `` `${slug}/${tool}: ${message}` `` — pushes the token off position 0. The
36
+ * error then books as `error` ("Pipeworx has a defect") instead of as the
37
+ * caller mistake it is, AND the raw token leaks into what the caller reads.
38
+ *
39
+ * Nothing about that fails loudly. The call still returns, the message still
40
+ * reads plausibly, and the misclassification only shows up as a pack sitting on
41
+ * the Problem Tools list for a bug it does not have. Found live in
42
+ * `medicaid-intelligence` on 2026-08-21; the same wrapper template is copied
43
+ * across 18 DMV packs, none of which emit a token *yet*.
44
+ *
45
+ * `scripts/check-error-class-prefix.mjs` is the gate that keeps this honest —
46
+ * it fails any pack that both emits a token and wraps a caught message without
47
+ * using one of the helpers below.
48
+ */
49
+
50
+ /**
51
+ * The canonical token set. `workers/gateway/src/error-class.ts` carries its own
52
+ * copy on the read side (it is deliberately importable without pulling a pack
53
+ * in); the gate asserts the two agree, because this list has already drifted
54
+ * twice — `not_found:` and `blocked_host:` were honoured by the classifier and
55
+ * not stripped, so both went out to callers verbatim for months.
56
+ */
57
+ const CLASS_TOKENS = [
58
+ 'upstream_down',
59
+ 'upstream_throttled',
60
+ 'user_error',
61
+ 'not_found',
62
+ 'blocked_host',
63
+ // `blocked_url:` is emitted at position 0 from five sites in ssrf.ts
64
+ // (`assertPublicHttpUrl`, and every redirect hop in `safeFetch`) and was in
65
+ // NEITHER reader — so it went to callers verbatim for its whole life. Caught
66
+ // 2026-08-21 by a live n8n call, which answered a private instance_url with
67
+ // "…host). blocked_url: refusing to fetch non-public or non-https URL".
68
+ // Exactly the drift the gate now blocks.
69
+ 'blocked_url',
70
+ // `auth_required:` joins the list 2026-08-29 (fleet #638). It exists for the
71
+ // same reason `user_error:` does: a bare 401/403 in an upstream body matches
72
+ // the `upstream_throttled` heuristic below before anything auth-specific, so
73
+ // a pack that needs to say "this is a credential problem, not a rate limit"
74
+ // has no wording-based route — only the explicit-prefix escape hatch works.
75
+ // tiingo and open-sanctions both reached for it on their own, on the
76
+ // (reasonable, but wrong at the time) assumption that any snake_case class
77
+ // already meant something to the gateway. Neither shipped a leak from
78
+ // MIS-CLASSIFICATION — the `error` field was already correct — the leak was
79
+ // the literal token riding along in `message`, unstripped, because this list
80
+ // didn't know the token either reader was seeing.
81
+ 'auth_required',
82
+ ] as const;
83
+
84
+ const CLASS_PREFIX_RE =
85
+ /^(?:upstream_down|upstream_throttled|user_error|not_found|blocked_host|blocked_url|auth_required)\s*:\s*/;
86
+
87
+ /**
88
+ * Split a caught message into its leading routing token (possibly empty) and
89
+ * the human-readable body, so a wrapper can put the token back on the front.
90
+ *
91
+ * const { token, body } = splitClassPrefix(message);
92
+ * return { error: `${token}my-pack/${name}: ${body}` };
93
+ *
94
+ * The `${token}` must be the FIRST thing in the template — that is the whole
95
+ * point, and it is what the gate checks.
96
+ */
97
+ function splitClassPrefix(message: string): { token: string; body: string } {
98
+ const token = message.match(CLASS_PREFIX_RE)?.[0] ?? '';
99
+ return { token, body: message.slice(token.length) };
100
+ }
101
+
102
+ /**
103
+ * Drop a leading routing token from a message that is about to become a
104
+ * FRAGMENT of a larger one — a per-mirror failure joined into "all providers
105
+ * failed (...)", say. Hoisting is wrong there: the fragment never reaches
106
+ * position 0, so the token cannot route anything and would only leak. The outer
107
+ * message declares its own class.
108
+ */
109
+ function dropClassPrefix(message: string): string {
110
+ return message.replace(CLASS_PREFIX_RE, '');
111
+ }
112
+
113
+
27
114
  /**
28
115
  * Was this failure OUR OWN web service? — the other half of `internal-db-class.ts`.
29
116
  *
@@ -1181,7 +1268,7 @@ const tools: McpToolExport['tools'] = [
1181
1268
  {
1182
1269
  name: 'edgar_company_filings',
1183
1270
  description:
1184
- '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.',
1271
+ '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. **When the question is about ONE filing rather than the list, say which one**: pass `accession` to narrow to it, or `filed` ("2026-09-29", "2026-09") when you know only the date, and add `include_primary_text:true` to get that filing\'s text back here instead of chaining a second call. 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.',
1185
1272
  summary: 'Every SEC filing a US public company has made, newest first.',
1186
1273
  inputSchema: {
1187
1274
  type: 'object' as const,
@@ -1195,6 +1282,18 @@ const tools: McpToolExport['tools'] = [
1195
1282
  description: 'Filter by SEC form type (e.g., "10-K", "10-Q", "8-K"). Omit for all types.',
1196
1283
  },
1197
1284
  limit: { type: 'number', description: 'Max filings to return (1-40, default 20)' },
1285
+ accession: {
1286
+ type: 'string',
1287
+ description: 'Narrow the list to ONE filing you already know the accession of, dashed or not (e.g. "0001628280-26-063820") — use this when the question is about a specific filing rather than "what has this company filed". Pair it with `include_primary_text:true` to get that filing\'s text back in the same call. A named accession that does not belong to this filer is an explicit error, never a silently unfiltered list.',
1288
+ },
1289
+ include_primary_text: {
1290
+ type: 'boolean',
1291
+ description: 'When true, also return the primary document\'s text (HTML stripped to plaintext, capped at ~40,000 chars) under `primary_text`, for the SINGLE filing `accession` names. Ignored — with a note saying so — when no accession narrows the list, since the text of twenty filings is not a thing this tool returns; use edgar_filing_text for that.',
1292
+ },
1293
+ filed: {
1294
+ type: 'string',
1295
+ description: 'Only filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" are accepted too). Use it when the question names WHEN ("Tesla\'s September 29 2026 8-K") and you do not have the accession: without it the newest filing of that form is returned, which for an 8-K is routinely a different filing from the one asked about.',
1296
+ },
1198
1297
  cik: {
1199
1298
  type: 'string',
1200
1299
  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.',
@@ -1258,7 +1357,7 @@ const tools: McpToolExport['tools'] = [
1258
1357
  concept: {
1259
1358
  type: 'string',
1260
1359
  description:
1261
- 'REQUIRED (alias `metric`). Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
1360
+ 'REQUIRED (alias `metric`). Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt", "SharesOutstanding" (newest cover-page count, dei), "Capex", "OperatingCashFlow", "ResearchAndDevelopment", "SGA", "CostOfRevenue", "DilutedShares", "PublicFloat". Exact us-gaap or dei tag names also work.',
1262
1361
  },
1263
1362
  period: {
1264
1363
  type: 'string',
@@ -1577,7 +1676,7 @@ const tools: McpToolExport['tools'] = [
1577
1676
  properties: {
1578
1677
  accession: {
1579
1678
  type: 'string',
1580
- description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead.',
1679
+ description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead, and the response will say `accession_source:"auto_selected"` and list under `auto_selected.candidates` the other filings you might have meant. If the question names WHICH filing (a date, "the September 29 8-K"), pass `filed` or the accession rather than relying on that choice.',
1581
1680
  },
1582
1681
  ticker: {
1583
1682
  type: 'string',
@@ -1591,6 +1690,11 @@ const tools: McpToolExport['tools'] = [
1591
1690
  type: 'string',
1592
1691
  description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A" — or a `|`-separated SET ("10-K|10-Q") for "the most recent of either, whichever is newer". Omit both accession and form_type to get the single most recent filing of ANY type at all (routine 8-Ks/Form 4s/Form 144s included, not just annual/quarterly reports) — use the `|` set instead when you specifically want the newest 10-K or 10-Q.',
1593
1692
  },
1693
+ filed: {
1694
+ type: 'string',
1695
+ description: 'Only consider filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" accepted). **Pass this whenever the question names a date** ("Tesla\'s September 29 2026 8-K"): without it the NEWEST filing of that form is used, and for an 8-K that is routinely a different filing.',
1696
+ },
1697
+ filing_date: { type: 'string', description: 'Alias for `filed`.' },
1594
1698
  include_primary_text: {
1595
1699
  type: 'boolean',
1596
1700
  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.',
@@ -1606,15 +1710,20 @@ const tools: McpToolExport['tools'] = [
1606
1710
  {
1607
1711
  name: 'edgar_filing_text',
1608
1712
  description:
1609
- '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. **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.',
1713
+ '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.',
1610
1714
  summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1611
1715
  inputSchema: {
1612
1716
  type: 'object' as const,
1613
1717
  properties: {
1614
1718
  accession: {
1615
1719
  type: 'string',
1616
- 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.',
1720
+ 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 — the response then says `accession_source:"auto_selected"` and lists under `auto_selected.candidates` the other filings you might have meant. If the question names WHICH filing (a date, "the September 29 8-K"), pass `filed` or an accession instead of relying on that choice.',
1721
+ },
1722
+ filed: {
1723
+ type: 'string',
1724
+ description: 'Only consider filings filed on this date, in this month, or in this year — "2026-09-29", "2026-09", "2026" (US spellings "09/29/2026" and "September 29, 2026" accepted). **Pass this whenever the question names a date** ("Tesla\'s September 29 2026 8-K"): without it the NEWEST filing of that form is read, and for an 8-K that is routinely a different filing — verified live 2026-10-18, the newest TSLA 8-K was six days later, 3,061 chars, and contained none of the credit-facility figures asked about.',
1617
1725
  },
1726
+ filing_date: { type: 'string', description: 'Alias for `filed`.' },
1618
1727
  ticker: {
1619
1728
  type: 'string',
1620
1729
  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.',
@@ -2303,13 +2412,100 @@ function recentFilingRows(data: SubmissionsPayload, keep: (form: string) => bool
2303
2412
  return filings;
2304
2413
  }
2305
2414
 
2306
- async function companyFilings(tickerOrCik: string, formType?: string, limit?: number) {
2415
+ async function companyFilings(
2416
+ tickerOrCik: string,
2417
+ formType?: string,
2418
+ limit?: number,
2419
+ accession?: string,
2420
+ includePrimaryText?: boolean,
2421
+ filed?: string,
2422
+ ) {
2307
2423
  const cik = await resolveCik(tickerOrCik);
2308
2424
  const paddedCik = padCik(cik);
2309
2425
  const count = Math.min(40, Math.max(1, limit ?? 20));
2310
2426
 
2311
2427
  const data = await fetchSubmissions(tickerOrCik, paddedCik);
2312
- const filings = recentFilingRows(data, (form) => !formType || form === formType, count);
2428
+
2429
+ // ── A NAMED FILING IS NOT A FILTER SUGGESTION (fleet #3319) ──────────────
2430
+ //
2431
+ // `accession` and `include_primary_text` used to be undeclared here, so a
2432
+ // caller that named a filing got the same unnarrowed list as one that named
2433
+ // nothing — the silently-ignored-filter-key failure, which returns HTTP 200
2434
+ // and reads as a clean success. Measured live 2026-10-18 on
2435
+ // ask_pipeworx_grounded: its last ladder attempt routed to this tool with
2436
+ // {accession:"0001628280-26-063820", ticker:"TSLA", include_primary_text:true}
2437
+ // — the router had already resolved the RIGHT 8-K — and the extraction model
2438
+ // received a 10-row filings list carrying no facility amounts and refused
2439
+ // `not_in_source`, 5 tries out of 5. The accession was in our hands the whole
2440
+ // time.
2441
+ const wantAcc = accession ? normalizeAccession(accession) : null;
2442
+ if (accession && !wantAcc) {
2443
+ throw new Error(
2444
+ `Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0001628280-26-063820", with or without dashes). Omit it to list this filer's recent filings, or pass \`filed\` ("2026-09-29") if you know only the date.`,
2445
+ );
2446
+ }
2447
+ const filedPrefix = normalizeFiledPrefix(filed);
2448
+ if (filed && !filedPrefix) {
2449
+ throw new Error(
2450
+ `Could not read "${filed}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`,
2451
+ );
2452
+ }
2453
+
2454
+ const formKeep = (form: string) => !formType || formType.toLowerCase() === 'all' || form === formType;
2455
+ let filings = recentFilingRows(data, formKeep, wantAcc || filedPrefix ? 40 : count);
2456
+ const totalBeforeNarrowing = filings.length;
2457
+ if (wantAcc) {
2458
+ const hit = filings.filter((f) => normalizeAccession(f.accession_number)?.dashed === wantAcc.dashed);
2459
+ if (!hit.length) {
2460
+ // Loud, not an unfiltered list: an accession this filer has not filed is
2461
+ // a wrong question, and answering it with somebody else's filings is how
2462
+ // a caller ends up quoting the wrong document.
2463
+ throw new Error(
2464
+ `not_found: accession ${wantAcc.dashed} is not among ${data.name}'s recent filings${formType && formType.toLowerCase() !== 'all' ? ` of form ${formType}` : ''}. Drop \`accession\` to list what this filer HAS filed (the recent index covers roughly the last 1,000 filings), or check the digits — an accession belongs to exactly one filer.`,
2465
+ );
2466
+ }
2467
+ filings = hit;
2468
+ } else if (filedPrefix) {
2469
+ filings = filings.filter((f) => (f.filing_date ?? '').startsWith(filedPrefix)).slice(0, count);
2470
+ if (!filings.length) {
2471
+ throw new Error(
2472
+ `not_found: ${data.name} has no${formType && formType.toLowerCase() !== 'all' ? ` ${formType}` : ''} filing dated ${filedPrefix} in SEC's recent index (${totalBeforeNarrowing} candidate filing(s) were checked). Widen the date ("${filedPrefix.slice(0, 7)}" for the whole month) or drop \`filed\` to see what is actually there.`,
2473
+ );
2474
+ }
2475
+ }
2476
+
2477
+ // `include_primary_text` is only answerable for ONE filing. Say which way it
2478
+ // went rather than returning a list and leaving the caller to notice the
2479
+ // field is missing (silent-zero policy).
2480
+ let primaryText: string | null = null;
2481
+ let primaryTextNote: string | null = null;
2482
+ if (includePrimaryText) {
2483
+ if (filings.length === 1) {
2484
+ const only = filings[0];
2485
+ try {
2486
+ const { text: body, truncated: rawTrunc } = await fetchTextBounded(
2487
+ only.document_url,
2488
+ { 'User-Agent': SEC_HEADERS['User-Agent'] },
2489
+ FILING_DOC_MAX_BYTES,
2490
+ );
2491
+ const isHtml = /\.x?html?$/i.test(only.primary_document) || /^\s*</.test(body);
2492
+ const text = isHtml ? htmlToText(body) : body;
2493
+ primaryText = text.slice(0, 40000);
2494
+ if (text.length > 40000 || rawTrunc) {
2495
+ primaryTextNote = `primary_text is the first 40,000 of ${text.length} characters${rawTrunc ? ' of the portion of an oversized document that was read at all' : ''} — NOT the whole document. Read the rest with edgar_filing_text({accession:"${only.accession_number}", ticker_or_cik:"${tickerOrCik}", offset:40000}).`;
2496
+ }
2497
+ } catch (err) {
2498
+ // The list is the core payload; a failed text fetch degrades with a
2499
+ // stated reason rather than failing the whole call.
2500
+ // dropClassPrefix: this is a NOTE fragment, not the call's error — the
2501
+ // call succeeded. A routing token carried in here would be read by the
2502
+ // gateway's classifier as this response's class, which it is not.
2503
+ primaryTextNote = `primary_text could not be fetched (${dropClassPrefix(err instanceof Error ? err.message : String(err))}). The filing row below is still correct; retry the text with edgar_filing_text({accession:"${only.accession_number}", ticker_or_cik:"${tickerOrCik}"}).`;
2504
+ }
2505
+ } else {
2506
+ primaryTextNote = `include_primary_text was requested but ${filings.length} filings match, and this tool returns the text of ONE filing only. Pass \`accession\` (or a narrower \`filed\`) to pick one, or call edgar_filing_text for a filing's full, pageable text.`;
2507
+ }
2508
+ }
2313
2509
 
2314
2510
  return {
2315
2511
  cik: data.cik,
@@ -2327,7 +2523,18 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
2327
2523
  state_of_incorporation: data.stateOfIncorporation,
2328
2524
  fiscal_year_end: data.fiscalYearEnd,
2329
2525
  filter_form_type: formType ?? 'all',
2526
+ ...(wantAcc ? { filter_accession: wantAcc.dashed } : {}),
2527
+ ...(filedPrefix ? { filter_filed: filedPrefix } : {}),
2528
+ // Narrowing is stated, so a one-row answer never reads as "this company
2529
+ // has filed exactly one thing".
2530
+ ...(wantAcc || filedPrefix
2531
+ ? {
2532
+ narrowed_note: `This is NOT ${data.name}'s recent filings list — it was narrowed to ${wantAcc ? `the single filing ${wantAcc.dashed}` : `filings dated ${filedPrefix}`}, ${filings.length} of ${totalBeforeNarrowing} candidate(s) examined. Drop that argument for the full list.`,
2533
+ }
2534
+ : {}),
2330
2535
  filings,
2536
+ ...(primaryText !== null ? { primary_text: primaryText } : {}),
2537
+ ...(primaryTextNote ? { primary_text_note: primaryTextNote } : {}),
2331
2538
  };
2332
2539
  }
2333
2540
 
@@ -2553,11 +2760,117 @@ function findLatestFormIndex(forms: string[], wantFormTypeArg: string | undefine
2553
2760
  return forms.findIndex((f) => wanted.includes(f.toUpperCase()));
2554
2761
  }
2555
2762
 
2763
+ /**
2764
+ * Normalise a caller's `filed` into a date PREFIX we can match against SEC's
2765
+ * own `filingDate` strings ("2026-09-29"). Accepts a full ISO date, a month
2766
+ * ("2026-09"), a year ("2026"), and the US spellings a model actually types
2767
+ * ("09/29/2026", "September 29 2026"). Returns null when nothing date-shaped
2768
+ * is in there, which is treated as "no date filter" rather than an error — a
2769
+ * wrong-shaped date that silently matched nothing would be the same class of
2770
+ * defect this whole change exists to remove.
2771
+ */
2772
+ // Full names AND the abbreviations in actual use, listed rather than matched by
2773
+ // prefix: a 3-letter prefix accepts "Septober" as September, which is the
2774
+ // silent-wrong-answer shape this whole change is about. An unrecognised month
2775
+ // returns null and the caller gets an explicit error.
2776
+ const FILED_MONTHS: Record<string, string> = {
2777
+ january: '01', jan: '01',
2778
+ february: '02', feb: '02',
2779
+ march: '03', mar: '03',
2780
+ april: '04', apr: '04',
2781
+ may: '05',
2782
+ june: '06', jun: '06',
2783
+ july: '07', jul: '07',
2784
+ august: '08', aug: '08',
2785
+ september: '09', sept: '09', sep: '09',
2786
+ october: '10', oct: '10',
2787
+ november: '11', nov: '11',
2788
+ december: '12', dec: '12',
2789
+ };
2790
+ export function normalizeFiledPrefix(filed: string | undefined): string | null {
2791
+ const s = (filed ?? '').trim();
2792
+ if (!s) return null;
2793
+ let m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
2794
+ if (m) return `${m[1]}-${m[2]}-${m[3]}`;
2795
+ m = /^(\d{4})-(\d{2})$/.exec(s);
2796
+ if (m) return `${m[1]}-${m[2]}`;
2797
+ m = /^(\d{4})$/.exec(s);
2798
+ if (m) return m[1];
2799
+ m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(s); // 09/29/2026 (US order)
2800
+ if (m) return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
2801
+ // "September 29, 2026" / "Sept 29 2026" / "September 2026"
2802
+ const words = /^([A-Za-z]{3,9})\.?\s+(?:(\d{1,2})(?:st|nd|rd|th)?,?\s+)?(\d{4})$/.exec(s);
2803
+ if (words) {
2804
+ const mo = FILED_MONTHS[words[1].toLowerCase()];
2805
+ if (mo) return words[2] ? `${words[3]}-${mo}-${words[2].padStart(2, '0')}` : `${words[3]}-${mo}`;
2806
+ }
2807
+ return null;
2808
+ }
2809
+
2810
+ /**
2811
+ * Recent-index positions of EVERY filing matching `wantFormTypeArg`, newest
2812
+ * first, optionally restricted to those whose filingDate starts with
2813
+ * `filedPrefix`. `findLatestFormIndex` above answers "which one filing", which
2814
+ * is all the auto-resolve path used to need; this answers "which filings could
2815
+ * the caller have meant", which is what lets the tool (a) honour a date in the
2816
+ * question and (b) say out loud that it picked one of several.
2817
+ */
2818
+ export function findFormIndices(
2819
+ forms: string[],
2820
+ wantFormTypeArg: string | undefined,
2821
+ filingDates: string[],
2822
+ filedPrefix: string | null,
2823
+ limit: number,
2824
+ ): number[] {
2825
+ const wanted = (wantFormTypeArg ?? '')
2826
+ .split('|')
2827
+ .map((f) => f.trim().toUpperCase())
2828
+ .filter(Boolean);
2829
+ const out: number[] = [];
2830
+ for (let i = 0; i < forms.length && out.length < limit; i++) {
2831
+ if (wanted.length && !wanted.includes((forms[i] ?? '').toUpperCase())) continue;
2832
+ if (filedPrefix && !(filingDates[i] ?? '').startsWith(filedPrefix)) continue;
2833
+ out.push(i);
2834
+ }
2835
+ return out;
2836
+ }
2837
+
2838
+ // How many auto-selected candidates this tool will READ when a `search` can
2839
+ // tell a silent filing apart from the right one, and how many it will LIST in
2840
+ // the envelope either way. Reading is bounded because each candidate is a real
2841
+ // SEC document fetch; listing is free (it comes out of the submissions payload
2842
+ // already in hand).
2843
+ const AUTO_SELECT_SCAN = 4;
2844
+ const AUTO_SELECT_LIST = 8;
2845
+
2846
+ /**
2847
+ * The `section` slice of a filing's text, as a function rather than inline, so
2848
+ * the auto-selection walk below can score a candidate against the SAME text
2849
+ * the search will actually run on. `section` is best-effort: an unmatched
2850
+ * section returns the whole document with section_found:false, unchanged.
2851
+ */
2852
+ export function sliceFilingSection(
2853
+ fullText: string,
2854
+ section: string | undefined,
2855
+ ): { text: string; applied: string | null; found: string | boolean | null } {
2856
+ const sec = (section ?? '').trim().toLowerCase();
2857
+ if (!sec) return { text: fullText, applied: null, found: null };
2858
+ const conf = FILING_SECTION_PATTERNS[sec];
2859
+ if (!conf) return { text: fullText, applied: null, found: `unknown_section:${sec}` };
2860
+ const idxs: number[] = [];
2861
+ for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
2862
+ const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
2863
+ if (chosen < 0) return { text: fullText, applied: null, found: false };
2864
+ // Back up a little so the section heading itself is included, not clipped mid-phrase.
2865
+ return { text: fullText.slice(Math.max(0, chosen - 120)), applied: sec, found: true };
2866
+ }
2867
+
2556
2868
  async function filingDocuments(
2557
2869
  accession: string | undefined,
2558
2870
  tickerOrCik: string,
2559
2871
  includePrimaryText?: boolean,
2560
2872
  wantFormTypeArg?: string,
2873
+ filedArg?: string,
2561
2874
  ) {
2562
2875
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2563
2876
  throw new Error(
@@ -2575,22 +2888,48 @@ async function filingDocuments(
2575
2888
  `Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0000320193-25-000079", with or without dashes). Get accession numbers from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`,
2576
2889
  );
2577
2890
  }
2891
+ // ── WHEN WE CHOSE THE FILING, SAY SO (fleet #3319) ───────────────────────
2892
+ // Same defect and same disclosure as edgar_filing_text: with `accession`
2893
+ // omitted this resolves the LATEST filing of form_type and then describes it
2894
+ // as though the caller had asked for it. There is no `search` here to tell
2895
+ // candidates apart, so the newest is still taken — but the choice, and the
2896
+ // filings not chosen, are now stated rather than invisible.
2897
+ let autoSelected: Record<string, unknown> | null = null;
2578
2898
  if (!acc) {
2579
2899
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
2900
+ const filedPrefix = normalizeFiledPrefix(filedArg);
2901
+ if (filedArg && !filedPrefix) {
2902
+ throw new Error(`Could not read "${filedArg}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`);
2903
+ }
2580
2904
  const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
2581
2905
  if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
2582
- const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
2906
+ const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[]; filingDate?: string[] } } };
2583
2907
  const r = sub.filings?.recent;
2584
2908
  const forms = r?.form ?? [];
2585
2909
  const accs = r?.accessionNumber ?? [];
2586
- const idx = findLatestFormIndex(forms, wantFormTypeArg);
2587
- if (idx < 0 || !accs[idx]) {
2910
+ const dates = r?.filingDate ?? [];
2911
+ const candidateIdx = findFormIndices(forms, wantFormTypeArg, dates, filedPrefix, AUTO_SELECT_LIST)
2912
+ .filter((i) => !!accs[i] && !!normalizeAccession(accs[i]));
2913
+ if (!candidateIdx.length) {
2914
+ const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
2588
2915
  throw new Error(
2589
- `No ${wantForm ?? 'recent'} filing found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
2916
+ `No ${wantForm ?? 'recent'} filing${dateClause} found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
2590
2917
  );
2591
2918
  }
2592
- acc = normalizeAccession(accs[idx]);
2919
+ const chosenIdx = candidateIdx[0];
2920
+ acc = normalizeAccession(accs[chosenIdx]);
2593
2921
  if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
2922
+ autoSelected = {
2923
+ reason: `No accession was given, so this tool CHOSE the filing below — ${filedPrefix ? `the newest ${wantForm ?? 'filing'} dated ${filedPrefix}` : wantForm ? `the newest ${wantForm}` : 'the single most recent filing of ANY type'}.`,
2924
+ requested: { form_type: wantForm ?? null, filed: filedPrefix ?? null },
2925
+ chosen: { accession: acc.dashed, form: forms[chosenIdx] ?? null, filed: dates[chosenIdx] ?? null },
2926
+ candidates: candidateIdx.map((i) => ({
2927
+ accession: normalizeAccession(accs[i])!.dashed,
2928
+ form: forms[i] ?? null,
2929
+ filed: dates[i] ?? null,
2930
+ })),
2931
+ note: `If the question named a particular filing, pass its \`accession\` (or \`filed\`: "${dates[chosenIdx] ?? '2026-09-29'}") rather than relying on this choice — a company files many ${wantForm ?? 'document'}s and the newest is routinely not the one asked about.`,
2932
+ };
2594
2933
  }
2595
2934
  const cikNoZeros = String(Number(cik.replace(/\D/g, ''))); // archive path uses CIK with no leading zeros
2596
2935
  const paddedCik = padCik(cik);
@@ -2660,6 +2999,10 @@ async function filingDocuments(
2660
2999
  company,
2661
3000
  form_type: formType,
2662
3001
  filing_date: filingDate,
3002
+ // Whether the caller NAMED this filing or we picked it (fleet #3319).
3003
+ // Present on every response so its absence is never the signal.
3004
+ accession_source: autoSelected ? 'auto_selected' : 'caller',
3005
+ ...(autoSelected ? { auto_selected: autoSelected } : {}),
2663
3006
  primary_document: primaryDocument,
2664
3007
  filing_url: `${folder}/${acc.dashed}-index.html`,
2665
3008
  documents,
@@ -2846,6 +3189,7 @@ async function filingText(
2846
3189
  offset?: number,
2847
3190
  wantFormTypeArg?: string,
2848
3191
  searchTerm?: string,
3192
+ filedArg?: string,
2849
3193
  ) {
2850
3194
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2851
3195
  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"})).');
@@ -2869,83 +3213,175 @@ async function filingText(
2869
3213
  const r = sub.filings?.recent;
2870
3214
  const accs = r?.accessionNumber ?? [];
2871
3215
  const forms = r?.form ?? [];
3216
+ const filingDates = r?.filingDate ?? [];
2872
3217
 
2873
- if (!acc) {
3218
+ // `searchPhrases` is parsed up here, rather than beside its use further down,
3219
+ // because the auto-selection walk below needs to know whether there is a
3220
+ // phrase that can distinguish "this is the filing the caller meant" from
3221
+ // "this filing does not mention the subject at all".
3222
+ //
3223
+ // fleet #2450: a question can ask for TWO facts from the SAME filing
3224
+ // ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
3225
+ // for a single-entity question — so a single `search` phrase per call meant
3226
+ // the second fact was structurally unreachable. `search` accepts multiple
3227
+ // phrases separated by `|` (same convention as `form_type`'s "10-K|10-Q"
3228
+ // set) and reports each phrase's passages independently under `searches`,
3229
+ // keyed by `phrase`. A single phrase (no `|`) keeps the original singular
3230
+ // `search` shape unchanged.
3231
+ const searchPhrases = [...new Set(
3232
+ (searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
3233
+ )].slice(0, 5);
3234
+
3235
+ // Everything downstream that depends on WHICH filing this is, as one step,
3236
+ // so the walk below can evaluate more than one candidate.
3237
+ const loadFiling = async (chosen: { dashed: string; nodash: string }) => {
3238
+ const i = accs.indexOf(chosen.dashed);
3239
+ const form = i >= 0 ? forms[i] ?? null : null;
3240
+ const filedOn = i >= 0 ? filingDates[i] ?? null : null;
3241
+ let primaryDoc = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
3242
+
3243
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${chosen.nodash}`;
3244
+ // If the accession wasn't in the recent index (older filing), fall back to the
3245
+ // folder index to find the primary document.
3246
+ if (!primaryDoc) {
3247
+ const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
3248
+ if (idxRes.status === 404) throw new Error(`No filing found for accession ${chosen.dashed} — it does not exist in SEC EDGAR for this filer. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"}) to list real accessions.`);
3249
+ if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
3250
+ const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
3251
+ const docs = idxJson.directory?.item ?? [];
3252
+ // Prefer the main htm document that isn't an exhibit/XBRL artifact.
3253
+ primaryDoc =
3254
+ docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
3255
+ if (!primaryDoc) throw new Error(`Could not identify the primary document for accession ${chosen.dashed}. Use edgar_filing_documents to list the filing's files and fetch one by URL.`);
3256
+ }
3257
+
3258
+ const url = `${folder}/${primaryDoc}`;
3259
+ const { text: raw, truncated: rawTrunc } = await fetchTextBounded(url, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
3260
+ const html = /\.x?html?$/i.test(primaryDoc) || /^\s*</.test(raw);
3261
+ return {
3262
+ acc: chosen,
3263
+ formType: form,
3264
+ filingDate: filedOn,
3265
+ primaryDocument: primaryDoc,
3266
+ docUrl: url,
3267
+ rawTruncated: rawTrunc,
3268
+ fullText: html ? htmlToText(raw) : raw,
3269
+ };
3270
+ };
3271
+
3272
+ // ── WHEN WE CHOSE THE FILING, SAY SO (fleet #3319) ───────────────────────
3273
+ //
3274
+ // With `accession` omitted this tool resolves the LATEST filing of
3275
+ // `form_type` — and then every field in the response describes that filing
3276
+ // as though the caller had asked for it. Measured live 2026-10-18: a
3277
+ // grounded question about "Tesla's September 29 2026 8-K" reached here as
3278
+ // {ticker:"TSLA", form_type:"8-K", search:"credit facility"}, resolved to
3279
+ // the 2026-10-02 8-K (a different filing, 3,061 chars, zero matches), and
3280
+ // refused `not_in_source` 5 tries out of 5 — while the filing asked about
3281
+ // sat two rows down the same index with all three figures in it. Nothing in
3282
+ // the envelope could tell a reader that a choice had been made at all, so
3283
+ // "wrong filing" and "filing is silent on this" were indistinguishable.
3284
+ //
3285
+ // Two changes, both narrow: a `filed` date goes straight to the right
3286
+ // filing, and when only a `search` is available the walk below reads on past
3287
+ // a candidate whose text does not contain the phrase AT ALL. Either way the
3288
+ // choice, and the candidates not chosen, are stated in `auto_selected`.
3289
+ let loaded: Awaited<ReturnType<typeof loadFiling>>;
3290
+ let autoSelected: Record<string, unknown> | null = null;
3291
+
3292
+ if (acc) {
3293
+ loaded = await loadFiling(acc);
3294
+ } else {
2874
3295
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
2875
- const idx = findLatestFormIndex(forms, wantFormTypeArg);
2876
- if (idx < 0 || !accs[idx]) {
2877
- 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.`);
3296
+ const filedPrefix = normalizeFiledPrefix(filedArg);
3297
+ if (filedArg && !filedPrefix) {
3298
+ throw new Error(`Could not read "${filedArg}" as a filing date. Pass an ISO date, month or year — "2026-09-29", "2026-09", "2026" — or the US spellings "09/29/2026" / "September 29, 2026".`);
3299
+ }
3300
+ const candidateIdx = findFormIndices(forms, wantFormTypeArg, filingDates, filedPrefix, AUTO_SELECT_LIST)
3301
+ .filter((idx) => !!accs[idx] && !!normalizeAccession(accs[idx]));
3302
+ if (!candidateIdx.length) {
3303
+ const dateClause = filedPrefix ? ` dated ${filedPrefix}` : '';
3304
+ 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.`);
2878
3305
  }
2879
- acc = normalizeAccession(accs[idx]);
2880
- if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
2881
- }
2882
3306
 
2883
- const i = accs.indexOf(acc.dashed);
2884
- const formType = i >= 0 ? forms[i] ?? null : null;
2885
- const filingDate = i >= 0 ? r?.filingDate?.[i] ?? null : null;
2886
- let primaryDocument = i >= 0 ? r?.primaryDocument?.[i] ?? null : null;
3307
+ const candidates = candidateIdx.map((idx) => ({
3308
+ accession: normalizeAccession(accs[idx])!.dashed,
3309
+ form: forms[idx] ?? null,
3310
+ filed: filingDates[idx] ?? null,
3311
+ }));
2887
3312
 
2888
- const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
2889
- // If the accession wasn't in the recent index (older filing), fall back to the
2890
- // folder index to find the primary document.
2891
- if (!primaryDocument) {
2892
- const idxRes = await pwFetch(`${folder}/index.json`, { headers: SEC_HEADERS });
2893
- 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.`);
2894
- if (!idxRes.ok) throw await httpError(idxRes, 'SEC EDGAR filing index error');
2895
- const idxJson = (await idxRes.json()) as { directory?: { item?: { name: string; type?: string }[] } };
2896
- const docs = idxJson.directory?.item ?? [];
2897
- // Prefer the main htm document that isn't an exhibit/XBRL artifact.
2898
- primaryDocument =
2899
- docs.find((d) => /\.htm/i.test(d.name) && !/^R\d|_cal|_def|_lab|_pre|_htm\.xml|-index/i.test(d.name))?.name ?? null;
2900
- 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.`);
3313
+ // Reading past the first candidate is only useful when a `search` can
3314
+ // score one — with no phrase there is nothing to tell the candidates
3315
+ // apart, so the newest is taken exactly as before. EXACT matches only:
3316
+ // the word-level fallback is "a lead, not a confirmation" by this tool's
3317
+ // own note, and scoring on it would stop the walk at a filing that merely
3318
+ // shares vocabulary.
3319
+ const walk = searchPhrases.length ? candidateIdx.slice(0, AUTO_SELECT_SCAN) : candidateIdx.slice(0, 1);
3320
+ const examined: Array<{ accession: string; form: string | null; filed: string | null; exact_matches?: number }> = [];
3321
+ let picked: Awaited<ReturnType<typeof loadFiling>> | null = null;
3322
+ let fallback: Awaited<ReturnType<typeof loadFiling>> | null = null;
3323
+ for (const idx of walk) {
3324
+ const cand = await loadFiling(normalizeAccession(accs[idx])!);
3325
+ const scoreText = sliceFilingSection(cand.fullText, section).text;
3326
+ const matches = searchPhrases.reduce((n, p) => n + findPassages(scoreText, p).total, 0);
3327
+ examined.push({
3328
+ accession: cand.acc.dashed,
3329
+ form: cand.formType,
3330
+ filed: cand.filingDate,
3331
+ ...(searchPhrases.length ? { exact_matches: matches } : {}),
3332
+ });
3333
+ fallback ??= cand; // the newest — the historical choice, and the fallback
3334
+ if (!searchPhrases.length || matches > 0) {
3335
+ picked = cand;
3336
+ break;
3337
+ }
3338
+ }
3339
+ loaded = picked ?? fallback!;
3340
+ const walkedOn = examined.length > 1 && picked !== null;
3341
+ const allSilent = searchPhrases.length > 0 && picked === null;
3342
+
3343
+ autoSelected = {
3344
+ // The one sentence a reader needs: nobody asked for THIS filing.
3345
+ reason: `No accession was given, so this tool CHOSE the filing below — ${filedPrefix ? `the newest ${wantForm ?? 'filing'} dated ${filedPrefix}` : wantForm ? `the newest ${wantForm}` : 'the single most recent filing of ANY type'}${walkedOn ? ', after the newer candidate(s) in `examined` contained no exact match for the search phrase(s)' : ''}${allSilent ? ', and NONE of the candidates examined contained an exact match for the search phrase(s) — this is the newest one, not a filing shown to be relevant' : ''}.`,
3346
+ requested: { form_type: wantForm ?? null, filed: filedPrefix ?? null },
3347
+ chosen: { accession: loaded.acc.dashed, form: loaded.formType, filed: loaded.filingDate },
3348
+ examined,
3349
+ // Everything the caller might have meant, whether or not it was read —
3350
+ // free, since it comes out of the submissions payload already in hand.
3351
+ candidates,
3352
+ note: `If the question named a particular filing, pass its \`accession\` (or \`filed\`: "${loaded.filingDate ?? '2026-09-29'}") rather than relying on this choice — a company files many ${wantForm ?? 'documents'}s and the newest is routinely not the one asked about.`,
3353
+ ...(allSilent
3354
+ ? {
3355
+ warning: `Treat a zero-match result here as INCONCLUSIVE about the company, not about this filing: ${examined.length} candidate filing(s) were read and none mentions the search phrase(s). The filing the question meant may be one of \`candidates\` that was not read.`,
3356
+ }
3357
+ : {}),
3358
+ };
2901
3359
  }
2902
3360
 
2903
- const docUrl = `${folder}/${primaryDocument}`;
2904
- const { text: body, truncated: rawTruncated } = await fetchTextBounded(docUrl, { 'User-Agent': SEC_HEADERS['User-Agent'] }, FILING_DOC_MAX_BYTES);
2905
- const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
2906
- let fullText = isHtml ? htmlToText(body) : body;
3361
+ acc = loaded.acc;
3362
+ const { formType, filingDate, primaryDocument, docUrl, rawTruncated } = loaded;
3363
+ let fullText = loaded.fullText;
3364
+ // Held before any narrowing below, because `total_chars` is reframed by a
3365
+ // section slice and then describes the SLICE — which is what this tool's own
3366
+ // offsets page against, so it cannot also carry the document's size. Without
3367
+ // this, a section slice and a search both shrink what we return while every
3368
+ // number in the response still says "whole document" (fleet #3316).
3369
+ const documentChars = fullText.length;
2907
3370
 
2908
3371
  // Optional section slice: locate the section heading (last occurrence = the
2909
3372
  // real body, past any table-of-contents mention) and reframe the text to start
2910
3373
  // there. `section` is best-effort — an unmatched section returns the whole doc.
2911
- let sectionApplied: string | null = null;
2912
- let sectionFound: string | boolean | null = null;
2913
- const sec = (section ?? '').trim().toLowerCase();
2914
- if (sec) {
2915
- const conf = FILING_SECTION_PATTERNS[sec];
2916
- if (!conf) {
2917
- sectionFound = `unknown_section:${sec}`;
2918
- } else {
2919
- const idxs: number[] = [];
2920
- for (const m of fullText.matchAll(conf.anchor)) if (m.index !== undefined) idxs.push(m.index);
2921
- const chosen = idxs.length === 0 ? -1 : conf.pick === 'first' ? idxs[0] : idxs[idxs.length - 1];
2922
- if (chosen >= 0) {
2923
- // Back up a little so the section heading itself is included, not clipped mid-phrase.
2924
- fullText = fullText.slice(Math.max(0, chosen - 120));
2925
- sectionApplied = sec;
2926
- sectionFound = true;
2927
- } else {
2928
- sectionFound = false;
2929
- }
2930
- }
2931
- }
3374
+ const sliced = sliceFilingSection(fullText, section);
3375
+ fullText = sliced.text;
3376
+ const sectionApplied = sliced.applied;
3377
+ const sectionFound = sliced.found;
2932
3378
 
2933
3379
  // `search` short-circuits the normal offset/max_chars windowing: it scans
2934
3380
  // the whole (possibly section-sliced) document and returns matching
2935
3381
  // passages with their own offsets, rather than one page of raw text the
2936
3382
  // caller has to eyeball for the fact they wanted (fleet #2446).
2937
3383
  //
2938
- // fleet #2450: a question can ask for TWO facts from the SAME filing
2939
- // ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
2940
- // for a single-entity question — so a single `search` phrase per call meant
2941
- // the second fact was structurally unreachable. `search` now accepts
2942
- // multiple phrases separated by `|` (same convention as `form_type`'s
2943
- // "10-K|10-Q" set) and reports each phrase's passages independently under
2944
- // `searches`, keyed by `phrase`. A single phrase (no `|`) keeps the original
2945
- // singular `search` shape unchanged.
2946
- const searchPhrases = [...new Set(
2947
- (searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
2948
- )].slice(0, 5);
3384
+ // (`searchPhrases` is parsed near the top — the auto-selection walk needs it.)
2949
3385
  if (searchPhrases.length > 0) {
2950
3386
  const buildOne = async (term: string) => {
2951
3387
  const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
@@ -2989,17 +3425,56 @@ async function filingText(
2989
3425
 
2990
3426
  const results = await Promise.all(searchPhrases.map(buildOne));
2991
3427
 
3428
+ // ── HOW MUCH OF THE DOCUMENT THIS RESPONSE ACTUALLY CARRIES (fleet #3316) ──
3429
+ //
3430
+ // A `search` returns matched passages INSTEAD of the text, and before this
3431
+ // every size field in the envelope described the document rather than the
3432
+ // answer: `total_chars: 9859` for a response holding 3,080 chars of it.
3433
+ // The one field that looked like a loss signal, `search.truncated`, is
3434
+ // about the SEARCH — false means "every distinct match location is here" —
3435
+ // so a reader checking for loss found a field saying there was none.
3436
+ //
3437
+ // That is not merely untidy. ask_pipeworx_grounded sizes its own
3438
+ // truncation by what it RECEIVES, so a narrowed payload fit its budget
3439
+ // whole and was reported to the extraction model as complete: measured
3440
+ // live 2026-10-18, 5 of 8 tries of "what credit facilities did Tesla enter
3441
+ // into" answered with a WRONG total at confidence 0.4-0.6 and no refusal,
3442
+ // off exactly this response shape.
3443
+ //
3444
+ // `returned_chars` / `truncated` therefore mean the same thing here as in
3445
+ // the paged branch below — how much came back, and whether that is all of
3446
+ // it. `next_offset` stays ABSENT: there is no page forward from a set of
3447
+ // passages, and the way to read more is a follow-up call at a passage's
3448
+ // own `offset`. Absent is a different fact from null and says so.
3449
+ const returnedChars = results.reduce(
3450
+ (n, r) => n + r.passages.reduce((m, psg) => m + psg.text.length, 0),
3451
+ 0,
3452
+ );
3453
+ const searchTruncated = returnedChars < documentChars;
3454
+
2992
3455
  return {
2993
3456
  accession: acc.dashed,
2994
3457
  cik: cikNoZeros,
2995
3458
  company: sub.name ?? null,
2996
3459
  form: formType,
2997
3460
  filed: filingDate,
3461
+ // Whether the caller NAMED this filing or we picked it (fleet #3319).
3462
+ // Present on every response so its absence is never the signal.
3463
+ accession_source: autoSelected ? ('auto_selected' as const) : ('caller' as const),
3464
+ ...(autoSelected ? { auto_selected: autoSelected } : {}),
2998
3465
  document: primaryDocument,
2999
3466
  document_url: docUrl,
3000
3467
  section: sectionApplied,
3001
3468
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
3002
3469
  total_chars: fullText.length,
3470
+ ...(fullText.length !== documentChars ? { document_chars: documentChars } : {}),
3471
+ returned_chars: returnedChars,
3472
+ truncated: searchTruncated,
3473
+ ...(searchTruncated
3474
+ ? {
3475
+ truncated_note: `These are matched passages, not the document: ${returnedChars} of ${documentChars} characters. Anything the phrase(s) did not match is NOT in this response — do not read a figure's absence here as absence from the filing. Call again without \`search\` (or with a passage's \`offset\`) to read the surrounding text.`,
3476
+ }
3477
+ : {}),
3003
3478
  ...(searchPhrases.length === 1
3004
3479
  ? { search: results[0] }
3005
3480
  : {
@@ -3028,11 +3503,16 @@ async function filingText(
3028
3503
  company: sub.name ?? null,
3029
3504
  form: formType,
3030
3505
  filed: filingDate,
3506
+ accession_source: autoSelected ? ('auto_selected' as const) : ('caller' as const),
3507
+ ...(autoSelected ? { auto_selected: autoSelected } : {}),
3031
3508
  document: primaryDocument,
3032
3509
  document_url: docUrl,
3033
3510
  section: sectionApplied,
3034
3511
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
3035
3512
  total_chars: total,
3513
+ // A section slice reframed `total_chars` to the slice (fleet #3316): say so,
3514
+ // or a 2,000-char section of a 400,000-char 10-K reads as a whole document.
3515
+ ...(total !== documentChars ? { document_chars: documentChars } : {}),
3036
3516
  offset: off,
3037
3517
  returned_chars: slice.length,
3038
3518
  truncated,
@@ -3148,6 +3628,17 @@ async function companyFacts(cikOrTicker: string) {
3148
3628
  // Anything not in the table still falls through to [concept] (the bare
3149
3629
  // literal), so we don't regress for niche concepts not yet observed in
3150
3630
  // failures.
3631
+ // Cover-page tags live in the `dei` taxonomy, not us-gaap. Until 2026-10-16
3632
+ // every candidate was fetched under /us-gaap/, so the dei tag listed below for
3633
+ // "shares outstanding" 404'd on every filer and
3634
+ // edgar_company_concept({concept:"EntityCommonStockSharesOutstanding"}) told
3635
+ // callers "this filer reports no XBRL tag matching ..." for Tesla, which has 67
3636
+ // such facts (latest 3,949,547,394 at 2026-07-16). A report built on that
3637
+ // answer took the share count from a stale 10-Q cover instead and was off 5%
3638
+ // on every per-share number. Resolve the namespace per TAG, not per lookup.
3639
+ const DEI_TAGS = new Set(['EntityCommonStockSharesOutstanding', 'EntityPublicFloat']);
3640
+ const namespaceForTag = (defaultNs: string, tag: string): string => (DEI_TAGS.has(tag) ? 'dei' : defaultNs);
3641
+
3151
3642
  const CONCEPT_CANDIDATES: Record<string, string[]> = {
3152
3643
  // ── friendly names ────────────────────────────────────────────────
3153
3644
  revenue: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
@@ -3165,6 +3656,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
3165
3656
  sharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
3166
3657
  commonsharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding', 'CommonStockSharesIssued'],
3167
3658
  commonstocksharesoutstanding: ['CommonStockSharesOutstanding', 'EntityCommonStockSharesOutstanding'],
3659
+ // The cash-flow and expense lines an equity model needs. Measured 2026-10-16:
3660
+ // a routed "Tesla capex Q2 2026" question reached this tool as
3661
+ // concept:"CapitalExpenditures", which no filer tags, and the answer was
3662
+ // "reports no matching XBRL tag" — a miss on a figure every 10-Q carries as
3663
+ // PaymentsToAcquirePropertyPlantAndEquipment. The report that asked went to a
3664
+ // third-party site for it instead.
3665
+ capex: ['PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets', 'PaymentsForCapitalImprovements'],
3666
+ capitalexpenditure: ['PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets', 'PaymentsForCapitalImprovements'],
3667
+ capitalexpenditures: ['PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets', 'PaymentsForCapitalImprovements'],
3668
+ operatingcashflow: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'],
3669
+ cashfromoperations: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'],
3670
+ netcashfromoperatingactivities: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'],
3671
+ netcashprovidedbyoperatingactivities: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'],
3672
+ researchanddevelopment: ['ResearchAndDevelopmentExpense', 'ResearchAndDevelopmentExpenseExcludingAcquiredInProcessCost'],
3673
+ researchanddevelopmentexpense: ['ResearchAndDevelopmentExpense', 'ResearchAndDevelopmentExpenseExcludingAcquiredInProcessCost'],
3674
+ sga: ['SellingGeneralAndAdministrativeExpense'],
3675
+ sellinggeneralandadministrative: ['SellingGeneralAndAdministrativeExpense'],
3676
+ costofrevenue: ['CostOfRevenue', 'CostOfGoodsAndServicesSold', 'CostOfGoodsSold'],
3677
+ costofgoodssold: ['CostOfGoodsAndServicesSold', 'CostOfGoodsSold', 'CostOfRevenue'],
3678
+ cogs: ['CostOfGoodsAndServicesSold', 'CostOfGoodsSold', 'CostOfRevenue'],
3679
+ dilutedshares: ['WeightedAverageNumberOfDilutedSharesOutstanding'],
3680
+ weightedaveragedilutedshares: ['WeightedAverageNumberOfDilutedSharesOutstanding'],
3681
+ basicshares: ['WeightedAverageNumberOfSharesOutstandingBasic'],
3682
+ weightedaveragebasicshares: ['WeightedAverageNumberOfSharesOutstandingBasic'],
3683
+ publicfloat: ['EntityPublicFloat'],
3168
3684
  cash: [
3169
3685
  'CashAndCashEquivalentsAtCarryingValue',
3170
3686
  'Cash',
@@ -3419,7 +3935,7 @@ async function companyConcept(
3419
3935
  const tryNamespace = async (ns: string, tags: string[]) => {
3420
3936
  for (const candidate of tags) {
3421
3937
  const r = await pwFetch(
3422
- `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
3938
+ `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${namespaceForTag(ns, candidate)}/${encodeURIComponent(candidate)}.json`,
3423
3939
  { headers: SEC_HEADERS },
3424
3940
  );
3425
3941
  if (!r.ok) { lastStatus = r.status; continue; }
@@ -3458,12 +3974,18 @@ async function companyConcept(
3458
3974
  if (!factsRes.ok) {
3459
3975
  lastStatus = factsRes.status;
3460
3976
  } else if (tokens.length) {
3461
- const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }> } };
3977
+ type FactsNs = Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }>;
3978
+ const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: FactsNs; dei?: FactsNs } };
3462
3979
  resolvedEntityName = facts.entityName ?? '';
3463
- const gaap = facts.facts?.['us-gaap'] ?? {};
3980
+ // Scan dei too: cover-page facts (shares outstanding, public float) are
3981
+ // reported there and nowhere in us-gaap for many filers.
3982
+ const scanNamespaces: [string, FactsNs][] = [
3983
+ ['us-gaap', facts.facts?.['us-gaap'] ?? {}],
3984
+ ['dei', facts.facts?.dei ?? {}],
3985
+ ];
3464
3986
  const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
3465
3987
  const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
3466
- for (const [tag, entry] of Object.entries(gaap)) {
3988
+ for (const [scanNs, nsFacts] of scanNamespaces) for (const [tag, entry] of Object.entries(nsFacts)) {
3467
3989
  if (!tokens.some((t) => norm(tag).includes(t))) continue;
3468
3990
  let latestEnd = '';
3469
3991
  for (const [, values] of unitEntries(entry.units)) {
@@ -3475,7 +3997,7 @@ async function companyConcept(
3475
3997
  matches.push({
3476
3998
  isFlow: FLOW.test(tag),
3477
3999
  latestEnd,
3478
- doc: { cik: Number(cik), entityName: facts.entityName ?? '', tag, taxonomy: 'us-gaap', label: entry.label ?? tag, description: entry.description ?? '', units: entry.units },
4000
+ doc: { cik: Number(cik), entityName: facts.entityName ?? '', tag, taxonomy: scanNs, label: entry.label ?? tag, description: entry.description ?? '', units: entry.units },
3479
4001
  });
3480
4002
  }
3481
4003
  matches.sort((a, b) => Number(a.isFlow) - Number(b.isFlow) || (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
@@ -3556,7 +4078,7 @@ async function companyConcept(
3556
4078
  reported: false,
3557
4079
  latest: null,
3558
4080
  values: [],
3559
- 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.`,
4081
+ note: `This filer reports no XBRL tag matching "${concept}" under any recognized name — checked us-gaap/dei candidates ${JSON.stringify(candidates)}, IFRS, and a full company-facts name scan (us-gaap and dei). 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.`,
3560
4082
  };
3561
4083
  }
3562
4084
  found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
@@ -3611,6 +4133,17 @@ async function companyConcept(
3611
4133
  value: entry.val,
3612
4134
  form: entry.form, // 10-K / 10-Q / 20-F / 40-F (+ /A amendments)
3613
4135
  filed: entry.filed,
4136
+ // Which filing this exact number came out of (fleet #3178). SEC's
4137
+ // companyconcept payload carries `accn` on every fact and we were
4138
+ // dropping it, so a caller holding a value and a period_end had no way
4139
+ // back to the document without a second edgar_company_filings call and
4140
+ // a date match — and a consumer that FANS OUT over this tool
4141
+ // (entity_profile's fundamentals) could not cite its own figures at all.
4142
+ // Reported rows only: a derived Q4/standalone-quarter row below is
4143
+ // arithmetic over several filings, not a fact any one of them states, so
4144
+ // it carries `accession: undefined` rather than the accession of
4145
+ // whichever leg happened to be the minuend.
4146
+ accession: entry.accn as string | undefined,
3614
4147
  unit,
3615
4148
  derived: false as boolean | undefined,
3616
4149
  note: undefined as string | undefined,
@@ -3660,6 +4193,7 @@ async function companyConcept(
3660
4193
  value: fyRow.value - qSum,
3661
4194
  form: 'derived',
3662
4195
  filed: fyRow.filed,
4196
+ accession: undefined, // arithmetic over FY + Q1-Q3, not a fact one filing states
3663
4197
  unit: fyRow.unit,
3664
4198
  derived: true,
3665
4199
  note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
@@ -3724,6 +4258,7 @@ async function companyConcept(
3724
4258
  value: q2ytd.value - q1.value,
3725
4259
  form: 'derived',
3726
4260
  filed: q2ytd.filed,
4261
+ accession: undefined, // YTD(Q2) minus Q1 — spans two filings
3727
4262
  unit: q2ytd.unit,
3728
4263
  derived: true,
3729
4264
  note: `Computed as the YTD figure filed for Q2 (${q2ytd.value}, covering ${q2ytd.period_start}..${q2ytd.period_end}) minus Q1 (${q1.value}) — this filer reports this concept cumulatively in its 10-Q (a cash-flow-statement concept, typically), not quarter-only.`,
@@ -3743,6 +4278,7 @@ async function companyConcept(
3743
4278
  value: q3ytd.value - q2ytd.value,
3744
4279
  form: 'derived',
3745
4280
  filed: q3ytd.filed,
4281
+ accession: undefined, // YTD(Q3) minus YTD(Q2) — spans two filings
3746
4282
  unit: q3ytd.unit,
3747
4283
  derived: true,
3748
4284
  note: `Computed as the YTD figure filed for Q3 (${q3ytd.value}) minus the YTD figure filed for Q2 (${q2ytd.value}) — this filer reports this concept cumulatively in its 10-Q, not quarter-only.`,
@@ -3843,8 +4379,18 @@ async function companyConcept(
3843
4379
  // neither the caller nor any automatic check can tell it apart". A
3844
4380
  // substituted tag is the same shape, and gets the same treatment.
3845
4381
  const requested = concept.trim();
3846
- const substituted = requested.length > 0
3847
- && data.tag.toLowerCase().replace(/[^a-z0-9]/g, '') !== requested.toLowerCase().replace(/[^a-z0-9]/g, '');
4382
+ // A friendly name the tool itself documents ("shares outstanding", "revenue")
4383
+ // resolving to one of ITS OWN listed tags is the advertised behaviour, not a
4384
+ // substitution — the warning below is for the fuzzy name-scan path, where a
4385
+ // narrower tag really can stand in for a broader concept. Until 2026-10-16
4386
+ // "shares outstanding" → CommonStockSharesOutstanding carried the full "NOT
4387
+ // interchangeable" warning on a correct answer.
4388
+ const requestedKey = requested.toLowerCase().replace(/[^a-z0-9]/g, '');
4389
+ const aliasServed = (CONCEPT_CANDIDATES[requestedKey] ?? []).some(
4390
+ (t) => t.toLowerCase() === data.tag.toLowerCase(),
4391
+ );
4392
+ const substituted = requested.length > 0 && !aliasServed
4393
+ && data.tag.toLowerCase().replace(/[^a-z0-9]/g, '') !== requestedKey;
3848
4394
 
3849
4395
  // Coverage-bound disclosure (fleet #693). A returned value can be genuinely
3850
4396
  // CURRENT for this exact XBRL tag while a newer periodic filing exists that
@@ -5135,6 +5681,9 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5135
5681
  companyArg(args),
5136
5682
  args.form_type as string | undefined,
5137
5683
  args.limit as number | undefined,
5684
+ args.accession as string | undefined,
5685
+ args.include_primary_text as boolean | undefined,
5686
+ (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
5138
5687
  );
5139
5688
  case 'edgar_company_facts':
5140
5689
  return companyFacts(companyArg(args));
@@ -5179,6 +5728,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5179
5728
  (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
5180
5729
  args.include_primary_text as boolean | undefined,
5181
5730
  args.form_type as string | undefined,
5731
+ (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
5182
5732
  );
5183
5733
  case 'edgar_filing_text':
5184
5734
  return filingText(
@@ -5189,6 +5739,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
5189
5739
  args.offset as number | undefined,
5190
5740
  args.form_type as string | undefined,
5191
5741
  (args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
5742
+ (args.filed ?? args.filing_date ?? args.filed_date) as string | undefined,
5192
5743
  );
5193
5744
  case 'edgar_ticker_to_cik':
5194
5745
  return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
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.10' },
13
+ { name: '@pipeworx/mcp-edgar', version: '0.3.12' },
14
14
  { capabilities: { tools: {} } },
15
15
  );
16
16