@pipeworx/mcp-edgar 0.3.0 → 0.3.2

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 884+ 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 884+ 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.3.0",
3
+ "version": "0.3.2",
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.3.0",
6
+ "version": "0.3.2",
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'],
@@ -164,18 +283,66 @@ 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, retrieve filing contents/attachments, or fetch the text of a filing. You can pass an exact accession, OR just a ticker + form_type to auto-resolve the latest matching filing (no accession lookup needed). Examples: edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the documents in NVIDIA\'s latest annual report; edgar_filing_documents({accession: "0000320193-25-000079", ticker: "AAPL", include_primary_text: true}) for a specific filing\'s text.',
319
+ inputSchema: {
320
+ type: 'object' as const,
321
+ properties: {
322
+ accession: {
323
+ type: 'string',
324
+ description: 'Optional SEC accession number of a specific filing, with or without dashes (e.g. "0000320193-25-000079"). Omit it to auto-resolve the latest filing — pass form_type instead.',
325
+ },
326
+ ticker: {
327
+ type: 'string',
328
+ description: 'The filer\'s ticker (e.g. "AAPL", "NVDA") or company name. Provide this OR cik. 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
+ form_type: {
335
+ type: 'string',
336
+ description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A". Omit both accession and form_type to get the single most recent filing of any type.',
337
+ },
338
+ include_primary_text: {
339
+ type: 'boolean',
340
+ description: 'When true, also fetch the primary document and return its text (HTML stripped to plaintext, truncated to ~40,000 chars). Default false.',
341
+ },
342
+ },
343
+ required: [],
344
+ },
345
+ },
179
346
  ];
180
347
 
181
348
  // ── Helpers ─────────────────────────────────────────────────────────
@@ -251,31 +418,11 @@ async function searchFilings(
251
418
  };
252
419
  }
253
420
 
421
+ // Resolves a ticker OR company name ("AAPL", "Apple", "apple inc") to its SEC
422
+ // identity. Delegates to the shared resolver so the ticker/name-resolution logic
423
+ // stays consistent across every SEC-keyed pack (edgar, sec).
254
424
  async function tickerToCik(ticker: string) {
255
- if (typeof ticker !== 'string' || !ticker.trim()) {
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`);
425
+ return resolveSecEntity(ticker, { headers: SEC_HEADERS });
279
426
  }
280
427
 
281
428
  async function resolveCik(tickerOrCik: string): Promise<string> {
@@ -356,7 +503,171 @@ async function companyFilings(tickerOrCik: string, formType?: string, limit?: nu
356
503
  };
357
504
  }
358
505
 
359
- async function companyFacts(cik: string) {
506
+ // ── Filing documents (list docs inside one filing by accession) ─────
507
+
508
+ // Normalize an accession number (dashed or undashed) to both forms.
509
+ // SEC accessions are 18 digits shaped 10-2-6 (e.g. 0000320193-25-000079).
510
+ function normalizeAccession(raw: string): { dashed: string; nodash: string } | null {
511
+ const digits = String(raw ?? '').replace(/\D/g, '');
512
+ if (digits.length !== 18) return null;
513
+ const dashed = `${digits.slice(0, 10)}-${digits.slice(10, 12)}-${digits.slice(12)}`;
514
+ return { dashed, nodash: digits };
515
+ }
516
+
517
+ // Strip an HTML document to readable plaintext. Workers have no DOMParser;
518
+ // drop script/style blocks, remove tags, decode entities, collapse whitespace.
519
+ function htmlToText(html: string): string {
520
+ return (decodeEntities(
521
+ html
522
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
523
+ .replace(/<[^>]+>/g, ' '),
524
+ ) ?? '')
525
+ .replace(/[ \t\f\v]+/g, ' ')
526
+ .replace(/\s*\n\s*/g, '\n')
527
+ .replace(/\n{3,}/g, '\n\n')
528
+ .trim();
529
+ }
530
+
531
+ async function filingDocuments(
532
+ accession: string | undefined,
533
+ tickerOrCik: string,
534
+ includePrimaryText?: boolean,
535
+ wantFormTypeArg?: string,
536
+ ) {
537
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
538
+ throw new Error(
539
+ 'A ticker or CIK is required. Pass ticker ("AAPL") or cik ("320193"), plus either an accession or a form_type (e.g. edgar_filing_documents({ticker: "NVDA", form_type: "10-K"}) for the latest 10-K).',
540
+ );
541
+ }
542
+ const cik = await resolveCik(tickerOrCik);
543
+ // Accession is optional: when omitted, resolve the latest filing matching
544
+ // form_type (or the single most recent filing of any type) so one-shot
545
+ // questions like "documents in NVIDIA's latest 10-K" work without the caller
546
+ // first having to look up an accession number.
547
+ let acc = accession ? normalizeAccession(accession) : null;
548
+ if (accession && !acc) {
549
+ throw new Error(
550
+ `Invalid accession "${accession}". A SEC accession number is 18 digits shaped 10-2-6 (e.g. "0000320193-25-000079", with or without dashes). Get accession numbers from edgar_company_filings, or omit accession and pass form_type to auto-resolve the latest.`,
551
+ );
552
+ }
553
+ if (!acc) {
554
+ const wantForm = wantFormTypeArg?.trim().toUpperCase();
555
+ const subRes = await fetch(`${DATA_BASE}/submissions/CIK${padCik(cik)}.json`, { headers: SEC_HEADERS });
556
+ if (!subRes.ok) throw new Error(`SEC submissions lookup failed (HTTP ${subRes.status}) resolving the latest filing for ${tickerOrCik}.`);
557
+ const sub = (await subRes.json()) as { filings?: { recent?: { accessionNumber?: string[]; form?: string[] } } };
558
+ const r = sub.filings?.recent;
559
+ const forms = r?.form ?? [];
560
+ const accs = r?.accessionNumber ?? [];
561
+ const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
562
+ if (idx < 0 || !accs[idx]) {
563
+ throw new Error(
564
+ `No ${wantForm ?? 'recent'} filing found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
565
+ );
566
+ }
567
+ acc = normalizeAccession(accs[idx]);
568
+ if (!acc) throw new Error('Failed to resolve a valid accession from the submissions index.');
569
+ }
570
+ const cikNoZeros = String(Number(cik.replace(/\D/g, ''))); // archive path uses CIK with no leading zeros
571
+ const paddedCik = padCik(cik);
572
+
573
+ // Match the accession in the filer's submissions to enrich with form/date/primaryDoc.
574
+ let company: string | null = null;
575
+ let formType: string | null = null;
576
+ let filingDate: string | null = null;
577
+ let primaryDocument: string | null = null;
578
+ let primaryDocDescription: string | null = null;
579
+ try {
580
+ const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
581
+ if (subRes.ok) {
582
+ const sub = (await subRes.json()) as {
583
+ name?: string;
584
+ filings?: {
585
+ recent?: {
586
+ accessionNumber?: string[];
587
+ form?: string[];
588
+ filingDate?: string[];
589
+ primaryDocument?: string[];
590
+ primaryDocDescription?: string[];
591
+ };
592
+ };
593
+ };
594
+ company = sub.name ?? null;
595
+ const r = sub.filings?.recent;
596
+ if (r?.accessionNumber) {
597
+ const i = r.accessionNumber.indexOf(acc.dashed);
598
+ if (i >= 0) {
599
+ formType = r.form?.[i] ?? null;
600
+ filingDate = r.filingDate?.[i] ?? null;
601
+ primaryDocument = r.primaryDocument?.[i] ?? null;
602
+ primaryDocDescription = r.primaryDocDescription?.[i] ?? null;
603
+ }
604
+ }
605
+ }
606
+ } catch {
607
+ // Metadata enrichment is best-effort; the document directory below is the core payload.
608
+ }
609
+
610
+ const folder = `https://www.sec.gov/Archives/edgar/data/${cikNoZeros}/${acc.nodash}`;
611
+ const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
612
+ if (idxRes.status === 404) {
613
+ throw new Error(
614
+ `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.`,
615
+ );
616
+ }
617
+ if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
618
+ const idx = (await idxRes.json()) as {
619
+ directory?: { item?: { name: string; type?: string; size?: string | number; 'last-modified'?: string }[] };
620
+ };
621
+
622
+ const items = idx.directory?.item ?? [];
623
+ const documents = items.map((it) => ({
624
+ name: it.name,
625
+ type: it.type ?? null,
626
+ ...(it.name === primaryDocument && primaryDocDescription ? { description: primaryDocDescription } : {}),
627
+ size: it.size !== undefined && it.size !== '' ? Number(it.size) : null,
628
+ last_modified: it['last-modified'] ?? null,
629
+ url: `${folder}/${it.name}`,
630
+ }));
631
+
632
+ const result: Record<string, unknown> = {
633
+ accession: acc.dashed,
634
+ cik: cikNoZeros,
635
+ company,
636
+ form_type: formType,
637
+ filing_date: filingDate,
638
+ primary_document: primaryDocument,
639
+ filing_url: `${folder}/${acc.dashed}-index.html`,
640
+ documents,
641
+ };
642
+
643
+ if (includePrimaryText && primaryDocument) {
644
+ try {
645
+ const docUrl = `${folder}/${primaryDocument}`;
646
+ const docRes = await fetch(docUrl, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
647
+ if (docRes.ok) {
648
+ const body = await docRes.text();
649
+ const isHtml = /\.x?html?$/i.test(primaryDocument) || /^\s*</.test(body);
650
+ const text = isHtml ? htmlToText(body) : body;
651
+ result.primary_text = text.length > 40000 ? text.slice(0, 40000) + '\n…[truncated]' : text;
652
+ } else {
653
+ result.primary_text = null;
654
+ result.primary_text_error = `fetch ${docRes.status}`;
655
+ }
656
+ } catch (e) {
657
+ result.primary_text = null;
658
+ result.primary_text_error = String(e);
659
+ }
660
+ }
661
+
662
+ return result;
663
+ }
664
+
665
+ async function companyFacts(cikOrTicker: string) {
666
+ // Was the only EDGAR tool that skipped resolveCik() — it called padCik()
667
+ // directly, so a ticker ("NVDA") got its letters stripped -> "0000000000" ->
668
+ // 404, and a missing arg crashed on .replace of undefined. Route through
669
+ // resolveCik like every sibling: validates the arg and resolves tickers.
670
+ const cik = await resolveCik(cikOrTicker);
360
671
  const paddedCik = padCik(cik);
361
672
  const res = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, {
362
673
  headers: SEC_HEADERS,
@@ -465,6 +776,22 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
465
776
  'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
466
777
  ],
467
778
  longtermdebt: ['LongTermDebt', 'LongTermDebtNoncurrent'],
779
+ // Common concepts an LLM asks for that were MISSING → 404 (net income was
780
+ // covered but these weren't). Keys are bare-alphanumeric (the lookup strips
781
+ // spaces/punctuation), so "total assets"/"earnings per share" etc. all match.
782
+ assets: ['Assets'],
783
+ totalassets: ['Assets'],
784
+ liabilities: ['Liabilities'],
785
+ totalliabilities: ['Liabilities'],
786
+ stockholdersequity: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest'],
787
+ equity: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest'],
788
+ eps: ['EarningsPerShareDiluted', 'EarningsPerShareBasic'],
789
+ earningspershare: ['EarningsPerShareDiluted', 'EarningsPerShareBasic'],
790
+ earningspersharediluted: ['EarningsPerShareDiluted', 'EarningsPerShareBasic'],
791
+ earningspersharebasic: ['EarningsPerShareBasic', 'EarningsPerShareDiluted'],
792
+ grossprofit: ['GrossProfit'],
793
+ operatingincome: ['OperatingIncomeLoss'],
794
+ operatingincomeloss: ['OperatingIncomeLoss'],
468
795
  // ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
469
796
  longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
470
797
  salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
@@ -493,6 +820,31 @@ const CONCEPT_CANDIDATES: Record<string, string[]> = {
493
820
  ],
494
821
  };
495
822
 
823
+ // Annual-report forms. 10-K/10-K/A = domestic; 20-F/40-F (+ amendments) =
824
+ // foreign private issuers (IFRS filers). The concept reader filtered to 10-K
825
+ // only, which silently dropped every foreign filer's data even when the tag
826
+ // existed → 404s / empty results.
827
+ const ANNUAL_FORMS = new Set(['10-K', '10-K/A', '20-F', '20-F/A', '40-F', '40-F/A']);
828
+
829
+ // ifrs-full taxonomy fallback for foreign private issuers (they report under
830
+ // ifrs-full, not us-gaap). Keyed by the same normalized metric name; tried only
831
+ // when the us-gaap candidates all miss, so domestic lookups pay no extra call.
832
+ const IFRS_FALLBACK: Record<string, string[]> = {
833
+ revenue: ['Revenue', 'RevenueFromContractsWithCustomers'],
834
+ revenues: ['Revenue', 'RevenueFromContractsWithCustomers'],
835
+ revenuefromcontractwithcustomerexcludingassessedtax: ['Revenue', 'RevenueFromContractsWithCustomers'],
836
+ salesrevenuenet: ['Revenue', 'RevenueFromContractsWithCustomers'],
837
+ netincome: ['ProfitLoss', 'ProfitLossAttributableToOwnersOfParent'],
838
+ netincomeloss: ['ProfitLoss', 'ProfitLossAttributableToOwnersOfParent'],
839
+ profitloss: ['ProfitLoss', 'ProfitLossAttributableToOwnersOfParent'],
840
+ cash: ['CashAndCashEquivalents'],
841
+ cashandcashequivalentsatcarryingvalue: ['CashAndCashEquivalents'],
842
+ assets: ['Assets'],
843
+ liabilities: ['Liabilities'],
844
+ stockholdersequity: ['Equity', 'EquityAttributableToOwnersOfParent'],
845
+ equity: ['Equity', 'EquityAttributableToOwnersOfParent'],
846
+ };
847
+
496
848
  async function companyConcept(cikOrTicker: string, concept: string) {
497
849
  // Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
498
850
  // Production analytics: 11% errors on edgar largely from LLMs passing
@@ -500,7 +852,11 @@ async function companyConcept(cikOrTicker: string, concept: string) {
500
852
  const cik = await resolveCik(cikOrTicker);
501
853
  const paddedCik = padCik(cik);
502
854
 
503
- const candidates = CONCEPT_CANDIDATES[concept.trim().toLowerCase()] ?? [concept];
855
+ // Normalize the lookup key to bare alphanumerics so natural-language input
856
+ // ("net income", "Long-Term Debt", "earnings per share") matches the no-space
857
+ // map keys — the #1 edgar_company_concept error was "net income" (with a space)
858
+ // missing the `netincome` key → 404.
859
+ const candidates = CONCEPT_CANDIDATES[concept.trim().toLowerCase().replace(/[^a-z0-9]/g, '')] ?? [concept];
504
860
 
505
861
  type ConceptDoc = {
506
862
  cik: number;
@@ -523,25 +879,68 @@ async function companyConcept(cikOrTicker: string, concept: string) {
523
879
  // $26.9B/FY2022 figure; freshest-data-wins returns the correct $215.9B.
524
880
  let lastStatus = 0;
525
881
  const found: { doc: ConceptDoc; latestEnd: string }[] = [];
526
- for (const candidate of candidates) {
527
- const r = await fetch(
528
- `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/us-gaap/${encodeURIComponent(candidate)}.json`,
529
- { headers: SEC_HEADERS },
530
- );
531
- if (!r.ok) { lastStatus = r.status; continue; }
532
- const doc = (await r.json()) as ConceptDoc;
533
- let latestEnd = '';
534
- for (const values of Object.values(doc.units)) {
535
- for (const e of values) {
536
- if (e.form !== '10-K' && e.form !== '10-K/A') continue;
537
- if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
882
+ const tryNamespace = async (ns: string, tags: string[]) => {
883
+ for (const candidate of tags) {
884
+ const r = await fetch(
885
+ `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/${ns}/${encodeURIComponent(candidate)}.json`,
886
+ { headers: SEC_HEADERS },
887
+ );
888
+ if (!r.ok) { lastStatus = r.status; continue; }
889
+ const doc = (await r.json()) as ConceptDoc;
890
+ let latestEnd = '';
891
+ for (const values of Object.values(doc.units)) {
892
+ for (const e of values) {
893
+ if (!ANNUAL_FORMS.has(e.form)) continue;
894
+ if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
895
+ }
538
896
  }
897
+ found.push({ doc, latestEnd });
898
+ }
899
+ };
900
+ await tryNamespace('us-gaap', candidates);
901
+ // Only try the ifrs-full taxonomy when us-gaap returned nothing — foreign
902
+ // private issuers file under ifrs-full (form 20-F/40-F). Domestic (us-gaap)
903
+ // lookups short-circuit here and pay no extra request.
904
+ if (found.length === 0) {
905
+ const ifrs = IFRS_FALLBACK[concept.trim().toLowerCase().replace(/[^a-z0-9]/g, '')] ?? [];
906
+ if (ifrs.length) await tryNamespace('ifrs-full', ifrs);
907
+ }
908
+ // Fallback: the filer tags this metric under a name not in our candidate list
909
+ // (very common for debt / lease / segment concepts — e.g. Ford dropped
910
+ // LongTermDebtNoncurrent, many filers use LongTermDebtAndCapitalLease...). Pull
911
+ // the full facts once and find the freshest us-gaap tag whose NAME matches the
912
+ // concept keywords, preferring balance-sheet stock tags over cash-flow tags.
913
+ if (found.length === 0) {
914
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
915
+ const tokens = candidates.map(norm).filter((t) => t.length >= 3);
916
+ const factsRes = await fetch(`${DATA_BASE}/api/xbrl/companyfacts/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
917
+ if (factsRes.ok && tokens.length) {
918
+ const facts = (await factsRes.json()) as { entityName?: string; facts?: { 'us-gaap'?: Record<string, { label?: string; description?: string; units: Record<string, ConceptDoc['units'][string]> }> } };
919
+ const gaap = facts.facts?.['us-gaap'] ?? {};
920
+ const FLOW = /^(ProceedsFrom|RepaymentsOf|PaymentsOf|IncreaseDecrease|AmortizationOf|Gain|Loss)/;
921
+ const matches: { isFlow: boolean; latestEnd: string; doc: ConceptDoc }[] = [];
922
+ for (const [tag, entry] of Object.entries(gaap)) {
923
+ if (!tokens.some((t) => norm(tag).includes(t))) continue;
924
+ let latestEnd = '';
925
+ for (const values of Object.values(entry.units)) {
926
+ for (const e of values) {
927
+ if (e.form !== '10-K' && e.form !== '10-K/A') continue;
928
+ if ((e.end ?? '') > latestEnd) latestEnd = e.end ?? '';
929
+ }
930
+ }
931
+ matches.push({
932
+ isFlow: FLOW.test(tag),
933
+ latestEnd,
934
+ doc: { cik: Number(cik), entityName: facts.entityName ?? '', tag, taxonomy: 'us-gaap', label: entry.label ?? tag, description: entry.description ?? '', units: entry.units },
935
+ });
936
+ }
937
+ matches.sort((a, b) => Number(a.isFlow) - Number(b.isFlow) || (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
938
+ if (matches.length) found.push({ doc: matches[0].doc, latestEnd: matches[0].latestEnd });
539
939
  }
540
- found.push({ doc, latestEnd });
541
940
  }
542
941
  if (found.length === 0) {
543
942
  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.`,
943
+ `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
944
  );
546
945
  }
547
946
  found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
@@ -564,7 +963,7 @@ async function companyConcept(cikOrTicker: string, concept: string) {
564
963
  const dedup = new Map<string, { entry: Raw; unit: string }>();
565
964
  for (const [unit, values] of Object.entries(data.units)) {
566
965
  for (const e of values) {
567
- if (e.form !== '10-K' && e.form !== '10-K/A') continue;
966
+ if (!ANNUAL_FORMS.has(e.form)) continue; // 10-K (+/A) domestic, 20-F/40-F (+/A) foreign
568
967
  const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
569
968
  const prior = dedup.get(key);
570
969
  if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
@@ -980,12 +1379,69 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
980
1379
  case 'edgar_fund_holdings':
981
1380
  return fundHoldings(args.ticker as string, args.limit as number | undefined);
982
1381
  case 'edgar_company_concept':
983
- return companyConcept(args.cik as string, args.concept as string);
1382
+ // Accept `ticker_or_cik` too: the schema/handler use `cik` (which takes a
1383
+ // ticker or CIK) but the sibling tools use `ticker_or_cik` and the error
1384
+ // message names it — agents naturally pass it and were rejected.
1385
+ return companyConcept((args.cik ?? args.ticker_or_cik) as string, args.concept as string);
1386
+ case 'edgar_filing_documents':
1387
+ return filingDocuments(
1388
+ args.accession as string | undefined,
1389
+ (args.cik ?? args.ticker ?? args.ticker_or_cik) as string,
1390
+ args.include_primary_text as boolean | undefined,
1391
+ args.form_type as string | undefined,
1392
+ );
984
1393
  case 'edgar_ticker_to_cik':
985
1394
  return tickerToCik(args.ticker as string);
1395
+ case 'edgar_xbrl_frames':
1396
+ return xbrlFrames(args);
986
1397
  default:
987
1398
  throw new Error(`Unknown tool: ${name}`);
988
1399
  }
989
1400
  }
990
1401
 
1402
+ interface FrameDatum { cik?: number; entityName?: string; val?: number; start?: string; end?: string; fy?: number; fp?: string; form?: string }
1403
+
1404
+ async function xbrlFrames(args: Record<string, unknown>) {
1405
+ const concept = String(args.concept ?? '').trim().replace(/[^A-Za-z0-9]/g, '');
1406
+ if (!concept) throw new Error('Required argument "concept" is missing (a US-GAAP tag, e.g. "Revenues", "NetIncomeLoss", "Assets").');
1407
+ const period = String(args.period ?? '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
1408
+ if (!/^CY\d{4}(Q[1-4]I?)?$/.test(period)) {
1409
+ throw new Error('Required argument "period" must be a calendar frame: "CY2023" (annual), "CY2023Q1" (quarter), or "CY2023Q1I" (instant). Got: ' + (args.period ?? ''));
1410
+ }
1411
+ const taxonomy = String(args.taxonomy ?? 'us-gaap').trim().toLowerCase() === 'dei' ? 'dei' : 'us-gaap';
1412
+ const unit = String(args.unit ?? 'USD').trim() || 'USD';
1413
+ const sortDesc = String(args.sort ?? 'desc').toLowerCase() !== 'asc';
1414
+ const limit = Math.min(200, Math.max(1, Number(args.limit) || 25));
1415
+
1416
+ const url = `${DATA_BASE}/api/xbrl/frames/${taxonomy}/${encodeURIComponent(concept)}/${encodeURIComponent(unit)}/${period}.json`;
1417
+ const res = await fetch(url, { headers: SEC_HEADERS });
1418
+ if (res.status === 404) {
1419
+ 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).` };
1420
+ }
1421
+ if (!res.ok) throw new Error(`SEC frames error: ${res.status}`);
1422
+ const data = (await res.json()) as { taxonomy?: string; tag?: string; uom?: string; label?: string; description?: string; data?: FrameDatum[] };
1423
+ const rows = (data.data ?? []).slice().sort((a, b) => sortDesc ? (b.val ?? 0) - (a.val ?? 0) : (a.val ?? 0) - (b.val ?? 0));
1424
+
1425
+ return {
1426
+ concept,
1427
+ period,
1428
+ unit,
1429
+ taxonomy,
1430
+ label: data.label ?? null,
1431
+ companies_reporting: rows.length,
1432
+ returned: Math.min(rows.length, limit),
1433
+ sort: sortDesc ? 'desc' : 'asc',
1434
+ source: 'SEC EDGAR XBRL frames (data.sec.gov)',
1435
+ 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.',
1436
+ companies: rows.slice(0, limit).map((r) => ({
1437
+ cik: r.cik ?? null,
1438
+ entity: r.entityName ?? null,
1439
+ value: r.val ?? null,
1440
+ period_start: r.start ?? null,
1441
+ period_end: r.end ?? null,
1442
+ form: r.form ?? null,
1443
+ })),
1444
+ };
1445
+ }
1446
+
991
1447
  export default { tools, callTool, meter: { credits: 10 } } satisfies McpToolExport;