@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 +2 -2
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +160 -3
- package/src/server.ts +1 -1
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
|
|
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
|
|
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
|
+
"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-
|
|
30
|
-
"sourceCommit": "
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
12
|
+
{ name: '@pipeworx/mcp-edgar', version: '0.3.4' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|