@pipeworx/mcp-edgar 0.3.5 → 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.5",
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-8ebd704e53458e829aea1033aa14ef78911a50cd60211956318c1574f8b1eb4c",
30
- "sourceCommit": "1064560fd9dcd0d6fc2606298c2ba244b3a1fbeb"
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.5",
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
@@ -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',
@@ -2609,6 +2609,74 @@ function findPassages(
2609
2609
  return { total: offsets.length, distinct_locations: groups.length, passages };
2610
2610
  }
2611
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
+
2612
2680
  // Fetch ONE filing's primary-document text, HTML-stripped, paged. Splitting this
2613
2681
  // out (rather than piggybacking edgar_filing_documents' include_primary_text,
2614
2682
  // which caps at 40k and can't page) lets the register pull deep disclosures —
@@ -2712,6 +2780,7 @@ async function filingText(
2712
2780
  const search = (searchTerm ?? '').trim();
2713
2781
  if (search) {
2714
2782
  const { total: totalMatches, distinct_locations: distinctLocations, passages } = findPassages(fullText, search);
2783
+ const wordHit = totalMatches === 0 ? findWordPassages(fullText, search) : null;
2715
2784
  return {
2716
2785
  accession: acc.dashed,
2717
2786
  cik: cikNoZeros,
@@ -2723,17 +2792,30 @@ async function filingText(
2723
2792
  section: sectionApplied,
2724
2793
  ...(sectionFound !== null ? { section_found: sectionFound } : {}),
2725
2794
  total_chars: fullText.length,
2726
- search: {
2727
- term: search,
2728
- total_matches: totalMatches,
2729
- distinct_locations: distinctLocations,
2730
- returned: passages.length,
2731
- truncated: distinctLocations > passages.length,
2732
- passages,
2733
- note: totalMatches === 0
2734
- ? '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.'
2735
- : `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.`,
2736
- },
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
+ },
2737
2819
  ...(rawTruncated
2738
2820
  ? {
2739
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.5' },
12
+ { name: '@pipeworx/mcp-edgar', version: '0.3.6' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15