@pipeworx/mcp-edgar 0.3.3 → 0.3.4

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
  The Securities and Exchange Commission's filings database. Every public company in the US files here, every filing is timestamped and immutable, and every disclosure (revenue, debt, risk factors, executive compensation, M&A activity, insider trading) is structured for machine retrieval. Free and authoritative.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1679+ live data sources.
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1683+ live data sources.
6
6
 
7
7
  ## Why this matters for AI agents
8
8
 
@@ -106,7 +106,7 @@ directly, instead of just this one's:
106
106
  }
107
107
  ```
108
108
 
109
- Both URLs reach the same gateway and the same 1679+ data sources. The
109
+ Both URLs reach the same gateway and the same 1683+ data sources. The
110
110
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
111
111
  reaches all of them from either one.
112
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "EDGAR MCP — SEC EDGAR public APIs (free, no auth)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-c758c9386febbe0e91470cf7aa45ee292d3edb8f1b165a1404f258617be43004",
30
- "sourceCommit": "76aa8f5d9b5dc2f665563030647c68e661478864"
29
+ "sourceHash": "v1-da12d4be03a1cfefdc692b867a5dfa74a7d539061c1d7ff62c65bb2d24944675",
30
+ "sourceCommit": "4fa4c64448be06f4a2ee4cee19f756f3558a7cad"
31
31
  }
32
32
  }
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.3",
6
+ "version": "0.3.4",
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
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
452
452
  ),
453
453
  );
454
454
  }
455
- throw err;
455
+ // Fleet #2382. Everything that isn't a timeout/abort here is a genuine
456
+ // NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
457
+ // Cloudflare's own "Network connection lost." — meaning `fetch()` itself
458
+ // threw and no HTTP response of any kind was ever received. Until this fix
459
+ // that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
460
+ // (or the Workers-runtime equivalent) names no upstream, carries no class
461
+ // token, and reads exactly like a defect in OUR code — because it says
462
+ // nothing about the call at all. It landed in `error`, the tier that means
463
+ // "Pipeworx has a defect", for every one of the (at the time of writing)
464
+ // ~470 packs that call this helper directly with no wrapper of their own.
465
+ //
466
+ // `dexscreener` hit this independently (fleet #1579) and fixed it with a
467
+ // bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
468
+ // but only covers one pack; every other caller of this shared helper still
469
+ // leaked the raw exception. Moving the same fix HERE — the one place that
470
+ // already carries the timeout case — covers every pack that uses
471
+ // `fetchWithTimeout` without a wrapper, for free, and without widening
472
+ // `classifyToolError`'s regex list: the fix is giving the message a proper
473
+ // `upstream_down:` token at the point the two facts (no response was ever
474
+ // received, and which host we were trying to reach) are actually in hand,
475
+ // not teaching the classifier to guess from prose after the fact.
476
+ //
477
+ // Safe on the same grounds as the timeout branch above: no argument a
478
+ // caller passes can make `fetch()` itself throw a connection-level error,
479
+ // so this is always an availability failure, never a caller mistake. Same
480
+ // `markInternalOrigin` treatment — an origin we run that never answered is
481
+ // still ours, not a third party's outage.
482
+ const raw = err instanceof Error ? err.message : String(err);
483
+ throw new Error(
484
+ markInternalOrigin(
485
+ `upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
486
+ `No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
487
+ 'are valid — do not re-check them on the strength of this error. Retry shortly.',
488
+ url,
489
+ ),
490
+ );
456
491
  }
457
492
  }
458
493
 
@@ -1551,7 +1586,7 @@ const tools: McpToolExport['tools'] = [
1551
1586
  {
1552
1587
  name: 'edgar_filing_text',
1553
1588
  description:
1554
- 'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, and the liquidity footnote. Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events). Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page and can make a late `section` (e.g. subsequent_events) come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
1589
+ 'AUTHORITATIVE full text of a SEC filing\'s primary document (10-K / 10-Q / 8-K body), HTML stripped to clean plaintext — the source for disclosures that live in prose, not XBRL: going-concern language, ATM / at-the-market equity facilities, committed-equity share caps, public-float figures, subsequent events, the liquidity footnote, and MD&A KPIs XBRL never tags (test volume, units shipped, subscriber counts, same-store sales). Pass an accession (from edgar_search_filings / edgar_company_filings) plus the filer\'s ticker or CIK; OR omit accession and pass ticker + form_type to auto-resolve the latest matching filing. **For a specific fact inside a long filing, pass `search`** (a word or exact phrase, e.g. "tests processed" or "processed approximately") instead of paging blind — it scans the WHOLE document (before any offset/max_chars windowing) and returns every matching passage with surrounding context and its own `offset` in the document, so a KPI ~100k characters in is found in one call instead of paging through `max_chars` windows by hand. A zero-match `search` is a real answer (the filing does not use that exact wording) — retry with a shorter or different phrase rather than assuming the tool failed. Optionally set `section` to return just one part (going_concern | liquidity | capital_resources | subsequent_events); `search` runs within that slice when both are given. Large docs (a 10-Q is ~100k+ chars of text) are PAGED, not spilled, when `search` is not used: the result caps at `max_chars` (default 50000) from `offset`, and returns `truncated` + `next_offset` — pass next_offset back as `offset` to read the next window. An especially large filing (e.g. an S-1 with heavy inline-XBRL tagging can exceed 10MB of raw HTML) is also capped on the READ side — the response sets `raw_truncated:true` when only the first portion of the document was read at all, which bounds how far `offset` can page (and how far `search` can scan) and can make a late section or search term come back not-found even though it exists further in. Use for "does $TICKER disclose substantial doubt / going concern", "what ATM facility does $TICKER have", "read the liquidity section of the latest 10-Q", "how many tests did $TICKER process this quarter". For the list of documents/exhibits in a filing use edgar_filing_documents; for structured financial numbers use edgar_company_concept.',
1555
1590
  summary: 'The full text of an SEC filing\'s main document, HTML stripped to clean plaintext.',
1556
1591
  inputSchema: {
1557
1592
  type: 'object' as const,
@@ -1570,7 +1605,7 @@ const tools: McpToolExport['tools'] = [
1570
1605
  },
1571
1606
  form_type: {
1572
1607
  type: 'string',
1573
- description: 'When accession is omitted, the form type of the latest filing to fetch — "10-K", "10-Q", "8-K", "DEF 14A", etc.',
1608
+ description: 'When accession is omitted, the form type of the latest filing to fetch — "10-K", "10-Q", "8-K", "DEF 14A", etc. A question asking for the "most recent 10-K OR 10-Q" (or otherwise not committed to one type) should OMIT this field entirely rather than guess "10-K" by habit — omitting form_type (along with accession) returns the single most recent filing of ANY type, and a 10-Q is very often more recent than the last 10-K since it files quarterly while the 10-K only files once a year (verified live 2026-09-25, fleet #2450: NTRA\'s most recent 10-Q was filed 2026-08-07, five months after its 2026-02-27 10-K — passing form_type:"10-K" here silently skips the newer filing and the KPI it asked about).',
1574
1609
  },
1575
1610
  section: {
1576
1611
  type: 'string',
@@ -1585,6 +1620,22 @@ const tools: McpToolExport['tools'] = [
1585
1620
  type: 'number',
1586
1621
  description: 'Character offset to start from (default 0). Pass the prior result\'s next_offset to page forward.',
1587
1622
  },
1623
+ search: {
1624
+ type: 'string',
1625
+ description: 'Find a specific fact instead of paging blind. This is a SUBSTRING match, not a relevance search — pass the exact 2-4 word phrase most likely to appear VERBATIM in the prose ("tests processed", "processed approximately", "going concern"), never a compound of every concept in the question. A phrase that ANDs several unrelated question-words together ("oncology Signatera revenue test volume") returns ZERO matches even in the CORRECT filing, because the filing\'s own sentence never contains all of those words together — verified live 2026-09-25 (fleet #2450) on a real Natera 10-Q that DOES report the exact number asked for: "Signatera revenue" and the 4-word compound above both found nothing, while the filing\'s own wording ("processed approximately", "tests processed") is what actually appears. When unsure of the filing\'s exact phrasing, prefer the SHORTEST distinctive 2-3 word fragment of the metric name itself (a unit, a verb+noun like "processed approximately") over restating the question. Case-insensitive substring match over the WHOLE document text (or the whole `section` slice, if both are given), run BEFORE max_chars/offset windowing. Returns every matching passage (surrounding context + its own offset in the document) instead of the normal paged `text` — use the returned offsets with a follow-up call (no `search`, `offset` set to one of them) if you need more surrounding text than the passage gives. Zero matches means try again with a SHORTER, more literal phrase before concluding the filing does not disclose it — this is a real answer about wording, not a tool failure. Accepted aliases: `contains`, `find`, `phrase`.',
1626
+ },
1627
+ contains: {
1628
+ type: 'string',
1629
+ description: 'Alias for `search`.',
1630
+ },
1631
+ find: {
1632
+ type: 'string',
1633
+ description: 'Alias for `search`.',
1634
+ },
1635
+ phrase: {
1636
+ type: 'string',
1637
+ description: 'Alias for `search`.',
1638
+ },
1588
1639
  ticker_or_cik: {
1589
1640
  type: 'string',
1590
1641
  description: 'Alias for `ticker` / `cik` — the single-argument spelling edgar_company_filings and edgar_insider_transactions use. Takes either a ticker or a CIK.',
@@ -2464,6 +2515,72 @@ const FILING_SECTION_PATTERNS: Record<string, { anchor: RegExp; pick: 'first' |
2464
2515
  const FILING_TEXT_DEFAULT_MAX = 50000;
2465
2516
  const FILING_TEXT_CAP = 100000;
2466
2517
 
2518
+ // fleet #2446: a KPI question ("how many tests did $TICKER process") routed
2519
+ // correctly to edgar_filing_text but the fact sat ~100k characters into a
2520
+ // 10-Q's MD&A, which meant paging max_chars windows by hand to find it. This
2521
+ // is a plain case-insensitive substring scan of the ALREADY-EXTRACTED plain-
2522
+ // text document (not a second fetch) — cheap, and it runs before the normal
2523
+ // offset/max_chars windowing so it sees the whole doc (or the whole `section`
2524
+ // slice) rather than one page of it.
2525
+ const SEARCH_CONTEXT_CHARS = 400;
2526
+ const SEARCH_MAX_PASSAGES = 10;
2527
+
2528
+ // A KPI figure looks like "2,056,800" or "1,708,200" — thousands-grouped, or
2529
+ // otherwise a bare run of 4+ digits (a raw count with no separator).
2530
+ const NEARBY_NUMBER_RE = /\d{1,3}(?:,\d{3})+|\d{4,}/;
2531
+
2532
+ function findPassages(
2533
+ text: string,
2534
+ term: string,
2535
+ contextChars = SEARCH_CONTEXT_CHARS,
2536
+ maxPassages = SEARCH_MAX_PASSAGES,
2537
+ ): { total: number; distinct_locations: number; passages: Array<{ offset: number; text: string }> } {
2538
+ const needle = term.toLowerCase();
2539
+ if (!needle) return { total: 0, distinct_locations: 0, passages: [] };
2540
+ const hay = text.toLowerCase();
2541
+ const offsets: number[] = [];
2542
+ let from = 0;
2543
+ for (;;) {
2544
+ const idx = hay.indexOf(needle, from);
2545
+ if (idx === -1) break;
2546
+ offsets.push(idx);
2547
+ from = idx + needle.length;
2548
+ }
2549
+
2550
+ // Merge occurrences close enough that their context windows would overlap
2551
+ // into ONE passage — a KPI sentence ("we processed approximately X tests,
2552
+ // compared to Y tests processed") can repeat the search term 2-3 times a
2553
+ // few words apart, and without this a common word burns most of the
2554
+ // (capped) passage budget on one sentence instead of covering the document.
2555
+ const groups: Array<{ first: number; last: number }> = [];
2556
+ for (const idx of offsets) {
2557
+ const g = groups[groups.length - 1];
2558
+ if (g && idx - g.last <= contextChars * 2) g.last = idx;
2559
+ else groups.push({ first: idx, last: idx });
2560
+ }
2561
+
2562
+ // Rank groups whose window carries a nearby NUMBER first. A KPI question
2563
+ // ("how many tests/units/subscribers") is answered by a figure next to the
2564
+ // word, not by every mention of the word — measured live (fleet #2446):
2565
+ // searching "tests" in Natera's Q2 2026 10-Q hits 78 times, mostly
2566
+ // forward-looking-statements boilerplate ("expectations... for our
2567
+ // tests"), with the actual "processed approximately 2,056,800 tests" KPI
2568
+ // buried past the first ~80 of them by document order. Ranking by nearby
2569
+ // digits (no second fetch — same text already in hand) reliably surfaces
2570
+ // it inside a small `maxPassages` cap instead of requiring the caller to
2571
+ // already know the more distinctive phrase to search for. Groups without a
2572
+ // nearby number keep DOCUMENT ORDER among themselves (stable sort).
2573
+ const scored = groups.map((g) => {
2574
+ const start = Math.max(0, g.first - contextChars);
2575
+ const end = Math.min(text.length, g.last + needle.length + contextChars);
2576
+ return { start, end, hasNumber: NEARBY_NUMBER_RE.test(text.slice(start, end)) ? 1 : 0 };
2577
+ });
2578
+ scored.sort((a, b) => b.hasNumber - a.hasNumber);
2579
+
2580
+ const passages = scored.slice(0, maxPassages).map((s) => ({ offset: s.start, text: text.slice(s.start, s.end) }));
2581
+ return { total: offsets.length, distinct_locations: groups.length, passages };
2582
+ }
2583
+
2467
2584
  // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
2468
2585
  // out (rather than piggybacking edgar_filing_documents' include_primary_text,
2469
2586
  // which caps at 40k and can't page) lets the register pull deep disclosures —
@@ -2475,6 +2592,7 @@ async function filingText(
2475
2592
  maxChars?: number,
2476
2593
  offset?: number,
2477
2594
  wantFormTypeArg?: string,
2595
+ searchTerm?: string,
2478
2596
  ) {
2479
2597
  if (typeof tickerOrCik !== 'string' || !tickerOrCik.trim()) {
2480
2598
  throw new Error('A company is required: pass `ticker` ("ACTU"), `cik` or their alias `ticker_or_cik` — plus either an accession or a form_type (e.g. edgar_filing_text({ticker:"ACTU", form_type:"10-Q"})).');
@@ -2559,6 +2677,44 @@ async function filingText(
2559
2677
  }
2560
2678
  }
2561
2679
 
2680
+ // `search` short-circuits the normal offset/max_chars windowing: it scans
2681
+ // the whole (possibly section-sliced) document and returns matching
2682
+ // passages with their own offsets, rather than one page of raw text the
2683
+ // caller has to eyeball for the fact they wanted (fleet #2446).
2684
+ const search = (searchTerm ?? '').trim();
2685
+ if (search) {
2686
+ const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, search);
2687
+ return {
2688
+ accession: acc.dashed,
2689
+ cik: cikNoZeros,
2690
+ company: sub.name ?? null,
2691
+ form: formType,
2692
+ filed: filingDate,
2693
+ document: primaryDocument,
2694
+ document_url: docUrl,
2695
+ section: sectionApplied,
2696
+ ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2697
+ total_chars: fullText.length,
2698
+ search: {
2699
+ term: search,
2700
+ total_matches: totalMatches,
2701
+ distinct_locations: distinctLocations,
2702
+ returned: passages.length,
2703
+ truncated: distinctLocations > passages.length,
2704
+ passages,
2705
+ note: totalMatches === 0
2706
+ ? 'No case-insensitive match for this exact wording in the document (or section, if one was given). That is a real answer, not a failure — retry with a shorter phrase or drop `section` to search the whole document.'
2707
+ : `Passages carrying a nearby number (a likely KPI figure) are ranked first; the rest keep document order. Each passage's offset is a position in the document text — pass it as \`offset\` (without \`search\`) to read more surrounding text via the normal paged text field.`,
2708
+ },
2709
+ ...(rawTruncated
2710
+ ? {
2711
+ raw_truncated: true,
2712
+ raw_truncated_note: `This document's raw HTML exceeds the ${(FILING_DOC_MAX_BYTES / 1_000_000).toFixed(0)}MB this tool reads in one call, so the search above only scanned the portion read — NOT the whole filing. Treat total_matches:0 as inconclusive, not "absent", for a raw_truncated document.`,
2713
+ }
2714
+ : {}),
2715
+ };
2716
+ }
2717
+
2562
2718
  const total = fullText.length;
2563
2719
  const off = Math.max(0, Math.floor(Number(offset) || 0));
2564
2720
  const cap = Math.min(FILING_TEXT_CAP, Math.max(1000, Math.floor(Number(maxChars) || FILING_TEXT_DEFAULT_MAX)));
@@ -4439,6 +4595,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
4439
4595
  args.max_chars as number | undefined,
4440
4596
  args.offset as number | undefined,
4441
4597
  args.form_type as string | undefined,
4598
+ (args.search ?? args.contains ?? args.find ?? args.phrase) as string | undefined,
4442
4599
  );
4443
4600
  case 'edgar_ticker_to_cik':
4444
4601
  return tickerToCik((args.ticker ?? args.ticker_or_cik ?? args.company) as string);
package/src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
9
9
  import pack from './index.js';
10
10
 
11
11
  const server = new Server(
12
- { name: '@pipeworx/mcp-edgar', version: '0.3.3' },
12
+ { name: '@pipeworx/mcp-edgar', version: '0.3.4' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15