@pipeworx/mcp-edgar 0.1.0 → 0.2.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 881+ 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 881+ 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.2.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.2.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,63 @@ 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
+ },
112
151
  {
113
152
  name: 'edgar_ticker_to_cik',
114
153
  description:
115
- 'Look up a company CIK number from its ticker symbol. The CIK is needed for other EDGAR tools.',
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.',
116
155
  inputSchema: {
117
156
  type: 'object' as const,
118
157
  properties: {
@@ -200,6 +239,9 @@ async function searchFilings(
200
239
  }
201
240
 
202
241
  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
+ }
203
245
  const res = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: SEC_HEADERS });
204
246
  if (!res.ok) throw new Error(`SEC ticker lookup error: ${res.status}`);
205
247
 
@@ -224,6 +266,14 @@ async function tickerToCik(ticker: string) {
224
266
  }
225
267
 
226
268
  async function resolveCik(tickerOrCik: string): Promise<string> {
269
+ if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
270
+ throw new Error(
271
+ 'Required argument "ticker_or_cik" is missing or empty. ' +
272
+ 'Try one of: edgar_company_filings({ticker_or_cik: "AAPL"}) by ticker, ' +
273
+ 'or edgar_company_filings({ticker_or_cik: "320193"}) by CIK number (Apple\'s CIK). ' +
274
+ 'Tickers are auto-resolved to CIKs internally.'
275
+ );
276
+ }
227
277
  if (isNumericCik(tickerOrCik)) return tickerOrCik.trim();
228
278
  const result = await tickerToCik(tickerOrCik);
229
279
  return result.cik;
@@ -345,9 +395,12 @@ async function companyFacts(cik: string) {
345
395
  if (!fact) continue;
346
396
 
347
397
  const usdEntries = fact.units['USD'] ?? fact.units['shares'] ?? [];
398
+ // Same fix as companyConcept: don't gate on `frame !== undefined`;
399
+ // off-calendar filers (NVDA, AAPL) get their modern annuals dropped.
400
+ // Sort by period end (desc) to surface the actual most-recent 10-K.
348
401
  const annual = usdEntries
349
- .filter((e) => e.form === '10-K' && e.frame !== undefined)
350
- .sort((a, b) => (b.fy ?? 0) - (a.fy ?? 0));
402
+ .filter((e) => e.form === '10-K' || e.form === '10-K/A')
403
+ .sort((a, b) => (b.end ?? '').localeCompare(a.end ?? ''));
351
404
 
352
405
  metrics[key] = {
353
406
  label: fact.label,
@@ -365,15 +418,78 @@ async function companyFacts(cik: string) {
365
418
  };
366
419
  }
367
420
 
368
- async function companyConcept(cik: string, concept: string) {
421
+ // Friendly-name → XBRL candidate tags. Same versioning issue as compare_entities:
422
+ // ASC 606 (2018) forced most filers from "Revenues" to
423
+ // "RevenueFromContractWithCustomerExcludingAssessedTax"; older companies
424
+ // still use SalesRevenueNet, etc.
425
+ //
426
+ // Two flavors of key in this table:
427
+ //
428
+ // 1. Friendly names ("revenue", "cash", "longtermdebt") — what an LLM
429
+ // naturally types in prose. Maps to a list of XBRL candidates that
430
+ // cover the common ASC-606 / pre-ASC-606 / pre-IFRS variants.
431
+ //
432
+ // 2. Literal XBRL concept names lowercased — what a more sophisticated
433
+ // LLM types after looking up a "real" GAAP tag. These are the ones
434
+ // that showed up in production analytics as the top 80-of-84 errors
435
+ // on edgar_company_concept: filers reported the same metric under a
436
+ // sibling concept the LLM didn't pick. We map each known-failing
437
+ // XBRL name back to its own list (itself first, then the realistic
438
+ // siblings) so the call walks the fallback before throwing.
439
+ //
440
+ // Anything not in the table still falls through to [concept] (the bare
441
+ // literal), so we don't regress for niche concepts not yet observed in
442
+ // failures.
443
+ const CONCEPT_CANDIDATES: Record<string, string[]> = {
444
+ // ── friendly names ────────────────────────────────────────────────
445
+ revenue: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
446
+ revenues: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet'],
447
+ netincome: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
448
+ netincomeloss: ['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
449
+ cash: [
450
+ 'CashAndCashEquivalentsAtCarryingValue',
451
+ 'Cash',
452
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
453
+ ],
454
+ longtermdebt: ['LongTermDebt', 'LongTermDebtNoncurrent'],
455
+ // ── XBRL-name fallbacks (drove 80-of-84 EDGAR errors in 48h analytics) ──
456
+ longtermdebtnoncurrent: ['LongTermDebtNoncurrent', 'LongTermDebt'],
457
+ salesrevenuenet: ['SalesRevenueNet', 'Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax'],
458
+ revenuefromcontractwithcustomerexcludingassessedtax: [
459
+ 'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
460
+ ],
461
+ revenuefromcontractwithcustomerincludingassessedtax: [
462
+ 'RevenueFromContractWithCustomerIncludingAssessedTax',
463
+ 'RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet',
464
+ ],
465
+ netincomelossavailabletocommonstockholdersbasic: [
466
+ 'NetIncomeLossAvailableToCommonStockholdersBasic', 'NetIncomeLoss', 'ProfitLoss',
467
+ ],
468
+ netincomelossavailabletocommonstockholdersdiluted: [
469
+ 'NetIncomeLossAvailableToCommonStockholdersDiluted',
470
+ 'NetIncomeLossAvailableToCommonStockholdersBasic', 'NetIncomeLoss', 'ProfitLoss',
471
+ ],
472
+ profitloss: ['ProfitLoss', 'NetIncomeLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic'],
473
+ cashcashequivalentsrestrictedcashandrestrictedcashequivalents: [
474
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents',
475
+ 'CashAndCashEquivalentsAtCarryingValue', 'Cash',
476
+ ],
477
+ cashandcashequivalentsatcarryingvalue: [
478
+ 'CashAndCashEquivalentsAtCarryingValue',
479
+ 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents', 'Cash',
480
+ ],
481
+ };
482
+
483
+ async function companyConcept(cikOrTicker: string, concept: string) {
484
+ // Auto-resolve ticker → CIK so callers can pass "AAPL" not "320193".
485
+ // Production analytics: 11% errors on edgar largely from LLMs passing
486
+ // tickers as cik (then padCik strips letters → 0000000000 → 404).
487
+ const cik = await resolveCik(cikOrTicker);
369
488
  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
489
 
376
- const data = (await res.json()) as {
490
+ const candidates = CONCEPT_CANDIDATES[concept.trim().toLowerCase()] ?? [concept];
491
+
492
+ type ConceptDoc = {
377
493
  cik: number;
378
494
  entityName: string;
379
495
  tag: string;
@@ -386,24 +502,72 @@ async function companyConcept(cik: string, concept: string) {
386
502
  >;
387
503
  };
388
504
 
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
-
505
+ // Fetch EVERY candidate that exists, then pick the one with the most recent
506
+ // 10-K data — not the first non-empty one. Filers switch revenue tags over
507
+ // time: NVDA reports current revenue under "Revenues" (through FY2026) while
508
+ // its older "RevenueFromContractWithCustomerExcludingAssessedTax" tag is
509
+ // frozen at FY2022 but still returns 200. First-non-empty returned the stale
510
+ // $26.9B/FY2022 figure; freshest-data-wins returns the correct $215.9B.
511
+ let lastStatus = 0;
512
+ 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 ?? '';
525
+ }
526
+ }
527
+ found.push({ doc, latestEnd });
528
+ }
529
+ if (found.length === 0) {
530
+ 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.`,
532
+ );
533
+ }
534
+ found.sort((a, b) => (b.latestEnd ?? '').localeCompare(a.latestEnd ?? ''));
535
+ const data = found[0].doc;
536
+
537
+ // Return annual (10-K) values sorted by fiscal year / period_end DESC.
538
+ //
539
+ // Filter notes:
540
+ // - DON'T filter on `frame !== undefined`. SEC populates `frame` only
541
+ // when a fact aligns to a calendar quarter (e.g. CY2020Q4). Off-
542
+ // calendar filers like NVDA (fiscal year ends late January) have no
543
+ // frame on most facts; the prior filter dropped their modern annual
544
+ // values entirely, leaving only the few entries that happened to
545
+ // align by accident. Result: NVDA "Revenues" returned FY2019-2022
546
+ // and the current $130B annual revenue was invisible. Run 6 audit
547
+ // caught this.
548
+ // - DO dedupe: a 10-K and its 10-K/A amendments both report the same
549
+ // (fy, fp, end) tuple. Keep the most-recently-filed version.
550
+ type Raw = { end: string; val: number; accn: string; fy: number; fp: string; form: string; filed: string; frame?: string };
551
+ const dedup = new Map<string, { entry: Raw; unit: string }>();
392
552
  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
- });
553
+ for (const e of values) {
554
+ if (e.form !== '10-K' && e.form !== '10-K/A') continue;
555
+ const key = `${unit}|${e.fy ?? ''}|${e.fp ?? 'FY'}|${e.end ?? ''}`;
556
+ const prior = dedup.get(key);
557
+ if (!prior || (e.filed ?? '') > (prior.entry.filed ?? '')) {
558
+ dedup.set(key, { entry: e, unit });
559
+ }
405
560
  }
406
561
  }
562
+ const entries = [...dedup.values()]
563
+ .map(({ entry, unit }) => ({
564
+ fiscal_year: entry.fy,
565
+ period_end: entry.end,
566
+ value: entry.val,
567
+ filed: entry.filed,
568
+ unit,
569
+ }))
570
+ .sort((a, b) => (b.period_end ?? '').localeCompare(a.period_end ?? ''));
407
571
 
408
572
  return {
409
573
  cik: String(data.cik),
@@ -415,6 +579,261 @@ async function companyConcept(cik: string, concept: string) {
415
579
  };
416
580
  }
417
581
 
582
+ // ── Insider transactions (Form 3/4/5) ───────────────────────────────
583
+
584
+ // SEC Form 4 transaction codes (Table I / II). Open-market buys (P) and
585
+ // sells (S) are the real signal; A/M/F/G are comp & mechanical.
586
+ const TXN_CODE_MEANING: Record<string, string> = {
587
+ P: 'Open-market or private purchase',
588
+ S: 'Open-market or private sale',
589
+ A: 'Grant/award (e.g. RSU/option grant)',
590
+ D: 'Disposition to the issuer (e.g. forfeiture)',
591
+ F: 'Shares withheld to pay exercise price or tax',
592
+ M: 'Exercise/conversion of derivative security',
593
+ C: 'Conversion of derivative security',
594
+ X: 'Exercise of in/at-the-money derivative',
595
+ G: 'Bona fide gift',
596
+ J: 'Other acquisition or disposition',
597
+ V: 'Transaction voluntarily reported early',
598
+ };
599
+
600
+ // Form 4 XML is small and flat; Workers have no DOMParser, so pull values
601
+ // with anchored regex. SEC wraps most leaf values in <tag><value>X</value></tag>,
602
+ // but plain <tag>X</tag> also occurs (e.g. <rptOwnerName>, <officerTitle>).
603
+ function xmlVal(block: string, tag: string): string | null {
604
+ const wrapped = new RegExp(`<${tag}>\\s*<value>([\\s\\S]*?)</value>`, 'i').exec(block);
605
+ if (wrapped) return wrapped[1].trim();
606
+ const plain = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i').exec(block);
607
+ return plain ? plain[1].trim() : null;
608
+ }
609
+
610
+ function xmlBlocks(xml: string, tag: string): string[] {
611
+ const out: string[] = [];
612
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'gi');
613
+ let m: RegExpExecArray | null;
614
+ while ((m = re.exec(xml)) !== null) out.push(m[1]);
615
+ return out;
616
+ }
617
+
618
+ function parseForm4Transactions(xml: string, includeDerivatives: boolean) {
619
+ const owner = xmlVal(xml, 'rptOwnerName');
620
+ const rel = xmlBlocks(xml, 'reportingOwnerRelationship')[0] ?? '';
621
+ const roles: string[] = [];
622
+ if (/<isDirector>\s*(1|true)/i.test(rel)) roles.push('Director');
623
+ const officerTitle = xmlVal(rel, 'officerTitle');
624
+ if (/<isOfficer>\s*(1|true)/i.test(rel)) roles.push(officerTitle ? `Officer (${officerTitle})` : 'Officer');
625
+ if (/<isTenPercentOwner>\s*(1|true)/i.test(rel)) roles.push('10% owner');
626
+ if (/<isOther>\s*(1|true)/i.test(rel)) roles.push('Other');
627
+
628
+ const txnTags = includeDerivatives
629
+ ? ['nonDerivativeTransaction', 'derivativeTransaction']
630
+ : ['nonDerivativeTransaction'];
631
+
632
+ const transactions: {
633
+ security: string | null;
634
+ date: string | null;
635
+ code: string | null;
636
+ code_meaning: string | null;
637
+ shares: number | null;
638
+ price_per_share: number | null;
639
+ acquired_disposed: string | null;
640
+ value_usd: number | null;
641
+ shares_owned_after: number | null;
642
+ derivative: boolean;
643
+ }[] = [];
644
+
645
+ for (const tag of txnTags) {
646
+ for (const b of xmlBlocks(xml, tag)) {
647
+ const code = xmlVal(b, 'transactionCode');
648
+ const shares = numOrNull(xmlVal(b, 'transactionShares'));
649
+ const price = numOrNull(xmlVal(b, 'transactionPricePerShare'));
650
+ const ad = xmlVal(b, 'transactionAcquiredDisposedCode');
651
+ transactions.push({
652
+ security: xmlVal(b, 'securityTitle'),
653
+ date: xmlVal(b, 'transactionDate'),
654
+ code,
655
+ code_meaning: code ? TXN_CODE_MEANING[code] ?? null : null,
656
+ shares,
657
+ price_per_share: price,
658
+ acquired_disposed: ad === 'A' ? 'acquired' : ad === 'D' ? 'disposed' : ad,
659
+ value_usd: shares !== null && price !== null ? Math.round(shares * price * 100) / 100 : null,
660
+ shares_owned_after: numOrNull(xmlVal(b, 'sharesOwnedFollowingTransaction')),
661
+ derivative: tag === 'derivativeTransaction',
662
+ });
663
+ }
664
+ }
665
+
666
+ return { owner, roles, transactions };
667
+ }
668
+
669
+ function numOrNull(s: string | null): number | null {
670
+ if (s === null) return null;
671
+ const n = Number(s.replace(/,/g, ''));
672
+ return Number.isFinite(n) ? n : null;
673
+ }
674
+
675
+ async function insiderTransactions(tickerOrCik: string, limit?: number, includeDerivatives?: boolean) {
676
+ const cik = await resolveCik(tickerOrCik);
677
+ const paddedCik = padCik(cik);
678
+ const count = Math.min(25, Math.max(1, limit ?? 10));
679
+
680
+ const res = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
681
+ if (!res.ok) throw new Error(`SEC EDGAR submissions error: ${res.status}`);
682
+ const data = (await res.json()) as {
683
+ cik: string;
684
+ name: string;
685
+ tickers: string[];
686
+ filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[]; primaryDocument: string[] } };
687
+ };
688
+
689
+ const recent = data.filings.recent;
690
+ const targets: { accession: string; date: string; form: string; doc: string }[] = [];
691
+ for (let i = 0; i < recent.form.length && targets.length < count; i++) {
692
+ const form = recent.form[i];
693
+ if (form === '3' || form === '4' || form === '5') {
694
+ targets.push({
695
+ accession: recent.accessionNumber[i],
696
+ date: recent.filingDate[i],
697
+ form,
698
+ doc: recent.primaryDocument[i],
699
+ });
700
+ }
701
+ }
702
+
703
+ const filings = await Promise.all(
704
+ targets.map(async (t) => {
705
+ const accPath = t.accession.replace(/-/g, '');
706
+ // primaryDocument for Form 4 is the XSL viewer path (e.g. "xslF345X06/foo.xml").
707
+ // Strip the leading xsl*/ directory to fetch the raw machine-readable XML.
708
+ const rawDoc = t.doc.replace(/^xsl[^/]*\//i, '');
709
+ const url = `https://www.sec.gov/Archives/edgar/data/${data.cik}/${accPath}/${rawDoc}`;
710
+ try {
711
+ const r = await fetch(url, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
712
+ if (!r.ok) return { accession_number: t.accession, filing_date: t.date, form: t.form, error: `fetch ${r.status}`, filing_url: url };
713
+ const xml = await r.text();
714
+ const parsed = parseForm4Transactions(xml, includeDerivatives ?? false);
715
+ return {
716
+ accession_number: t.accession,
717
+ filing_date: t.date,
718
+ form: t.form,
719
+ owner: parsed.owner,
720
+ owner_roles: parsed.roles,
721
+ transactions: parsed.transactions,
722
+ filing_url: url,
723
+ };
724
+ } catch (e) {
725
+ return { accession_number: t.accession, filing_date: t.date, form: t.form, error: String(e), filing_url: url };
726
+ }
727
+ }),
728
+ );
729
+
730
+ return {
731
+ cik: data.cik,
732
+ company_name: data.name,
733
+ tickers: data.tickers ?? [],
734
+ form_4_filings_parsed: filings.length,
735
+ note: 'Transaction codes: P=open-market buy (strongest signal), S=sale, A=grant/award (routine comp), M=option exercise, F=tax withholding, G=gift.',
736
+ filings,
737
+ };
738
+ }
739
+
740
+ // ── Institutional holdings (Form 13F-HR) ────────────────────────────
741
+
742
+ // 13F info tables come with a namespace prefix (e.g. <ns1:infoTable>) that
743
+ // varies by filer/agent. Strip prefixes so the same regex works everywhere.
744
+ function stripNs(xml: string): string {
745
+ return xml.replace(/<(\/?)[a-zA-Z0-9]+:/g, '<$1');
746
+ }
747
+
748
+ async function institutionalHoldings(tickerOrCik: string, limit?: number) {
749
+ const cik = await resolveCik(tickerOrCik);
750
+ const paddedCik = padCik(cik);
751
+ const topN = Math.min(100, Math.max(1, limit ?? 25));
752
+
753
+ const subRes = await fetch(`${DATA_BASE}/submissions/CIK${paddedCik}.json`, { headers: SEC_HEADERS });
754
+ if (!subRes.ok) throw new Error(`SEC EDGAR submissions error: ${subRes.status}`);
755
+ const sub = (await subRes.json()) as {
756
+ cik: string;
757
+ name: string;
758
+ filings: { recent: { accessionNumber: string[]; filingDate: string[]; form: string[]; reportDate: string[] } };
759
+ };
760
+
761
+ const r = sub.filings.recent;
762
+ let target: { accession: string; filed: string; period: string } | null = null;
763
+ for (let i = 0; i < r.form.length; i++) {
764
+ if (r.form[i] === '13F-HR' || r.form[i] === '13F-HR/A') {
765
+ target = { accession: r.accessionNumber[i], filed: r.filingDate[i], period: r.reportDate?.[i] ?? '' };
766
+ break;
767
+ }
768
+ }
769
+ if (!target) {
770
+ throw new Error(
771
+ `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.`,
772
+ );
773
+ }
774
+
775
+ // Find the information-table XML in the accession folder: the .xml file that
776
+ // is neither primary_doc.xml (cover page) nor an xsl-rendered viewer copy.
777
+ const accPath = target.accession.replace(/-/g, '');
778
+ const folder = `https://www.sec.gov/Archives/edgar/data/${sub.cik}/${accPath}`;
779
+ const idxRes = await fetch(`${folder}/index.json`, { headers: SEC_HEADERS });
780
+ if (!idxRes.ok) throw new Error(`SEC EDGAR filing index error: ${idxRes.status}`);
781
+ const idx = (await idxRes.json()) as { directory: { item: { name: string }[] } };
782
+ const infoFile = idx.directory.item.find(
783
+ (it) => it.name.toLowerCase().endsWith('.xml') && it.name !== 'primary_doc.xml' && !/^xsl/i.test(it.name),
784
+ );
785
+ if (!infoFile) {
786
+ throw new Error(`13F information table not found in filing ${target.accession}.`);
787
+ }
788
+
789
+ const tableRes = await fetch(`${folder}/${infoFile.name}`, { headers: { 'User-Agent': SEC_HEADERS['User-Agent'] } });
790
+ if (!tableRes.ok) throw new Error(`SEC EDGAR 13F table error: ${tableRes.status}`);
791
+ const xml = stripNs(await tableRes.text());
792
+
793
+ // Aggregate rows by issuer+cusip+putCall (managers file multiple sub-portfolio
794
+ // rows per security; sum them). Value is reported in whole USD post-2023.
795
+ type Agg = { issuer: string; cusip: string; put_call: string | null; value_usd: number; shares: number };
796
+ const byKey = new Map<string, Agg>();
797
+ for (const b of xmlBlocks(xml, 'infoTable')) {
798
+ const issuer = xmlVal(b, 'nameOfIssuer') ?? 'UNKNOWN';
799
+ const cusip = xmlVal(b, 'cusip') ?? '';
800
+ const putCall = xmlVal(b, 'putCall');
801
+ const value = numOrNull(xmlVal(b, 'value')) ?? 0;
802
+ const shares = numOrNull(xmlVal(b, 'sshPrnamt')) ?? 0;
803
+ const key = `${cusip}|${putCall ?? ''}`;
804
+ const prior = byKey.get(key);
805
+ if (prior) {
806
+ prior.value_usd += value;
807
+ prior.shares += shares;
808
+ } else {
809
+ byKey.set(key, { issuer, cusip, put_call: putCall, value_usd: value, shares });
810
+ }
811
+ }
812
+
813
+ const all = [...byKey.values()].sort((a, b) => b.value_usd - a.value_usd);
814
+ const totalValue = all.reduce((s, h) => s + h.value_usd, 0);
815
+ const holdings = all.slice(0, topN).map((h) => ({
816
+ issuer: h.issuer,
817
+ cusip: h.cusip,
818
+ put_call: h.put_call,
819
+ value_usd: h.value_usd,
820
+ shares: h.shares,
821
+ pct_of_portfolio: totalValue > 0 ? Math.round((h.value_usd / totalValue) * 10000) / 100 : null,
822
+ }));
823
+
824
+ return {
825
+ cik: sub.cik,
826
+ manager_name: sub.name,
827
+ form: '13F-HR',
828
+ report_period: target.period,
829
+ filed_date: target.filed,
830
+ total_portfolio_value_usd: totalValue,
831
+ total_positions: all.length,
832
+ holdings_returned: holdings.length,
833
+ holdings,
834
+ };
835
+ }
836
+
418
837
  // ── callTool router ─────────────────────────────────────────────────
419
838
 
420
839
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -435,6 +854,17 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
435
854
  );
436
855
  case 'edgar_company_facts':
437
856
  return companyFacts(args.cik as string);
857
+ case 'edgar_insider_transactions':
858
+ return insiderTransactions(
859
+ args.ticker_or_cik as string,
860
+ args.limit as number | undefined,
861
+ args.include_derivatives as boolean | undefined,
862
+ );
863
+ case 'edgar_institutional_holdings':
864
+ return institutionalHoldings(
865
+ args.ticker_or_cik as string,
866
+ args.limit as number | undefined,
867
+ );
438
868
  case 'edgar_company_concept':
439
869
  return companyConcept(args.cik as string, args.concept as string);
440
870
  case 'edgar_ticker_to_cik':