@pipeworx/mcp-edgar 0.3.5 → 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 +2 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +205 -16
- package/src/server.ts +1 -1
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
|
|
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
|
|
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.
|
|
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-
|
|
30
|
-
"sourceCommit": "
|
|
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
|
+
"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.
|
|
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',
|
|
@@ -2609,6 +2609,74 @@ function findPassages(
|
|
|
2609
2609
|
return { total: offsets.length, distinct_locations: groups.length, passages };
|
|
2610
2610
|
}
|
|
2611
2611
|
|
|
2612
|
+
// fleet #2450: the router answers in ONE tool call, so "retry with a shorter
|
|
2613
|
+
// phrase" (the old zero-match note) never happens inside ask_pipeworx. Measured
|
|
2614
|
+
// live 2026-09-26: 5 of 5 routed calls for the Natera "revenue and test volume
|
|
2615
|
+
// in its most recent 10-K or 10-Q" question reached the RIGHT filing and passed
|
|
2616
|
+
// a compound phrase ("Signatera revenue", "oncology Signatera revenue test
|
|
2617
|
+
// volume") that matches nothing verbatim — while the word "Signatera" alone
|
|
2618
|
+
// lands on the "processed approximately 2,056,800 tests" sentence. A description
|
|
2619
|
+
// telling the router to pass shorter phrases did not change what it passed.
|
|
2620
|
+
// So when the exact phrase misses, fall back to the phrase's WORDS: anchor on
|
|
2621
|
+
// the rarer words, rank each window by how many distinct query words it holds
|
|
2622
|
+
// (then by a nearby figure), and say plainly that this is a word-level match.
|
|
2623
|
+
const WORD_FALLBACK_STOP = new Set([
|
|
2624
|
+
'the', 'and', 'for', 'its', 'our', 'their', 'with', 'from', 'that', 'this', 'what', 'which',
|
|
2625
|
+
'most', 'recent', 'latest', 'report', 'reported', 'reports', 'filing', 'total', 'any', 'all',
|
|
2626
|
+
]);
|
|
2627
|
+
const WORD_FALLBACK_ANCHOR_MAX = 60;
|
|
2628
|
+
|
|
2629
|
+
export function findWordPassages(
|
|
2630
|
+
text: string,
|
|
2631
|
+
phrase: string,
|
|
2632
|
+
contextChars = SEARCH_CONTEXT_CHARS,
|
|
2633
|
+
maxPassages = SEARCH_MAX_PASSAGES,
|
|
2634
|
+
): { words: Array<{ word: string; occurrences: number }>; distinct_locations: number; passages: Array<{ offset: number; text: string; words_matched: string[] }> } | null {
|
|
2635
|
+
const words = [...new Set(phrase.toLowerCase().split(/[^a-z0-9&-]+/).filter((w) => w.length >= 3 && !WORD_FALLBACK_STOP.has(w)))];
|
|
2636
|
+
if (words.length < 2) return null;
|
|
2637
|
+
const hay = text.toLowerCase();
|
|
2638
|
+
const occ = new Map<string, number[]>();
|
|
2639
|
+
for (const w of words) {
|
|
2640
|
+
const offs: number[] = [];
|
|
2641
|
+
let from = 0;
|
|
2642
|
+
for (;;) {
|
|
2643
|
+
const i = hay.indexOf(w, from);
|
|
2644
|
+
if (i === -1) break;
|
|
2645
|
+
offs.push(i);
|
|
2646
|
+
from = i + w.length;
|
|
2647
|
+
}
|
|
2648
|
+
occ.set(w, offs);
|
|
2649
|
+
}
|
|
2650
|
+
const present = words.filter((w) => (occ.get(w) ?? []).length > 0);
|
|
2651
|
+
if (present.length === 0) return null;
|
|
2652
|
+
// Anchor on every word rare enough to be distinctive; if every word is
|
|
2653
|
+
// common, anchor on the rarest one only (bounded work on a 200k-char doc).
|
|
2654
|
+
const byRarity = [...present].sort((a, b) => occ.get(a)!.length - occ.get(b)!.length);
|
|
2655
|
+
const anchors = byRarity.filter((w) => occ.get(w)!.length <= WORD_FALLBACK_ANCHOR_MAX);
|
|
2656
|
+
const anchorWords = anchors.length ? anchors : [byRarity[0]];
|
|
2657
|
+
const anchorOffs = [...new Set(anchorWords.flatMap((w) => occ.get(w)!))].sort((a, b) => a - b);
|
|
2658
|
+
|
|
2659
|
+
const groups: Array<{ first: number; last: number }> = [];
|
|
2660
|
+
for (const idx of anchorOffs) {
|
|
2661
|
+
const g = groups[groups.length - 1];
|
|
2662
|
+
if (g && idx - g.last <= contextChars * 2) g.last = idx;
|
|
2663
|
+
else groups.push({ first: idx, last: idx });
|
|
2664
|
+
}
|
|
2665
|
+
const scored = groups.map((g) => {
|
|
2666
|
+
const start = Math.max(0, g.first - contextChars);
|
|
2667
|
+
const end = Math.min(text.length, g.last + contextChars);
|
|
2668
|
+
const win = hay.slice(start, end);
|
|
2669
|
+
const matched = present.filter((w) => win.includes(w));
|
|
2670
|
+
return { start, end, matched, hasNumber: NEARBY_NUMBER_RE.test(text.slice(start, end)) ? 1 : 0 };
|
|
2671
|
+
});
|
|
2672
|
+
scored.sort((a, b) => (b.matched.length - a.matched.length) || (b.hasNumber - a.hasNumber));
|
|
2673
|
+
return {
|
|
2674
|
+
words: words.map((w) => ({ word: w, occurrences: (occ.get(w) ?? []).length })),
|
|
2675
|
+
distinct_locations: groups.length,
|
|
2676
|
+
passages: scored.slice(0, maxPassages).map((s) => ({ offset: s.start, text: text.slice(s.start, s.end), words_matched: s.matched })),
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2612
2680
|
// Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
|
|
2613
2681
|
// out (rather than piggybacking edgar_filing_documents' include_primary_text,
|
|
2614
2682
|
// which caps at 40k and can't page) lets the register pull deep disclosures —
|
|
@@ -2709,9 +2777,61 @@ async function filingText(
|
|
|
2709
2777
|
// the whole (possibly section-sliced) document and returns matching
|
|
2710
2778
|
// passages with their own offsets, rather than one page of raw text the
|
|
2711
2779
|
// caller has to eyeball for the fact they wanted (fleet #2446).
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
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
|
|
2807
|
+
? {
|
|
2808
|
+
term,
|
|
2809
|
+
match_mode: 'words' as const,
|
|
2810
|
+
exact_matches: 0,
|
|
2811
|
+
words: wordHit.words,
|
|
2812
|
+
distinct_locations: wordHit.distinct_locations,
|
|
2813
|
+
returned: wordHit.passages.length,
|
|
2814
|
+
truncated: wordHit.distinct_locations > wordHit.passages.length,
|
|
2815
|
+
passages: wordHit.passages,
|
|
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.`,
|
|
2817
|
+
}
|
|
2818
|
+
: {
|
|
2819
|
+
term,
|
|
2820
|
+
match_mode: 'exact' as const,
|
|
2821
|
+
total_matches: totalMatches,
|
|
2822
|
+
distinct_locations: distinctLocations,
|
|
2823
|
+
returned: passages.length,
|
|
2824
|
+
truncated: distinctLocations > passages.length,
|
|
2825
|
+
passages,
|
|
2826
|
+
note: totalMatches === 0
|
|
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.'
|
|
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.`,
|
|
2829
|
+
};
|
|
2830
|
+
return xbrl ? { ...base, xbrl } : base;
|
|
2831
|
+
};
|
|
2832
|
+
|
|
2833
|
+
const results = await Promise.all(searchPhrases.map(buildOne));
|
|
2834
|
+
|
|
2715
2835
|
return {
|
|
2716
2836
|
accession: acc.dashed,
|
|
2717
2837
|
cik: cikNoZeros,
|
|
@@ -2723,17 +2843,12 @@ async function filingText(
|
|
|
2723
2843
|
section: sectionApplied,
|
|
2724
2844
|
...(sectionFound !== null ? { section_found: sectionFound } : {}),
|
|
2725
2845
|
total_chars: fullText.length,
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
passages,
|
|
2733
|
-
note: totalMatches === 0
|
|
2734
|
-
? 'No case-insensitive match for this exact wording in the document (or section, if one was given). That is a real answer, not a failure — retry with a shorter phrase or drop `section` to search the whole document.'
|
|
2735
|
-
: `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.`,
|
|
2736
|
-
},
|
|
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
|
+
}),
|
|
2737
2852
|
...(rawTruncated
|
|
2738
2853
|
? {
|
|
2739
2854
|
raw_truncated: true,
|
|
@@ -3488,6 +3603,80 @@ async function companyConcept(
|
|
|
3488
3603
|
};
|
|
3489
3604
|
}
|
|
3490
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
|
+
|
|
3491
3680
|
// ── Insider transactions (Form 3/4/5) ───────────────────────────────
|
|
3492
3681
|
|
|
3493
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.
|
|
12
|
+
{ name: '@pipeworx/mcp-edgar', version: '0.3.7' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|