@pipeworx/mcp-edgar 0.3.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 +475 -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.3.
|
|
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'],
|
|
@@ -164,18 +283,62 @@ const tools: McpToolExport['tools'] = [
|
|
|
164
283
|
{
|
|
165
284
|
name: 'edgar_ticker_to_cik',
|
|
166
285
|
description:
|
|
167
|
-
'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.',
|
|
168
287
|
inputSchema: {
|
|
169
288
|
type: 'object' as const,
|
|
170
289
|
properties: {
|
|
171
290
|
ticker: {
|
|
172
291
|
type: 'string',
|
|
173
|
-
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")',
|
|
174
293
|
},
|
|
175
294
|
},
|
|
176
295
|
required: ['ticker'],
|
|
177
296
|
},
|
|
178
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
|
+
},
|
|
179
342
|
];
|
|
180
343
|
|
|
181
344
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
@@ -251,31 +414,11 @@ async function searchFilings(
|
|
|
251
414
|
};
|
|
252
415
|
}
|
|
253
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).
|
|
254
420
|
async function tickerToCik(ticker: string) {
|
|
255
|
-
|
|
256
|
-
throw new Error('Required argument "ticker" is missing or empty. Pass a ticker symbol like "AAPL" or "MSFT".');
|
|
257
|
-
}
|
|
258
|
-
const res = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
|
|
259
|
-
if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
|
|
260
|
-
|
|
261
|
-
const data = (await res.json()) as Record<
|
|
262
|
-
string,
|
|
263
|
-
{ cik_str: number; ticker: string; title: string }
|
|
264
|
-
>;
|
|
265
|
-
|
|
266
|
-
const upperTicker = ticker.toUpperCase().trim();
|
|
267
|
-
for (const entry of Object.values(data)) {
|
|
268
|
-
if (entry.ticker === upperTicker) {
|
|
269
|
-
return {
|
|
270
|
-
ticker: entry.ticker,
|
|
271
|
-
cik: String(entry.cik_str),
|
|
272
|
-
cik_padded: String(entry.cik_str).padStart(10, '0'),
|
|
273
|
-
company_name: entry.title,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
throw new Error(`Ticker "${ticker}" not found in SEC company tickers`);
|
|
421
|
+
return resolveSecEntity(ticker, { headers: SEC_HEADERS });
|
|
279
422
|
}
|
|
280
423
|
|
|
281
424
|
async function resolveCik(tickerOrCik: string): Promise<string> {
|
|
@@ -356,7 +499,150 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
|
|
|
356
499
|
};
|
|
357
500
|
}
|
|
358
501
|
|
|
359
|
-
|
|
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);
|
|
360
646
|
const paddedCik = padCik(cik);
|
|
361
647
|
const res = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
|
|
362
648
|
headers: SEC_HEADERS,
|
|
@@ -465,6 +751,22 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
465
751
|
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
|
|
466
752
|
],
|
|
467
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'],
|
|
468
770
|
// ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
|
|
469
771
|
longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
|
|
470
772
|
salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
|
|
@@ -493,6 +795,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
|
|
|
493
795
|
],
|
|
494
796
|
};
|
|
495
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
|
+
|
|
496
823
|
async function companyConcept(cikOrTicker: string, concept: string) {
|
|
497
824
|
// Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
|
|
498
825
|
// Production analytics: 11% errors on edgar largely from LLMs passing
|
|
@@ -500,7 +827,11 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
500
827
|
const cik = await resolveCik(cikOrTicker);
|
|
501
828
|
const paddedCik = padCik(cik);
|
|
502
829
|
|
|
503
|
-
|
|
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];
|
|
504
835
|
|
|
505
836
|
type ConceptDoc = {
|
|
506
837
|
cik: number;
|
|
@@ -523,25 +854,68 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
523
854
|
// $26.9B/FY2022 figure; freshest-data-wins returns the correct $215.9B.
|
|
524
855
|
let lastStatus = 0;
|
|
525
856
|
const found: { doc: ConceptDoc; latestEnd: string }[] = [];
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
for (const
|
|
536
|
-
|
|
537
|
-
|
|
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
|
+
}
|
|
538
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
|
+
});
|
|
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 });
|
|
539
914
|
}
|
|
540
|
-
found.push({ doc, latestEnd });
|
|
541
915
|
}
|
|
542
916
|
if (found.length === 0) {
|
|
543
917
|
throw new Error(
|
|
544
|
-
`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.`,
|
|
545
919
|
);
|
|
546
920
|
}
|
|
547
921
|
found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
|
|
@@ -564,7 +938,7 @@ async function companyConcept(cikOrTicker: string, concept: string) {
|
|
|
564
938
|
const dedup = new Map<string, { entry: Raw; unit: string }>();
|
|
565
939
|
for (const [unit, values] of Object.entries(data.units)) {
|
|
566
940
|
for (const e of values) {
|
|
567
|
-
if (e.form
|
|
941
|
+
if (!ANNUAL_FORMS.has(e.form)) continue; // 10-K (+/A) domestic, 20-F/40-F (+/A) foreign
|
|
568
942
|
const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
|
|
569
943
|
const prior = dedup.get(key);
|
|
570
944
|
if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
|
|
@@ -980,12 +1354,68 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
980
1354
|
case 'edgar_fund_holdings':
|
|
981
1355
|
return fundHoldings(args.ticker as string, args.limit as number | undefined);
|
|
982
1356
|
case 'edgar_company_concept':
|
|
983
|
-
|
|
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
|
+
);
|
|
984
1367
|
case 'edgar_ticker_to_cik':
|
|
985
1368
|
return tickerToCik(args.ticker as string);
|
|
1369
|
+
case 'edgar_xbrl_frames':
|
|
1370
|
+
return xbrlFrames(args);
|
|
986
1371
|
default:
|
|
987
1372
|
throw new Error(`Unknown tool: ${name}`);
|
|
988
1373
|
}
|
|
989
1374
|
}
|
|
990
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
|
+
|
|
991
1421
|
export default { tools, callTool, meter: { credits: 10 } } satisfies McpToolExport;
|