@pipeworx/mcp-edgar 0.3.6 → 0.3.7

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 1684+ live data sources.
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 1684+ 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.7",
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-32b9918a5b5098bb4af39453590b40c58883a8cca190d5ec3630203cf8236594",
30
+ "sourceCommit": "7ab72cdcaf29d0be5874bd2191074e69f97c9b0b"
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.7",
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,
@@ -3570,6 +3603,80 @@ async function companyConcept(
3570
3603
  };
3571
3604
  }
3572
3605
 
3606
+ // fleet #2450: a ClearGene-style question ("Signatera revenue AND test volume
3607
+ // from Natera's latest 10-Q") never got both facts, because a financial TOTAL
3608
+ // like revenue is frequently XBRL-only and never printed as an absolute
3609
+ // dollar figure anywhere in the filing's prose — verified live 2026-09-29:
3610
+ // Natera's Q2 2026 10-Q text discusses revenue only as percentage/dollar
3611
+ // DELTAS ("increased by $206.2 million, or 37.7%"), never the $752.75M total
3612
+ // itself, so no `search` phrasing, exact or word-fallback, can ever surface
3613
+ // it from the text. edgar_filing_text's `search` now recognizes when a
3614
+ // requested phrase NAMES a financial concept this filer tags in XBRL (the
3615
+ // same friendly-name table edgar_company_concept resolves) and attaches that
3616
+ // concept's value for THIS SAME accession/period alongside the text
3617
+ // passages — one tool call gets the KPI from prose and the total from XBRL.
3618
+ // Matching is by N-GRAM of the phrase's tokens (not a raw substring) so a
3619
+ // short ambiguous key ("cash", "eps") only fires on a real standalone token,
3620
+ // not embedded inside an unrelated word.
3621
+ function detectFinancialConcept(phrase: string): string | null {
3622
+ const tokens = phrase.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
3623
+ for (let n = Math.min(3, tokens.length); n >= 1; n--) {
3624
+ for (let i = 0; i + n <= tokens.length; i++) {
3625
+ const gram = tokens.slice(i, i + n).join('');
3626
+ if (CONCEPT_CANDIDATES[gram]) return gram;
3627
+ }
3628
+ }
3629
+ return null;
3630
+ }
3631
+
3632
+ // Best-effort: find the XBRL fact for `friendlyConcept` that belongs to the
3633
+ // EXACT filing edgar_filing_text is already reading (same form + same filed
3634
+ // date), not just "the latest" — a different period would silently pair the
3635
+ // wrong quarter's total with this filing's prose. Never throws: an XBRL miss
3636
+ // (concept not reported, SEC rate-limited, etc.) just means no `xbrl`
3637
+ // companion is attached, and the text passages above stand on their own.
3638
+ async function financialFactForFiling(
3639
+ tickerOrCik: string,
3640
+ friendlyConcept: string,
3641
+ wantForm: string | null,
3642
+ wantFiled: string | null,
3643
+ ): Promise<Record<string, unknown> | null> {
3644
+ if (!wantForm || !wantFiled) return null;
3645
+ try {
3646
+ const cc = (await companyConcept(tickerOrCik, friendlyConcept)) as {
3647
+ concept?: string;
3648
+ label?: string;
3649
+ requested_concept?: string;
3650
+ concept_substituted?: boolean;
3651
+ reported?: boolean;
3652
+ values?: Array<{
3653
+ form: string; filed: string; value: number; unit: string;
3654
+ fiscal_year?: number; fiscal_period?: string;
3655
+ period_start?: string; period_end?: string; derived?: boolean;
3656
+ }>;
3657
+ };
3658
+ if (!cc || cc.reported === false || !Array.isArray(cc.values)) return null;
3659
+ const exact = cc.values.find((v) => v.form === wantForm && v.filed === wantFiled && !v.derived);
3660
+ if (!exact) return null;
3661
+ return {
3662
+ concept: cc.concept ?? friendlyConcept,
3663
+ label: cc.label ?? null,
3664
+ value: exact.value,
3665
+ unit: exact.unit,
3666
+ fiscal_year: exact.fiscal_year ?? null,
3667
+ fiscal_period: exact.fiscal_period ?? null,
3668
+ period_start: exact.period_start ?? null,
3669
+ period_end: exact.period_end ?? null,
3670
+ form: exact.form,
3671
+ filed: exact.filed,
3672
+ source: 'edgar_company_concept (XBRL) — same accession, not extracted from the prose above',
3673
+ 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.',
3674
+ };
3675
+ } catch {
3676
+ return null;
3677
+ }
3678
+ }
3679
+
3573
3680
  // ── Insider transactions (Form 3/4/5) ───────────────────────────────
3574
3681
 
3575
3682
  // 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.7' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15