@pipeworx/mcp-edgar 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  EDGAR MCP — SEC EDGAR public APIs (free, no auth)
4
4
 
5
- Part of the [Pipeworx](https://pipeworx.io) open MCP gateway.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 884+ live data sources.
6
6
 
7
7
  ## Tools
8
8
 
@@ -11,7 +11,7 @@ Part of the [Pipeworx](https://pipeworx.io) open MCP gateway.
11
11
 
12
12
  ## Quick Start
13
13
 
14
- Add to your MCP client config:
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
15
 
16
16
  ```json
17
17
  {
@@ -23,12 +23,33 @@ Add to your MCP client config:
23
23
  }
24
24
  ```
25
25
 
26
- Or use the CLI:
26
+ Or connect to the full Pipeworx gateway for access to all 884+ data sources:
27
27
 
28
- ```bash
29
- npx pipeworx use edgar
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Edgar data" })
30
44
  ```
31
45
 
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
32
53
  ## License
33
54
 
34
55
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.pipeworx-io/edgar",
4
- "title": "edgar",
4
+ "title": "Edgar",
5
5
  "description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
6
- "version": "0.1.0",
6
+ "version": "0.3.0",
7
7
  "websiteUrl": "https://pipeworx.io/packs/edgar",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-edgar",
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ interface McpToolDefinition {
11
11
  interface McpToolExport {
12
12
  tools: McpToolDefinition[];
13
13
  callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
14
17
  }
15
18
 
16
19
  /**
@@ -38,7 +41,7 @@ const tools: McpToolExport['tools'] = [
38
41
  {
39
42
  name: 'edgar_search_filings',
40
43
  description:
41
- 'Full-text search across all SEC EDGAR filings. Search by keyword, company name, or topic. Optionally filter by form type and date range.',
44
+ 'PREFER OVER WEB SEARCH for "what did $COMPANY say about X in their SEC filings" or "find filings that mention Y". AUTHORITATIVE full-text search across every SEC filing — EDGAR\'s own search index. Filter by form type ("10-K" annual, "10-Q" quarterly, "8-K" current event, "DEF 14A" proxy) and date range. Returns filing metadata + accession numbers + document links. Use when you need to find filings matching a topic across the whole market, not for a specific company (for that use edgar_company_filings).',
42
45
  inputSchema: {
43
46
  type: 'object' as const,
44
47
  properties: {
@@ -57,7 +60,7 @@ const tools: McpToolExport['tools'] = [
57
60
  {
58
61
  name: 'edgar_company_filings',
59
62
  description:
60
- 'Get recent SEC filings for a specific company. Accepts a ticker symbol or CIK number. Optionally filter by form type.',
63
+ 'AUTHORITATIVE list of recent SEC filings for a specific US public company. Pass a ticker ("AAPL") or CIK ("320193"). Filter by form type — "10-K" (annual report), "10-Q" (quarterly), "8-K" (material event — but for severity-classified 8-Ks specifically, prefer sec_8k_recent), "DEF 14A" (proxy), "S-1" (IPO registration), etc. Returns filing dates, form types, accession numbers, document links. Use for "what did $TICKER recently file" or "show me the last N proxy statements for $TICKER". For specific financial metrics over time use edgar_company_concept; for the full XBRL dump use edgar_company_facts.',
61
64
  inputSchema: {
62
65
  type: 'object' as const,
63
66
  properties: {
@@ -77,7 +80,7 @@ const tools: McpToolExport['tools'] = [
77
80
  {
78
81
  name: 'edgar_company_facts',
79
82
  description:
80
- 'Get structured XBRL financial data for a company by CIK. Returns key financial metrics like revenue, net income, assets, and more with their most recent annual values.',
83
+ 'AUTHORITATIVE full XBRL fundamentals dump for a US public company by CIK. Returns every reported financial metric (hundreds of concepts: revenue, net income, assets, liabilities, EPS, cash flow lines, segment breakdowns) with annual and historical values pulled straight from the company\'s SEC filings — the official numbers, not estimates. Use when you need the complete fundamental picture vs. one metric (for one metric use edgar_company_concept). Large payload; agents typically use this once to discover available concepts then narrow to edgar_company_concept for follow-up queries.',
81
84
  inputSchema: {
82
85
  type: 'object' as const,
83
86
  properties: {
@@ -92,27 +95,76 @@ const tools: McpToolExport['tools'] = [
92
95
  {
93
96
  name: 'edgar_company_concept',
94
97
  description:
95
- 'Get a specific financial metric over time for a company. Returns all reported values across filings for a given US-GAAP concept.',
98
+ 'AUTHORITATIVE historical financials for any US public company. Source: SEC XBRL filings (the official numbers companies file, not third-party scrapes). Pass a ticker or CIK plus a friendly metric name — Revenue, NetIncomeLoss, Cash, LongTermDebt, EarningsPerShareDiluted — and the tool resolves the right XBRL tag for that filer (post-ASC-606 companies use RevenueFromContractWithCustomerExcludingAssessedTax instead of "Revenues", etc.). Returns annual values with fiscal years, period ends, filing types. Use for "what was AAPL\'s revenue in 2024", "show me NVDA\'s long-term debt trend", anything where you need the SEC-filed number rather than an estimate.',
96
99
  inputSchema: {
97
100
  type: 'object' as const,
98
101
  properties: {
99
102
  cik: {
100
103
  type: 'string',
101
- description: 'Company CIK number (e.g., "320193" for Apple)',
104
+ description: 'Ticker (e.g., "AAPL") or CIK number (e.g., "320193"). Tickers are auto-resolved.',
102
105
  },
103
106
  concept: {
104
107
  type: 'string',
105
108
  description:
106
- 'US-GAAP concept name (e.g., "Revenue", "NetIncomeLoss", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted")',
109
+ 'Metric name. Common: "Revenue" / "Revenues", "NetIncomeLoss", "Cash", "Assets", "Liabilities", "StockholdersEquity", "EarningsPerShareDiluted", "LongTermDebt".',
107
110
  },
108
111
  },
109
112
  required: ['cik', 'concept'],
110
113
  },
111
114
  },
115
+ {
116
+ name: 'edgar_insider_transactions',
117
+ description:
118
+ 'AUTHORITATIVE insider trading activity (SEC Form 3/4/5) for a US public company — who bought or sold, how many shares, at what price, and what they hold now. Pass a ticker ("TSLA") or CIK. Returns each recent Form 4 filing parsed into structured transactions: reporting owner + role (director/officer/10% holder), transaction code (P=open-market purchase, S=sale, A=grant/award, M=option exercise, G=gift, F=tax-withholding), shares, price per share, acquired/disposed, and shares owned after. Use for "insider buying at $TICKER", "did executives sell recently", "latest Form 4 activity". Open-market purchases (code P) are the strongest conviction signal; awards (code A) are routine comp. For the raw filing list use edgar_company_filings with form_type:"4".',
119
+ inputSchema: {
120
+ type: 'object' as const,
121
+ properties: {
122
+ ticker_or_cik: {
123
+ type: 'string',
124
+ description: 'Ticker symbol (e.g., "TSLA") or CIK number (e.g., "1318605")',
125
+ },
126
+ limit: { type: 'number', description: 'Max Form 4/3/5 filings to parse (1-25, default 10)' },
127
+ include_derivatives: {
128
+ type: 'boolean',
129
+ description: 'Also include derivative (options/RSU) transactions. Default false (non-derivative common-stock only).',
130
+ },
131
+ },
132
+ required: ['ticker_or_cik'],
133
+ },
134
+ },
135
+ {
136
+ name: 'edgar_institutional_holdings',
137
+ description:
138
+ "AUTHORITATIVE stock portfolio of a large institutional investor (SEC Form 13F-HR) — what a fund/manager owns, share counts, and position values. Pass the MANAGER's ticker or CIK (e.g. \"BRK-B\" or CIK \"1067983\" for Berkshire Hathaway; \"1350694\" for Bridgewater). Returns the latest quarterly 13F: top holdings aggregated by issuer with value (USD), shares, and % of portfolio, plus the report period. Use for \"what does Berkshire own\", \"Bridgewater's biggest positions\", \"which funds hold $TICKER\" (run per manager). Note: 13F covers US-listed long equity + options held by managers with >$100M AUM, filed ~45 days after quarter-end; it excludes shorts, cash, and non-US holdings. Values are whole USD for filings since 2023; older ones are in thousands.",
139
+ inputSchema: {
140
+ type: 'object' as const,
141
+ properties: {
142
+ ticker_or_cik: {
143
+ type: 'string',
144
+ description: 'The institutional manager\'s ticker (e.g. "BRK-B") or CIK (e.g. "1067983"). NOT the held stock — the fund/manager doing the filing.',
145
+ },
146
+ limit: { type: 'number', description: 'Top N holdings by value to return (1-100, default 25)' },
147
+ },
148
+ required: ['ticker_or_cik'],
149
+ },
150
+ },
151
+ {
152
+ name: 'edgar_fund_holdings',
153
+ description:
154
+ "AUTHORITATIVE portfolio holdings of a US ETF or mutual fund (SEC Form N-PORT) — what the fund actually owns. Pass the FUND's ticker (e.g. \"ARKK\", \"QQQ\", \"VTI\", \"VOO\", \"IVV\"). Returns the latest monthly portfolio: net assets, holdings count, and top positions by weight — each with name, CUSIP, value (USD), and % of fund. Use for \"what does ARKK hold\", \"top holdings of QQQ\", \"is $STOCK in VTI\". Distinct from edgar_institutional_holdings (13F = what an investment MANAGER like Berkshire owns); this is a registered fund's own N-PORT. Covers US-registered open-end funds + ETFs; data is ~30-60 days delayed. Note: a few legacy ETFs structured as unit investment trusts (e.g. SPY, DIA) don't file N-PORT and won't resolve — use IVV or VOO for S&P 500 exposure.",
155
+ inputSchema: {
156
+ type: 'object' as const,
157
+ properties: {
158
+ ticker: { type: 'string', description: 'ETF or mutual-fund ticker (e.g. "ARKK", "SPY", "QQQ"). Fund tickers, not company stock tickers.' },
159
+ limit: { type: 'number', description: 'Top N holdings by weight to return (1-100, default 25)' },
160
+ },
161
+ required: ['ticker'],
162
+ },
163
+ },
112
164
  {
113
165
  name: 'edgar_ticker_to_cik',
114
166
  description:
115
- 'Look up a company CIK number from its ticker symbol. The CIK is needed for other EDGAR tools.',
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.',
116
168
  inputSchema: {
117
169
  type: 'object' as const,
118
170
  properties: {
@@ -200,6 +252,9 @@ async function searchFilings(
200
252
  }
201
253
 
202
254
  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
+ }
203
258
  const res = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
204
259
  if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
205
260
 
@@ -224,6 +279,14 @@ async function tickerToCik(ticker: string) {
224
279
  }
225
280
 
226
281
  async function resolveCik(tickerOrCik: string): Promise<string> {
282
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
283
+ throw new Error(
284
+ 'Required argument "ticker_or_cik" is missing or empty. ' +
285
+ 'Try one of: edgar_company_filings({ticker_or_cik: "AAPL"}) by ticker, ' +
286
+ 'or edgar_company_filings({ticker_or_cik: "320193"}) by CIK number (Apple\'s CIK). ' +
287
+ 'Tickers are auto-resolved to CIKs internally.'
288
+ );
289
+ }
227
290
  if (isNumericCik(tickerOrCik)) return tickerOrCik.trim();
228
291
  const result = await tickerToCik(tickerOrCik);
229
292
  return result.cik;
@@ -345,9 +408,12 @@ async function companyFacts(cik: string) {
345
408
  if (!fact) continue;
346
409
 
347
410
  const usdEntries = fact.units['USD'] ?? fact.units['shares'] ?? [];
411
+ // Same fix as companyConcept: don't gate on `frame !== undefined`;
412
+ // off-calendar filers (NVDA, AAPL) get their modern annuals dropped.
413
+ // Sort by period end (desc) to surface the actual most-recent 10-K.
348
414
  const annual = usdEntries
349
- .filter((e) => e.form === '10-K' && e.frame !== undefined)
350
- .sort((a, b) => (b.fy ?? 0) - (a.fy ?? 0));
415
+ .filter((e) => e.form === '10-K' || e.form === '10-K/A')
416
+ .sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
351
417
 
352
418
  metrics[key] = {
353
419
  label: fact.label,
@@ -365,15 +431,78 @@ async function companyFacts(cik: string) {
365
431
  };
366
432
  }
367
433
 
368
- async function companyConcept(cik: string, concept: string) {
434
+ // Friendly-name → XBRL candidate tags. Same versioning issue as compare_entities:
435
+ // ASC 606 (2018) forced most filers from "Revenues" to
436
+ // "RevenueFromContractWithCustomerExcludingAssessedTax"; older companies
437
+ // still use SalesRevenueNet, etc.
438
+ //
439
+ // Two flavors of key in this table:
440
+ //
441
+ // 1. Friendly names ("revenue", "cash", "longtermdebt") — what an LLM
442
+ // naturally types in prose. Maps to a list of XBRL candidates that
443
+ // cover the common ASC-606 / pre-ASC-606 / pre-IFRS variants.
444
+ //
445
+ // 2. Literal XBRL concept names lowercased — what a more sophisticated
446
+ // LLM types after looking up a "real" GAAP tag. These are the ones
447
+ // that showed up in production analytics as the top 80-of-84 errors
448
+ // on edgar_company_concept: filers reported the same metric under a
449
+ // sibling concept the LLM didn't pick. We map each known-failing
450
+ // XBRL name back to its own list (itself first, then the realistic
451
+ // siblings) so the call walks the fallback before throwing.
452
+ //
453
+ // Anything not in the table still falls through to [concept] (the bare
454
+ // literal), so we don't regress for niche concepts not yet observed in
455
+ // failures.
456
+ const CONCEPT_CANDIDATES: Record<string, string[]> = {
457
+ // ── friendly names ────────────────────────────────────────────────
458
+ revenue: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
459
+ revenues: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
460
+ netincome: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
461
+ netincomeloss: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
462
+ cash: [
463
+ 'CashAndCashEquivalentsAtCarryingValue',
464
+ 'Cash',
465
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
466
+ ],
467
+ longtermdebt: ['LongTermDebt', 'LongTermDebtNoncurrent'],
468
+ // ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
469
+ longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
470
+ salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
471
+ revenuefromcontractwithcustomerexcludingassessedtax: [
472
+ 'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
473
+ ],
474
+ revenuefromcontractwithcustomerincludingassessedtax: [
475
+ 'RevenueFromContractWithCustomerIncludingAssessedTax',
476
+ 'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
477
+ ],
478
+ netincomelossavailabletocommonstockholdersbasic: [
479
+ 'NetIncomeLossAvailableToCommonStockholdersBasic', 'NetIncomeLoss', 'ProfitLoss',
480
+ ],
481
+ netincomelossavailabletocommonstockholdersdiluted: [
482
+ 'NetIncomeLossAvailableToCommonStockholdersDiluted',
483
+ 'NetIncomeLossAvailableToCommonStockholdersBasic', 'NetIncomeLoss', 'ProfitLoss',
484
+ ],
485
+ profitloss: ['ProfitLoss', 'NetIncomeLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
486
+ cashcashequivalentsrestrictedcashandrestrictedcashequivalents: [
487
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
488
+ 'CashAndCashEquivalentsAtCarryingValue', 'Cash',
489
+ ],
490
+ cashandcashequivalentsatcarryingvalue: [
491
+ 'CashAndCashEquivalentsAtCarryingValue',
492
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents', 'Cash',
493
+ ],
494
+ };
495
+
496
+ async function companyConcept(cikOrTicker: string, concept: string) {
497
+ // Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
498
+ // Production analytics: 11% errors on edgar largely from LLMs passing
499
+ // tickers as cik (then padCik strips letters → 0000000000 → 404).
500
+ const cik = await resolveCik(cikOrTicker);
369
501
  const paddedCik = padCik(cik);
370
- const res = await fetch(
371
- `${DATA_BASE}/api/xbrl/companyconcept/CIK${paddedCik}/us-gaap/${encodeURIComponent(concept)}.json`,
372
- { headers: SEC_HEADERS },
373
- );
374
- if (!res.ok) throw new Error(`SEC EDGAR company concept error: ${res.status} — concept "${concept}" may not exist for CIK ${cik}`);
375
502
 
376
- const data = (await res.json()) as {
503
+ const candidates = CONCEPT_CANDIDATES[concept.trim().toLowerCase()] ?? [concept];
504
+
505
+ type ConceptDoc = {
377
506
  cik: number;
378
507
  entityName: string;
379
508
  tag: string;
@@ -386,24 +515,72 @@ async function companyConcept(cik: string, concept: string) {
386
515
  >;
387
516
  };
388
517
 
389
- // Return annual (10-K) values sorted by fiscal year descending
390
- const entries: { fiscal_year: number; period_end: string; value: number; filed: string; unit: string }[] = [];
391
-
518
+ // Fetch EVERY candidate that exists, then pick the one with the most recent
519
+ // 10-K data — not the first non-empty one. Filers switch revenue tags over
520
+ // time: NVDA reports current revenue under "Revenues" (through FY2026) while
521
+ // its older "RevenueFromContractWithCustomerExcludingAssessedTax" tag is
522
+ // frozen at FY2022 but still returns 200. First-non-empty returned the stale
523
+ // $26.9B/FY2022 figure; freshest-data-wins returns the correct $215.9B.
524
+ let lastStatus = 0;
525
+ 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 ?? '';
538
+ }
539
+ }
540
+ found.push({ doc, latestEnd });
541
+ }
542
+ if (found.length === 0) {
543
+ 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.`,
545
+ );
546
+ }
547
+ found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
548
+ const data = found[0].doc;
549
+
550
+ // Return annual (10-K) values sorted by fiscal year / period_end DESC.
551
+ //
552
+ // Filter notes:
553
+ // - DON'T filter on `frame !== undefined`. SEC populates `frame` only
554
+ // when a fact aligns to a calendar quarter (e.g. CY2020Q4). Off-
555
+ // calendar filers like NVDA (fiscal year ends late January) have no
556
+ // frame on most facts; the prior filter dropped their modern annual
557
+ // values entirely, leaving only the few entries that happened to
558
+ // align by accident. Result: NVDA "Revenues" returned FY2019-2022
559
+ // and the current $130B annual revenue was invisible. Run 6 audit
560
+ // caught this.
561
+ // - DO dedupe: a 10-K and its 10-K/A amendments both report the same
562
+ // (fy, fp, end) tuple. Keep the most-recently-filed version.
563
+ type Raw = { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
564
+ const dedup = new Map<string, { entry: Raw; unit: string }>();
392
565
  for (const [unit, values] of Object.entries(data.units)) {
393
- const annuals = values
394
- .filter((e) => e.form === '10-K' && e.frame !== undefined)
395
- .sort((a, b) => (b.fy ?? 0) - (a.fy ?? 0));
396
-
397
- for (const entry of annuals) {
398
- entries.push({
399
- fiscal_year: entry.fy,
400
- period_end: entry.end,
401
- value: entry.val,
402
- filed: entry.filed,
403
- unit,
404
- });
566
+ for (const e of values) {
567
+ if (e.form !== '10-K' && e.form !== '10-K/A') continue;
568
+ const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
569
+ const prior = dedup.get(key);
570
+ if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
571
+ dedup.set(key, { entry: e, unit });
572
+ }
405
573
  }
406
574
  }
575
+ const entries = [...dedup.values()]
576
+ .map(({ entry, unit }) => ({
577
+ fiscal_year: entry.fy,
578
+ period_end: entry.end,
579
+ value: entry.val,
580
+ filed: entry.filed,
581
+ unit,
582
+ }))
583
+ .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
407
584
 
408
585
  return {
409
586
  cik: String(data.cik),
@@ -415,6 +592,360 @@ async function companyConcept(cik: string, concept: string) {
415
592
  };
416
593
  }
417
594
 
595
+ // ── Insider transactions (Form 3/4/5) ───────────────────────────────
596
+
597
+ // SEC Form 4 transaction codes (Table I / II). Open-market buys (P) and
598
+ // sells (S) are the real signal; A/M/F/G are comp & mechanical.
599
+ const TXN_CODE_MEANING: Record<string, string> = {
600
+ P: 'Open-market or private purchase',
601
+ S: 'Open-market or private sale',
602
+ A: 'Grant/award (e.g. RSU/option grant)',
603
+ D: 'Disposition to the issuer (e.g. forfeiture)',
604
+ F: 'Shares withheld to pay exercise price or tax',
605
+ M: 'Exercise/conversion of derivative security',
606
+ C: 'Conversion of derivative security',
607
+ X: 'Exercise of in/at-the-money derivative',
608
+ G: 'Bona fide gift',
609
+ J: 'Other acquisition or disposition',
610
+ V: 'Transaction voluntarily reported early',
611
+ };
612
+
613
+ // Form 4 XML is small and flat; Workers have no DOMParser, so pull values
614
+ // with anchored regex. SEC wraps most leaf values in <tag><value>X</value></tag>,
615
+ // but plain <tag>X</tag> also occurs (e.g. <rptOwnerName>, <officerTitle>).
616
+ function xmlVal(block: string, tag: string): string | null {
617
+ const wrapped = new RegExp(`<${tag}>\\s*<value>([\\s\\S]*?)</value>`, 'i').exec(block);
618
+ if (wrapped) return wrapped[1].trim();
619
+ const plain = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i').exec(block);
620
+ return plain ? plain[1].trim() : null;
621
+ }
622
+
623
+ function xmlBlocks(xml: string, tag: string): string[] {
624
+ const out: string[] = [];
625
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'gi');
626
+ let m: RegExpExecArray | null;
627
+ while ((m = re.exec(xml)) !== null) out.push(m[1]);
628
+ return out;
629
+ }
630
+
631
+ function parseForm4Transactions(xml: string, includeDerivatives: boolean) {
632
+ const owner = xmlVal(xml, 'rptOwnerName');
633
+ const rel = xmlBlocks(xml, 'reportingOwnerRelationship')[0] ?? '';
634
+ const roles: string[] = [];
635
+ if (/<isDirector>\s*(1|true)/i.test(rel)) roles.push('Director');
636
+ const officerTitle = xmlVal(rel, 'officerTitle');
637
+ if (/<isOfficer>\s*(1|true)/i.test(rel)) roles.push(officerTitle ? `Officer (${officerTitle})` : 'Officer');
638
+ if (/<isTenPercentOwner>\s*(1|true)/i.test(rel)) roles.push('10% owner');
639
+ if (/<isOther>\s*(1|true)/i.test(rel)) roles.push('Other');
640
+
641
+ const txnTags = includeDerivatives
642
+ ? ['nonDerivativeTransaction', 'derivativeTransaction']
643
+ : ['nonDerivativeTransaction'];
644
+
645
+ const transactions: {
646
+ security: string | null;
647
+ date: string | null;
648
+ code: string | null;
649
+ code_meaning: string | null;
650
+ shares: number | null;
651
+ price_per_share: number | null;
652
+ acquired_disposed: string | null;
653
+ value_usd: number | null;
654
+ shares_owned_after: number | null;
655
+ derivative: boolean;
656
+ }[] = [];
657
+
658
+ for (const tag of txnTags) {
659
+ for (const b of xmlBlocks(xml, tag)) {
660
+ const code = xmlVal(b, 'transactionCode');
661
+ const shares = numOrNull(xmlVal(b, 'transactionShares'));
662
+ const price = numOrNull(xmlVal(b, 'transactionPricePerShare'));
663
+ const ad = xmlVal(b, 'transactionAcquiredDisposedCode');
664
+ transactions.push({
665
+ security: xmlVal(b, 'securityTitle'),
666
+ date: xmlVal(b, 'transactionDate'),
667
+ code,
668
+ code_meaning: code ? TXN_CODE_MEANING[code] ?? null : null,
669
+ shares,
670
+ price_per_share: price,
671
+ acquired_disposed: ad === 'A' ? 'acquired' : ad === 'D' ? 'disposed' : ad,
672
+ value_usd: shares !== null && price !== null ? Math.round(shares * price * 100) / 100 : null,
673
+ shares_owned_after: numOrNull(xmlVal(b, 'sharesOwnedFollowingTransaction')),
674
+ derivative: tag === 'derivativeTransaction',
675
+ });
676
+ }
677
+ }
678
+
679
+ return { owner, roles, transactions };
680
+ }
681
+
682
+ function numOrNull(s: string | null): number | null {
683
+ if (s === null) return null;
684
+ const n = Number(s.replace(/,/g, ''));
685
+ return Number.isFinite(n) ? n : null;
686
+ }
687
+
688
+ async function insiderTransactions(tickerOrCik: string, limit?: number, includeDerivatives?: boolean) {
689
+ const cik = await resolveCik(tickerOrCik);
690
+ const paddedCik = padCik(cik);
691
+ const count = Math.min(25, Math.max(1, limit ?? 10));
692
+
693
+ const res = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
694
+ if (!res.ok) throw new Error(`SEC EDGAR submissions error: ${res.status}`);
695
+ const data = (await res.json()) as {
696
+ cik: string;
697
+ name: string;
698
+ tickers: string[];
699
+ filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[]; primaryDocument: string[] } };
700
+ };
701
+
702
+ const recent = data.filings.recent;
703
+ const targets: { accession: string; date: string; form: string; doc: string }[] = [];
704
+ for (let i = 0; i < recent.form.length && targets.length < count; i++) {
705
+ const form = recent.form[i];
706
+ if (form === '3' || form === '4' || form === '5') {
707
+ targets.push({
708
+ accession: recent.accessionNumber[i],
709
+ date: recent.filingDate[i],
710
+ form,
711
+ doc: recent.primaryDocument[i],
712
+ });
713
+ }
714
+ }
715
+
716
+ const filings = await Promise.all(
717
+ targets.map(async (t) => {
718
+ const accPath = t.accession.replace(/-/g, '');
719
+ // primaryDocument for Form 4 is the XSL viewer path (e.g. "xslF345X06/foo.xml").
720
+ // Strip the leading xsl*/ directory to fetch the raw machine-readable XML.
721
+ const rawDoc = t.doc.replace(/^xsl[^/]*\//i, '');
722
+ const url = `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accPath}/${rawDoc}`;
723
+ try {
724
+ const r = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
725
+ if (!r.ok) return { accession_number: t.accession, filing_date: t.date, form: t.form, error: `fetch ${r.status}`, filing_url: url };
726
+ const xml = await r.text();
727
+ const parsed = parseForm4Transactions(xml, includeDerivatives ?? false);
728
+ return {
729
+ accession_number: t.accession,
730
+ filing_date: t.date,
731
+ form: t.form,
732
+ owner: parsed.owner,
733
+ owner_roles: parsed.roles,
734
+ transactions: parsed.transactions,
735
+ filing_url: url,
736
+ };
737
+ } catch (e) {
738
+ return { accession_number: t.accession, filing_date: t.date, form: t.form, error: String(e), filing_url: url };
739
+ }
740
+ }),
741
+ );
742
+
743
+ return {
744
+ cik: data.cik,
745
+ company_name: data.name,
746
+ tickers: data.tickers ?? [],
747
+ form_4_filings_parsed: filings.length,
748
+ note: 'Transaction codes: P=open-market buy (strongest signal), S=sale, A=grant/award (routine comp), M=option exercise, F=tax withholding, G=gift.',
749
+ filings,
750
+ };
751
+ }
752
+
753
+ // ── Institutional holdings (Form 13F-HR) ────────────────────────────
754
+
755
+ // 13F info tables come with a namespace prefix (e.g. <ns1:infoTable>) that
756
+ // varies by filer/agent. Strip prefixes so the same regex works everywhere.
757
+ function stripNs(xml: string): string {
758
+ return xml.replace(/<(\/?)[a-zA-Z0-9]+:/g, '<$1');
759
+ }
760
+
761
+ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
762
+ const cik = await resolveCik(tickerOrCik);
763
+ const paddedCik = padCik(cik);
764
+ const topN = Math.min(100, Math.max(1, limit ?? 25));
765
+
766
+ const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
767
+ if (!subRes.ok) throw new Error(`SEC EDGAR submissions error: ${subRes.status}`);
768
+ const sub = (await subRes.json()) as {
769
+ cik: string;
770
+ name: string;
771
+ filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[]; reportDate: string[] } };
772
+ };
773
+
774
+ const r = sub.filings.recent;
775
+ let target: { accession: string; filed: string; period: string } | null = null;
776
+ for (let i = 0; i < r.form.length; i++) {
777
+ if (r.form[i] === '13F-HR' || r.form[i] === '13F-HR/A') {
778
+ target = { accession: r.accessionNumber[i], filed: r.filingDate[i], period: r.reportDate?.[i] ?? '' };
779
+ break;
780
+ }
781
+ }
782
+ if (!target) {
783
+ throw new Error(
784
+ `No 13F-HR filing found for "${tickerOrCik}" (CIK ${paddedCik}). This filer may not be a 13F institutional manager (>$100M AUM). Pass the manager's CIK directly, e.g. "1067983" for Berkshire Hathaway.`,
785
+ );
786
+ }
787
+
788
+ // Find the information-table XML in the accession folder: the .xml file that
789
+ // is neither primary_doc.xml (cover page) nor an xsl-rendered viewer copy.
790
+ const accPath = target.accession.replace(/-/g, '');
791
+ const folder = `https://www.sec.gov/Archives/edgar/data/${sub.cik}/${accPath}`;
792
+ const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
793
+ if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
794
+ const idx = (await idxRes.json()) as { directory: { item: { name: string }[] } };
795
+ const infoFile = idx.directory.item.find(
796
+ (it) => it.name.toLowerCase().endsWith('.xml') && it.name !== 'primary_doc.xml' && !/^xsl/i.test(it.name),
797
+ );
798
+ if (!infoFile) {
799
+ throw new Error(`13F information table not found in filing ${target.accession}.`);
800
+ }
801
+
802
+ const tableRes = await fetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
803
+ if (!tableRes.ok) throw new Error(`SEC EDGAR 13F table error: ${tableRes.status}`);
804
+ const xml = stripNs(await tableRes.text());
805
+
806
+ // Aggregate rows by issuer+cusip+putCall (managers file multiple sub-portfolio
807
+ // rows per security; sum them). Value is reported in whole USD post-2023.
808
+ type Agg = { issuer: string; cusip: string; put_call: string | null; value_usd: number; shares: number };
809
+ const byKey = new Map<string, Agg>();
810
+ for (const b of xmlBlocks(xml, 'infoTable')) {
811
+ const issuer = xmlVal(b, 'nameOfIssuer') ?? 'UNKNOWN';
812
+ const cusip = xmlVal(b, 'cusip') ?? '';
813
+ const putCall = xmlVal(b, 'putCall');
814
+ const value = numOrNull(xmlVal(b, 'value')) ?? 0;
815
+ const shares = numOrNull(xmlVal(b, 'sshPrnamt')) ?? 0;
816
+ const key = `${cusip}|${putCall ?? ''}`;
817
+ const prior = byKey.get(key);
818
+ if (prior) {
819
+ prior.value_usd += value;
820
+ prior.shares += shares;
821
+ } else {
822
+ byKey.set(key, { issuer, cusip, put_call: putCall, value_usd: value, shares });
823
+ }
824
+ }
825
+
826
+ const all = [...byKey.values()].sort((a, b) => b.value_usd - a.value_usd);
827
+ const totalValue = all.reduce((s, h) => s + h.value_usd, 0);
828
+ const holdings = all.slice(0, topN).map((h) => ({
829
+ issuer: h.issuer,
830
+ cusip: h.cusip,
831
+ put_call: h.put_call,
832
+ value_usd: h.value_usd,
833
+ shares: h.shares,
834
+ pct_of_portfolio: totalValue > 0 ? Math.round((h.value_usd / totalValue) * 10000) / 100 : null,
835
+ }));
836
+
837
+ return {
838
+ cik: sub.cik,
839
+ manager_name: sub.name,
840
+ form: '13F-HR',
841
+ report_period: target.period,
842
+ filed_date: target.filed,
843
+ total_portfolio_value_usd: totalValue,
844
+ total_positions: all.length,
845
+ holdings_returned: holdings.length,
846
+ holdings,
847
+ };
848
+ }
849
+
850
+ // ── Fund / ETF holdings (Form N-PORT) ───────────────────────────────
851
+
852
+ function decodeEntities(s: string | null): string | null {
853
+ if (s == null) return null;
854
+ return s
855
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
856
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'");
857
+ }
858
+
859
+ // ETF/mutual-fund ticker → {cik, seriesId}. The MF ticker file (~28k rows) is
860
+ // separate from company_tickers.json; cache it in-isolate after first fetch.
861
+ let mfTickerCache: Map<string, { cik: string; seriesId: string }> | null = null;
862
+ async function resolveFundTicker(ticker: string): Promise<{ cik: string; seriesId: string } | null> {
863
+ if (!mfTickerCache) {
864
+ const res = await fetch('https://www.sec.gov/files/company_tickers_mf.json', { headers: SEC_HEADERS });
865
+ if (!res.ok) throw new Error(`SEC fund-ticker lookup error: ${res.status}`);
866
+ const data = (await res.json()) as { fields: string[]; data: Array<[number, string, string, string]> };
867
+ // fields: [cik, seriesId, classId, symbol]
868
+ mfTickerCache = new Map();
869
+ for (const [cik, seriesId, , symbol] of data.data) {
870
+ if (symbol && !mfTickerCache.has(symbol)) mfTickerCache.set(symbol, { cik: String(cik), seriesId });
871
+ }
872
+ }
873
+ return mfTickerCache.get(ticker.toUpperCase().trim()) ?? null;
874
+ }
875
+
876
+ async function fundHoldings(ticker: string, limit?: number) {
877
+ const t = String(ticker ?? '').trim();
878
+ if (!t) throw new Error('Required argument "ticker" is missing. Pass an ETF/fund ticker like "ARKK" or "SPY".');
879
+ const topN = Math.min(100, Math.max(1, limit ?? 25));
880
+
881
+ const resolved = await resolveFundTicker(t);
882
+ if (!resolved) {
883
+ return { ticker: t.toUpperCase(), error: 'not_found', message: `"${t}" is not a known N-PORT-filing fund/ETF ticker. Use a fund ticker like "ARKK", "QQQ", "VTI", or "VOO". (Some legacy ETFs structured as unit investment trusts — SPY, DIA — don't file N-PORT; use IVV or VOO for S&P 500.)` };
884
+ }
885
+ const { cik, seriesId } = resolved;
886
+ const paddedCik = padCik(cik);
887
+
888
+ const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
889
+ if (!subRes.ok) throw new Error(`SEC submissions error: ${subRes.status}`);
890
+ const sub = (await subRes.json()) as {
891
+ cik: string; name: string;
892
+ filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[] } };
893
+ };
894
+ const r = sub.filings.recent;
895
+ const nports: { accession: string; date: string }[] = [];
896
+ for (let i = 0; i < r.form.length && nports.length < 12; i++) {
897
+ if (r.form[i] === 'NPORT-P') nports.push({ accession: r.accessionNumber[i], date: r.filingDate[i] });
898
+ }
899
+ if (nports.length === 0) {
900
+ return { ticker: t.toUpperCase(), cik, error: 'no_nport', message: `No N-PORT filings found for ${sub.name}.` };
901
+ }
902
+
903
+ // A trust files one N-PORT per series; fetch recent candidates in parallel and
904
+ // match the series. Newest-first order means .find() returns the latest match.
905
+ const candidates = await Promise.all(
906
+ nports.map(async (f) => {
907
+ const accPath = f.accession.replace(/-/g, '');
908
+ const url = `https://www.sec.gov/Archives/edgar/data/${cik}/${accPath}/primary_doc.xml`;
909
+ try {
910
+ const res = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
911
+ if (!res.ok) return null;
912
+ return { date: f.date, accession: f.accession, xml: stripNs(await res.text()) };
913
+ } catch {
914
+ return null;
915
+ }
916
+ }),
917
+ );
918
+ const match = candidates.find((c) => c && xmlVal(c.xml, 'seriesId') === seriesId);
919
+ if (!match) {
920
+ return { ticker: t.toUpperCase(), cik, series_id: seriesId, error: 'series_not_matched', message: `Found N-PORT filings for ${sub.name} but none of the ${nports.length} most recent matched series ${seriesId}.` };
921
+ }
922
+
923
+ const xml = match.xml;
924
+ const holdings = xmlBlocks(xml, 'invstOrSec')
925
+ .map((h) => ({
926
+ name: decodeEntities(xmlVal(h, 'name')),
927
+ title: decodeEntities(xmlVal(h, 'title')),
928
+ cusip: xmlVal(h, 'cusip'),
929
+ balance: numOrNull(xmlVal(h, 'balance')),
930
+ units: xmlVal(h, 'units'),
931
+ value_usd: numOrNull(xmlVal(h, 'valUSD')),
932
+ pct_of_fund: numOrNull(xmlVal(h, 'pctVal')),
933
+ }))
934
+ .sort((a, b) => (b.pct_of_fund ?? 0) - (a.pct_of_fund ?? 0));
935
+
936
+ return {
937
+ ticker: t.toUpperCase(),
938
+ fund_name: xmlVal(xml, 'seriesName'),
939
+ series_id: seriesId,
940
+ cik,
941
+ report_period_end: xmlVal(xml, 'repPdDate') ?? match.date,
942
+ net_assets_usd: numOrNull(xmlVal(xml, 'netAssets')),
943
+ total_holdings: holdings.length,
944
+ holdings_returned: Math.min(holdings.length, topN),
945
+ holdings: holdings.slice(0, topN),
946
+ };
947
+ }
948
+
418
949
  // ── callTool router ─────────────────────────────────────────────────
419
950
 
420
951
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -435,6 +966,19 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
435
966
  );
436
967
  case 'edgar_company_facts':
437
968
  return companyFacts(args.cik as string);
969
+ case 'edgar_insider_transactions':
970
+ return insiderTransactions(
971
+ args.ticker_or_cik as string,
972
+ args.limit as number | undefined,
973
+ args.include_derivatives as boolean | undefined,
974
+ );
975
+ case 'edgar_institutional_holdings':
976
+ return institutionalHoldings(
977
+ args.ticker_or_cik as string,
978
+ args.limit as number | undefined,
979
+ );
980
+ case 'edgar_fund_holdings':
981
+ return fundHoldings(args.ticker as string, args.limit as number | undefined);
438
982
  case 'edgar_company_concept':
439
983
  return companyConcept(args.cik as string, args.concept as string);
440
984
  case 'edgar_ticker_to_cik':