@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 +2 -2
- package/package.json +1 -1
- package/server.json +1 -1
- package/src/index.ts +114 -0
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
|
|
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
|
|
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
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.
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
856
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/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':
|