@pipeworx/mcp-edgar 0.2.0 → 0.3.1
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 +589 -45
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 1337+ 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 1337+ 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.1",
|
|
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
|
@@ -16,6 +16,124 @@ interface McpToolExport {
|
|
|
16
16
|
provider?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
|
|
20
|
+
// imports — so publish-pack.sh can inline it into standalone pack builds the same
|
|
21
|
+
// way it inlines the McpToolExport type.
|
|
22
|
+
//
|
|
23
|
+
// Recurring failure mode across financial packs: callers pass a company NAME
|
|
24
|
+
// ("Apple", "apple inc") where a ticker / CIK / provider symbol is expected, and
|
|
25
|
+
// the pack 404s or throws "not found". `rankMatches` is a generic name-ranker any
|
|
26
|
+
// pack can run over its OWN list (US tickers, B3 tickers, drug names, airports…);
|
|
27
|
+
// `resolveSecEntity` wraps it around the SEC company_tickers.json universe, shared
|
|
28
|
+
// by the packs that key on CIK (edgar, sec).
|
|
29
|
+
|
|
30
|
+
type MatchKind = 'exact' | 'prefix' | 'word' | 'substring';
|
|
31
|
+
|
|
32
|
+
interface RankedMatch<T> {
|
|
33
|
+
item: T;
|
|
34
|
+
kind: MatchKind;
|
|
35
|
+
score: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalize = (s: string): string =>
|
|
39
|
+
s.toUpperCase().replace(/[.,]/g, '').replace(/\s+/g, ' ').trim();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rank `items` by how well their name matches `query`:
|
|
43
|
+
* exact (4) > prefix (3) > whole-word (2) > substring (1). Ties break by shortest
|
|
44
|
+
* name — the primary entity (e.g. "Apple Inc." over "Apple Hospitality REIT").
|
|
45
|
+
* Returns only items that match at all, best first. Pure (no I/O).
|
|
46
|
+
*/
|
|
47
|
+
function rankMatches<T>(
|
|
48
|
+
query: string,
|
|
49
|
+
items: T[],
|
|
50
|
+
getName: (item: T) => string,
|
|
51
|
+
): RankedMatch<T>[] {
|
|
52
|
+
const q = normalize(query);
|
|
53
|
+
if (!q) return [];
|
|
54
|
+
const scored: { item: T; kind: MatchKind; score: number; len: number }[] = [];
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
const name = getName(item);
|
|
57
|
+
const n = normalize(name);
|
|
58
|
+
let kind: MatchKind | null = null;
|
|
59
|
+
let score = 0;
|
|
60
|
+
if (n === q) { kind = 'exact'; score = 4; }
|
|
61
|
+
else if (n.startsWith(q)) { kind = 'prefix'; score = 3; }
|
|
62
|
+
else if (n.includes(` ${q} `) || n.endsWith(` ${q}`)) { kind = 'word'; score = 2; }
|
|
63
|
+
else if (n.includes(q)) { kind = 'substring'; score = 1; }
|
|
64
|
+
if (kind) scored.push({ item, kind, score, len: name.length });
|
|
65
|
+
}
|
|
66
|
+
scored.sort((a, b) => b.score - a.score || a.len - b.len);
|
|
67
|
+
return scored.map(({ item, kind, score }) => ({ item, kind, score }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface SecTickerRow { cik_str: number; ticker: string; title: string }
|
|
71
|
+
|
|
72
|
+
interface SecEntity {
|
|
73
|
+
ticker: string;
|
|
74
|
+
cik: string;
|
|
75
|
+
cik_padded: string;
|
|
76
|
+
company_name: string;
|
|
77
|
+
matched_by: 'ticker' | 'company_name';
|
|
78
|
+
alternatives?: { ticker: string; company_name: string; cik: string }[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SEC_TICKERS_URL = 'https://www.sec.gov/files/company_tickers.json';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a ticker OR company name to its SEC identity (CIK + canonical name).
|
|
85
|
+
* Exact ticker first (the common, unambiguous case), then fuzzy company-name
|
|
86
|
+
* fallback so "Apple" / "APPLE" → AAPL's CIK. Throws if nothing matches.
|
|
87
|
+
*
|
|
88
|
+
* `headers` lets callers pass their pack's SEC User-Agent — www.sec.gov requires
|
|
89
|
+
* a UA. `fetchImpl` defaults to global fetch (override in tests).
|
|
90
|
+
*/
|
|
91
|
+
async function resolveSecEntity(
|
|
92
|
+
query: string,
|
|
93
|
+
opts: { fetchImpl?: typeof fetch; headers?: Record<string, string> } = {},
|
|
94
|
+
): Promise<SecEntity> {
|
|
95
|
+
if (typeof query !== 'string' || !query.trim()) {
|
|
96
|
+
throw new Error('Required argument is missing or empty. Pass a ticker like "AAPL" or a company name like "Apple".');
|
|
97
|
+
}
|
|
98
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
99
|
+
const res = await doFetch(SEC_TICKERS_URL, { headers: opts.headers });
|
|
100
|
+
if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
|
|
101
|
+
const data = (await res.json()) as Record<string, SecTickerRow>;
|
|
102
|
+
const rows = Object.values(data);
|
|
103
|
+
|
|
104
|
+
// 1) Exact ticker match — the common, unambiguous case.
|
|
105
|
+
const q = query.toUpperCase().trim();
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
if (r.ticker === q) return toEntity(r, 'ticker');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2) Company-name fallback.
|
|
111
|
+
const ranked = rankMatches(query, rows, (r) => r.title);
|
|
112
|
+
if (ranked.length) {
|
|
113
|
+
const best = toEntity(ranked[0].item, 'company_name');
|
|
114
|
+
const alts = ranked.slice(1, 4).map((m) => ({
|
|
115
|
+
ticker: m.item.ticker,
|
|
116
|
+
company_name: m.item.title,
|
|
117
|
+
cik: String(m.item.cik_str),
|
|
118
|
+
}));
|
|
119
|
+
if (alts.length) best.alternatives = alts;
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error(`No SEC company matches "${query}". Pass a ticker like "AAPL" or a company name like "Apple Inc.".`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toEntity(r: SecTickerRow, matched_by: 'ticker' | 'company_name'): SecEntity {
|
|
127
|
+
return {
|
|
128
|
+
ticker: r.ticker,
|
|
129
|
+
cik: String(r.cik_str),
|
|
130
|
+
cik_padded: String(r.cik_str).padStart(10, '0'),
|
|
131
|
+
company_name: r.title,
|
|
132
|
+
matched_by,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
19
137
|
/**
|
|
20
138
|
* EDGAR MCP — SEC EDGAR public APIs (free, no auth)
|
|
21
139
|
*
|
|
@@ -25,6 +143,7 @@ interface McpToolExport {
|
|
|
25
143
|
* - edgar_company_facts: get structured XBRL financial data for a company
|
|
26
144
|
* - edgar_company_concept: get a specific financial metric over time
|
|
27
145
|
* - edgar_ticker_to_cik: look up CIK from ticker symbol
|
|
146
|
+
* - edgar_filing_documents: list the documents inside one filing by accession number
|
|
28
147
|
*
|
|
29
148
|
* Note: SEC requires a descriptive User-Agent header per their guidelines.
|
|
30
149
|
*/
|
|
@@ -86,7 +205,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
86
205
|
properties: {
|
|
87
206
|
cik: {
|
|
88
207
|
type: 'string',
|
|
89
|
-
description: '
|
|
208
|
+
description: 'Ticker ("NVDA") or CIK number ("320193"). Tickers are auto-resolved to CIKs internally.',
|
|
90
209
|
},
|
|
91
210
|
},
|
|
92
211
|
required: ['cik'],
|
|
@@ -148,21 +267,78 @@ const tools: McpToolExport['tools'] = [
|
|
|
148
267
|
required: ['ticker_or_cik'],
|
|
149
268
|
},
|
|
150
269
|
},
|
|
270
|
+
{
|
|
271
|
+
name: 'edgar_fund_holdings',
|
|
272
|
+
description:
|
|
273
|
+
"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.",
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object' as const,
|
|
276
|
+
properties: {
|
|
277
|
+
ticker: { type: 'string', description: 'ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
|
|
278
|
+
limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
|
|
279
|
+
},
|
|
280
|
+
required: ['ticker'],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
151
283
|
{
|
|
152
284
|
name: 'edgar_ticker_to_cik',
|
|
153
285
|
description:
|
|
154
|
-
'Resolve a US stock ticker (e.g. "TSLA") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name}. Cheap, no rate limit concerns. Most other tools also accept tickers directly and call this internally — only use it explicitly when you want the CIK as data.',
|
|
286
|
+
'Resolve a US stock ticker (e.g. "TSLA") OR a company name (e.g. "Tesla", "Apple Inc") to the SEC\'s 10-digit CIK identifier — required by every other SEC tool. Call THIS FIRST when you have a ticker/name and need to use edgar_company_concept, edgar_company_filings, edgar_company_facts, sec_8k_recent, or any other SEC-keyed tool. Returns {cik, cik_padded, company_name, ticker, matched_by}; when matched by name it also returns `alternatives` for disambiguation. Cheap, no rate limit concerns. Most other tools also accept tickers/names directly and call this internally — only use it explicitly when you want the CIK as data.',
|
|
155
287
|
inputSchema: {
|
|
156
288
|
type: 'object' as const,
|
|
157
289
|
properties: {
|
|
158
290
|
ticker: {
|
|
159
291
|
type: 'string',
|
|
160
|
-
description: 'Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA")',
|
|
292
|
+
description: 'Stock ticker symbol (e.g., "AAPL", "MSFT", "TSLA") or company name (e.g., "Apple", "Microsoft")',
|
|
161
293
|
},
|
|
162
294
|
},
|
|
163
295
|
required: ['ticker'],
|
|
164
296
|
},
|
|
165
297
|
},
|
|
298
|
+
{
|
|
299
|
+
name: 'edgar_xbrl_frames',
|
|
300
|
+
description:
|
|
301
|
+
'Compare ONE financial metric across ALL public companies for a single period (SEC XBRL "frames"). PREFER OVER WEB SEARCH for "which companies had the most revenue/net income/assets in <year>", "rank companies by <metric>", cross-company financial comparison. concept is a US-GAAP tag (e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense", "CashAndCashEquivalentsAtCarryingValue"). period is a calendar frame: "CY2023" (annual), "CY2023Q1" (quarter), or "CY2023Q1I" (instant/balance-sheet, period-end). Returns companies + values, sorted descending by default. Differs from edgar_company_concept (one company over time) — this is one period across every filer.',
|
|
302
|
+
inputSchema: {
|
|
303
|
+
type: 'object' as const,
|
|
304
|
+
properties: {
|
|
305
|
+
concept: { type: 'string', description: 'US-GAAP (or dei) tag, e.g. "Revenues", "NetIncomeLoss", "Assets", "ResearchAndDevelopmentExpense".' },
|
|
306
|
+
period: { type: 'string', description: 'Calendar frame: "CY2023" (annual duration), "CY2023Q1" (quarterly duration), or "CY2023Q1I" (instant, balance-sheet items at period end).' },
|
|
307
|
+
unit: { type: 'string', description: 'Unit of measure (default "USD"). Use "shares" for share counts, "USD-per-shares" for per-share.' },
|
|
308
|
+
taxonomy: { type: 'string', description: 'Taxonomy: "us-gaap" (default) or "dei".' },
|
|
309
|
+
sort: { type: 'string', description: '"desc" (default, largest first) or "asc".', enum: ['desc', 'asc'] },
|
|
310
|
+
limit: { type: 'number', description: 'Max companies to return (1-200, default 25).' },
|
|
311
|
+
},
|
|
312
|
+
required: ['concept', 'period'],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: 'edgar_filing_documents',
|
|
317
|
+
description:
|
|
318
|
+
'AUTHORITATIVE list of the SEC filing documents inside ONE specific filing, by accession number. Retrieve a filing / its contents / attachments: pass the accession (e.g. "0000320193-25-000079", with or without dashes) plus the filer\'s ticker ("AAPL") or CIK ("320193"). Returns every document in the filing folder — the primary document (10-K / 10-Q / 8-K body), all exhibits, and XBRL files — each with name, type, size, and a direct https URL, plus the filing\'s form type, filing date, and human -index.html page. Set include_primary_text:true to also pull the primary document\'s text (HTML stripped to plaintext, ~40k chars). Use to list a 10-K / 10-Q / 8-K\'s exhibits by accession number, retrieve filing contents/attachments, or fetch the text of a known filing. Get accession numbers from edgar_company_filings or edgar_search_filings. Example: edgar_filing_documents({accession: "0000320193-25-000079", ticker: "AAPL"}).',
|
|
319
|
+
inputSchema: {
|
|
320
|
+
type: 'object' as const,
|
|
321
|
+
properties: {
|
|
322
|
+
accession: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
description: 'SEC accession number of the filing, with or without dashes (e.g. "0000320193-25-000079" or "000032019325000079"). 18 digits shaped 10-2-6.',
|
|
325
|
+
},
|
|
326
|
+
ticker: {
|
|
327
|
+
type: 'string',
|
|
328
|
+
description: 'The filer\'s ticker (e.g. "AAPL") or company name. Provide this OR cik — needed to build the filing archive path. Tickers are auto-resolved to CIKs.',
|
|
329
|
+
},
|
|
330
|
+
cik: {
|
|
331
|
+
type: 'string',
|
|
332
|
+
description: 'The filer\'s CIK number (e.g. "320193"). Provide this OR ticker.',
|
|
333
|
+
},
|
|
334
|
+
include_primary_text: {
|
|
335
|
+
type: 'boolean',
|
|
336
|
+
description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false.',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
required: ['accession'],
|
|
340
|
+
},
|
|
341
|
+
},
|
|
166
342
|
];
|
|
167
343
|
|
|
168
344
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
@@ -238,31 +414,11 @@ async function searchFilings(
|
|
|
238
414
|
};
|
|
239
415
|
}
|
|
240
416
|
|
|
417
|
+
// Resolves a ticker OR company name ("AAPL", "Apple", "apple inc") to its SEC
|
|
418
|
+
// identity. Delegates to the shared resolver so the ticker/name-resolution logic
|
|
419
|
+
// stays consistent across every SEC-keyed pack (edgar, sec).
|
|
241
420
|
async function tickerToCik(ticker: string) {
|
|
242
|
-
|
|
243
|
-
throw new Error('Required argument "ticker" is missing or empty. Pass a ticker symbol like "AAPL" or "MSFT".');
|
|
244
|
-
}
|
|
245
|
-
const res = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
|
|
246
|
-
if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
|
|
247
|
-
|
|
248
|
-
const data = (await res.json()) as Record<
|
|
249
|
-
string,
|
|
250
|
-
{ cik_str: number; ticker: string; title: string }
|
|
251
|
-
>;
|
|
252
|
-
|
|
253
|
-
const upperTicker = ticker.toUpperCase().trim();
|
|
254
|
-
for (const entry of Object.values(data)) {
|
|
255
|
-
if (entry.ticker === upperTicker) {
|
|
256
|
-
return {
|
|
257
|
-
ticker: entry.ticker,
|
|
258
|
-
cik: String(entry.cik_str),
|
|
259
|
-
cik_padded: String(entry.cik_str).padStart(10, '0'),
|
|
260
|
-
company_name: entry.title,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
throw new Error(`Ticker "${ticker}" not found in SEC company tickers`);
|
|
421
|
+
return resolveSecEntity(ticker, { headers: SEC_HEADERS });
|
|
266
422
|
}
|
|
267
423
|
|
|
268
424
|
async function resolveCik(tickerOrCik: string): Promise<string> {
|
|
@@ -343,7 +499,150 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
|
|
|
343
499
|
};
|
|
344
500
|
}
|
|
345
501
|
|
|
346
|
-
|
|
502
|
+
// ── Filing documents (list docs inside one filing by accession) ─────
|
|
503
|
+
|
|
504
|
+
// Normalize an accession number (dashed or undashed) to both forms.
|
|
505
|
+
// SEC accessions are 18 digits shaped 10-2-6 (e.g. 0000320193-25-000079).
|
|
506
|
+
function normalizeAccession(raw: string): { dashed: string; nodash: string } | null {
|
|
507
|
+
const digits = String(raw ?? '').replace(/\D/g, '');
|
|
508
|
+
if (digits.length !== 18) return null;
|
|
509
|
+
const dashed = `${digits.slice(0, 10)}-${digits.slice(10, 12)}-${digits.slice(12)}`;
|
|
510
|
+
return { dashed, nodash: digits };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Strip an HTML document to readable plaintext. Workers have no DOMParser;
|
|
514
|
+
// drop script/style blocks, remove tags, decode entities, collapse whitespace.
|
|
515
|
+
function htmlToText(html: string): string {
|
|
516
|
+
return (decodeEntities(
|
|
517
|
+
html
|
|
518
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
519
|
+
.replace(/<[^>]+>/g, ' '),
|
|
520
|
+
) ?? '')
|
|
521
|
+
.replace(/[ \t\f\v]+/g, ' ')
|
|
522
|
+
.replace(/\s*\n\s*/g, '\n')
|
|
523
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
524
|
+
.trim();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function filingDocuments(
|
|
528
|
+
accession: string,
|
|
529
|
+
tickerOrCik: string,
|
|
530
|
+
includePrimaryText?: boolean,
|
|
531
|
+
) {
|
|
532
|
+
const acc = normalizeAccession(accession);
|
|
533
|
+
if (!acc) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0000320193-25-000079" or "000032019325000079", with or without dashes). Get accession numbers from edgar_company_filings or edgar_search_filings.`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
'A ticker or CIK is required to build the filing archive path. Pass ticker ("AAPL") or cik ("320193"), e.g. edgar_filing_documents({accession: "' +
|
|
541
|
+
acc.dashed + '", ticker: "AAPL"}).',
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const cik = await resolveCik(tickerOrCik);
|
|
545
|
+
const cikNoZeros = String(Number(cik.replace(/\D/g, ''))); // archive path uses CIK with no leading zeros
|
|
546
|
+
const paddedCik = padCik(cik);
|
|
547
|
+
|
|
548
|
+
// Match the accession in the filer's submissions to enrich with form/date/primaryDoc.
|
|
549
|
+
let company: string | null = null;
|
|
550
|
+
let formType: string | null = null;
|
|
551
|
+
let filingDate: string | null = null;
|
|
552
|
+
let primaryDocument: string | null = null;
|
|
553
|
+
let primaryDocDescription: string | null = null;
|
|
554
|
+
try {
|
|
555
|
+
const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
556
|
+
if (subRes.ok) {
|
|
557
|
+
const sub = (await subRes.json()) as {
|
|
558
|
+
name?: string;
|
|
559
|
+
filings?: {
|
|
560
|
+
recent?: {
|
|
561
|
+
accessionNumber?: string[];
|
|
562
|
+
form?: string[];
|
|
563
|
+
filingDate?: string[];
|
|
564
|
+
primaryDocument?: string[];
|
|
565
|
+
primaryDocDescription?: string[];
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
};
|
|
569
|
+
company = sub.name ?? null;
|
|
570
|
+
const r = sub.filings?.recent;
|
|
571
|
+
if (r?.accessionNumber) {
|
|
572
|
+
const i = r.accessionNumber.indexOf(acc.dashed);
|
|
573
|
+
if (i >= 0) {
|
|
574
|
+
formType = r.form?.[i] ?? null;
|
|
575
|
+
filingDate = r.filingDate?.[i] ?? null;
|
|
576
|
+
primaryDocument = r.primaryDocument?.[i] ?? null;
|
|
577
|
+
primaryDocDescription = r.primaryDocDescription?.[i] ?? null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// Metadata enrichment is best-effort; the document directory below is the core payload.
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
|
|
586
|
+
const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
|
|
587
|
+
if (idxRes.status === 404) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`No filing found for accession ${acc.dashed} — that accession number does not exist in SEC EDGAR (double-check the digits; it may belong to a different filer). Use edgar_company_filings({ticker_or_cik: "${tickerOrCik}"}) to list this company's real accession numbers.`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
|
|
593
|
+
const idx = (await idxRes.json()) as {
|
|
594
|
+
directory?: { item?: { name: string; type?: string; size?: string | number; 'last-modified'?: string }[] };
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const items = idx.directory?.item ?? [];
|
|
598
|
+
const documents = items.map((it) => ({
|
|
599
|
+
name: it.name,
|
|
600
|
+
type: it.type ?? null,
|
|
601
|
+
...(it.name === primaryDocument && primaryDocDescription ? { description: primaryDocDescription } : {}),
|
|
602
|
+
size: it.size !== undefined && it.size !== '' ? Number(it.size) : null,
|
|
603
|
+
last_modified: it['last-modified'] ?? null,
|
|
604
|
+
url: `${folder}/${it.name}`,
|
|
605
|
+
}));
|
|
606
|
+
|
|
607
|
+
const result: Record<string, unknown> = {
|
|
608
|
+
accession: acc.dashed,
|
|
609
|
+
cik: cikNoZeros,
|
|
610
|
+
company,
|
|
611
|
+
form_type: formType,
|
|
612
|
+
filing_date: filingDate,
|
|
613
|
+
primary_document: primaryDocument,
|
|
614
|
+
filing_url: `${folder}/${acc.dashed}-index.html`,
|
|
615
|
+
documents,
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
if (includePrimaryText && primaryDocument) {
|
|
619
|
+
try {
|
|
620
|
+
const docUrl = `${folder}/${primaryDocument}`;
|
|
621
|
+
const docRes = await fetch(docUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
622
|
+
if (docRes.ok) {
|
|
623
|
+
const body = await docRes.text();
|
|
624
|
+
const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
|
|
625
|
+
const text = isHtml ? htmlToText(body) : body;
|
|
626
|
+
result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
|
|
627
|
+
} else {
|
|
628
|
+
result.primary_text = null;
|
|
629
|
+
result.primary_text_error = `fetch ${docRes.status}`;
|
|
630
|
+
}
|
|
631
|
+
} catch (e) {
|
|
632
|
+
result.primary_text = null;
|
|
633
|
+
result.primary_text_error = String(e);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function companyFacts(cikOrTicker: string) {
|
|
641
|
+
// Was the only EDGAR tool that skipped resolveCik() — it called padCik()
|
|
642
|
+
// directly, so a ticker ("NVDA") got its letters stripped -> "0000000000" ->
|
|
643
|
+
// 404, and a missing arg crashed on .replace of undefined. Route through
|
|
644
|
+
// resolveCik like every sibling: validates the arg and resolves tickers.
|
|
645
|
+
const cik = await resolveCik(cikOrTicker);
|
|
347
646
|
const paddedCik = padCik(cik);
|
|
348
647
|
const res = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
|
|
349
648
|
headers: SEC_HEADERS,
|
|
@@ -452,6 +751,22 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
452
751
|
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
|
|
453
752
|
],
|
|
454
753
|
longtermdebt: ['LongTermDebt', 'LongTermDebtNoncurrent'],
|
|
754
|
+
// Common concepts an LLM asks for that were MISSING → 404 (net income was
|
|
755
|
+
// covered but these weren't). Keys are bare-alphanumeric (the lookup strips
|
|
756
|
+
// spaces/punctuation), so "total assets"/"earnings per share" etc. all match.
|
|
757
|
+
assets: ['Assets'],
|
|
758
|
+
totalassets: ['Assets'],
|
|
759
|
+
liabilities: ['Liabilities'],
|
|
760
|
+
totalliabilities: ['Liabilities'],
|
|
761
|
+
stockholdersequity: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest'],
|
|
762
|
+
equity: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest'],
|
|
763
|
+
eps: ['EarningsPerShareDiluted', 'EarningsPerShareBasic'],
|
|
764
|
+
earningspershare: ['EarningsPerShareDiluted', 'EarningsPerShareBasic'],
|
|
765
|
+
earningspersharediluted: ['EarningsPerShareDiluted', 'EarningsPerShareBasic'],
|
|
766
|
+
earningspersharebasic: ['EarningsPerShareBasic', 'EarningsPerShareDiluted'],
|
|
767
|
+
grossprofit: ['GrossProfit'],
|
|
768
|
+
operatingincome: ['OperatingIncomeLoss'],
|
|
769
|
+
operatingincomeloss: ['OperatingIncomeLoss'],
|
|
455
770
|
// ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
|
|
456
771
|
longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
|
|
457
772
|
salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
|
|
@@ -480,6 +795,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
480
795
|
],
|
|
481
796
|
};
|
|
482
797
|
|
|
798
|
+
// Annual-report forms. 10-K/10-K/A = domestic; 20-F/40-F (+ amendments) =
|
|
799
|
+
// foreign private issuers (IFRS filers). The concept reader filtered to 10-K
|
|
800
|
+
// only, which silently dropped every foreign filer's data even when the tag
|
|
801
|
+
// existed → 404s / empty results.
|
|
802
|
+
const ANNUAL_FORMS = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
|
|
803
|
+
|
|
804
|
+
// ifrs-full taxonomy fallback for foreign private issuers (they report under
|
|
805
|
+
// ifrs-full, not us-gaap). Keyed by the same normalized metric name; tried only
|
|
806
|
+
// when the us-gaap candidates all miss, so domestic lookups pay no extra call.
|
|
807
|
+
const IFRS_FALLBACK: Record<string, string[]> = {
|
|
808
|
+
revenue: ['Revenue', 'RevenueFromContractsWithCustomers'],
|
|
809
|
+
revenues: ['Revenue', 'RevenueFromContractsWithCustomers'],
|
|
810
|
+
revenuefromcontractwithcustomerexcludingassessedtax: ['Revenue', 'RevenueFromContractsWithCustomers'],
|
|
811
|
+
salesrevenuenet: ['Revenue', 'RevenueFromContractsWithCustomers'],
|
|
812
|
+
netincome: ['ProfitLoss', 'ProfitLossAttributableToOwnersOfParent'],
|
|
813
|
+
netincomeloss: ['ProfitLoss', 'ProfitLossAttributableToOwnersOfParent'],
|
|
814
|
+
profitloss: ['ProfitLoss', 'ProfitLossAttributableToOwnersOfParent'],
|
|
815
|
+
cash: ['CashAndCashEquivalents'],
|
|
816
|
+
cashandcashequivalentsatcarryingvalue: ['CashAndCashEquivalents'],
|
|
817
|
+
assets: ['Assets'],
|
|
818
|
+
liabilities: ['Liabilities'],
|
|
819
|
+
stockholdersequity: ['Equity', 'EquityAttributableToOwnersOfParent'],
|
|
820
|
+
equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
|
|
821
|
+
};
|
|
822
|
+
|
|
483
823
|
async function companyConcept(cikOrTicker: string, concept: string) {
|
|
484
824
|
// Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
|
|
485
825
|
// Production analytics: 11% errors on edgar largely from LLMs passing
|
|
@@ -487,7 +827,11 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
487
827
|
const cik = await resolveCik(cikOrTicker);
|
|
488
828
|
const paddedCik = padCik(cik);
|
|
489
829
|
|
|
490
|
-
|
|
830
|
+
// Normalize the lookup key to bare alphanumerics so natural-language input
|
|
831
|
+
// ("net income", "Long-Term Debt", "earnings per share") matches the no-space
|
|
832
|
+
// map keys — the #1 edgar_company_concept error was "net income" (with a space)
|
|
833
|
+
// missing the `netincome` key → 404.
|
|
834
|
+
const candidates = CONCEPT_CANDIDATES[concept.trim().toLowerCase().replace(/[^a-z0-9]/g, '')] ?? [concept];
|
|
491
835
|
|
|
492
836
|
type ConceptDoc = {
|
|
493
837
|
cik: number;
|
|
@@ -510,25 +854,68 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
510
854
|
// $26.9B/FY2022 figure; freshest-data-wins returns the correct $215.9B.
|
|
511
855
|
let lastStatus = 0;
|
|
512
856
|
const found: { doc: ConceptDoc; latestEnd: string }[] = [];
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
for (const
|
|
523
|
-
|
|
524
|
-
|
|
857
|
+
const tryNamespace = async (ns: string, tags: string[]) => {
|
|
858
|
+
for (const candidate of tags) {
|
|
859
|
+
const r = await fetch(
|
|
860
|
+
`${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
|
|
861
|
+
{ headers: SEC_HEADERS },
|
|
862
|
+
);
|
|
863
|
+
if (!r.ok) { lastStatus = r.status; continue; }
|
|
864
|
+
const doc = (await r.json()) as ConceptDoc;
|
|
865
|
+
let latestEnd = '';
|
|
866
|
+
for (const values of Object.values(doc.units)) {
|
|
867
|
+
for (const e of values) {
|
|
868
|
+
if (!ANNUAL_FORMS.has(e.form)) continue;
|
|
869
|
+
if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
found.push({ doc, latestEnd });
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
await tryNamespace('us-gaap', candidates);
|
|
876
|
+
// Only try the ifrs-full taxonomy when us-gaap returned nothing — foreign
|
|
877
|
+
// private issuers file under ifrs-full (form 20-F/40-F). Domestic (us-gaap)
|
|
878
|
+
// lookups short-circuit here and pay no extra request.
|
|
879
|
+
if (found.length === 0) {
|
|
880
|
+
const ifrs = IFRS_FALLBACK[concept.trim().toLowerCase().replace(/[^a-z0-9]/g, '')] ?? [];
|
|
881
|
+
if (ifrs.length) await tryNamespace('ifrs-full', ifrs);
|
|
882
|
+
}
|
|
883
|
+
// Fallback: the filer tags this metric under a name not in our candidate list
|
|
884
|
+
// (very common for debt / lease / segment concepts — e.g. Ford dropped
|
|
885
|
+
// LongTermDebtNoncurrent, many filers use LongTermDebtAndCapitalLease...). Pull
|
|
886
|
+
// the full facts once and find the freshest us-gaap tag whose NAME matches the
|
|
887
|
+
// concept keywords, preferring balance-sheet stock tags over cash-flow tags.
|
|
888
|
+
if (found.length === 0) {
|
|
889
|
+
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
890
|
+
const tokens = candidates.map(norm).filter((t) => t.length >= 3);
|
|
891
|
+
const factsRes = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
892
|
+
if (factsRes.ok && tokens.length) {
|
|
893
|
+
const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }> } };
|
|
894
|
+
const gaap = facts.facts?.['us-gaap'] ?? {};
|
|
895
|
+
const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
|
|
896
|
+
const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
|
|
897
|
+
for (const [tag, entry] of Object.entries(gaap)) {
|
|
898
|
+
if (!tokens.some((t) => norm(tag).includes(t))) continue;
|
|
899
|
+
let latestEnd = '';
|
|
900
|
+
for (const values of Object.values(entry.units)) {
|
|
901
|
+
for (const e of values) {
|
|
902
|
+
if (e.form !== '10-K' && e.form !== '10-K/A') continue;
|
|
903
|
+
if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
matches.push({
|
|
907
|
+
isFlow: FLOW.test(tag),
|
|
908
|
+
latestEnd,
|
|
909
|
+
doc: { cik: Number(cik), entityName: facts.entityName ?? '', tag, taxonomy: 'us-gaap', label: entry.label ?? tag, description: entry.description ?? '', units: entry.units },
|
|
910
|
+
});
|
|
525
911
|
}
|
|
912
|
+
matches.sort((a, b) => Number(a.isFlow) - Number(b.isFlow) || (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
|
|
913
|
+
if (matches.length) found.push({ doc: matches[0].doc, latestEnd: matches[0].latestEnd });
|
|
526
914
|
}
|
|
527
|
-
found.push({ doc, latestEnd });
|
|
528
915
|
}
|
|
529
916
|
if (found.length === 0) {
|
|
530
917
|
throw new Error(
|
|
531
|
-
`SEC EDGAR company concept error: ${lastStatus} — none of the candidate concepts ${JSON.stringify(candidates)} exist for CIK ${paddedCik}. Use edgar_company_facts({cik: "${cikOrTicker}"}) to see every concept this filer reports.`,
|
|
918
|
+
`SEC EDGAR company concept error: ${lastStatus} — none of the candidate concepts ${JSON.stringify(candidates)} exist for CIK ${paddedCik}, and no us-gaap tag matched. Use edgar_company_facts({cik: "${cikOrTicker}"}) to see every concept this filer reports.`,
|
|
532
919
|
);
|
|
533
920
|
}
|
|
534
921
|
found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
|
|
@@ -551,7 +938,7 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
551
938
|
const dedup = new Map<string, { entry: Raw; unit: string }>();
|
|
552
939
|
for (const [unit, values] of Object.entries(data.units)) {
|
|
553
940
|
for (const e of values) {
|
|
554
|
-
if (e.form
|
|
941
|
+
if (!ANNUAL_FORMS.has(e.form)) continue; // 10-K (+/A) domestic, 20-F/40-F (+/A) foreign
|
|
555
942
|
const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
|
|
556
943
|
const prior = dedup.get(key);
|
|
557
944
|
if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
|
|
@@ -834,6 +1221,105 @@ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
|
|
|
834
1221
|
};
|
|
835
1222
|
}
|
|
836
1223
|
|
|
1224
|
+
// ── Fund / ETF holdings (Form N-PORT) ───────────────────────────────
|
|
1225
|
+
|
|
1226
|
+
function decodeEntities(s: string | null): string | null {
|
|
1227
|
+
if (s == null) return null;
|
|
1228
|
+
return s
|
|
1229
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1230
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'");
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// ETF/mutual-fund ticker → {cik, seriesId}. The MF ticker file (~28k rows) is
|
|
1234
|
+
// separate from company_tickers.json; cache it in-isolate after first fetch.
|
|
1235
|
+
let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
|
|
1236
|
+
async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
|
|
1237
|
+
if (!mfTickerCache) {
|
|
1238
|
+
const res = await fetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
|
|
1239
|
+
if (!res.ok) throw new Error(`SEC fund-ticker lookup error: ${res.status}`);
|
|
1240
|
+
const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
|
|
1241
|
+
// fields: [cik, seriesId, classId, symbol]
|
|
1242
|
+
mfTickerCache = new Map();
|
|
1243
|
+
for (const [cik, seriesId, , symbol] of data.data) {
|
|
1244
|
+
if (symbol && !mfTickerCache.has(symbol)) mfTickerCache.set(symbol, { cik: String(cik), seriesId });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return mfTickerCache.get(ticker.toUpperCase().trim()) ?? null;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function fundHoldings(ticker: string, limit?: number) {
|
|
1251
|
+
const t = String(ticker ?? '').trim();
|
|
1252
|
+
if (!t) throw new Error('Required argument "ticker" is missing. Pass an ETF/fund ticker like "ARKK" or "SPY".');
|
|
1253
|
+
const topN = Math.min(100, Math.max(1, limit ?? 25));
|
|
1254
|
+
|
|
1255
|
+
const resolved = await resolveFundTicker(t);
|
|
1256
|
+
if (!resolved) {
|
|
1257
|
+
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.)` };
|
|
1258
|
+
}
|
|
1259
|
+
const { cik, seriesId } = resolved;
|
|
1260
|
+
const paddedCik = padCik(cik);
|
|
1261
|
+
|
|
1262
|
+
const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
|
|
1263
|
+
if (!subRes.ok) throw new Error(`SEC submissions error: ${subRes.status}`);
|
|
1264
|
+
const sub = (await subRes.json()) as {
|
|
1265
|
+
cik: string; name: string;
|
|
1266
|
+
filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
|
|
1267
|
+
};
|
|
1268
|
+
const r = sub.filings.recent;
|
|
1269
|
+
const nports: { accession: string; date: string }[] = [];
|
|
1270
|
+
for (let i = 0; i < r.form.length && nports.length < 12; i++) {
|
|
1271
|
+
if (r.form[i] === 'NPORT-P') nports.push({ accession: r.accessionNumber[i], date: r.filingDate[i] });
|
|
1272
|
+
}
|
|
1273
|
+
if (nports.length === 0) {
|
|
1274
|
+
return { ticker: t.toUpperCase(), cik, error: 'no_nport', message: `No N-PORT filings found for ${sub.name}.` };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// A trust files one N-PORT per series; fetch recent candidates in parallel and
|
|
1278
|
+
// match the series. Newest-first order means .find() returns the latest match.
|
|
1279
|
+
const candidates = await Promise.all(
|
|
1280
|
+
nports.map(async (f) => {
|
|
1281
|
+
const accPath = f.accession.replace(/-/g, '');
|
|
1282
|
+
const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
|
|
1283
|
+
try {
|
|
1284
|
+
const res = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
|
|
1285
|
+
if (!res.ok) return null;
|
|
1286
|
+
return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
|
|
1287
|
+
} catch {
|
|
1288
|
+
return null;
|
|
1289
|
+
}
|
|
1290
|
+
}),
|
|
1291
|
+
);
|
|
1292
|
+
const match = candidates.find((c) => c && xmlVal(c.xml, 'seriesId') === seriesId);
|
|
1293
|
+
if (!match) {
|
|
1294
|
+
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}.` };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const xml = match.xml;
|
|
1298
|
+
const holdings = xmlBlocks(xml, 'invstOrSec')
|
|
1299
|
+
.map((h) => ({
|
|
1300
|
+
name: decodeEntities(xmlVal(h, 'name')),
|
|
1301
|
+
title: decodeEntities(xmlVal(h, 'title')),
|
|
1302
|
+
cusip: xmlVal(h, 'cusip'),
|
|
1303
|
+
balance: numOrNull(xmlVal(h, 'balance')),
|
|
1304
|
+
units: xmlVal(h, 'units'),
|
|
1305
|
+
value_usd: numOrNull(xmlVal(h, 'valUSD')),
|
|
1306
|
+
pct_of_fund: numOrNull(xmlVal(h, 'pctVal')),
|
|
1307
|
+
}))
|
|
1308
|
+
.sort((a, b) => (b.pct_of_fund ?? 0) - (a.pct_of_fund ?? 0));
|
|
1309
|
+
|
|
1310
|
+
return {
|
|
1311
|
+
ticker: t.toUpperCase(),
|
|
1312
|
+
fund_name: xmlVal(xml, 'seriesName'),
|
|
1313
|
+
series_id: seriesId,
|
|
1314
|
+
cik,
|
|
1315
|
+
report_period_end: xmlVal(xml, 'repPdDate') ?? match.date,
|
|
1316
|
+
net_assets_usd: numOrNull(xmlVal(xml, 'netAssets')),
|
|
1317
|
+
total_holdings: holdings.length,
|
|
1318
|
+
holdings_returned: Math.min(holdings.length, topN),
|
|
1319
|
+
holdings: holdings.slice(0, topN),
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
837
1323
|
// ── callTool router ─────────────────────────────────────────────────
|
|
838
1324
|
|
|
839
1325
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -865,13 +1351,71 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
865
1351
|
args.ticker_or_cik as string,
|
|
866
1352
|
args.limit as number | undefined,
|
|
867
1353
|
);
|
|
1354
|
+
case 'edgar_fund_holdings':
|
|
1355
|
+
return fundHoldings(args.ticker as string, args.limit as number | undefined);
|
|
868
1356
|
case 'edgar_company_concept':
|
|
869
|
-
|
|
1357
|
+
// Accept `ticker_or_cik` too: the schema/handler use `cik` (which takes a
|
|
1358
|
+
// ticker or CIK) but the sibling tools use `ticker_or_cik` and the error
|
|
1359
|
+
// message names it — agents naturally pass it and were rejected.
|
|
1360
|
+
return companyConcept((args.cik ?? args.ticker_or_cik) as string, args.concept as string);
|
|
1361
|
+
case 'edgar_filing_documents':
|
|
1362
|
+
return filingDocuments(
|
|
1363
|
+
args.accession as string,
|
|
1364
|
+
(args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
|
|
1365
|
+
args.include_primary_text as boolean | undefined,
|
|
1366
|
+
);
|
|
870
1367
|
case 'edgar_ticker_to_cik':
|
|
871
1368
|
return tickerToCik(args.ticker as string);
|
|
1369
|
+
case 'edgar_xbrl_frames':
|
|
1370
|
+
return xbrlFrames(args);
|
|
872
1371
|
default:
|
|
873
1372
|
throw new Error(`Unknown tool: ${name}`);
|
|
874
1373
|
}
|
|
875
1374
|
}
|
|
876
1375
|
|
|
1376
|
+
interface FrameDatum { cik?: number; entityName?: string; val?: number; start?: string; end?: string; fy?: number; fp?: string; form?: string }
|
|
1377
|
+
|
|
1378
|
+
async function xbrlFrames(args: Record<string, unknown>) {
|
|
1379
|
+
const concept = String(args.concept ?? '').trim().replace(/[^A-Za-z0-9]/g, '');
|
|
1380
|
+
if (!concept) throw new Error('Required argument "concept" is missing (a US-GAAP tag, e.g. "Revenues", "NetIncomeLoss", "Assets").');
|
|
1381
|
+
const period = String(args.period ?? '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
1382
|
+
if (!/^CY\d{4}(Q[1-4]I?)?$/.test(period)) {
|
|
1383
|
+
throw new Error('Required argument "period" must be a calendar frame: "CY2023" (annual), "CY2023Q1" (quarter), or "CY2023Q1I" (instant). Got: ' + (args.period ?? ''));
|
|
1384
|
+
}
|
|
1385
|
+
const taxonomy = String(args.taxonomy ?? 'us-gaap').trim().toLowerCase() === 'dei' ? 'dei' : 'us-gaap';
|
|
1386
|
+
const unit = String(args.unit ?? 'USD').trim() || 'USD';
|
|
1387
|
+
const sortDesc = String(args.sort ?? 'desc').toLowerCase() !== 'asc';
|
|
1388
|
+
const limit = Math.min(200, Math.max(1, Number(args.limit) || 25));
|
|
1389
|
+
|
|
1390
|
+
const url = `${DATA_BASE}/api/xbrl/frames/${taxonomy}/${encodeURIComponent(concept)}/${encodeURIComponent(unit)}/${period}.json`;
|
|
1391
|
+
const res = await fetch(url, { headers: SEC_HEADERS });
|
|
1392
|
+
if (res.status === 404) {
|
|
1393
|
+
return { error: 'not_found', concept, period, unit, taxonomy, message: `No SEC frame for ${taxonomy}/${concept}/${unit}/${period}. Check the tag spelling/casing, unit (USD vs shares), and period type (add "I" for balance-sheet/instant items like Assets).` };
|
|
1394
|
+
}
|
|
1395
|
+
if (!res.ok) throw new Error(`SEC frames error: ${res.status}`);
|
|
1396
|
+
const data = (await res.json()) as { taxonomy?: string; tag?: string; uom?: string; label?: string; description?: string; data?: FrameDatum[] };
|
|
1397
|
+
const rows = (data.data ?? []).slice().sort((a, b) => sortDesc ? (b.val ?? 0) - (a.val ?? 0) : (a.val ?? 0) - (b.val ?? 0));
|
|
1398
|
+
|
|
1399
|
+
return {
|
|
1400
|
+
concept,
|
|
1401
|
+
period,
|
|
1402
|
+
unit,
|
|
1403
|
+
taxonomy,
|
|
1404
|
+
label: data.label ?? null,
|
|
1405
|
+
companies_reporting: rows.length,
|
|
1406
|
+
returned: Math.min(rows.length, limit),
|
|
1407
|
+
sort: sortDesc ? 'desc' : 'asc',
|
|
1408
|
+
source: 'SEC EDGAR XBRL frames (data.sec.gov)',
|
|
1409
|
+
note: 'Values are AS-FILED in each company\'s XBRL submission for this calendar frame. Extreme outliers are often filer reporting errors (wrong units/scale) — cross-check surprising top values with edgar_company_concept before treating a ranking as authoritative.',
|
|
1410
|
+
companies: rows.slice(0, limit).map((r) => ({
|
|
1411
|
+
cik: r.cik ?? null,
|
|
1412
|
+
entity: r.entityName ?? null,
|
|
1413
|
+
value: r.val ?? null,
|
|
1414
|
+
period_start: r.start ?? null,
|
|
1415
|
+
period_end: r.end ?? null,
|
|
1416
|
+
form: r.form ?? null,
|
|
1417
|
+
})),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
877
1421
|
export default { tools, callTool, meter: { credits: 10 } } satisfies McpToolExport;
|