@pipeworx/mcp-edgar 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Securities and Exchange Commission's filings database. Every public company in the US files here, every filing is timestamped and immutable, and every disclosure (revenue, debt, risk factors, executive compensation, M&A activity, insider trading) is structured for machine retrieval. Free and authoritative.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1763+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1807+ 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 1763+ data sources. The
109
+ Both URLs reach the same gateway and the same 1807+ 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.8",
3
+ "version": "0.3.10",
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-9871b5732265c259f8fc2a821a7721a32960cabdc9041cc1d5ea4a1e0b8d3f37",
30
- "sourceCommit": "051ee2039fc96224cb5b7358fd69d525bced13a6"
29
+ "sourceHash": "v1-c6ffe46614ee14db9190ceb27485537bf8164b74f6f5768addd234dad2987dff",
30
+ "sourceCommit": "8dbe088fb6f9d2c88f934173742329b4e20a67d6"
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.8",
6
+ "version": "0.3.10",
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
@@ -699,6 +699,16 @@ const normalize = (s: string): string =>
699
699
  * exact (4) > prefix (3) > whole-word (2) > substring (1). Ties break by shortest
700
700
  * name — the primary entity (e.g. "Apple Inc." over "Apple Hospitality REIT").
701
701
  * Returns only items that match at all, best first. Pure (no I/O).
702
+ *
703
+ * `prefix` requires a WORD BOUNDARY (`n.startsWith(q + ' ')`), not a bare
704
+ * `startsWith`. A bare `startsWith` let "ZOOM" claim "ZOOMCAR HOLDINGS INC" at
705
+ * the SAME tier as "Apple" claiming "APPLE INC." — "Zoomcar" is a different
706
+ * word that happens to begin with the same letters, not the brand name plus a
707
+ * corporate-form suffix. That conflation is what sent resolve_entity("Zoom")
708
+ * to Zoomcar and resolve_entity("Audi") to AudioEye Inc (fleet #2776's
709
+ * mis-resolution fixture) — both now correctly fall through to the weaker
710
+ * `substring` tier instead of tying with a genuine word-boundary prefix hit
711
+ * like "Palantir" -> "Palantir Technologies Inc." (fleet #3100).
702
712
  */
703
713
  function rankMatches<T>(
704
714
  query: string,
@@ -714,7 +724,7 @@ function rankMatches<T>(
714
724
  let kind: MatchKind | null = null;
715
725
  let score = 0;
716
726
  if (n === q) { kind = 'exact'; score = 4; }
717
- else if (n.startsWith(q)) { kind = 'prefix'; score = 3; }
727
+ else if (n.startsWith(`${q} `)) { kind = 'prefix'; score = 3; }
718
728
  else if (n.includes(` ${q} `) || n.endsWith(` ${q}`)) { kind = 'word'; score = 2; }
719
729
  else if (n.includes(q)) { kind = 'substring'; score = 1; }
720
730
  if (kind) scored.push({ item, kind, score, len: name.length });
@@ -731,7 +741,15 @@ interface SecEntity {
731
741
  cik_padded: string;
732
742
  company_name: string;
733
743
  matched_by: 'ticker' | 'company_name';
734
- alternatives?: { ticker: string; company_name: string; cik: string }[];
744
+ /**
745
+ * The `rankMatches` kind that won, when `matched_by` is `'company_name'`
746
+ * (a ticker hit has no kind — it did not go through ranking at all). Lets a
747
+ * caller tell a word-boundary PREFIX hit ("Palantir" -> "Palantir
748
+ * Technologies Inc.") from a weaker `word`/`substring` guess, which
749
+ * `matched_by: 'company_name'` alone cannot (fleet #3100).
750
+ */
751
+ matched_kind?: MatchKind;
752
+ alternatives?: { ticker: string; company_name: string; cik: string; kind: MatchKind }[];
735
753
  }
736
754
 
737
755
  const SEC_TICKERS_URL = 'https://www.sec.gov/files/company_tickers.json';
@@ -767,10 +785,12 @@ async function resolveSecEntity(
767
785
  const ranked = rankMatches(query, rows, (r) => r.title);
768
786
  if (ranked.length) {
769
787
  const best = toEntity(ranked[0].item, 'company_name');
788
+ best.matched_kind = ranked[0].kind;
770
789
  const alts = ranked.slice(1, 4).map((m) => ({
771
790
  ticker: m.item.ticker,
772
791
  company_name: m.item.title,
773
792
  cik: String(m.item.cik_str),
793
+ kind: m.kind,
774
794
  }));
775
795
  if (alts.length) best.alternatives = alts;
776
796
  return best;
@@ -1854,6 +1874,23 @@ function significantTokens(name: string): string[] {
1854
1874
  return normName(name).split(' ').filter((t) => t.length > 1 && !GENERIC_NAME_TOKENS.has(t));
1855
1875
  }
1856
1876
 
1877
+ // 2026-10-09: shared/src/resolve.ts's rankMatches only strips '.' and ',', so
1878
+ // "BRISTOL-MYERS SQUIBB COMPANY" (hyphen kept, full "COMPANY") never matched
1879
+ // SEC's own listed title "BRISTOL MYERS SQUIBB CO" — resolved via full-text
1880
+ // fallback as no_us_registrant instead of the direct CIK 14272 hit. Do not fix
1881
+ // this in shared (used by other packs); derive a second, edgar-only candidate
1882
+ // query that also hits the hyphen (normName already strips it) and the SEC
1883
+ // house style for a trailing corporate suffix.
1884
+ const SUFFIX_TO_SEC_STYLE: Record<string, string> = {
1885
+ COMPANY: 'CO', CORPORATION: 'CORP', INCORPORATED: 'INC', LIMITED: 'LTD',
1886
+ };
1887
+ function secStyleQuery(name: string): string {
1888
+ const tokens = normName(name).split(' ').filter(Boolean);
1889
+ const last = tokens[tokens.length - 1];
1890
+ if (last && SUFFIX_TO_SEC_STYLE[last]) tokens[tokens.length - 1] = SUFFIX_TO_SEC_STYLE[last];
1891
+ return tokens.join(' ');
1892
+ }
1893
+
1857
1894
  function distinctiveToken(name: string): string {
1858
1895
  return significantTokens(name)[0] ?? normName(name).split(' ')[0] ?? '';
1859
1896
  }
@@ -1861,8 +1898,14 @@ function distinctiveToken(name: string): string {
1861
1898
  // SEC company names use "&", not "and". Produce query variants: &-normalized
1862
1899
  // full name first, then progressively drop trailing generic words (browse-edgar
1863
1900
  // matches on a name prefix and a too-specific full name can return nothing).
1901
+ // 2026-10-13: browse-edgar's `company=` param is a literal prefix match and
1902
+ // does NOT fold punctuation itself -- company=Bristol-Myers%20Squibb returns
1903
+ // 0 candidates while company=Bristol%20Myers%20Squibb returns CIK 14272 (the
1904
+ // filer's own listed title has no hyphen). Fold -/() to spaces before
1905
+ // querying, same characters normName() already strips for the ranking path,
1906
+ // so every query we send is already in SEC's unpunctuated house style.
1864
1907
  function secQueryVariants(name: string): string[] {
1865
- const amp = name.replace(/\band\b/gi, '&').replace(/\s+/g, ' ').trim();
1908
+ const amp = name.replace(/\band\b/gi, '&').replace(/[-/()]/g, ' ').replace(/\s+/g, ' ').trim();
1866
1909
  const out = [amp];
1867
1910
  const words = amp.split(' ');
1868
1911
  for (let end = words.length; end > 1; end--) {
@@ -1880,28 +1923,99 @@ async function fetchCompanyTickers(): Promise<SecTickerRow[]> {
1880
1923
  return Object.values(data);
1881
1924
  }
1882
1925
 
1926
+ // Legal-form designators. A filer name carrying none of these (and no
1927
+ // descriptor word like PHARMACEUTICA / PRODUCTS / PARTNERS) in a 2-4 word
1928
+ // "Last First M" shape is an individual Form 3/4/5 reporting owner, never a
1929
+ // company's own CIK. #2998: "JANSSEN PHARMACEUTICALS INC" fell back to the
1930
+ // bare "JANSSEN" query, whose browse-edgar list is alphabetical by person
1931
+ // name, and took "Janssen Angela K" (CIK 1664284) as J&J's subsidiary.
1932
+ const LEGAL_FORM_TOKENS = new Set([
1933
+ 'INC', 'CORP', 'CO', 'COMPANY', 'CORPORATION', 'INCORPORATED', 'LTD', 'LIMITED',
1934
+ 'LLC', 'LP', 'LLP', 'PLC', 'SA', 'AG', 'NV', 'BV', 'GMBH', 'AB', 'AS', 'OY', 'SPA',
1935
+ 'SE', 'KK', 'PTE', 'PTY', 'SARL', 'SAS', 'TRUST', 'FUND', 'PARTNERSHIP',
1936
+ ]);
1937
+ 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/;
1938
+
1939
+ /** Person-shaped filer name ("Janssen Angela K", "JANSSEN MICHAEL J"). */
1940
+ function isPersonShapedName(name: string): boolean {
1941
+ const n = name.toUpperCase().replace(/\./g, '').replace(/[,&/()-]/g, ' ').replace(/\s+/g, ' ').trim();
1942
+ const toks = n.split(' ').filter(Boolean);
1943
+ if (toks.length < 2 || toks.length > 4) return false;
1944
+ if (toks.some((t) => LEGAL_FORM_TOKENS.has(t) || /\d/.test(t))) return false;
1945
+ return !COMPANY_WORDS.test(n);
1946
+ }
1947
+
1948
+ const STOP_TOKENS = new Set(['THE', 'OF', 'AND']);
1949
+
1950
+ // Two tokens agree when equal, or one is a >=6-char prefix of the other
1951
+ // ("PHARMACEUTICA" ~ "PHARMACEUTICALS").
1952
+ const tokAgree = (a: string, b: string): boolean =>
1953
+ a === b || (Math.min(a.length, b.length) >= 6 && (a.startsWith(b) || b.startsWith(a)));
1954
+
1955
+ /** Similarity of a candidate filer name to the FULL sponsor query, ignoring
1956
+ * legal-form suffixes (INC matching INC says nothing). Significant tokens
1957
+ * weigh 10, descriptor words (PHARMACEUTICALS, BIOTECH...) weigh 1. */
1958
+ function nameSimilarity(query: string, candidate: string): { sig: number; desc: number; score: number } {
1959
+ const keep = (t: string) => t.length > 1 && !LEGAL_FORM_TOKENS.has(t) && !STOP_TOKENS.has(t);
1960
+ const qAll = normName(query).split(' ').filter(keep);
1961
+ const cAll = normName(candidate).split(' ').filter(keep);
1962
+ const sigSet = new Set(significantTokens(query));
1963
+ let sig = 0, desc = 0;
1964
+ for (const q of qAll) {
1965
+ if (!cAll.some((c) => tokAgree(q, c))) continue;
1966
+ if (sigSet.has(q)) sig++; else desc++;
1967
+ }
1968
+ return { sig, desc, score: sig * 10 + desc };
1969
+ }
1970
+
1971
+ const decodeHtml = (s: string): string =>
1972
+ s.replace(/&amp;/g, '&').replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
1973
+
1974
+ /** browse-edgar HTML company search -> [{cik, name}]. The atom output carries
1975
+ * no names (SEC renders them as "ARRAY(0x...)"), so a ranking by name needs
1976
+ * the HTML list; an exact single hit renders the company page instead. */
1977
+ function parseBrowseEdgarCompanies(html: string): { cik: string; name: string }[] {
1978
+ const out: { cik: string; name: string }[] = [];
1979
+ const single = html.match(/<span class="companyName">([\s\S]*?)<acronym[^>]*>CIK<\/acronym>#:\s*<a[^>]*>(\d+)/);
1980
+ if (single) return [{ cik: String(Number(single[2])), name: decodeHtml(single[1]).trim() }];
1981
+ for (const m of html.matchAll(/CIK=(\d+)&amp;owner=include[^"]*">\d+<\/a><\/td>\s*<td scope="row">([\s\S]*?)<\/td>/g)) {
1982
+ out.push({ cik: String(Number(m[1])), name: decodeHtml(m[2].split(/<br/i)[0]).trim() });
1983
+ }
1984
+ return out;
1985
+ }
1986
+
1883
1987
  /** 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)? browse-edgar
1885
- * orders by relevance so the leading <cik> tags are the closest matches; each
1886
- * candidate is validated against its real name from the submissions API
1887
- * (which doubles as the entity profile). Returns {cik, profile} or null. */
1988
+ * subsidiary, or a private co that filed a Form D / draft S-1)? Candidates
1989
+ * come from browse-edgar's company search (count=40 -- its order is
1990
+ * alphabetical, not relevance, so the first hits of a short query are often
1991
+ * people); individual reporting owners are dropped, the rest ranked by
1992
+ * similarity to the FULL sponsor name, and the best are validated against
1993
+ * their real name from the submissions API (which doubles as the entity
1994
+ * profile). When the query carries descriptor words beyond its one
1995
+ * distinctive token ("JANSSEN PHARMACEUTICALS"), a candidate must agree on
1996
+ * at least one of them. Returns {cik, profile} or null. */
1888
1997
  async function edgarOwnCik(sponsor: string): Promise<{ cik: string; profile: EntityProfile } | null> {
1889
1998
  const sig = significantTokens(sponsor);
1890
1999
  const need = Math.min(2, sig.length || 1);
2000
+ const queryDesc = nameSimilarity(sponsor, sponsor).desc;
2001
+ const needDesc = sig.length <= 1 && queryDesc > 0 ? 1 : 0;
2002
+ const passes = (s: { sig: number; desc: number }) => s.sig >= need && s.desc >= needDesc;
1891
2003
  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=10&output=atom`;
2004
+ const url = `https://www.sec.gov/cgi-bin/browse-edgar?company=${encodeURIComponent(q)}&action=getcompany&type=&dateb=&owner=include&count=40`;
1893
2005
  const res = await pwFetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } }).catch(() => null);
1894
2006
  if (!res || !res.ok) continue;
1895
- const xml = await res.text();
1896
- const ciks = [...xml.matchAll(/<cik>(\d+)<\/cik>/gi)].map((m) => String(Number(m[1])));
1897
- for (const cik of [...new Set(ciks)].slice(0, 3)) {
1898
- const profile = await edgarEntityProfile(cik);
1899
- if (!profile?.name) continue;
1900
- const nameToks = new Set(significantTokens(profile.name));
1901
- const overlap = sig.filter((t) => nameToks.has(t)).length;
1902
- if (overlap >= need) return { cik, profile };
2007
+ const found = parseBrowseEdgarCompanies(await res.text());
2008
+ const ranked = found
2009
+ .filter((c) => !isPersonShapedName(c.name))
2010
+ .map((c, i) => ({ ...c, i, sim: nameSimilarity(sponsor, c.name) }))
2011
+ .filter((c) => passes(c.sim))
2012
+ .sort((a, b) => b.sim.score - a.sim.score || a.i - b.i);
2013
+ for (const c of ranked.slice(0, 3)) {
2014
+ const profile = await edgarEntityProfile(c.cik);
2015
+ if (!profile?.name || isPersonShapedName(profile.name)) continue;
2016
+ if (passes(nameSimilarity(sponsor, profile.name))) return { cik: c.cik, profile };
1903
2017
  }
1904
- if (ciks.length) break; // first variant that returned candidates is authoritative
2018
+ if (found.length) break; // first variant that returned candidates is authoritative
1905
2019
  }
1906
2020
  return null;
1907
2021
  }
@@ -1972,16 +2086,24 @@ async function sponsorToFiler(sponsor: string): Promise<unknown> {
1972
2086
  const tickers = await fetchCompanyTickers();
1973
2087
 
1974
2088
  // 1) Direct listed match (exact / prefix / whole-word — not loose substring).
2089
+ // Also rank a hyphen-normalized, suffix-normalized form ("Bristol-Myers
2090
+ // Squibb Company" -> "BRISTOL MYERS SQUIBB CO") and keep whichever scores
2091
+ // higher, so inputs that already match keep behaving exactly as before.
1975
2092
  const ranked = rankMatches(name, tickers, (r) => r.title);
1976
- if (ranked.length && ranked[0].score >= 2) {
1977
- const m = ranked[0].item;
2093
+ const secStyled = secStyleQuery(name);
2094
+ const rankedSecStyle = secStyled === name.toUpperCase() ? [] : rankMatches(secStyled, tickers, (r) => r.title);
2095
+ const best = rankedSecStyle.length && (!ranked.length || rankedSecStyle[0].score > ranked[0].score)
2096
+ ? rankedSecStyle
2097
+ : ranked;
2098
+ if (best.length && best[0].score >= 2) {
2099
+ const m = best[0].item;
1978
2100
  return {
1979
2101
  status: 'resolved',
1980
2102
  method: 'direct',
1981
- confidence: ranked[0].score >= 3 ? 'high' : 'medium',
2103
+ confidence: best[0].score >= 3 ? 'high' : 'medium',
1982
2104
  sponsor: name,
1983
2105
  filer: filerOut(m),
1984
- alternatives: ranked.slice(1, 4).map((x) => filerOut(x.item)),
2106
+ alternatives: best.slice(1, 4).map((x) => filerOut(x.item)),
1985
2107
  };
1986
2108
  }
1987
2109
 
@@ -1994,24 +2116,59 @@ async function sponsorToFiler(sponsor: string): Promise<unknown> {
1994
2116
  }
1995
2117
  }
1996
2118
 
1997
- // 3) Full-text: listed filers referencing the sponsor; confirm a parent by
1998
- // requiring the sponsor's distinctive token in the filer's name (kills
1999
- // "merely mentioned by an unrelated filer" false positives).
2119
+ // 3) Full-text: listed filers referencing the sponsor; confirm a parent.
2120
+ // Rule A (shared_token_fulltext): the sponsor's distinctive token appears
2121
+ // in the candidate's OWN name (kills "merely mentioned by an unrelated
2122
+ // filer" false positives) — catches e.g. "Capricor Inc." -> "Capricor
2123
+ // Therapeutics". This does NOT catch a brand subsidiary whose name shares
2124
+ // no word with its parent ("Janssen" / "Johnson & Johnson", "Merck Sharp
2125
+ // and Dohme" is the one case that happens to share "Merck") — for that we
2126
+ // never promote on mention-count alone (a press/litigation mention is not
2127
+ // ownership); we ask SEC's own authoritative source instead: does the
2128
+ // dominant candidate's latest 10-K Exhibit 21 subsidiary list (Item
2129
+ // 601(b)(21)) name the sponsor? Rule B (ex21_subsidiary_match) fires only
2130
+ // then, and only against the single dominant candidate, so a genuine miss
2131
+ // never pays for an extra filing+exhibit fetch. #2971.
2000
2132
  const ft = await fullTextListedFilers(name).catch(() => ({ candidates: [] as ListedFiler[], total: 0 }));
2001
- const confirmed = ft.candidates.find((c) => normName(c.name).split(' ').includes(disc));
2133
+ let confirmed = ft.candidates.find((c) => normName(c.name).split(' ').includes(disc));
2134
+ let rule: 'shared_token_fulltext' | 'ex21_subsidiary_match' | null = confirmed ? 'shared_token_fulltext' : null;
2135
+ let ex21Match: string | null = null;
2136
+
2137
+ if (!confirmed && ft.candidates.length) {
2138
+ const top = ft.candidates[0];
2139
+ const second = ft.candidates[1];
2140
+ // Dominance gate before spending the EX-21 fetch: top mention count must
2141
+ // be meaningful on its own AND clearly ahead of the runner-up.
2142
+ const dominant = top.hits >= 5 && (!second || top.hits >= 3 * second.hits);
2143
+ if (dominant) {
2144
+ ex21Match = await exhibit21NamesSponsor(top.cik, name).catch(() => null);
2145
+ if (ex21Match) {
2146
+ confirmed = top;
2147
+ rule = 'ex21_subsidiary_match';
2148
+ }
2149
+ }
2150
+ }
2151
+
2002
2152
  if (confirmed) {
2153
+ const directMatch = own ? { cik: own.cik, name: own.profile?.name ?? null, listed: false } : null;
2003
2154
  return {
2004
2155
  status: 'resolved_via_parent',
2005
- method: 'subsidiary_fulltext',
2156
+ method: rule === 'ex21_subsidiary_match' ? 'subsidiary_exhibit21' : 'subsidiary_fulltext',
2157
+ rule,
2006
2158
  confidence: confirmed.hits >= 10 ? 'high' : 'medium',
2007
2159
  sponsor: name,
2008
2160
  filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
2161
+ parent_filer: { company_name: confirmed.name, ticker: confirmed.ticker, cik: confirmed.cik, cik_padded: confirmed.cik.padStart(10, '0') },
2162
+ direct_match: directMatch,
2009
2163
  evidence: {
2010
2164
  parent_mention_filings: confirmed.hits,
2011
2165
  distinctive_token: disc,
2012
2166
  own_subsidiary_cik: own?.cik ?? null,
2013
2167
  own_subsidiary_name: own?.profile?.name ?? null,
2014
- note: '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.',
2168
+ exhibit_21_subsidiary_name: ex21Match,
2169
+ note: rule === 'ex21_subsidiary_match'
2170
+ ? `"${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.`
2171
+ : '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
2172
  },
2016
2173
  alternatives: ft.candidates.slice(0, 3).map((c) => ({ company_name: c.name, ticker: c.ticker, cik: c.cik, mention_filings: c.hits })),
2017
2174
  };
@@ -4466,6 +4623,45 @@ function parseSubsidiaryTable(html: string): Subsidiary[] {
4466
4623
  return out;
4467
4624
  }
4468
4625
 
4626
+ // Used by sponsor_to_filer (#2971) to confirm a dominant full-text-mention
4627
+ // candidate as the sponsor's real listed parent via SEC's own authoritative
4628
+ // ownership disclosure, for the case where the subsidiary's name shares no
4629
+ // word with its parent's (Janssen / Johnson & Johnson). Walks the SAME path
4630
+ // as filer_to_sponsors (latest 10-K -> -index.html -> EX-21 -> parsed table)
4631
+ // but stops at the first subsidiary whose name shares a significant token
4632
+ // with the sponsor, rather than returning the whole list. One filing fetch +
4633
+ // one exhibit fetch, bounded by the caller's dominance gate so it only runs
4634
+ // against a serious candidate. Returns the matching subsidiary's own EX-21
4635
+ // name, or null (no 10-K / no EX-21 / didn't parse / sponsor not listed).
4636
+ async function exhibit21NamesSponsor(cik: string, sponsorName: string): Promise<string | null> {
4637
+ const paddedCik = padCik(cik);
4638
+ const subRes = await pwFetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
4639
+ if (!subRes.ok) return null;
4640
+ const sub = (await subRes.json()) as {
4641
+ filings?: { recent?: { accessionNumber?: string[]; form?: string[] } };
4642
+ };
4643
+ const forms = sub.filings?.recent?.form ?? [];
4644
+ const accs = sub.filings?.recent?.accessionNumber ?? [];
4645
+ const idx10k = forms.findIndex((f) => f === '10-K' || f === '10-K/A');
4646
+ if (idx10k < 0) return null;
4647
+ const accession = accs[idx10k];
4648
+ if (!accession) return null;
4649
+ const accessionPath = accession.replace(/-/g, '');
4650
+ const cikNoZeros = String(Number(cik));
4651
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${accessionPath}`;
4652
+ const idxRes = await pwFetch(`${folder}/${accession}-index.html`, { headers: SEC_HEADERS });
4653
+ if (!idxRes.ok) return null;
4654
+ const exUrl = findExhibit21Url(await idxRes.text());
4655
+ if (!exUrl) return null;
4656
+ const exRes = await pwFetch(exUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
4657
+ if (!exRes.ok) return null;
4658
+ const parsed = parseSubsidiaryTable(await exRes.text());
4659
+ const sig = new Set(significantTokens(sponsorName));
4660
+ if (!sig.size) return null;
4661
+ const hit = parsed.find((s) => significantTokens(s.name).some((t) => sig.has(t)));
4662
+ return hit ? hit.name : null;
4663
+ }
4664
+
4469
4665
  async function filerToSponsors(
4470
4666
  tickerOrCik: string | undefined,
4471
4667
  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.8' },
13
+ { name: '@pipeworx/mcp-edgar', version: '0.3.10' },
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 result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
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 {