@pipeworx/mcp-edgar 0.2.0 → 0.3.0

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
  EDGAR MCP — SEC EDGAR public APIs (free, no auth)
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 881+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 884+ live data sources.
6
6
 
7
7
  ## Tools
8
8
 
@@ -23,7 +23,7 @@ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
23
23
  }
24
24
  ```
25
25
 
26
- Or connect to the full Pipeworx gateway for access to all 881+ data sources:
26
+ Or connect to the full Pipeworx gateway for access to all 884+ data sources:
27
27
 
28
28
  ```json
29
29
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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.2.0",
6
+ "version": "0.3.0",
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
@@ -148,6 +148,19 @@ const tools: McpToolExport['tools'] = [
148
148
  required: ['ticker_or_cik'],
149
149
  },
150
150
  },
151
+ {
152
+ name: 'edgar_fund_holdings',
153
+ description:
154
+ "AUTHORITATIVE portfolio holdings of a US ETF or mutual fund (SEC Form N-PORT) — what the fund actually owns. Pass the FUND's ticker (e.g. \"ARKK\", \"QQQ\", \"VTI\", \"VOO\", \"IVV\"). Returns the latest monthly portfolio: net assets, holdings count, and top positions by weight — each with name, CUSIP, value (USD), and % of fund. Use for \"what does ARKK hold\", \"top holdings of QQQ\", \"is $STOCK in VTI\". Distinct from edgar_institutional_holdings (13F = what an investment MANAGER like Berkshire owns); this is a registered fund's own N-PORT. Covers US-registered open-end funds + ETFs; data is ~30-60 days delayed. Note: a few legacy ETFs structured as unit investment trusts (e.g. SPY, DIA) don't file N-PORT and won't resolve — use IVV or VOO for S&P 500 exposure.",
155
+ inputSchema: {
156
+ type: 'object' as const,
157
+ properties: {
158
+ ticker: { type: 'string', description: 'ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
159
+ limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
160
+ },
161
+ required: ['ticker'],
162
+ },
163
+ },
151
164
  {
152
165
  name: 'edgar_ticker_to_cik',
153
166
  description:
@@ -834,6 +847,105 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
834
847
  };
835
848
  }
836
849
 
850
+ // ── Fund / ETF holdings (Form N-PORT) ───────────────────────────────
851
+
852
+ function decodeEntities(s: string | null): string | null {
853
+ if (s == null) return null;
854
+ return s
855
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
856
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'");
857
+ }
858
+
859
+ // ETF/mutual-fund ticker → {cik, seriesId}. The MF ticker file (~28k rows) is
860
+ // separate from company_tickers.json; cache it in-isolate after first fetch.
861
+ let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
862
+ async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
863
+ if (!mfTickerCache) {
864
+ const res = await fetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
865
+ if (!res.ok) throw new Error(`SEC fund-ticker lookup error: ${res.status}`);
866
+ const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
867
+ // fields: [cik, seriesId, classId, symbol]
868
+ mfTickerCache = new Map();
869
+ for (const [cik, seriesId, , symbol] of data.data) {
870
+ if (symbol && !mfTickerCache.has(symbol)) mfTickerCache.set(symbol, { cik: String(cik), seriesId });
871
+ }
872
+ }
873
+ return mfTickerCache.get(ticker.toUpperCase().trim()) ?? null;
874
+ }
875
+
876
+ async function fundHoldings(ticker: string, limit?: number) {
877
+ const t = String(ticker ?? '').trim();
878
+ if (!t) throw new Error('Required argument "ticker" is missing. Pass an ETF/fund ticker like "ARKK" or "SPY".');
879
+ const topN = Math.min(100, Math.max(1, limit ?? 25));
880
+
881
+ const resolved = await resolveFundTicker(t);
882
+ if (!resolved) {
883
+ return { ticker: t.toUpperCase(), error: 'not_found', message: `"${t}" is not a known N-PORT-filing fund/ETF ticker. Use a fund ticker like "ARKK", "QQQ", "VTI", or "VOO". (Some legacy ETFs structured as unit investment trusts — SPY, DIA — don't file N-PORT; use IVV or VOO for S&P 500.)` };
884
+ }
885
+ const { cik, seriesId } = resolved;
886
+ const paddedCik = padCik(cik);
887
+
888
+ const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
889
+ if (!subRes.ok) throw new Error(`SEC submissions error: ${subRes.status}`);
890
+ const sub = (await subRes.json()) as {
891
+ cik: string; name: string;
892
+ filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
893
+ };
894
+ const r = sub.filings.recent;
895
+ const nports: { accession: string; date: string }[] = [];
896
+ for (let i = 0; i < r.form.length && nports.length < 12; i++) {
897
+ if (r.form[i] === 'NPORT-P') nports.push({ accession: r.accessionNumber[i], date: r.filingDate[i] });
898
+ }
899
+ if (nports.length === 0) {
900
+ return { ticker: t.toUpperCase(), cik, error: 'no_nport', message: `No N-PORT filings found for ${sub.name}.` };
901
+ }
902
+
903
+ // A trust files one N-PORT per series; fetch recent candidates in parallel and
904
+ // match the series. Newest-first order means .find() returns the latest match.
905
+ const candidates = await Promise.all(
906
+ nports.map(async (f) => {
907
+ const accPath = f.accession.replace(/-/g, '');
908
+ const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
909
+ try {
910
+ const res = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
911
+ if (!res.ok) return null;
912
+ return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
913
+ } catch {
914
+ return null;
915
+ }
916
+ }),
917
+ );
918
+ const match = candidates.find((c) => c && xmlVal(c.xml, 'seriesId') === seriesId);
919
+ if (!match) {
920
+ return { ticker: t.toUpperCase(), cik, series_id: seriesId, error: 'series_not_matched', message: `Found N-PORT filings for ${sub.name} but none of the ${nports.length} most recent matched series ${seriesId}.` };
921
+ }
922
+
923
+ const xml = match.xml;
924
+ const holdings = xmlBlocks(xml, 'invstOrSec')
925
+ .map((h) => ({
926
+ name: decodeEntities(xmlVal(h, 'name')),
927
+ title: decodeEntities(xmlVal(h, 'title')),
928
+ cusip: xmlVal(h, 'cusip'),
929
+ balance: numOrNull(xmlVal(h, 'balance')),
930
+ units: xmlVal(h, 'units'),
931
+ value_usd: numOrNull(xmlVal(h, 'valUSD')),
932
+ pct_of_fund: numOrNull(xmlVal(h, 'pctVal')),
933
+ }))
934
+ .sort((a, b) => (b.pct_of_fund ?? 0) - (a.pct_of_fund ?? 0));
935
+
936
+ return {
937
+ ticker: t.toUpperCase(),
938
+ fund_name: xmlVal(xml, 'seriesName'),
939
+ series_id: seriesId,
940
+ cik,
941
+ report_period_end: xmlVal(xml, 'repPdDate') ?? match.date,
942
+ net_assets_usd: numOrNull(xmlVal(xml, 'netAssets')),
943
+ total_holdings: holdings.length,
944
+ holdings_returned: Math.min(holdings.length, topN),
945
+ holdings: holdings.slice(0, topN),
946
+ };
947
+ }
948
+
837
949
  // ── callTool router ─────────────────────────────────────────────────
838
950
 
839
951
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -865,6 +977,8 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
865
977
  args.ticker_or_cik as string,
866
978
  args.limit as number | undefined,
867
979
  );
980
+ case 'edgar_fund_holdings':
981
+ return fundHoldings(args.ticker as string, args.limit as number | undefined);
868
982
  case 'edgar_company_concept':
869
983
  return companyConcept(args.cik as string, args.concept as string);
870
984
  case 'edgar_ticker_to_cik':