@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 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 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 881+ data sources:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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.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: 'Company CIK number (e.g., "320193" for Apple). Use edgar_ticker_to_cik to look up if needed.',
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
- if (typeof ticker !== 'string' || !ticker.trim()) {
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
- async function companyFacts(cik: string) {
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
- const candidates = CONCEPT_CANDIDATES[concept.trim().toLowerCase()] ?? [concept];
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
- for (const candidate of candidates) {
514
- const r = await fetch(
515
- `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/us-gaap/${encodeURIComponent(candidate)}.json`,
516
- { headers: SEC_HEADERS },
517
- );
518
- if (!r.ok) { lastStatus = r.status; continue; }
519
- const doc = (await r.json()) as ConceptDoc;
520
- let latestEnd = '';
521
- for (const values of Object.values(doc.units)) {
522
- for (const e of values) {
523
- if (e.form !== '10-K' && e.form !== '10-K/A') continue;
524
- if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
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 !== '10-K' && e.form !== '10-K/A') continue;
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
1230
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/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
- return companyConcept(args.cik as string, args.concept as string);
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;