@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 +2 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +311 -24
- 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 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
|
|
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.
|
|
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-
|
|
30
|
-
"sourceCommit": "
|
|
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
|
+
"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
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
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
|
|
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 "${
|
|
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
|
|
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.
|
|
12
|
+
{ name: '@pipeworx/mcp-edgar', version: '0.3.8' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|