@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 +3 -3
- package/server.json +1 -1
- package/src/index.ts +126 -16
- package/src/server.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-edgar",
|
|
3
|
-
"version": "0.3.
|
|
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-
|
|
30
|
-
"sourceCommit": "
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
:
|
|
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.
|
|
12
|
+
{ name: '@pipeworx/mcp-edgar', version: '0.3.6' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|