@pipeworx/mcp-edgar 0.3.6 → 0.3.8

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 1683+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1763+ 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
 
@@ -106,7 +106,7 @@ directly, instead of just this one's:
106
106
  }
107
107
  ```
108
108
 
109
- Both URLs reach the same gateway and the same 1683+ data sources. The
109
+ Both URLs reach the same gateway and the same 1763+ data sources. The
110
110
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
111
111
  reaches all of them from either one.
112
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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-11e345a6ca7725b75cd7b396ad673e52735a250951201acc76dbfd62907be72f",
30
- "sourceCommit": "6182c4cfebddb94950e060b5d2fca18f18ad1aca"
29
+ "sourceHash": "v1-9871b5732265c259f8fc2a821a7721a32960cabdc9041cc1d5ea4a1e0b8d3f37",
30
+ "sourceCommit": "051ee2039fc96224cb5b7358fd69d525bced13a6"
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.6",
6
+ "version": "0.3.8",
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
@@ -1586,7 +1586,7 @@ const tools: McpToolExport['tools'] = [
1586
1586
  {
1587
1587
  name: 'edgar_filing_text',
1588
1588
  description:
1589
- 'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page (and how far `search` can scan) and can make a late section or search term come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
1589
+ 'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. **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.',
1590
1590
  summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1591
1591
  inputSchema: {
1592
1592
  type: 'object' as const,
@@ -1622,7 +1622,7 @@ const tools: McpToolExport['tools'] = [
1622
1622
  },
1623
1623
  search: {
1624
1624
  type: 'string',
1625
- description: 'Find a specific fact instead of paging blind. Pass a short 2-4 word phrase likely to appear VERBATIM in the prose ("processed approximately", "tests processed", "going concern") rather than restating the question. Case-insensitive substring match over the WHOLE document text (or the whole `section` slice), run BEFORE max_chars/offset windowing; returns matching passages (context + their own offset) instead of the paged `text`, ranking passages with a nearby figure first. If a multi-word phrase has no verbatim match, it falls back to the phrase\'s individual words and returns the passages holding the most of them (search.match_mode "words") — read those passages for the fact rather than treating them as confirmed. Use a returned offset with a follow-up call (no `search`) to read more surrounding text. Accepted aliases: `contains`, `find`, `phrase`.',
1625
+ description: 'Find a specific fact instead of paging blind. Pass a short 2-4 word phrase likely to appear VERBATIM in the prose ("processed approximately", "tests processed", "going concern") rather than restating the question. Case-insensitive substring match over the WHOLE document text (or the whole `section` slice), run BEFORE max_chars/offset windowing; returns matching passages (context + their own offset) instead of the paged `text`, ranking passages with a nearby figure first. If a multi-word phrase has no verbatim match, it falls back to the phrase\'s individual words and returns the passages holding the most of them (search.match_mode "words") — read those passages for the fact rather than treating them as confirmed. Use a returned offset with a follow-up call (no `search`) to read more surrounding text. **For TWO OR MORE facts from this same filing in one call, separate phrases with `|`** (e.g. "Signatera revenue|tests processed") — each is searched independently and reported under `searches[]`, keyed by `phrase`, instead of the singular `search`. This is the way to answer a compound question ("what was revenue AND test volume in the latest 10-Q") without a second call. Note: a phrase naming a financial TOTAL (revenue, net income, EPS, assets, cash, gross/operating profit, long-term debt, stockholders equity, shares outstanding) often has NO verbatim match at all, because filers frequently state only the growth delta/percentage in prose and report the actual total exclusively in XBRL — when a phrase names one of these, the response also attaches that concept\'s value for THIS SAME accession under `xbrl` (sourced like edgar_company_concept, not text-matched), so you don\'t need a separate edgar_company_concept call just to get the number the text search alone would report as a false "not found". Accepted aliases: `contains`, `find`, `phrase`.',
1626
1626
  },
1627
1627
  contains: {
1628
1628
  type: 'string',
@@ -2777,36 +2777,47 @@ async function filingText(
2777
2777
  // the whole (possibly section-sliced) document and returns matching
2778
2778
  // passages with their own offsets, rather than one page of raw text the
2779
2779
  // caller has to eyeball for the fact they wanted (fleet #2446).
2780
- const search = (searchTerm ?? '').trim();
2781
- if (search) {
2782
- const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, search);
2783
- const wordHit = totalMatches === 0 ? findWordPassages(fullText, search) : null;
2784
- return {
2785
- accession: acc.dashed,
2786
- cik: cikNoZeros,
2787
- company: sub.name ?? null,
2788
- form: formType,
2789
- filed: filingDate,
2790
- document: primaryDocument,
2791
- document_url: docUrl,
2792
- section: sectionApplied,
2793
- ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2794
- total_chars: fullText.length,
2795
- search: wordHit
2780
+ //
2781
+ // fleet #2450: a question can ask for TWO facts from the SAME filing
2782
+ // ("revenue AND test volume") and ask_pipeworx's router picks ONE tool call
2783
+ // for a single-entity question — so a single `search` phrase per call meant
2784
+ // the second fact was structurally unreachable. `search` now accepts
2785
+ // multiple phrases separated by `|` (same convention as `form_type`'s
2786
+ // "10-K|10-Q" set) and reports each phrase's passages independently under
2787
+ // `searches`, keyed by `phrase`. A single phrase (no `|`) keeps the original
2788
+ // singular `search` shape unchanged.
2789
+ const searchPhrases = [...new Set(
2790
+ (searchTerm ?? '').split('|').map((p) => p.trim()).filter(Boolean),
2791
+ )].slice(0, 5);
2792
+ if (searchPhrases.length > 0) {
2793
+ const buildOne = async (term: string) => {
2794
+ const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, term);
2795
+ const wordHit = totalMatches === 0 ? findWordPassages(fullText, term) : null;
2796
+ // A phrase naming a financial concept (revenue, net income, EPS, ...)
2797
+ // is frequently XBRL-only — never printed as an absolute figure in the
2798
+ // prose at all (verified live: Natera's 10-Q states revenue only as a
2799
+ // dollar/percent DELTA, never the total) — so attach the filer's own
2800
+ // XBRL fact for THIS accession when the phrase names one, regardless of
2801
+ // whether the text search above found anything.
2802
+ const financialConcept = detectFinancialConcept(term);
2803
+ const xbrl = financialConcept
2804
+ ? await financialFactForFiling(tickerOrCik, financialConcept, formType, filingDate)
2805
+ : null;
2806
+ const base = wordHit
2796
2807
  ? {
2797
- term: search,
2798
- match_mode: 'words',
2808
+ term,
2809
+ match_mode: 'words' as const,
2799
2810
  exact_matches: 0,
2800
2811
  words: wordHit.words,
2801
2812
  distinct_locations: wordHit.distinct_locations,
2802
2813
  returned: wordHit.passages.length,
2803
2814
  truncated: wordHit.distinct_locations > wordHit.passages.length,
2804
2815
  passages: wordHit.passages,
2805
- note: `The exact phrase "${search}" does not appear in this document, so these are passages holding its individual WORDS, ranked by how many of them each passage contains (then by a nearby figure). Read each passage for the fact asked — a word-level match is a lead, not a confirmation. Each offset can be passed as \`offset\` (without \`search\`) to read more surrounding text.`,
2816
+ note: `The exact phrase "${term}" does not appear in this document, so these are passages holding its individual WORDS, ranked by how many of them each passage contains (then by a nearby figure). Read each passage for the fact asked — a word-level match is a lead, not a confirmation. Each offset can be passed as \`offset\` (without \`search\`) to read more surrounding text.`,
2806
2817
  }
2807
2818
  : {
2808
- term: search,
2809
- match_mode: 'exact',
2819
+ term,
2820
+ match_mode: 'exact' as const,
2810
2821
  total_matches: totalMatches,
2811
2822
  distinct_locations: distinctLocations,
2812
2823
  returned: passages.length,
@@ -2815,7 +2826,29 @@ async function filingText(
2815
2826
  note: totalMatches === 0
2816
2827
  ? '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.'
2817
2828
  : `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.`,
2818
- },
2829
+ };
2830
+ return xbrl ? { ...base, xbrl } : base;
2831
+ };
2832
+
2833
+ const results = await Promise.all(searchPhrases.map(buildOne));
2834
+
2835
+ return {
2836
+ accession: acc.dashed,
2837
+ cik: cikNoZeros,
2838
+ company: sub.name ?? null,
2839
+ form: formType,
2840
+ filed: filingDate,
2841
+ document: primaryDocument,
2842
+ document_url: docUrl,
2843
+ section: sectionApplied,
2844
+ ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2845
+ total_chars: fullText.length,
2846
+ ...(searchPhrases.length === 1
2847
+ ? { search: results[0] }
2848
+ : {
2849
+ searches: searchPhrases.map((phrase, i) => ({ phrase, ...results[i] })),
2850
+ searches_note: 'Multiple `|`-separated phrases were requested — each is searched independently against the SAME document/section and reported under its own entry, keyed by `phrase`. Read each entry for its own fact rather than assuming they describe the same period (e.g. a quarterly revenue total and a six-month cumulative KPI can both be real and still not comparable).',
2851
+ }),
2819
2852
  ...(rawTruncated
2820
2853
  ? {
2821
2854
  raw_truncated: true,
@@ -3073,6 +3106,83 @@ const IFRS_FALLBACK: Record<string, string[]> = {
3073
3106
  equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
3074
3107
  };
3075
3108
 
3109
+ // --- Liquidity companion tags (fleet #2879 / feedback #137) ----------------
3110
+ // "Cash" alone (CashAndCashEquivalentsAtCarryingValue, or its substitutes)
3111
+ // excludes marketable/short-term securities, which biotechs and other
3112
+ // cash-burning companies routinely park a large share of their runway in.
3113
+ // Verified live for Capricor (CIK 1133869, accession 0001104659-26-097294,
3114
+ // period 2026-06-30): Cash alone is $20,935,187; adding
3115
+ // AvailableForSaleSecuritiesDebtSecuritiesCurrent ($216,999,595) gets to
3116
+ // $237,934,782 — the $237.9M the reporter expected, an 11x difference from
3117
+ // the cash-only figure. Agents asking "Cash" are almost always asking a
3118
+ // runway/liquidity question, so when the resolved tag is cash-shaped, also
3119
+ // probe for marketable-securities tags at the SAME period_end and attach a
3120
+ // derived total_liquidity. Order matters: try the tag this filer is most
3121
+ // likely to use first (fewer wasted fetches); first one with any data wins.
3122
+ const CASH_LIKE_TAGS = new Set([
3123
+ 'CashAndCashEquivalentsAtCarryingValue', 'Cash',
3124
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
3125
+ ]);
3126
+ const MARKETABLE_SECURITIES_CURRENT_TAGS = [
3127
+ 'AvailableForSaleSecuritiesDebtSecuritiesCurrent', 'MarketableSecuritiesCurrent', 'ShortTermInvestments',
3128
+ ];
3129
+ const MARKETABLE_SECURITIES_NONCURRENT_TAGS = [
3130
+ 'AvailableForSaleSecuritiesDebtSecuritiesNoncurrent', 'MarketableSecuritiesNoncurrent', 'LongTermInvestments',
3131
+ ];
3132
+
3133
+ interface LiquidityBreakdown {
3134
+ value: number;
3135
+ derived: true;
3136
+ components: Record<string, number>;
3137
+ note: string;
3138
+ }
3139
+
3140
+ /** Full time series for one us-gaap concept, keyed by period end date (the
3141
+ * freshest-filed fact per end date wins on duplicates). Null when the filer
3142
+ * has no data under this exact tag (a 404 or an empty series) — callers try
3143
+ * the next candidate tag rather than treating that as "no securities". */
3144
+ async function fetchConceptSeries(
3145
+ paddedCik: string,
3146
+ tag: string,
3147
+ ): Promise<Map<string, { value: number; form: string; filed: string }> | null> {
3148
+ const r = await pwFetch(
3149
+ `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/us-gaap/${encodeURIComponent(tag)}.json`,
3150
+ { headers: SEC_HEADERS },
3151
+ ).catch(() => null);
3152
+ if (!r || !r.ok) return null;
3153
+ const doc = (await r.json()) as {
3154
+ units?: Record<string, { end?: string; val: number; form: string; filed: string }[]>;
3155
+ };
3156
+ const map = new Map<string, { value: number; form: string; filed: string }>();
3157
+ for (const values of Object.values(doc.units ?? {})) {
3158
+ if (!Array.isArray(values)) continue;
3159
+ for (const e of values) {
3160
+ const key = e.end ?? '';
3161
+ if (!key) continue;
3162
+ const prior = map.get(key);
3163
+ if (!prior || (e.filed ?? '') > prior.filed) map.set(key, { value: e.val, form: e.form, filed: e.filed });
3164
+ }
3165
+ }
3166
+ return map.size ? map : null;
3167
+ }
3168
+
3169
+ /** Try each candidate tag in order; return the first with any data. A filer
3170
+ * uses exactly one of these at a time but SWITCHES over its history (Capricor
3171
+ * reported MarketableSecuritiesCurrent through FY2025Q3, then switched to
3172
+ * AvailableForSaleSecuritiesDebtSecuritiesCurrent from FY2025Q4 on — verified
3173
+ * live), so trying only the newest-looking tag would miss older periods and
3174
+ * trying only one fixed tag would miss filers who never used it. */
3175
+ async function firstAvailableSeries(
3176
+ paddedCik: string,
3177
+ tags: string[],
3178
+ ): Promise<{ tag: string; series: Map<string, { value: number; form: string; filed: string }> } | null> {
3179
+ for (const tag of tags) {
3180
+ const series = await fetchConceptSeries(paddedCik, tag);
3181
+ if (series) return { tag, series };
3182
+ }
3183
+ return null;
3184
+ }
3185
+
3076
3186
  async function companyConcept(
3077
3187
  cikOrTicker: string,
3078
3188
  concept: string,
@@ -3347,6 +3457,7 @@ async function companyConcept(
3347
3457
  unit,
3348
3458
  derived: false as boolean | undefined,
3349
3459
  note: undefined as string | undefined,
3460
+ total_liquidity: undefined as LiquidityBreakdown | undefined,
3350
3461
  }))
3351
3462
  .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
3352
3463
 
@@ -3395,6 +3506,7 @@ async function companyConcept(
3395
3506
  unit: fyRow.unit,
3396
3507
  derived: true,
3397
3508
  note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
3509
+ total_liquidity: undefined,
3398
3510
  });
3399
3511
  }
3400
3512
  if (derivedQ4.length > 0) {
@@ -3414,6 +3526,78 @@ async function companyConcept(
3414
3526
  entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
3415
3527
  }
3416
3528
 
3529
+ // Derive STANDALONE quarters from YEAR-TO-DATE duration data (fleet #2879 /
3530
+ // feedback #137, item 3). Income-statement concepts (Revenue, NetIncomeLoss)
3531
+ // are usually filed quarter-only in a 10-Q and the dedup above already
3532
+ // prefers that discrete fact over a same-day YTD one. CASH-FLOW-STATEMENT
3533
+ // concepts (NetCashProvidedByUsedInOperatingActivities and siblings) have no
3534
+ // discrete-quarter fact at all — US GAAP presents the cash-flow statement
3535
+ // cumulatively, so a 10-Q's "Q2" figure IS six months, not two. Taking that
3536
+ // as a quarterly burn rate overstates it ~2x in Q2 and ~3x in Q3. Verified
3537
+ // live for Capricor (CIK 1133869) NetCashProvidedByUsedInOperatingActivities,
3538
+ // FY2026: Q1 (2026-01-01..03-31) = -$29,254,891; "Q2" (2026-01-01..06-30,
3539
+ // span 181 days — not the 80-100d a real quarter spans) = -$60,740,078. The
3540
+ // real Q2 burn is -$31,485,187 (Q2 YTD minus Q1), roughly double what the
3541
+ // filed Q2 figure alone would suggest if read as quarterly.
3542
+ //
3543
+ // Detected structurally (by span, not a hardcoded tag list, so it covers any
3544
+ // concept a filer reports this way): anchor on a genuinely quarter-length Q1
3545
+ // (span 80-100d), then look for a same-fiscal-year-start Q2/Q3 row whose span
3546
+ // is ~2x/~3x a quarter (170-190d / 260-290d) — i.e. YTD-shaped. The ORIGINAL
3547
+ // YTD-labeled row is kept as-filed; the derived standalone figure is added
3548
+ // alongside it with form:"derived", so a caller filtering on fiscal_period
3549
+ // sees both the as-filed YTD number and the true quarterly one, and nothing
3550
+ // is silently replaced.
3551
+ const standaloneQuarters: typeof entries = [];
3552
+ for (const q1 of entries) {
3553
+ if (q1.fiscal_period !== 'Q1' || q1.unit.includes('/')) continue;
3554
+ const q1Span = spanDays(q1.period_start, q1.period_end);
3555
+ if (!(q1Span >= 80 && q1Span <= 100)) continue; // Q1 YTD === Q1 quarter; this anchors the fiscal year's start date
3556
+ const q2ytd = entries.find((e) => {
3557
+ if (e.fiscal_period !== 'Q2' || e.unit !== q1.unit || e.period_start !== q1.period_start) return false;
3558
+ const s = spanDays(e.period_start, e.period_end);
3559
+ return s >= 170 && s <= 190;
3560
+ });
3561
+ if (!q2ytd) continue;
3562
+ standaloneQuarters.push({
3563
+ fiscal_year: q2ytd.fiscal_year,
3564
+ fiscal_period: 'Q2',
3565
+ period_start: q1.period_end,
3566
+ period_end: q2ytd.period_end,
3567
+ value: q2ytd.value - q1.value,
3568
+ form: 'derived',
3569
+ filed: q2ytd.filed,
3570
+ unit: q2ytd.unit,
3571
+ derived: true,
3572
+ 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.`,
3573
+ total_liquidity: undefined,
3574
+ });
3575
+ const q3ytd = entries.find((e) => {
3576
+ if (e.fiscal_period !== 'Q3' || e.unit !== q1.unit || e.period_start !== q1.period_start) return false;
3577
+ const s = spanDays(e.period_start, e.period_end);
3578
+ return s >= 260 && s <= 290;
3579
+ });
3580
+ if (q3ytd) {
3581
+ standaloneQuarters.push({
3582
+ fiscal_year: q3ytd.fiscal_year,
3583
+ fiscal_period: 'Q3',
3584
+ period_start: q2ytd.period_end,
3585
+ period_end: q3ytd.period_end,
3586
+ value: q3ytd.value - q2ytd.value,
3587
+ form: 'derived',
3588
+ filed: q3ytd.filed,
3589
+ unit: q3ytd.unit,
3590
+ derived: true,
3591
+ 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.`,
3592
+ total_liquidity: undefined,
3593
+ });
3594
+ }
3595
+ }
3596
+ if (standaloneQuarters.length > 0) {
3597
+ entries.push(...standaloneQuarters);
3598
+ entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
3599
+ }
3600
+
3417
3601
  // PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
3418
3602
  // names of two fields on every row below, and callers asking a one-period
3419
3603
  // question ("net income fiscal 2024") send them as ARGUMENTS — measured on
@@ -3523,6 +3707,35 @@ async function companyConcept(
3523
3707
  // must be the freshest fact WITHIN the period asked for, or the one field a
3524
3708
  // synthesizer reaches for first would answer a different year than the
3525
3709
  // question (fleet #2058).
3710
+ // LIQUIDITY ENRICHMENT (fleet #2879 / feedback #137, items 1-2). Only when
3711
+ // the resolved tag is cash-shaped — these securities tags don't apply to
3712
+ // Revenue, LongTermDebt, etc. Mutates the row objects in place (not a copy),
3713
+ // so `latestFact` below and `latest`/`values`/`annual_values` in the return
3714
+ // all pick up the same enrichment without threading it through separately.
3715
+ if (CASH_LIKE_TAGS.has(data.tag)) {
3716
+ const [curRes, noncurRes] = await Promise.all([
3717
+ firstAvailableSeries(paddedCik, MARKETABLE_SECURITIES_CURRENT_TAGS),
3718
+ firstAvailableSeries(paddedCik, MARKETABLE_SECURITIES_NONCURRENT_TAGS),
3719
+ ]);
3720
+ if (curRes || noncurRes) {
3721
+ for (const row of rows) {
3722
+ if (!row.period_end) continue;
3723
+ const cur = curRes?.series.get(row.period_end);
3724
+ const noncur = noncurRes?.series.get(row.period_end);
3725
+ if (!cur && !noncur) continue; // no companion data for this exact period-end — don't imply $0 securities
3726
+ const components: Record<string, number> = { [`cash (${data.tag})`]: row.value };
3727
+ if (cur) components[`marketable_securities_current (${curRes!.tag})`] = cur.value;
3728
+ if (noncur) components[`marketable_securities_noncurrent (${noncurRes!.tag})`] = noncur.value;
3729
+ row.total_liquidity = {
3730
+ value: Object.values(components).reduce((s, v) => s + v, 0),
3731
+ derived: true,
3732
+ components,
3733
+ note: 'Derived: cash plus marketable/short-term securities reported for the same period_end. Not itself an SEC-filed figure — see components for the tags and values it was summed from.',
3734
+ };
3735
+ }
3736
+ }
3737
+ }
3738
+
3526
3739
  const latestFact = rows.find((e) => !e.derived) ?? null;
3527
3740
  let coverageBound: string | undefined;
3528
3741
  if (latestFact?.filed) {
@@ -3570,6 +3783,80 @@ async function companyConcept(
3570
3783
  };
3571
3784
  }
3572
3785
 
3786
+ // fleet #2450: a ClearGene-style question ("Signatera revenue AND test volume
3787
+ // from Natera's latest 10-Q") never got both facts, because a financial TOTAL
3788
+ // like revenue is frequently XBRL-only and never printed as an absolute
3789
+ // dollar figure anywhere in the filing's prose — verified live 2026-09-29:
3790
+ // Natera's Q2 2026 10-Q text discusses revenue only as percentage/dollar
3791
+ // DELTAS ("increased by $206.2 million, or 37.7%"), never the $752.75M total
3792
+ // itself, so no `search` phrasing, exact or word-fallback, can ever surface
3793
+ // it from the text. edgar_filing_text's `search` now recognizes when a
3794
+ // requested phrase NAMES a financial concept this filer tags in XBRL (the
3795
+ // same friendly-name table edgar_company_concept resolves) and attaches that
3796
+ // concept's value for THIS SAME accession/period alongside the text
3797
+ // passages — one tool call gets the KPI from prose and the total from XBRL.
3798
+ // Matching is by N-GRAM of the phrase's tokens (not a raw substring) so a
3799
+ // short ambiguous key ("cash", "eps") only fires on a real standalone token,
3800
+ // not embedded inside an unrelated word.
3801
+ function detectFinancialConcept(phrase: string): string | null {
3802
+ const tokens = phrase.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
3803
+ for (let n = Math.min(3, tokens.length); n >= 1; n--) {
3804
+ for (let i = 0; i + n <= tokens.length; i++) {
3805
+ const gram = tokens.slice(i, i + n).join('');
3806
+ if (CONCEPT_CANDIDATES[gram]) return gram;
3807
+ }
3808
+ }
3809
+ return null;
3810
+ }
3811
+
3812
+ // Best-effort: find the XBRL fact for `friendlyConcept` that belongs to the
3813
+ // EXACT filing edgar_filing_text is already reading (same form + same filed
3814
+ // date), not just "the latest" — a different period would silently pair the
3815
+ // wrong quarter's total with this filing's prose. Never throws: an XBRL miss
3816
+ // (concept not reported, SEC rate-limited, etc.) just means no `xbrl`
3817
+ // companion is attached, and the text passages above stand on their own.
3818
+ async function financialFactForFiling(
3819
+ tickerOrCik: string,
3820
+ friendlyConcept: string,
3821
+ wantForm: string | null,
3822
+ wantFiled: string | null,
3823
+ ): Promise<Record<string, unknown> | null> {
3824
+ if (!wantForm || !wantFiled) return null;
3825
+ try {
3826
+ const cc = (await companyConcept(tickerOrCik, friendlyConcept)) as {
3827
+ concept?: string;
3828
+ label?: string;
3829
+ requested_concept?: string;
3830
+ concept_substituted?: boolean;
3831
+ reported?: boolean;
3832
+ values?: Array<{
3833
+ form: string; filed: string; value: number; unit: string;
3834
+ fiscal_year?: number; fiscal_period?: string;
3835
+ period_start?: string; period_end?: string; derived?: boolean;
3836
+ }>;
3837
+ };
3838
+ if (!cc || cc.reported === false || !Array.isArray(cc.values)) return null;
3839
+ const exact = cc.values.find((v) => v.form === wantForm && v.filed === wantFiled && !v.derived);
3840
+ if (!exact) return null;
3841
+ return {
3842
+ concept: cc.concept ?? friendlyConcept,
3843
+ label: cc.label ?? null,
3844
+ value: exact.value,
3845
+ unit: exact.unit,
3846
+ fiscal_year: exact.fiscal_year ?? null,
3847
+ fiscal_period: exact.fiscal_period ?? null,
3848
+ period_start: exact.period_start ?? null,
3849
+ period_end: exact.period_end ?? null,
3850
+ form: exact.form,
3851
+ filed: exact.filed,
3852
+ source: 'edgar_company_concept (XBRL) — same accession, not extracted from the prose above',
3853
+ note: 'Many financial totals are reported ONLY in XBRL and never appear as an absolute dollar figure in the filing text (the prose typically states growth deltas/percentages instead) — this value is the filer\'s own XBRL fact for this exact filing, not a text match.',
3854
+ };
3855
+ } catch {
3856
+ return null;
3857
+ }
3858
+ }
3859
+
3573
3860
  // ── Insider transactions (Form 3/4/5) ───────────────────────────────
3574
3861
 
3575
3862
  // SEC Form 4 transaction codes (Table I / II). Open-market buys (P) and
package/src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
9
9
  import pack from './index.js';
10
10
 
11
11
  const server = new Server(
12
- { name: '@pipeworx/mcp-edgar', version: '0.3.6' },
12
+ { name: '@pipeworx/mcp-edgar', version: '0.3.8' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15