@pipeworx/mcp-edgar 0.3.4 → 0.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-edgar",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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-da12d4be03a1cfefdc692b867a5dfa74a7d539061c1d7ff62c65bb2d24944675",
30
- "sourceCommit": "4fa4c64448be06f4a2ee4cee19f756f3558a7cad"
29
+ "sourceHash": "v1-11e345a6ca7725b75cd7b396ad673e52735a250951201acc76dbfd62907be72f",
30
+ "sourceCommit": "6182c4cfebddb94950e060b5d2fca18f18ad1aca"
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.4",
6
+ "version": "0.3.6",
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
@@ -1569,7 +1569,7 @@ const tools: McpToolExport['tools'] = [
1569
1569
  },
1570
1570
  form_type: {
1571
1571
  type: 'string',
1572
- description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A". Omit both accession and form_type to get the single most recent filing of any type.',
1572
+ description: 'When accession is omitted, the form type of the latest filing to fetch, e.g. "10-K", "10-Q", "8-K", "DEF 14A" — or a `|`-separated SET ("10-K|10-Q") for "the most recent of either, whichever is newer". Omit both accession and form_type to get the single most recent filing of ANY type at all (routine 8-Ks/Form 4s/Form 144s included, not just annual/quarterly reports) — use the `|` set instead when you specifically want the newest 10-K or 10-Q.',
1573
1573
  },
1574
1574
  include_primary_text: {
1575
1575
  type: 'boolean',
@@ -1605,7 +1605,7 @@ const tools: McpToolExport['tools'] = [
1605
1605
  },
1606
1606
  form_type: {
1607
1607
  type: 'string',
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).',
1608
+ description: 'When accession is omitted, the form type of the latest filing to fetch — "10-K", "10-Q", "8-K", "DEF 14A", etc. For "the most recent 10-K OR 10-Q" (or any "whichever of these is newer" question), pass a `|`-separated SET, e.g. "10-K|10-Q" — do NOT guess a single type ("10-K" by habit skips a newer 10-Q) and do NOT omit this field to get "any type", since a company files far more 8-Ks/Form 4s/Form 144s between annual or quarterly reports than it files the reports themselves and an empty form_type returns the single most recent filing of ANY kind (verified live 2026-09-25, fleet #2450: NTRA\'s single most recent SEC filing was a Form 144 insider-sale notice, filed weeks after its real 10-Q and completely unrelated to the question asked).',
1609
1609
  },
1610
1610
  section: {
1611
1611
  type: 'string',
@@ -1622,7 +1622,7 @@ const tools: McpToolExport['tools'] = [
1622
1622
  },
1623
1623
  search: {
1624
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`.',
1625
+ description: 'Find a specific fact instead of paging blind. Pass a short 2-4 word phrase likely to appear VERBATIM in the prose ("processed approximately", "tests processed", "going concern") rather than restating the question. Case-insensitive substring match over the WHOLE document text (or the whole `section` slice), run BEFORE max_chars/offset windowing; returns matching passages (context + their own offset) instead of the paged `text`, ranking passages with a nearby figure first. If a multi-word phrase has no verbatim match, it falls back to the phrase\'s individual words and returns the passages holding the most of them (search.match_mode "words") — read those passages for the fact rather than treating them as confirmed. Use a returned offset with a follow-up call (no `search`) to read more surrounding text. Accepted aliases: `contains`, `find`, `phrase`.',
1626
1626
  },
1627
1627
  contains: {
1628
1628
  type: 'string',
@@ -2368,6 +2368,34 @@ function htmlToText(html: string): string {
2368
2368
  .trim();
2369
2369
  }
2370
2370
 
2371
+ /**
2372
+ * Index into SEC's own recent-first `forms` array of the latest filing
2373
+ * matching `wantFormTypeArg` — a single form ("10-Q") or a `|`-separated SET
2374
+ * ("10-K|10-Q" for "the most recent annual or quarterly report, whichever is
2375
+ * newer"), same pipe convention this catalog already uses for status fields
2376
+ * (grants-gov's "posted|forecasted"). Omitted/empty returns 0 — the single
2377
+ * most recent filing of ANY type.
2378
+ *
2379
+ * Fleet #2450: a question asking for "the most recent 10-K or 10-Q" cannot
2380
+ * be answered by omitting form_type — that resolves to the single most
2381
+ * recent filing of ANY type, and a company files far more 8-Ks, Form 4s and
2382
+ * Form 144s between annual/quarterly reports than it files the reports
2383
+ * themselves. Verified live: NTRA's single most recent SEC filing on
2384
+ * 2026-09-23 was a Form 144 insider-sale notice, not an annual or quarterly
2385
+ * report at all — form_type left empty silently returns THAT instead of the
2386
+ * 10-Q the question meant. Nor does picking one type and hoping work: a
2387
+ * 10-Q is very often newer than the last 10-K. "10-K|10-Q" is the answer to
2388
+ * "I don't care which, just the newest of these".
2389
+ */
2390
+ function findLatestFormIndex(forms: string[], wantFormTypeArg: string | undefined): number {
2391
+ const wanted = (wantFormTypeArg ?? '')
2392
+ .split('|')
2393
+ .map((f) => f.trim().toUpperCase())
2394
+ .filter(Boolean);
2395
+ if (!wanted.length) return 0;
2396
+ return forms.findIndex((f) => wanted.includes(f.toUpperCase()));
2397
+ }
2398
+
2371
2399
  async function filingDocuments(
2372
2400
  accession: string | undefined,
2373
2401
  tickerOrCik: string,
@@ -2398,7 +2426,7 @@ async function filingDocuments(
2398
2426
  const r = sub.filings?.recent;
2399
2427
  const forms = r?.form ?? [];
2400
2428
  const accs = r?.accessionNumber ?? [];
2401
- const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
2429
+ const idx = findLatestFormIndex(forms, wantFormTypeArg);
2402
2430
  if (idx < 0 || !accs[idx]) {
2403
2431
  throw new Error(
2404
2432
  `No ${wantForm ?? 'recent'} filing found for ${tickerOrCik} in the recent index. Use edgar_company_filings({ticker:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list available filings, then pass an accession.`,
@@ -2581,6 +2609,74 @@ function findPassages(
2581
2609
  return { total: offsets.length, distinct_locations: groups.length, passages };
2582
2610
  }
2583
2611
 
2612
+ // fleet #2450: the router answers in ONE tool call, so "retry with a shorter
2613
+ // phrase" (the old zero-match note) never happens inside ask_pipeworx. Measured
2614
+ // live 2026-09-26: 5 of 5 routed calls for the Natera "revenue and test volume
2615
+ // in its most recent 10-K or 10-Q" question reached the RIGHT filing and passed
2616
+ // a compound phrase ("Signatera revenue", "oncology Signatera revenue test
2617
+ // volume") that matches nothing verbatim — while the word "Signatera" alone
2618
+ // lands on the "processed approximately 2,056,800 tests" sentence. A description
2619
+ // telling the router to pass shorter phrases did not change what it passed.
2620
+ // So when the exact phrase misses, fall back to the phrase's WORDS: anchor on
2621
+ // the rarer words, rank each window by how many distinct query words it holds
2622
+ // (then by a nearby figure), and say plainly that this is a word-level match.
2623
+ const WORD_FALLBACK_STOP = new Set([
2624
+ 'the', 'and', 'for', 'its', 'our', 'their', 'with', 'from', 'that', 'this', 'what', 'which',
2625
+ 'most', 'recent', 'latest', 'report', 'reported', 'reports', 'filing', 'total', 'any', 'all',
2626
+ ]);
2627
+ const WORD_FALLBACK_ANCHOR_MAX = 60;
2628
+
2629
+ export function findWordPassages(
2630
+ text: string,
2631
+ phrase: string,
2632
+ contextChars = SEARCH_CONTEXT_CHARS,
2633
+ maxPassages = SEARCH_MAX_PASSAGES,
2634
+ ): { words: Array<{ word: string; occurrences: number }>; distinct_locations: number; passages: Array<{ offset: number; text: string; words_matched: string[] }> } | null {
2635
+ const words = [...new Set(phrase.toLowerCase().split(/[^a-z0-9&-]+/).filter((w) => w.length >= 3 && !WORD_FALLBACK_STOP.has(w)))];
2636
+ if (words.length < 2) return null;
2637
+ const hay = text.toLowerCase();
2638
+ const occ = new Map<string, number[]>();
2639
+ for (const w of words) {
2640
+ const offs: number[] = [];
2641
+ let from = 0;
2642
+ for (;;) {
2643
+ const i = hay.indexOf(w, from);
2644
+ if (i === -1) break;
2645
+ offs.push(i);
2646
+ from = i + w.length;
2647
+ }
2648
+ occ.set(w, offs);
2649
+ }
2650
+ const present = words.filter((w) => (occ.get(w) ?? []).length > 0);
2651
+ if (present.length === 0) return null;
2652
+ // Anchor on every word rare enough to be distinctive; if every word is
2653
+ // common, anchor on the rarest one only (bounded work on a 200k-char doc).
2654
+ const byRarity = [...present].sort((a, b) => occ.get(a)!.length - occ.get(b)!.length);
2655
+ const anchors = byRarity.filter((w) => occ.get(w)!.length <= WORD_FALLBACK_ANCHOR_MAX);
2656
+ const anchorWords = anchors.length ? anchors : [byRarity[0]];
2657
+ const anchorOffs = [...new Set(anchorWords.flatMap((w) => occ.get(w)!))].sort((a, b) => a - b);
2658
+
2659
+ const groups: Array<{ first: number; last: number }> = [];
2660
+ for (const idx of anchorOffs) {
2661
+ const g = groups[groups.length - 1];
2662
+ if (g && idx - g.last <= contextChars * 2) g.last = idx;
2663
+ else groups.push({ first: idx, last: idx });
2664
+ }
2665
+ const scored = groups.map((g) => {
2666
+ const start = Math.max(0, g.first - contextChars);
2667
+ const end = Math.min(text.length, g.last + contextChars);
2668
+ const win = hay.slice(start, end);
2669
+ const matched = present.filter((w) => win.includes(w));
2670
+ return { start, end, matched, hasNumber: NEARBY_NUMBER_RE.test(text.slice(start, end)) ? 1 : 0 };
2671
+ });
2672
+ scored.sort((a, b) => (b.matched.length - a.matched.length) || (b.hasNumber - a.hasNumber));
2673
+ return {
2674
+ words: words.map((w) => ({ word: w, occurrences: (occ.get(w) ?? []).length })),
2675
+ distinct_locations: groups.length,
2676
+ passages: scored.slice(0, maxPassages).map((s) => ({ offset: s.start, text: text.slice(s.start, s.end), words_matched: s.matched })),
2677
+ };
2678
+ }
2679
+
2584
2680
  // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
2585
2681
  // out (rather than piggybacking edgar_filing_documents' include_primary_text,
2586
2682
  // which caps at 40k and can't page) lets the register pull deep disclosures —
@@ -2619,7 +2715,7 @@ async function filingText(
2619
2715
 
2620
2716
  if (!acc) {
2621
2717
  const wantForm = wantFormTypeArg?.trim().toUpperCase();
2622
- const idx = wantForm ? forms.findIndex((f) => f.toUpperCase() === wantForm) : 0;
2718
+ const idx = findLatestFormIndex(forms, wantFormTypeArg);
2623
2719
  if (idx < 0 || !accs[idx]) {
2624
2720
  throw new Error(`No ${wantForm ?? 'recent'} filing found for ${tickerOrCik}. Use edgar_company_filings({ticker_or_cik:"${tickerOrCik}"${wantForm ? `, form_type:"${wantForm}"` : ''}}) to list filings, then pass an accession.`);
2625
2721
  }
@@ -2684,6 +2780,7 @@ async function filingText(
2684
2780
  const search = (searchTerm ?? '').trim();
2685
2781
  if (search) {
2686
2782
  const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, search);
2783
+ const wordHit = totalMatches === 0 ? findWordPassages(fullText, search) : null;
2687
2784
  return {
2688
2785
  accession: acc.dashed,
2689
2786
  cik: cikNoZeros,
@@ -2695,17 +2792,30 @@ async function filingText(
2695
2792
  section: sectionApplied,
2696
2793
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2697
2794
  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
- },
2795
+ search: wordHit
2796
+ ? {
2797
+ term: search,
2798
+ match_mode: 'words',
2799
+ exact_matches: 0,
2800
+ words: wordHit.words,
2801
+ distinct_locations: wordHit.distinct_locations,
2802
+ returned: wordHit.passages.length,
2803
+ truncated: wordHit.distinct_locations > wordHit.passages.length,
2804
+ passages: wordHit.passages,
2805
+ note: `The exact phrase "${search}" does not appear in this document, so these are passages holding its individual WORDS, ranked by how many of them each passage contains (then by a nearby figure). Read each passage for the fact asked — a word-level match is a lead, not a confirmation. Each offset can be passed as \`offset\` (without \`search\`) to read more surrounding text.`,
2806
+ }
2807
+ : {
2808
+ term: search,
2809
+ match_mode: 'exact',
2810
+ total_matches: totalMatches,
2811
+ distinct_locations: distinctLocations,
2812
+ returned: passages.length,
2813
+ truncated: distinctLocations > passages.length,
2814
+ passages,
2815
+ note: totalMatches === 0
2816
+ ? 'No case-insensitive match for this wording (nor, for a multi-word phrase, for any of its individual words) in the document (or section, if one was given). That is a real answer, not a failure — retry with different wording or drop `section` to search the whole document.'
2817
+ : `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.`,
2818
+ },
2709
2819
  ...(rawTruncated
2710
2820
  ? {
2711
2821
  raw_truncated: true,
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.4' },
12
+ { name: '@pipeworx/mcp-edgar', version: '0.3.6' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15