@pipeworx/mcp-edgar 0.3.7 → 0.3.9
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 +381 -25
- package/src/server.ts +5 -2
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 1784+ 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 1784+ 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.9",
|
|
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-d35d4a284237ace3314859c5df49ad1b77a0a9e2e7696d3f0b601f112b20e42a",
|
|
30
|
+
"sourceCommit": "3723e46c6d115bd4a1fd2175e6bf758555e7cb50"
|
|
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.9",
|
|
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
|
@@ -1854,6 +1854,23 @@ function significantTokens(name: string): string[] {
|
|
|
1854
1854
|
return normName(name).split(' ').filter((t) => t.length > 1 && !GENERIC_NAME_TOKENS.has(t));
|
|
1855
1855
|
}
|
|
1856
1856
|
|
|
1857
|
+
// 2026-10-09: shared/src/resolve.ts's rankMatches only strips '.' and ',', so
|
|
1858
|
+
// "BRISTOL-MYERS SQUIBB COMPANY" (hyphen kept, full "COMPANY") never matched
|
|
1859
|
+
// SEC's own listed title "BRISTOL MYERS SQUIBB CO" — resolved via full-text
|
|
1860
|
+
// fallback as no_us_registrant instead of the direct CIK 14272 hit. Do not fix
|
|
1861
|
+
// this in shared (used by other packs); derive a second, edgar-only candidate
|
|
1862
|
+
// query that also hits the hyphen (normName already strips it) and the SEC
|
|
1863
|
+
// house style for a trailing corporate suffix.
|
|
1864
|
+
const SUFFIX_TO_SEC_STYLE: Record<string, string> = {
|
|
1865
|
+
COMPANY: 'CO', CORPORATION: 'CORP', INCORPORATED: 'INC', LIMITED: 'LTD',
|
|
1866
|
+
};
|
|
1867
|
+
function secStyleQuery(name: string): string {
|
|
1868
|
+
const tokens = normName(name).split(' ').filter(Boolean);
|
|
1869
|
+
const last = tokens[tokens.length - 1];
|
|
1870
|
+
if (last && SUFFIX_TO_SEC_STYLE[last]) tokens[tokens.length - 1] = SUFFIX_TO_SEC_STYLE[last];
|
|
1871
|
+
return tokens.join(' ');
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1857
1874
|
function distinctiveToken(name: string): string {
|
|
1858
1875
|
return significantTokens(name)[0] ?? normName(name).split(' ')[0] ?? '';
|
|
1859
1876
|
}
|
|
@@ -1861,8 +1878,14 @@ function distinctiveToken(name: string): string {
|
|
|
1861
1878
|
// SEC company names use "&", not "and". Produce query variants: &-normalized
|
|
1862
1879
|
// full name first, then progressively drop trailing generic words (browse-edgar
|
|
1863
1880
|
// matches on a name prefix and a too-specific full name can return nothing).
|
|
1881
|
+
// 2026-10-13: browse-edgar's `company=` param is a literal prefix match and
|
|
1882
|
+
// does NOT fold punctuation itself -- company=Bristol-Myers%20Squibb returns
|
|
1883
|
+
// 0 candidates while company=Bristol%20Myers%20Squibb returns CIK 14272 (the
|
|
1884
|
+
// filer's own listed title has no hyphen). Fold -/() to spaces before
|
|
1885
|
+
// querying, same characters normName() already strips for the ranking path,
|
|
1886
|
+
// so every query we send is already in SEC's unpunctuated house style.
|
|
1864
1887
|
function secQueryVariants(name: string): string[] {
|
|
1865
|
-
const amp = name.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
|
|
1888
|
+
const amp = name.replace(/\band\b/gi, '&').replace(/[-/()]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1866
1889
|
const out = [amp];
|
|
1867
1890
|
const words = amp.split(' ');
|
|
1868
1891
|
for (let end = words.length; end > 1; end--) {
|
|
@@ -1880,28 +1903,99 @@ async function fetchCompanyTickers(): Promise<SecTickerRow[]> {
|
|
|
1880
1903
|
return Object.values(data);
|
|
1881
1904
|
}
|
|
1882
1905
|
|
|
1906
|
+
// Legal-form designators. A filer name carrying none of these (and no
|
|
1907
|
+
// descriptor word like PHARMACEUTICA / PRODUCTS / PARTNERS) in a 2-4 word
|
|
1908
|
+
// "Last First M" shape is an individual Form 3/4/5 reporting owner, never a
|
|
1909
|
+
// company's own CIK. #2998: "JANSSEN PHARMACEUTICALS INC" fell back to the
|
|
1910
|
+
// bare "JANSSEN" query, whose browse-edgar list is alphabetical by person
|
|
1911
|
+
// name, and took "Janssen Angela K" (CIK 1664284) as J&J's subsidiary.
|
|
1912
|
+
const LEGAL_FORM_TOKENS = new Set([
|
|
1913
|
+
'INC', 'CORP', 'CO', 'COMPANY', 'CORPORATION', 'INCORPORATED', 'LTD', 'LIMITED',
|
|
1914
|
+
'LLC', 'LP', 'LLP', 'PLC', 'SA', 'AG', 'NV', 'BV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
|
|
1915
|
+
'SE', 'KK', 'PTE', 'PTY', 'SARL', 'SAS', 'TRUST', 'FUND', 'PARTNERSHIP',
|
|
1916
|
+
]);
|
|
1917
|
+
const COMPANY_WORDS = /\b(PHARMA\w*|THERAP\w*|BIO\w*|PRODUCTS?|PARTNERS|ASSOCIATES|CAPITAL|VENTURES|HOLDINGS?|GROUP|LABORATOR\w*|LABS|SCIENCES?|MEDICAL|HEALTH\w*|INTERNATIONAL|GLOBAL|SERVICES|SYSTEMS|TECHNOLOG\w*|INDUSTRIES|ENTERPRISES|MANAGEMENT|INVESTMENTS?|FOUNDATION|UNIVERSITY|BANK|FINANCIAL|ENERGY|RESEARCH|AMERICA|USA|US)\b/;
|
|
1918
|
+
|
|
1919
|
+
/** Person-shaped filer name ("Janssen Angela K", "JANSSEN MICHAEL J"). */
|
|
1920
|
+
function isPersonShapedName(name: string): boolean {
|
|
1921
|
+
const n = name.toUpperCase().replace(/\./g, '').replace(/[,&/()-]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1922
|
+
const toks = n.split(' ').filter(Boolean);
|
|
1923
|
+
if (toks.length < 2 || toks.length > 4) return false;
|
|
1924
|
+
if (toks.some((t) => LEGAL_FORM_TOKENS.has(t) || /\d/.test(t))) return false;
|
|
1925
|
+
return !COMPANY_WORDS.test(n);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const STOP_TOKENS = new Set(['THE', 'OF', 'AND']);
|
|
1929
|
+
|
|
1930
|
+
// Two tokens agree when equal, or one is a >=6-char prefix of the other
|
|
1931
|
+
// ("PHARMACEUTICA" ~ "PHARMACEUTICALS").
|
|
1932
|
+
const tokAgree = (a: string, b: string): boolean =>
|
|
1933
|
+
a === b || (Math.min(a.length, b.length) >= 6 && (a.startsWith(b) || b.startsWith(a)));
|
|
1934
|
+
|
|
1935
|
+
/** Similarity of a candidate filer name to the FULL sponsor query, ignoring
|
|
1936
|
+
* legal-form suffixes (INC matching INC says nothing). Significant tokens
|
|
1937
|
+
* weigh 10, descriptor words (PHARMACEUTICALS, BIOTECH...) weigh 1. */
|
|
1938
|
+
function nameSimilarity(query: string, candidate: string): { sig: number; desc: number; score: number } {
|
|
1939
|
+
const keep = (t: string) => t.length > 1 && !LEGAL_FORM_TOKENS.has(t) && !STOP_TOKENS.has(t);
|
|
1940
|
+
const qAll = normName(query).split(' ').filter(keep);
|
|
1941
|
+
const cAll = normName(candidate).split(' ').filter(keep);
|
|
1942
|
+
const sigSet = new Set(significantTokens(query));
|
|
1943
|
+
let sig = 0, desc = 0;
|
|
1944
|
+
for (const q of qAll) {
|
|
1945
|
+
if (!cAll.some((c) => tokAgree(q, c))) continue;
|
|
1946
|
+
if (sigSet.has(q)) sig++; else desc++;
|
|
1947
|
+
}
|
|
1948
|
+
return { sig, desc, score: sig * 10 + desc };
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
const decodeHtml = (s: string): string =>
|
|
1952
|
+
s.replace(/&/g, '&').replace(/'|'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
1953
|
+
|
|
1954
|
+
/** browse-edgar HTML company search -> [{cik, name}]. The atom output carries
|
|
1955
|
+
* no names (SEC renders them as "ARRAY(0x...)"), so a ranking by name needs
|
|
1956
|
+
* the HTML list; an exact single hit renders the company page instead. */
|
|
1957
|
+
function parseBrowseEdgarCompanies(html: string): { cik: string; name: string }[] {
|
|
1958
|
+
const out: { cik: string; name: string }[] = [];
|
|
1959
|
+
const single = html.match(/<span class="companyName">([\s\S]*?)<acronym[^>]*>CIK<\/acronym>#:\s*<a[^>]*>(\d+)/);
|
|
1960
|
+
if (single) return [{ cik: String(Number(single[2])), name: decodeHtml(single[1]).trim() }];
|
|
1961
|
+
for (const m of html.matchAll(/CIK=(\d+)&owner=include[^"]*">\d+<\/a><\/td>\s*<td scope="row">([\s\S]*?)<\/td>/g)) {
|
|
1962
|
+
out.push({ cik: String(Number(m[1])), name: decodeHtml(m[2].split(/<br/i)[0]).trim() });
|
|
1963
|
+
}
|
|
1964
|
+
return out;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1883
1967
|
/** Does the sponsor name have its OWN SEC filer CIK (registered entity — a
|
|
1884
|
-
* subsidiary, or a private co that filed a Form D / draft S-1)?
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
1968
|
+
* subsidiary, or a private co that filed a Form D / draft S-1)? Candidates
|
|
1969
|
+
* come from browse-edgar's company search (count=40 -- its order is
|
|
1970
|
+
* alphabetical, not relevance, so the first hits of a short query are often
|
|
1971
|
+
* people); individual reporting owners are dropped, the rest ranked by
|
|
1972
|
+
* similarity to the FULL sponsor name, and the best are validated against
|
|
1973
|
+
* their real name from the submissions API (which doubles as the entity
|
|
1974
|
+
* profile). When the query carries descriptor words beyond its one
|
|
1975
|
+
* distinctive token ("JANSSEN PHARMACEUTICALS"), a candidate must agree on
|
|
1976
|
+
* at least one of them. Returns {cik, profile} or null. */
|
|
1888
1977
|
async function edgarOwnCik(sponsor: string): Promise<{ cik: string; profile: EntityProfile } | null> {
|
|
1889
1978
|
const sig = significantTokens(sponsor);
|
|
1890
1979
|
const need = Math.min(2, sig.length || 1);
|
|
1980
|
+
const queryDesc = nameSimilarity(sponsor, sponsor).desc;
|
|
1981
|
+
const needDesc = sig.length <= 1 && queryDesc > 0 ? 1 : 0;
|
|
1982
|
+
const passes = (s: { sig: number; desc: number }) => s.sig >= need && s.desc >= needDesc;
|
|
1891
1983
|
for (const q of secQueryVariants(sponsor)) {
|
|
1892
|
-
const url = `https://www.sec.gov/cgi-bin/browse-edgar?company=${encodeURIComponent(q)}&action=getcompany&type=&dateb=&owner=include&count=
|
|
1984
|
+
const url = `https://www.sec.gov/cgi-bin/browse-edgar?company=${encodeURIComponent(q)}&action=getcompany&type=&dateb=&owner=include&count=40`;
|
|
1893
1985
|
const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } }).catch(() => null);
|
|
1894
1986
|
if (!res || !res.ok) continue;
|
|
1895
|
-
const
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1987
|
+
const found = parseBrowseEdgarCompanies(await res.text());
|
|
1988
|
+
const ranked = found
|
|
1989
|
+
.filter((c) => !isPersonShapedName(c.name))
|
|
1990
|
+
.map((c, i) => ({ ...c, i, sim: nameSimilarity(sponsor, c.name) }))
|
|
1991
|
+
.filter((c) => passes(c.sim))
|
|
1992
|
+
.sort((a, b) => b.sim.score - a.sim.score || a.i - b.i);
|
|
1993
|
+
for (const c of ranked.slice(0, 3)) {
|
|
1994
|
+
const profile = await edgarEntityProfile(c.cik);
|
|
1995
|
+
if (!profile?.name || isPersonShapedName(profile.name)) continue;
|
|
1996
|
+
if (passes(nameSimilarity(sponsor, profile.name))) return { cik: c.cik, profile };
|
|
1903
1997
|
}
|
|
1904
|
-
if (
|
|
1998
|
+
if (found.length) break; // first variant that returned candidates is authoritative
|
|
1905
1999
|
}
|
|
1906
2000
|
return null;
|
|
1907
2001
|
}
|
|
@@ -1972,16 +2066,24 @@ async function sponsorToFiler(sponsor: string): Promise<unknown> {
|
|
|
1972
2066
|
const tickers = await fetchCompanyTickers();
|
|
1973
2067
|
|
|
1974
2068
|
// 1) Direct listed match (exact / prefix / whole-word — not loose substring).
|
|
2069
|
+
// Also rank a hyphen-normalized, suffix-normalized form ("Bristol-Myers
|
|
2070
|
+
// Squibb Company" -> "BRISTOL MYERS SQUIBB CO") and keep whichever scores
|
|
2071
|
+
// higher, so inputs that already match keep behaving exactly as before.
|
|
1975
2072
|
const ranked = rankMatches(name, tickers, (r) => r.title);
|
|
1976
|
-
|
|
1977
|
-
|
|
2073
|
+
const secStyled = secStyleQuery(name);
|
|
2074
|
+
const rankedSecStyle = secStyled === name.toUpperCase() ? [] : rankMatches(secStyled, tickers, (r) => r.title);
|
|
2075
|
+
const best = rankedSecStyle.length && (!ranked.length || rankedSecStyle[0].score > ranked[0].score)
|
|
2076
|
+
? rankedSecStyle
|
|
2077
|
+
: ranked;
|
|
2078
|
+
if (best.length && best[0].score >= 2) {
|
|
2079
|
+
const m = best[0].item;
|
|
1978
2080
|
return {
|
|
1979
2081
|
status: 'resolved',
|
|
1980
2082
|
method: 'direct',
|
|
1981
|
-
confidence:
|
|
2083
|
+
confidence: best[0].score >= 3 ? 'high' : 'medium',
|
|
1982
2084
|
sponsor: name,
|
|
1983
2085
|
filer: filerOut(m),
|
|
1984
|
-
alternatives:
|
|
2086
|
+
alternatives: best.slice(1, 4).map((x) => filerOut(x.item)),
|
|
1985
2087
|
};
|
|
1986
2088
|
}
|
|
1987
2089
|
|
|
@@ -1994,24 +2096,59 @@ async function sponsorToFiler(sponsor: string): Promise<unknown> {
|
|
|
1994
2096
|
}
|
|
1995
2097
|
}
|
|
1996
2098
|
|
|
1997
|
-
// 3) Full-text: listed filers referencing the sponsor; confirm a parent
|
|
1998
|
-
//
|
|
1999
|
-
// "merely mentioned by an unrelated
|
|
2099
|
+
// 3) Full-text: listed filers referencing the sponsor; confirm a parent.
|
|
2100
|
+
// Rule A (shared_token_fulltext): the sponsor's distinctive token appears
|
|
2101
|
+
// in the candidate's OWN name (kills "merely mentioned by an unrelated
|
|
2102
|
+
// filer" false positives) — catches e.g. "Capricor Inc." -> "Capricor
|
|
2103
|
+
// Therapeutics". This does NOT catch a brand subsidiary whose name shares
|
|
2104
|
+
// no word with its parent ("Janssen" / "Johnson & Johnson", "Merck Sharp
|
|
2105
|
+
// and Dohme" is the one case that happens to share "Merck") — for that we
|
|
2106
|
+
// never promote on mention-count alone (a press/litigation mention is not
|
|
2107
|
+
// ownership); we ask SEC's own authoritative source instead: does the
|
|
2108
|
+
// dominant candidate's latest 10-K Exhibit 21 subsidiary list (Item
|
|
2109
|
+
// 601(b)(21)) name the sponsor? Rule B (ex21_subsidiary_match) fires only
|
|
2110
|
+
// then, and only against the single dominant candidate, so a genuine miss
|
|
2111
|
+
// never pays for an extra filing+exhibit fetch. #2971.
|
|
2000
2112
|
const ft = await fullTextListedFilers(name).catch(() => ({ candidates: [] as ListedFiler[], total: 0 }));
|
|
2001
|
-
|
|
2113
|
+
let confirmed = ft.candidates.find((c) => normName(c.name).split(' ').includes(disc));
|
|
2114
|
+
let rule: 'shared_token_fulltext' | 'ex21_subsidiary_match' | null = confirmed ? 'shared_token_fulltext' : null;
|
|
2115
|
+
let ex21Match: string | null = null;
|
|
2116
|
+
|
|
2117
|
+
if (!confirmed && ft.candidates.length) {
|
|
2118
|
+
const top = ft.candidates[0];
|
|
2119
|
+
const second = ft.candidates[1];
|
|
2120
|
+
// Dominance gate before spending the EX-21 fetch: top mention count must
|
|
2121
|
+
// be meaningful on its own AND clearly ahead of the runner-up.
|
|
2122
|
+
const dominant = top.hits >= 5 && (!second || top.hits >= 3 * second.hits);
|
|
2123
|
+
if (dominant) {
|
|
2124
|
+
ex21Match = await exhibit21NamesSponsor(top.cik, name).catch(() => null);
|
|
2125
|
+
if (ex21Match) {
|
|
2126
|
+
confirmed = top;
|
|
2127
|
+
rule = 'ex21_subsidiary_match';
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2002
2132
|
if (confirmed) {
|
|
2133
|
+
const directMatch = own ? { cik: own.cik, name: own.profile?.name ?? null, listed: false } : null;
|
|
2003
2134
|
return {
|
|
2004
2135
|
status: 'resolved_via_parent',
|
|
2005
|
-
method: 'subsidiary_fulltext',
|
|
2136
|
+
method: rule === 'ex21_subsidiary_match' ? 'subsidiary_exhibit21' : 'subsidiary_fulltext',
|
|
2137
|
+
rule,
|
|
2006
2138
|
confidence: confirmed.hits >= 10 ? 'high' : 'medium',
|
|
2007
2139
|
sponsor: name,
|
|
2008
2140
|
filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
|
|
2141
|
+
parent_filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
|
|
2142
|
+
direct_match: directMatch,
|
|
2009
2143
|
evidence: {
|
|
2010
2144
|
parent_mention_filings: confirmed.hits,
|
|
2011
2145
|
distinctive_token: disc,
|
|
2012
2146
|
own_subsidiary_cik: own?.cik ?? null,
|
|
2013
2147
|
own_subsidiary_name: own?.profile?.name ?? null,
|
|
2014
|
-
|
|
2148
|
+
exhibit_21_subsidiary_name: ex21Match,
|
|
2149
|
+
note: rule === 'ex21_subsidiary_match'
|
|
2150
|
+
? `"${name}" is named as a subsidiary in ${confirmed.name}'s own SEC Exhibit 21 filing (as "${ex21Match}") -- the authoritative ownership disclosure, used because the two names share no common word.`
|
|
2151
|
+
: 'Sponsor appears to be an operating subsidiary. Resolved to the listed parent whose SEC filings reference it and whose name shares the sponsor\'s distinctive token.',
|
|
2015
2152
|
},
|
|
2016
2153
|
alternatives: ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits })),
|
|
2017
2154
|
};
|
|
@@ -3106,6 +3243,83 @@ const IFRS_FALLBACK: Record<string, string[]> = {
|
|
|
3106
3243
|
equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
|
|
3107
3244
|
};
|
|
3108
3245
|
|
|
3246
|
+
// --- Liquidity companion tags (fleet #2879 / feedback #137) ----------------
|
|
3247
|
+
// "Cash" alone (CashAndCashEquivalentsAtCarryingValue, or its substitutes)
|
|
3248
|
+
// excludes marketable/short-term securities, which biotechs and other
|
|
3249
|
+
// cash-burning companies routinely park a large share of their runway in.
|
|
3250
|
+
// Verified live for Capricor (CIK 1133869, accession 0001104659-26-097294,
|
|
3251
|
+
// period 2026-06-30): Cash alone is $20,935,187; adding
|
|
3252
|
+
// AvailableForSaleSecuritiesDebtSecuritiesCurrent ($216,999,595) gets to
|
|
3253
|
+
// $237,934,782 — the $237.9M the reporter expected, an 11x difference from
|
|
3254
|
+
// the cash-only figure. Agents asking "Cash" are almost always asking a
|
|
3255
|
+
// runway/liquidity question, so when the resolved tag is cash-shaped, also
|
|
3256
|
+
// probe for marketable-securities tags at the SAME period_end and attach a
|
|
3257
|
+
// derived total_liquidity. Order matters: try the tag this filer is most
|
|
3258
|
+
// likely to use first (fewer wasted fetches); first one with any data wins.
|
|
3259
|
+
const CASH_LIKE_TAGS = new Set([
|
|
3260
|
+
'CashAndCashEquivalentsAtCarryingValue', 'Cash',
|
|
3261
|
+
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
|
|
3262
|
+
]);
|
|
3263
|
+
const MARKETABLE_SECURITIES_CURRENT_TAGS = [
|
|
3264
|
+
'AvailableForSaleSecuritiesDebtSecuritiesCurrent', 'MarketableSecuritiesCurrent', 'ShortTermInvestments',
|
|
3265
|
+
];
|
|
3266
|
+
const MARKETABLE_SECURITIES_NONCURRENT_TAGS = [
|
|
3267
|
+
'AvailableForSaleSecuritiesDebtSecuritiesNoncurrent', 'MarketableSecuritiesNoncurrent', 'LongTermInvestments',
|
|
3268
|
+
];
|
|
3269
|
+
|
|
3270
|
+
interface LiquidityBreakdown {
|
|
3271
|
+
value: number;
|
|
3272
|
+
derived: true;
|
|
3273
|
+
components: Record<string, number>;
|
|
3274
|
+
note: string;
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
/** Full time series for one us-gaap concept, keyed by period end date (the
|
|
3278
|
+
* freshest-filed fact per end date wins on duplicates). Null when the filer
|
|
3279
|
+
* has no data under this exact tag (a 404 or an empty series) — callers try
|
|
3280
|
+
* the next candidate tag rather than treating that as "no securities". */
|
|
3281
|
+
async function fetchConceptSeries(
|
|
3282
|
+
paddedCik: string,
|
|
3283
|
+
tag: string,
|
|
3284
|
+
): Promise<Map<string, { value: number; form: string; filed: string }> | null> {
|
|
3285
|
+
const r = await pwFetch(
|
|
3286
|
+
`${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/us-gaap/${encodeURIComponent(tag)}.json`,
|
|
3287
|
+
{ headers: SEC_HEADERS },
|
|
3288
|
+
).catch(() => null);
|
|
3289
|
+
if (!r || !r.ok) return null;
|
|
3290
|
+
const doc = (await r.json()) as {
|
|
3291
|
+
units?: Record<string, { end?: string; val: number; form: string; filed: string }[]>;
|
|
3292
|
+
};
|
|
3293
|
+
const map = new Map<string, { value: number; form: string; filed: string }>();
|
|
3294
|
+
for (const values of Object.values(doc.units ?? {})) {
|
|
3295
|
+
if (!Array.isArray(values)) continue;
|
|
3296
|
+
for (const e of values) {
|
|
3297
|
+
const key = e.end ?? '';
|
|
3298
|
+
if (!key) continue;
|
|
3299
|
+
const prior = map.get(key);
|
|
3300
|
+
if (!prior || (e.filed ?? '') > prior.filed) map.set(key, { value: e.val, form: e.form, filed: e.filed });
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return map.size ? map : null;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/** Try each candidate tag in order; return the first with any data. A filer
|
|
3307
|
+
* uses exactly one of these at a time but SWITCHES over its history (Capricor
|
|
3308
|
+
* reported MarketableSecuritiesCurrent through FY2025Q3, then switched to
|
|
3309
|
+
* AvailableForSaleSecuritiesDebtSecuritiesCurrent from FY2025Q4 on — verified
|
|
3310
|
+
* live), so trying only the newest-looking tag would miss older periods and
|
|
3311
|
+
* trying only one fixed tag would miss filers who never used it. */
|
|
3312
|
+
async function firstAvailableSeries(
|
|
3313
|
+
paddedCik: string,
|
|
3314
|
+
tags: string[],
|
|
3315
|
+
): Promise<{ tag: string; series: Map<string, { value: number; form: string; filed: string }> } | null> {
|
|
3316
|
+
for (const tag of tags) {
|
|
3317
|
+
const series = await fetchConceptSeries(paddedCik, tag);
|
|
3318
|
+
if (series) return { tag, series };
|
|
3319
|
+
}
|
|
3320
|
+
return null;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3109
3323
|
async function companyConcept(
|
|
3110
3324
|
cikOrTicker: string,
|
|
3111
3325
|
concept: string,
|
|
@@ -3380,6 +3594,7 @@ async function companyConcept(
|
|
|
3380
3594
|
unit,
|
|
3381
3595
|
derived: false as boolean | undefined,
|
|
3382
3596
|
note: undefined as string | undefined,
|
|
3597
|
+
total_liquidity: undefined as LiquidityBreakdown | undefined,
|
|
3383
3598
|
}))
|
|
3384
3599
|
.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
3385
3600
|
|
|
@@ -3428,6 +3643,7 @@ async function companyConcept(
|
|
|
3428
3643
|
unit: fyRow.unit,
|
|
3429
3644
|
derived: true,
|
|
3430
3645
|
note: 'Computed as FY minus Q1-Q3 — SEC filers do not report a standalone Q4 fact.',
|
|
3646
|
+
total_liquidity: undefined,
|
|
3431
3647
|
});
|
|
3432
3648
|
}
|
|
3433
3649
|
if (derivedQ4.length > 0) {
|
|
@@ -3447,6 +3663,78 @@ async function companyConcept(
|
|
|
3447
3663
|
entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
3448
3664
|
}
|
|
3449
3665
|
|
|
3666
|
+
// Derive STANDALONE quarters from YEAR-TO-DATE duration data (fleet #2879 /
|
|
3667
|
+
// feedback #137, item 3). Income-statement concepts (Revenue, NetIncomeLoss)
|
|
3668
|
+
// are usually filed quarter-only in a 10-Q and the dedup above already
|
|
3669
|
+
// prefers that discrete fact over a same-day YTD one. CASH-FLOW-STATEMENT
|
|
3670
|
+
// concepts (NetCashProvidedByUsedInOperatingActivities and siblings) have no
|
|
3671
|
+
// discrete-quarter fact at all — US GAAP presents the cash-flow statement
|
|
3672
|
+
// cumulatively, so a 10-Q's "Q2" figure IS six months, not two. Taking that
|
|
3673
|
+
// as a quarterly burn rate overstates it ~2x in Q2 and ~3x in Q3. Verified
|
|
3674
|
+
// live for Capricor (CIK 1133869) NetCashProvidedByUsedInOperatingActivities,
|
|
3675
|
+
// FY2026: Q1 (2026-01-01..03-31) = -$29,254,891; "Q2" (2026-01-01..06-30,
|
|
3676
|
+
// span 181 days — not the 80-100d a real quarter spans) = -$60,740,078. The
|
|
3677
|
+
// real Q2 burn is -$31,485,187 (Q2 YTD minus Q1), roughly double what the
|
|
3678
|
+
// filed Q2 figure alone would suggest if read as quarterly.
|
|
3679
|
+
//
|
|
3680
|
+
// Detected structurally (by span, not a hardcoded tag list, so it covers any
|
|
3681
|
+
// concept a filer reports this way): anchor on a genuinely quarter-length Q1
|
|
3682
|
+
// (span 80-100d), then look for a same-fiscal-year-start Q2/Q3 row whose span
|
|
3683
|
+
// is ~2x/~3x a quarter (170-190d / 260-290d) — i.e. YTD-shaped. The ORIGINAL
|
|
3684
|
+
// YTD-labeled row is kept as-filed; the derived standalone figure is added
|
|
3685
|
+
// alongside it with form:"derived", so a caller filtering on fiscal_period
|
|
3686
|
+
// sees both the as-filed YTD number and the true quarterly one, and nothing
|
|
3687
|
+
// is silently replaced.
|
|
3688
|
+
const standaloneQuarters: typeof entries = [];
|
|
3689
|
+
for (const q1 of entries) {
|
|
3690
|
+
if (q1.fiscal_period !== 'Q1' || q1.unit.includes('/')) continue;
|
|
3691
|
+
const q1Span = spanDays(q1.period_start, q1.period_end);
|
|
3692
|
+
if (!(q1Span >= 80 && q1Span <= 100)) continue; // Q1 YTD === Q1 quarter; this anchors the fiscal year's start date
|
|
3693
|
+
const q2ytd = entries.find((e) => {
|
|
3694
|
+
if (e.fiscal_period !== 'Q2' || e.unit !== q1.unit || e.period_start !== q1.period_start) return false;
|
|
3695
|
+
const s = spanDays(e.period_start, e.period_end);
|
|
3696
|
+
return s >= 170 && s <= 190;
|
|
3697
|
+
});
|
|
3698
|
+
if (!q2ytd) continue;
|
|
3699
|
+
standaloneQuarters.push({
|
|
3700
|
+
fiscal_year: q2ytd.fiscal_year,
|
|
3701
|
+
fiscal_period: 'Q2',
|
|
3702
|
+
period_start: q1.period_end,
|
|
3703
|
+
period_end: q2ytd.period_end,
|
|
3704
|
+
value: q2ytd.value - q1.value,
|
|
3705
|
+
form: 'derived',
|
|
3706
|
+
filed: q2ytd.filed,
|
|
3707
|
+
unit: q2ytd.unit,
|
|
3708
|
+
derived: true,
|
|
3709
|
+
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.`,
|
|
3710
|
+
total_liquidity: undefined,
|
|
3711
|
+
});
|
|
3712
|
+
const q3ytd = entries.find((e) => {
|
|
3713
|
+
if (e.fiscal_period !== 'Q3' || e.unit !== q1.unit || e.period_start !== q1.period_start) return false;
|
|
3714
|
+
const s = spanDays(e.period_start, e.period_end);
|
|
3715
|
+
return s >= 260 && s <= 290;
|
|
3716
|
+
});
|
|
3717
|
+
if (q3ytd) {
|
|
3718
|
+
standaloneQuarters.push({
|
|
3719
|
+
fiscal_year: q3ytd.fiscal_year,
|
|
3720
|
+
fiscal_period: 'Q3',
|
|
3721
|
+
period_start: q2ytd.period_end,
|
|
3722
|
+
period_end: q3ytd.period_end,
|
|
3723
|
+
value: q3ytd.value - q2ytd.value,
|
|
3724
|
+
form: 'derived',
|
|
3725
|
+
filed: q3ytd.filed,
|
|
3726
|
+
unit: q3ytd.unit,
|
|
3727
|
+
derived: true,
|
|
3728
|
+
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.`,
|
|
3729
|
+
total_liquidity: undefined,
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
if (standaloneQuarters.length > 0) {
|
|
3734
|
+
entries.push(...standaloneQuarters);
|
|
3735
|
+
entries.sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3450
3738
|
// PERIOD FILTER (fleet #2058). `fiscal_year` and `fiscal_period` are the
|
|
3451
3739
|
// names of two fields on every row below, and callers asking a one-period
|
|
3452
3740
|
// question ("net income fiscal 2024") send them as ARGUMENTS — measured on
|
|
@@ -3556,6 +3844,35 @@ async function companyConcept(
|
|
|
3556
3844
|
// must be the freshest fact WITHIN the period asked for, or the one field a
|
|
3557
3845
|
// synthesizer reaches for first would answer a different year than the
|
|
3558
3846
|
// question (fleet #2058).
|
|
3847
|
+
// LIQUIDITY ENRICHMENT (fleet #2879 / feedback #137, items 1-2). Only when
|
|
3848
|
+
// the resolved tag is cash-shaped — these securities tags don't apply to
|
|
3849
|
+
// Revenue, LongTermDebt, etc. Mutates the row objects in place (not a copy),
|
|
3850
|
+
// so `latestFact` below and `latest`/`values`/`annual_values` in the return
|
|
3851
|
+
// all pick up the same enrichment without threading it through separately.
|
|
3852
|
+
if (CASH_LIKE_TAGS.has(data.tag)) {
|
|
3853
|
+
const [curRes, noncurRes] = await Promise.all([
|
|
3854
|
+
firstAvailableSeries(paddedCik, MARKETABLE_SECURITIES_CURRENT_TAGS),
|
|
3855
|
+
firstAvailableSeries(paddedCik, MARKETABLE_SECURITIES_NONCURRENT_TAGS),
|
|
3856
|
+
]);
|
|
3857
|
+
if (curRes || noncurRes) {
|
|
3858
|
+
for (const row of rows) {
|
|
3859
|
+
if (!row.period_end) continue;
|
|
3860
|
+
const cur = curRes?.series.get(row.period_end);
|
|
3861
|
+
const noncur = noncurRes?.series.get(row.period_end);
|
|
3862
|
+
if (!cur && !noncur) continue; // no companion data for this exact period-end — don't imply $0 securities
|
|
3863
|
+
const components: Record<string, number> = { [`cash (${data.tag})`]: row.value };
|
|
3864
|
+
if (cur) components[`marketable_securities_current (${curRes!.tag})`] = cur.value;
|
|
3865
|
+
if (noncur) components[`marketable_securities_noncurrent (${noncurRes!.tag})`] = noncur.value;
|
|
3866
|
+
row.total_liquidity = {
|
|
3867
|
+
value: Object.values(components).reduce((s, v) => s + v, 0),
|
|
3868
|
+
derived: true,
|
|
3869
|
+
components,
|
|
3870
|
+
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.',
|
|
3871
|
+
};
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3559
3876
|
const latestFact = rows.find((e) => !e.derived) ?? null;
|
|
3560
3877
|
let coverageBound: string | undefined;
|
|
3561
3878
|
if (latestFact?.filed) {
|
|
@@ -4286,6 +4603,45 @@ function parseSubsidiaryTable(html: string): Subsidiary[] {
|
|
|
4286
4603
|
return out;
|
|
4287
4604
|
}
|
|
4288
4605
|
|
|
4606
|
+
// Used by sponsor_to_filer (#2971) to confirm a dominant full-text-mention
|
|
4607
|
+
// candidate as the sponsor's real listed parent via SEC's own authoritative
|
|
4608
|
+
// ownership disclosure, for the case where the subsidiary's name shares no
|
|
4609
|
+
// word with its parent's (Janssen / Johnson & Johnson). Walks the SAME path
|
|
4610
|
+
// as filer_to_sponsors (latest 10-K -> -index.html -> EX-21 -> parsed table)
|
|
4611
|
+
// but stops at the first subsidiary whose name shares a significant token
|
|
4612
|
+
// with the sponsor, rather than returning the whole list. One filing fetch +
|
|
4613
|
+
// one exhibit fetch, bounded by the caller's dominance gate so it only runs
|
|
4614
|
+
// against a serious candidate. Returns the matching subsidiary's own EX-21
|
|
4615
|
+
// name, or null (no 10-K / no EX-21 / didn't parse / sponsor not listed).
|
|
4616
|
+
async function exhibit21NamesSponsor(cik: string, sponsorName: string): Promise<string | null> {
|
|
4617
|
+
const paddedCik = padCik(cik);
|
|
4618
|
+
const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
4619
|
+
if (!subRes.ok) return null;
|
|
4620
|
+
const sub = (await subRes.json()) as {
|
|
4621
|
+
filings?: { recent?: { accessionNumber?: string[]; form?: string[] } };
|
|
4622
|
+
};
|
|
4623
|
+
const forms = sub.filings?.recent?.form ?? [];
|
|
4624
|
+
const accs = sub.filings?.recent?.accessionNumber ?? [];
|
|
4625
|
+
const idx10k = forms.findIndex((f) => f === '10-K' || f === '10-K/A');
|
|
4626
|
+
if (idx10k < 0) return null;
|
|
4627
|
+
const accession = accs[idx10k];
|
|
4628
|
+
if (!accession) return null;
|
|
4629
|
+
const accessionPath = accession.replace(/-/g, '');
|
|
4630
|
+
const cikNoZeros = String(Number(cik));
|
|
4631
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
|
|
4632
|
+
const idxRes = await pwFetch(`${folder}/${accession}-index.html`, { headers: SEC_HEADERS });
|
|
4633
|
+
if (!idxRes.ok) return null;
|
|
4634
|
+
const exUrl = findExhibit21Url(await idxRes.text());
|
|
4635
|
+
if (!exUrl) return null;
|
|
4636
|
+
const exRes = await pwFetch(exUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
4637
|
+
if (!exRes.ok) return null;
|
|
4638
|
+
const parsed = parseSubsidiaryTable(await exRes.text());
|
|
4639
|
+
const sig = new Set(significantTokens(sponsorName));
|
|
4640
|
+
if (!sig.size) return null;
|
|
4641
|
+
const hit = parsed.find((s) => significantTokens(s.name).some((t) => sig.has(t)));
|
|
4642
|
+
return hit ? hit.name : null;
|
|
4643
|
+
}
|
|
4644
|
+
|
|
4289
4645
|
async function filerToSponsors(
|
|
4290
4646
|
tickerOrCik: string | undefined,
|
|
4291
4647
|
nameFilterArg: string | undefined,
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-edgar', version: '0.3.
|
|
13
|
+
{ name: '@pipeworx/mcp-edgar', version: '0.3.9' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|