@pipeworx/mcp-emarket-storage 0.1.4 → 0.1.5

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
@@ -5,7 +5,7 @@ Italian regulated company disclosures direct from eMarket Storage (Teleborsa)
5
5
  built to fix the months-long lag in the community ESEF index for current-year
6
6
  Italian filings.
7
7
 
8
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1819+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
8
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1855+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
9
9
 
10
10
  ## Tools
11
11
 
@@ -16,6 +16,16 @@ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents
16
16
  - `emarket_get_disclosure(id, date)` — fetch one disclosure by its Teleborsa
17
17
  protocollo id and publication date (both come from a prior search — this
18
18
  source has no by-id-only lookup).
19
+ - `emarket_search_documents(company?, azienda_id?, category?, date_from?, date_to?, limit?, offset?)`
20
+ — search eMarket Storage's SEPARATE document archive (annual/half-year
21
+ financial reports, ESEF/XBRL ZIP packages), not the press-release stream
22
+ `emarket_search_disclosures` returns. Use this when you need the actual
23
+ filing. `has_xbrl` flags rows carrying a downloadable ESEF/XBRL ZIP.
24
+
25
+ **All three search parameters above (company/azienda_id on every tool, plus
26
+ category/market) are optional.** Omit the issuer entirely to get every
27
+ covered issuer's rows for a date window in ONE call — the cheap way to do a
28
+ daily check across many issuers (see "One call per day" below).
19
29
 
20
30
  ## Why this pack exists
21
31
 
@@ -28,7 +38,25 @@ Directive disclosures, directly. **Measured live 2026-09-23**: a
28
38
  returned a WEBUILD disclosure published 2026-09-23T17:13 local time (same
29
39
  day as the query), with its PDF confirmed reachable (HTTP 200).
30
40
 
31
- ## ⚠️ Four things a caller must know
41
+ ## One call per day, not one call per issuer
42
+
43
+ A design partner running a daily check across ~155 issuers was calling once
44
+ per issuer and burning through a 50-credit-per-call allowance in ~35 calls
45
+ (fleet #3300, 2026-10). **That is unnecessary — every search tool here
46
+ already supports a date-window query with NO issuer filter at all**, which
47
+ returns every covered issuer's rows for that window in one call:
48
+
49
+ ```
50
+ emarket_search_disclosures({date_from: "2026-10-19", date_to: "2026-10-19"})
51
+ emarket_search_documents({date_from: "2026-10-19", date_to: "2026-10-19"})
52
+ ```
53
+
54
+ Verified live 2026-10-19 — both return every eMarket-covered issuer's rows
55
+ for the day, paginating (page_size 24) only if that single day's market-wide
56
+ volume exceeds one page, which is rare. A 155-issuer daily check is one (or a
57
+ small, bounded few) calls per pack per day, not 155.
58
+
59
+ ## ⚠️ Five things a caller must know
32
60
 
33
61
  1. **eMarket-covered issuers only — this is not the full Italian market.**
34
62
  Italy has a SECOND, separate OAM, **1INFO** (a different keyless JSON API
@@ -64,6 +92,14 @@ day as the query), with its PDF confirmed reachable (HTTP 200).
64
92
  asking for "the 21st through the 22nd" now correctly gets rows from both
65
93
  days. Confirmed locally: the same 21→22 request now returns rows dated
66
94
  both 2026-09-21 and 2026-09-22.
95
+ 5. **Two separate archives on the same site.** `emarket_search_disclosures`
96
+ is the press-release stream; `emarket_search_documents` is the actual
97
+ regulated-filing archive (annual reports, ESEF/XBRL ZIPs). The same
98
+ `category` code on each does NOT return the same rows — verified live
99
+ 2026-10-19: Interpump's `category="100"` on `emarket_search_disclosures`
100
+ returns only a results press release, while the same filter on
101
+ `emarket_search_documents` returns "IP - Annual Financial Report 2025"
102
+ plus its ESEF XBRL ZIP. Use the right tool for what you actually need.
67
103
 
68
104
  ## Auth
69
105
 
@@ -108,8 +144,17 @@ Keyless. No account, no API key.
108
144
  listing page load. An ambiguous match (multiple issuers tied at the same
109
145
  score) returns `candidates` instead of guessing.
110
146
  - `emarket_get_disclosure` has no direct by-id endpoint on the source — it
111
- re-fetches the given date's listing (paging up to 15 pages, i.e. up to 360
112
- disclosures market-wide for that day) and matches on the protocollo id.
147
+ re-fetches the given date's listing (paging up to 8 pages, i.e. up to 192
148
+ disclosures market-wide for that day — measured live, the busiest single
149
+ day found was 3 pages deep) and matches on the protocollo id. Each page
150
+ fetch gets a 10s timeout rather than the shared 25s default, bounding the
151
+ worst case to 80s sequential rather than 375s — a design partner reported
152
+ ~230s stalls in production (fleet #3300) that line up with the old numbers.
153
+ - `emarket_search_documents` reuses the same HTML row parser as
154
+ `emarket_search_disclosures`; its ESEF/XBRL rows carry a
155
+ `<span class="icon-esef">` marker before the title text and link to a
156
+ `/sites/default/files/xbrl/….zip` path instead of the usual
157
+ `/comunicati/….pdf` — both are handled, `has_xbrl` reflects which.
113
158
 
114
159
  ## Quick Start
115
160
 
@@ -155,7 +200,7 @@ directly, instead of just this one's:
155
200
  }
156
201
  ```
157
202
 
158
- Both URLs reach the same gateway and the same 1819+ data sources. The
203
+ Both URLs reach the same gateway and the same 1855+ data sources. The
159
204
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
160
205
  reaches all of them from either one.
161
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-emarket-storage",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Italian regulated disclosures direct from eMarket Storage (Teleborsa) — keyless, same-day-fresh.",
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-2960ffa7f8d2253e830ed8e2c253d574f3aacded6fe241b9780a8159767a0737",
30
- "sourceCommit": "a03f96ed672b8c149593c64ecbf30280cabf084c"
29
+ "sourceHash": "v1-5188becb87f802a8be2754a7f586ed6904d08cb36bdb8205027768c5365b35e8",
30
+ "sourceCommit": "5749f83c0ea115fab4c0c0ef04b66b05f2507d67"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/emarket-storage",
4
4
  "title": "Emarket Storage",
5
5
  "description": "Italian regulated disclosures direct from eMarket Storage (Teleborsa) — keyless, same-day-fresh.",
6
- "version": "0.1.4",
6
+ "version": "0.1.5",
7
7
  "websiteUrl": "https://pipeworx.io/packs/emarket-storage",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-emarket-storage",
package/src/index.ts CHANGED
@@ -671,6 +671,254 @@ function collapse(s: string): string {
671
671
  return s.replace(/\s+/g, ' ').trim();
672
672
  }
673
673
 
674
+ // ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
675
+ //
676
+ // `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
677
+ // multi-megabyte upstream body is held TWICE at peak: once as the raw string
678
+ // and once as the object graph (which for an array of small objects runs
679
+ // several times the byte size). Nothing in `workers/gateway` can see that
680
+ // peak — it happens inside the pack, before any gateway code holds a payload —
681
+ // which is why the gateway-side payload caps of #3293 and #3320 did not move
682
+ // the 503/1102 rate at all.
683
+ //
684
+ // Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
685
+ // statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
686
+ // gnomad_gene BRCA1 2,732,706 B
687
+ // Five of those in flight at the quality runner's width is ~40 MB of parse
688
+ // peak against a 128 MB isolate; Cloudflare kills the isolate and every
689
+ // request in flight returns 503/1102, which is why the victims look random.
690
+ //
691
+ // `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
692
+ // returns one enormous top-level JSON array with no server-side paging. It
693
+ // parses elements off the stream and DISCARDS the consumed bytes, so peak
694
+ // retained memory is one element plus one chunk rather than the whole body,
695
+ // and it cancels the body as soon as it has what it was asked for.
696
+
697
+ /** Default ceiling for a single upstream JSON body read into an isolate.
698
+ * Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
699
+ * below the point where a handful of concurrent reads exhausts 128 MB. */
700
+ const DEFAULT_MAX_JSON_BYTES = 1_500_000;
701
+
702
+ interface JsonArrayPrefix<T> {
703
+ /** The decoded elements, at most `maxItems` of them. */
704
+ items: T[];
705
+ /** True when the upstream array had more elements than we read. */
706
+ truncated: boolean;
707
+ /** Bytes actually pulled off the socket — the number that bounds memory. */
708
+ bytesRead: number;
709
+ /** `Content-Length` if the upstream declared one, else null. */
710
+ declaredBytes: number | null;
711
+ }
712
+
713
+ /**
714
+ * Read the FIRST `maxItems` elements of a top-level JSON array off a response
715
+ * stream without ever materialising the whole body.
716
+ *
717
+ * Use this instead of `parseJson` when the upstream returns one big array and
718
+ * offers no `limit`/`offset` of its own. Peak retained memory is one element
719
+ * plus the current chunk; the body is cancelled as soon as `maxItems` is
720
+ * reached, so the remaining megabytes are never transferred at all.
721
+ *
722
+ * const { items, truncated } = await readJsonArrayPrefix<Cube>(
723
+ * res, 'StatCan WDS', { maxItems: limit + offset });
724
+ *
725
+ * `maxBytes` is a second, independent backstop for a pathological element (a
726
+ * single 50 MB object): hitting it stops the read and sets `truncated`, rather
727
+ * than throwing, because a partial answer plus an explicit truncated flag is
728
+ * more useful to a caller than an error.
729
+ */
730
+ async function readJsonArrayPrefix<T>(
731
+ res: Response,
732
+ name: string,
733
+ opts: { maxItems: number; maxBytes?: number },
734
+ ): Promise<JsonArrayPrefix<T>> {
735
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
736
+ const declaredRaw = Number(res.headers.get('content-length'));
737
+ const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
738
+
739
+ // No streaming body available (a mock, or an already-buffered response):
740
+ // fall back to the whole-text path so behaviour stays correct, just unbounded.
741
+ if (!res.body) {
742
+ const all = await parseJson<T[]>(res, name);
743
+ if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
744
+ return {
745
+ items: all.slice(0, opts.maxItems),
746
+ truncated: all.length > opts.maxItems,
747
+ bytesRead: declaredBytes ?? 0,
748
+ declaredBytes,
749
+ };
750
+ }
751
+
752
+ const reader = res.body.getReader();
753
+ const decoder = new TextDecoder();
754
+ const items: T[] = [];
755
+
756
+ let buf = '';
757
+ let scan = 0;
758
+ let bytesRead = 0;
759
+ let started = false; // consumed the opening '['
760
+ let arrayEnded = false;
761
+ let depth = 0;
762
+ let inStr = false;
763
+ let esc = false;
764
+ let elemStart = -1;
765
+ let truncated = false;
766
+
767
+ const push = (text: string) => {
768
+ const t = text.trim();
769
+ if (!t) return;
770
+ items.push(JSON.parse(t) as T);
771
+ };
772
+
773
+ try {
774
+ outer: while (items.length < opts.maxItems && !arrayEnded) {
775
+ const { value, done } = await reader.read();
776
+ if (done) break;
777
+ bytesRead += value.byteLength;
778
+ buf += decoder.decode(value, { stream: true });
779
+ if (bytesRead > maxBytes) { truncated = true; break; }
780
+
781
+ for (; scan < buf.length; scan++) {
782
+ const ch = buf[scan];
783
+
784
+ if (inStr) {
785
+ if (esc) { esc = false; continue; }
786
+ if (ch === '\\') { esc = true; continue; }
787
+ if (ch === '"') inStr = false;
788
+ continue;
789
+ }
790
+
791
+ if (!started) {
792
+ if (ch === '[') { started = true; continue; }
793
+ if (ch.trim() === '') continue;
794
+ throw new Error(
795
+ `upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
796
+ 'This reader is only for upstreams that answer with one top-level array.',
797
+ );
798
+ }
799
+
800
+ if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
801
+
802
+ if (ch === '{' || ch === '[') {
803
+ if (depth === 0 && elemStart < 0) elemStart = scan;
804
+ depth++;
805
+ continue;
806
+ }
807
+
808
+ if (ch === '}' || ch === ']') {
809
+ if (depth > 0) {
810
+ depth--;
811
+ if (depth === 0 && elemStart >= 0) {
812
+ push(buf.slice(elemStart, scan + 1));
813
+ elemStart = -1;
814
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
815
+ }
816
+ continue;
817
+ }
818
+ // depth 0 and a closing bracket: the end of the top-level array.
819
+ if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
820
+ arrayEnded = true;
821
+ break outer;
822
+ }
823
+
824
+ if (ch === ',' && depth === 0) {
825
+ if (elemStart >= 0) {
826
+ push(buf.slice(elemStart, scan));
827
+ elemStart = -1;
828
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
829
+ }
830
+ continue;
831
+ }
832
+
833
+ // A bare scalar element (number / true / false / null).
834
+ if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
835
+ }
836
+
837
+ // Drop everything already consumed. THIS is what bounds memory: without
838
+ // it `buf` grows to the full body and the reader is streaming in name only.
839
+ if (elemStart >= 0) {
840
+ buf = buf.slice(elemStart);
841
+ scan -= elemStart;
842
+ elemStart = 0;
843
+ } else {
844
+ buf = buf.slice(scan);
845
+ scan = 0;
846
+ }
847
+ }
848
+ } finally {
849
+ // Cancel rather than drain: the point is that the remaining megabytes are
850
+ // never transferred. A cancel on an already-finished body is a no-op.
851
+ await reader.cancel().catch(() => {});
852
+ }
853
+
854
+ return { items, truncated, bytesRead, declaredBytes };
855
+ }
856
+
857
+ /**
858
+ * `parseJson`, with a hard ceiling on how many bytes are read.
859
+ *
860
+ * For an upstream whose body is not a single array (so `readJsonArrayPrefix`
861
+ * does not apply) but which can still answer with megabytes. Unlike the array
862
+ * reader this one THROWS when the cap is hit, because a truncated prefix of an
863
+ * arbitrary JSON document is not parseable and there is no partial answer to
864
+ * hand back. A declared `Content-Length` over the cap is refused before a
865
+ * single chunk is read.
866
+ */
867
+ async function parseJsonBounded<T>(
868
+ res: Response,
869
+ name: string,
870
+ maxBytes: number = DEFAULT_MAX_JSON_BYTES,
871
+ ): Promise<T> {
872
+ const declaredRaw = Number(res.headers.get('content-length'));
873
+ const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
874
+
875
+ const tooBig = (n: number) =>
876
+ new Error(
877
+ `upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
878
+ 'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
879
+ 'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
880
+ 'a specific id, or a lower limit) and retry.',
881
+ );
882
+
883
+ if (declared !== null && declared > maxBytes) {
884
+ await res.body?.cancel().catch(() => {});
885
+ throw tooBig(declared);
886
+ }
887
+ if (!res.body) return parseJson<T>(res, name);
888
+
889
+ const reader = res.body.getReader();
890
+ const decoder = new TextDecoder();
891
+ let raw = '';
892
+ let bytesRead = 0;
893
+ try {
894
+ for (;;) {
895
+ const { value, done } = await reader.read();
896
+ if (done) break;
897
+ bytesRead += value.byteLength;
898
+ if (bytesRead > maxBytes) throw tooBig(bytesRead);
899
+ raw += decoder.decode(value, { stream: true });
900
+ }
901
+ raw += decoder.decode();
902
+ } finally {
903
+ await reader.cancel().catch(() => {});
904
+ }
905
+
906
+ if (!raw.trim()) {
907
+ throw new Error(
908
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
909
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
910
+ );
911
+ }
912
+ try {
913
+ return JSON.parse(raw) as T;
914
+ } catch {
915
+ throw new Error(
916
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
917
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
918
+ );
919
+ }
920
+ }
921
+
674
922
 
675
923
  // Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
676
924
  // imports — so publish-pack.sh can inline it into standalone pack builds the same
@@ -858,9 +1106,25 @@ function significantNameTokens(name: string): string[] {
858
1106
  * Drupal View; this pack parses its HTML with targeted regex (Cloudflare
859
1107
  * Workers have no DOMParser — same approach as spain-tenders/gdacs).
860
1108
  *
1109
+ * ⚠️ TWO SEPARATE ARCHIVES, SAME SITE, SAME FILTERS. comunicati-finanziari
1110
+ * (emarket_search_disclosures) is the issuer's own PRESS-RELEASE stream;
1111
+ * emarketstorage.it/it/documenti (emarket_search_documents) is the actual
1112
+ * regulated-FILING archive — annual/half-year reports and ESEF/XBRL ZIP
1113
+ * packages. Both expose the same azienda ids, category codes, pagination and
1114
+ * date-filter quirk, but a category filter on one does NOT return the same
1115
+ * rows as the other: verified live 2026-10-19, Interpump's category=100 on
1116
+ * comunicati-finanziari returns only a press release ("IP - 2025 Financial
1117
+ * Results"), while the identical filter on documenti returns "IP - Annual
1118
+ * Financial Report 2025" plus its ESEF XBRL ZIP
1119
+ * (/sites/default/files/xbrl/2026-04/20260402_181127.zip). Use
1120
+ * emarket_search_documents when you actually need the filing, not an
1121
+ * announcement about it.
1122
+ *
861
1123
  * ⚠️ DOCUMENT INDEX, NOT XBRL FACTS. Every row is a disclosure EVENT — a
862
- * title, an issuer, a timestamp, and a link to the underlying PDF. There is
863
- * no per-fact structured-data extraction (no "net income = X" lookup).
1124
+ * title, an issuer, a timestamp, and a link to the underlying PDF (or, on
1125
+ * emarket_search_documents, occasionally an ESEF/XBRL ZIP — `has_xbrl` flags
1126
+ * those). There is no per-fact structured-data extraction (no "net income =
1127
+ * X" lookup).
864
1128
  *
865
1129
  * ⚠️ eMARKET-COVERED ISSUERS ONLY — NOT EVERY ITALIAN LISTED COMPANY. Italy
866
1130
  * has a SECOND OAM, 1INFO (a separate, keyless JSON API covering ~314
@@ -896,13 +1160,28 @@ function significantNameTokens(name: string): string[] {
896
1160
  const UA = 'Pipeworx/1.0 (+https://pipeworx.io; support@pipeworx.io)';
897
1161
  const ORIGIN = 'https://www.emarketstorage.it';
898
1162
  const LISTING_PATH = '/it/comunicati-finanziari';
899
- const PAGE_SIZE = 24; // Drupal view's fixed page size — verified live, every page but the last was exactly 24 rows.
900
-
901
- async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response> {
1163
+ // A SEPARATE Drupal view, same filters/markup/category codes/pagination as
1164
+ // LISTING_PATH, but a different underlying archive: comunicati-finanziari is
1165
+ // the press-release stream (an issuer's OWN announcements about its results),
1166
+ // while documenti is the actual regulated-FILING archive — annual-report PDFs
1167
+ // and ESEF/XBRL ZIP packages included. Verified live 2026-10-19: Interpump's
1168
+ // category=100 (1.1 Relazioni finanziarie annuali) on comunicati-finanziari
1169
+ // returns 2 press releases ("IP - 2025 Financial Results") with no annual
1170
+ // report anywhere; the SAME azienda_id + category on documenti returns the
1171
+ // actual "IP - Annual Financial Report 2025" PDF plus its ESEF XBRL ZIP
1172
+ // (/sites/default/files/xbrl/2026-04/20260402_181127.zip). Both routes share
1173
+ // the azienda id space (1309 = Interpump Group on both) and the same
1174
+ // date-filter exclusive-of-day-to quirk, so the fetch/paging/date machinery
1175
+ // below is shared; only the listing path and the ESEF-icon markup differ.
1176
+ const DOCUMENTI_PATH = '/it/documenti';
1177
+ const PAGE_SIZE = 24; // Drupal view's fixed page size — verified live, every page but the last was exactly 24 rows (true on both routes).
1178
+
1179
+ async function pwFetch(url: string | URL, init?: RequestInit, timeoutMs?: number): Promise<Response> {
902
1180
  return fetchWithTimeout(
903
1181
  url,
904
1182
  { ...init, headers: { Accept: 'text/html', 'User-Agent': UA, ...(init?.headers ?? {}) } },
905
1183
  'eMarket Storage (Teleborsa OAM)',
1184
+ timeoutMs,
906
1185
  );
907
1186
  }
908
1187
 
@@ -932,7 +1211,7 @@ const tools: McpToolExport['tools'] = [
932
1211
  {
933
1212
  name: 'emarket_search_disclosures',
934
1213
  description:
935
- `Search Italian regulated company disclosures from eMarket Storage (Teleborsa), one of Italy's two EU Transparency Directive Officially Appointed Mechanisms. PREFER OVER esef_filing_search FOR ITALIAN ISSUERS COVERED HERE — this source is updated same-day, unlike the community ESEF index which runs months behind. Identify the issuer with company (free-text name, resolved against eMarket's own ~480-issuer list) or azienda_id (Teleborsa's internal numeric id, from a prior result) — there is no ISIN/LEI field on this source. date_to IS INCLUSIVE (the last day you want included) — this tool handles the underlying source's own date_to being EXCLUSIVE by always querying one calendar day past it, so date_from="2026-09-21", date_to="2026-09-22" correctly returns rows from BOTH the 21st and the 22nd; a single date_from with no date_to searches just that one day. COVERAGE: only issuers that disclose through eMarket Storage — a second Italian mechanism, 1INFO, covers a separate set of issuers (e.g. IREN, SOMEC) and is NOT included; use oneinfo-storage's oneinfo_search_disclosures for those. An unmatched company name is reported as "not an eMarket Storage issuer", not as an error. Category codes: ${CATEGORY_HINT}. Returns disclosure EVENTS (title, company, timestamp, PDF link) — NOT extracted XBRL facts.`,
1214
+ `Search Italian regulated company disclosures from eMarket Storage (Teleborsa), one of Italy's two EU Transparency Directive Officially Appointed Mechanisms. PREFER OVER esef_filing_search FOR ITALIAN ISSUERS COVERED HERE — this source is updated same-day, unlike the community ESEF index which runs months behind. Identify the issuer with company (free-text name, resolved against eMarket's own ~480-issuer list) or azienda_id (Teleborsa's internal numeric id, from a prior result) — there is no ISIN/LEI field on this source. BOTH ARE OPTIONAL — omit company/azienda_id entirely to get every covered issuer's disclosures in the date window in ONE call (page with limit/offset, page_size=24, only if that day's volume exceeds one page); cheaper than one call per issuer for a multi-issuer daily check. date_to IS INCLUSIVE (the last day you want included) — this tool handles the underlying source's own date_to being EXCLUSIVE by always querying one calendar day past it, so date_from="2026-09-21", date_to="2026-09-22" correctly returns rows from BOTH the 21st and the 22nd; a single date_from with no date_to searches just that one day. COVERAGE: only issuers that disclose through eMarket Storage — a second Italian mechanism, 1INFO, covers a separate set of issuers (e.g. IREN, SOMEC) and is NOT included; use oneinfo-storage's oneinfo_search_disclosures for those. An unmatched company name is reported as "not an eMarket Storage issuer", not as an error. Category codes: ${CATEGORY_HINT}. Returns disclosure EVENTS (title, company, timestamp, PDF link) — NOT extracted XBRL facts.`,
936
1215
  inputSchema: {
937
1216
  type: 'object',
938
1217
  properties: {
@@ -961,6 +1240,23 @@ const tools: McpToolExport['tools'] = [
961
1240
  required: ['id', 'date'],
962
1241
  },
963
1242
  },
1243
+ {
1244
+ name: 'emarket_search_documents',
1245
+ description:
1246
+ `Search eMarket Storage's DOCUMENT ARCHIVE — a SEPARATE repository from emarket_search_disclosures' press-release stream, covering the actual regulated filings: annual/half-year financial reports, ESEF/XBRL packages, AGM documentation and similar. Use this (not emarket_search_disclosures) when you need the annual report or ESEF package itself, not an issuer's press release ABOUT it — verified live 2026-10-19: Interpump's category=100 on emarket_search_disclosures returns only its results press release, while this tool's category=100 on the same issuer returns "IP - Annual Financial Report 2025" plus its ESEF XBRL ZIP. Same issuer ids, category codes, and INCLUSIVE date_to convention as emarket_search_disclosures — see that tool's description for the date-window mechanics. has_xbrl flags rows that carry a downloadable ESEF/XBRL ZIP (document_url then points at the .zip, not a .pdf). Category codes: ${CATEGORY_HINT}.`,
1247
+ inputSchema: {
1248
+ type: 'object',
1249
+ properties: {
1250
+ company: { type: 'string', description: 'Free-text issuer name, e.g. "Interpump", "Saipem". Resolved against the same issuer picklist as emarket_search_disclosures. Optional — omit along with azienda_id to get every issuer\'s documents in the date window in one call.' },
1251
+ azienda_id: { type: ['string', 'number'], description: "Teleborsa's internal issuer id, from a prior emarket_search_disclosures or emarket_search_documents result." },
1252
+ category: { type: 'string', description: 'Category code to filter by, e.g. "100" for annual financial reports/ESEF packages. See tool description for the full code list.' },
1253
+ date_from: { type: 'string', description: 'Only documents published on or after this date (YYYY-MM-DD, inclusive). If date_to is omitted, searches just this one day.' },
1254
+ date_to: { type: 'string', description: 'Only documents published on or before this date (YYYY-MM-DD, INCLUSIVE). Same exclusive-upstream-bound handling as emarket_search_disclosures.' },
1255
+ limit: { type: ['number', 'string'], description: `Number of documents to return (1-${PAGE_SIZE}). Default 10.` },
1256
+ offset: { type: ['number', 'string'], description: 'Result offset for pagination. Default 0.' },
1257
+ },
1258
+ },
1259
+ },
964
1260
  ];
965
1261
 
966
1262
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -970,6 +1266,8 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
970
1266
  return await searchDisclosures(args);
971
1267
  case 'emarket_get_disclosure':
972
1268
  return await getDisclosure(args);
1269
+ case 'emarket_search_documents':
1270
+ return await searchDocuments(args);
973
1271
  default:
974
1272
  return { error: `Unknown tool: ${name}` };
975
1273
  }
@@ -1016,6 +1314,7 @@ interface RawRow {
1016
1314
  company: string;
1017
1315
  title: string;
1018
1316
  documentUrl: string | null;
1317
+ hasXbrl: boolean;
1019
1318
  publishedAtLocal: string | null;
1020
1319
  }
1021
1320
 
@@ -1038,20 +1337,33 @@ function extractRows(html: string): RawRow[] {
1038
1337
  return rows;
1039
1338
  }
1040
1339
 
1340
+ // Shared row shape across BOTH comunicati-finanziari (press releases) and
1341
+ // documenti (the regulated-filing archive). The documenti route's ESEF/XBRL
1342
+ // rows differ from every other row in two ways, both handled here:
1343
+ // - the document link is /sites/default/files/xbrl/…/*.zip, not the
1344
+ // /sites/default/files/comunicati/…/*.pdf path every other row uses;
1345
+ // - the title link's own text is preceded by an icon marker,
1346
+ // <span class="icon-esef" title="…"></span>, which the old title regex
1347
+ // (expecting no nested tag before the text) silently failed to match —
1348
+ // verified live 2026-10-19, the row parsed with title: null even though
1349
+ // the row itself (and its ZIP) were present and fetchable.
1041
1350
  function parseRow(block: string): RawRow | null {
1042
1351
  const protoM = /data-protocollo="(\d+)"/.exec(block);
1043
1352
  if (!protoM) return null;
1044
1353
  const aziendaM = /comunicati-finanziari\?azienda=(\d+)/.exec(block);
1045
- const pdfM = /href="(\/sites\/default\/files\/comunicati\/[^"]+\.pdf)"/.exec(block);
1354
+ const pdfM = /href="(\/sites\/default\/files\/(?:comunicati|xbrl)\/[^"]+\.(?:pdf|zip))"/i.exec(block);
1046
1355
  const timeM = /<time[^>]*>([^<]+)<\/time>/.exec(block);
1047
1356
  const companyM = /class="news-azienda"><a[^>]*>([^<]*)<\/a>/.exec(block);
1048
- const titleM = /class="news-title"><a[^>]*>([^<]*)<\/a>/.exec(block);
1357
+ // Allow an optional leading <span class="icon-esef" …></span> before the
1358
+ // actual title text (present on every ESEF/XBRL row on the documenti route).
1359
+ const titleM = /class="news-title"><a[^>]*>(?:<span[^>]*class="icon-esef"[^>]*><\/span>)?([^<]*)<\/a>/.exec(block);
1049
1360
  return {
1050
1361
  protocollo: protoM[1],
1051
1362
  aziendaId: aziendaM ? aziendaM[1] : null,
1052
1363
  company: companyM ? stripMarkup(companyM[1]).trim() : '',
1053
1364
  title: titleM ? stripMarkup(titleM[1]).trim() : '',
1054
1365
  documentUrl: pdfM ? `${ORIGIN}${pdfM[1]}` : null,
1366
+ hasXbrl: /icon-esef/.test(block) || /\.zip"/i.test(block),
1055
1367
  publishedAtLocal: timeM ? isoFromItalianDateTime(timeM[1].trim()) : null,
1056
1368
  };
1057
1369
  }
@@ -1074,8 +1386,8 @@ function parseListing(html: string): { rows: RawRow[]; hasNext: boolean; lastPag
1074
1386
  return { rows, hasNext, lastPageIndex: lastM ? Number(lastM[1]) : null };
1075
1387
  }
1076
1388
 
1077
- async function fetchListing(params: URLSearchParams): Promise<{ rows: RawRow[]; hasNext: boolean; lastPageIndex: number | null }> {
1078
- const res = await pwFetch(`${ORIGIN}${LISTING_PATH}?${params.toString()}`);
1389
+ async function fetchListing(params: URLSearchParams, path: string = LISTING_PATH, timeoutMs?: number): Promise<{ rows: RawRow[]; hasNext: boolean; lastPageIndex: number | null }> {
1390
+ const res = await pwFetch(`${ORIGIN}${path}?${params.toString()}`, undefined, timeoutMs);
1079
1391
  if (!res.ok) throw await httpError(res, 'eMarket Storage (Teleborsa OAM)');
1080
1392
  return parseListing(await res.text());
1081
1393
  }
@@ -1106,6 +1418,7 @@ function shapeRow(r: RawRow, categoryArg: string | undefined): Record<string, un
1106
1418
  published_at_local: r.publishedAtLocal,
1107
1419
  timezone: 'Europe/Rome',
1108
1420
  document_url: r.documentUrl,
1421
+ has_xbrl: r.hasXbrl,
1109
1422
  };
1110
1423
  }
1111
1424
 
@@ -1233,11 +1546,26 @@ async function getDisclosure(args: Record<string, unknown>): Promise<unknown> {
1233
1546
  if (!date) throw new Error('emarket_get_disclosure requires "date" — the disclosure\'s publication date (YYYY-MM-DD). This source has no by-id lookup, only by-day listings.');
1234
1547
 
1235
1548
  const dateTo = addOneDay(date);
1236
- const MAX_PAGES = 15; // bounds the worst case (a very busy day for the whole market) to ~15 sequential fetches.
1549
+ // Bounds the worst case to a handful of SEQUENTIAL page fetches — and each
1550
+ // one gets a SHORT per-call timeout, not the shared 25s default. Both
1551
+ // numbers were sized against measured reality, not guessed: the busiest
1552
+ // single market-wide day found live (2026-03-20, annual-results season)
1553
+ // was 3 pages deep (page=2 was the last), and an ordinary day is 1-2.
1554
+ // MAX_PAGES=8 is already 2.6x that measured peak. The reason to shorten the
1555
+ // per-fetch timeout rather than rely on MAX_PAGES alone: a design partner
1556
+ // reported ~230s stalls in production (fleet #3300) — measured against the
1557
+ // OLD numbers (MAX_PAGES=15 × the shared 25s default = up to 375s
1558
+ // sequential worst case), which is exactly the order of magnitude they hit.
1559
+ // These are small HTML page fetches that normally return in well under a
1560
+ // second (verified live throughout this fix); 10s is generous slack for a
1561
+ // slow-but-alive upstream while keeping the new worst case (8 × 10s = 80s)
1562
+ // well inside any reasonable client timeout.
1563
+ const MAX_PAGES = 8;
1564
+ const PAGE_TIMEOUT_MS = 10_000;
1237
1565
  for (let page = 0; page < MAX_PAGES; page++) {
1238
1566
  const params = new URLSearchParams({ data_from: date, data_to: dateTo });
1239
1567
  if (page > 0) params.set('page', String(page));
1240
- const { rows, hasNext } = await fetchListing(params);
1568
+ const { rows, hasNext } = await fetchListing(params, LISTING_PATH, PAGE_TIMEOUT_MS);
1241
1569
  const hit = rows.find((r) => r.protocollo === id);
1242
1570
  if (hit) return shapeRow(hit, undefined);
1243
1571
  if (!hasNext) break;
@@ -1245,4 +1573,81 @@ async function getDisclosure(args: Record<string, unknown>): Promise<unknown> {
1245
1573
  return { error: 'disclosure not found for that id/date', id, date };
1246
1574
  }
1247
1575
 
1576
+ async function searchDocuments(args: Record<string, unknown>): Promise<unknown> {
1577
+ const companyQuery = strArg(args.company);
1578
+ const aziendaIdArg = strArg(args.azienda_id);
1579
+ const categoryArg = strArg(args.category);
1580
+ const dateFrom = strArg(args.date_from);
1581
+ const dateToArg = strArg(args.date_to);
1582
+ const limit = clampInt(args.limit, 10, 1, PAGE_SIZE);
1583
+ const offset = clampInt(args.offset, 0, 0, 100000);
1584
+
1585
+ // Same INCLUSIVE date_to / exclusive-upstream-bound handling as
1586
+ // emarket_search_disclosures (verified live 2026-10-19 that this route has
1587
+ // the identical off-by-one quirk) — see that tool's implementation comment
1588
+ // for the measured proof.
1589
+ const inclusiveDateTo = dateToArg ?? dateFrom;
1590
+ const upstreamDateTo = inclusiveDateTo ? addOneDay(inclusiveDateTo) : undefined;
1591
+
1592
+ let aziendaId: string | undefined = aziendaIdArg;
1593
+ let resolvedCompany: string | undefined;
1594
+ let candidates: { azienda_id: string; company: string }[] | undefined;
1595
+
1596
+ if (!aziendaId && companyQuery) {
1597
+ const issuers = await getAziendaMap();
1598
+ const ranked = rankMatches(companyQuery, issuers, (e) => e.name);
1599
+ if (ranked.length === 0) {
1600
+ return {
1601
+ found: false,
1602
+ reason: 'no_match',
1603
+ message: `"${companyQuery}" does not match any eMarket Storage issuer.`,
1604
+ note: "This pack only covers issuers that disclose through eMarket Storage. A separate mechanism, 1INFO, covers a different set of Italian issuers (e.g. IREN, SOMEC) and is not included here — try oneinfo-storage's oneinfo_search_disclosures instead.",
1605
+ };
1606
+ }
1607
+ const top = ranked[0];
1608
+ const tiedAtTop = ranked.filter((r) => r.score === top.score);
1609
+ if (tiedAtTop.length > 1) {
1610
+ return {
1611
+ found: false,
1612
+ reason: 'ambiguous_match',
1613
+ message: `"${companyQuery}" matches more than one eMarket Storage issuer equally well — pass azienda_id or a more specific company name.`,
1614
+ candidates: tiedAtTop.slice(0, 10).map((r) => ({ azienda_id: r.item.id, company: r.item.name })),
1615
+ };
1616
+ }
1617
+ aziendaId = top.item.id;
1618
+ resolvedCompany = top.item.name;
1619
+ candidates = ranked.slice(0, 5).map((r) => ({ azienda_id: r.item.id, company: r.item.name }));
1620
+ }
1621
+
1622
+ const page = Math.floor(offset / PAGE_SIZE);
1623
+ const withinPageOffset = offset - page * PAGE_SIZE;
1624
+
1625
+ const params = new URLSearchParams();
1626
+ if (dateFrom) params.set('data_from', dateFrom);
1627
+ if (upstreamDateTo) params.set('data_to', upstreamDateTo);
1628
+ if (aziendaId) params.set('azienda', aziendaId);
1629
+ if (categoryArg) params.set('categoria', categoryArg);
1630
+ if (page > 0) params.set('page', String(page));
1631
+
1632
+ const { rows, hasNext, lastPageIndex } = await fetchListing(params, DOCUMENTI_PATH);
1633
+ const pageSlice = rows.slice(withinPageOffset, withinPageOffset + limit);
1634
+ const shaped = pageSlice.map((r) => shapeRow(r, categoryArg));
1635
+
1636
+ return {
1637
+ count: shaped.length,
1638
+ limit,
1639
+ offset,
1640
+ has_more: hasNext || withinPageOffset + limit < rows.length,
1641
+ page_size: PAGE_SIZE,
1642
+ ...(lastPageIndex !== null ? { at_least_total: lastPageIndex * PAGE_SIZE } : {}),
1643
+ ...(dateFrom ? { date_from: dateFrom } : {}),
1644
+ ...(inclusiveDateTo ? { date_to: inclusiveDateTo, upstream_date_to_exclusive: upstreamDateTo } : {}),
1645
+ ...(aziendaId ? { azienda_id: aziendaId } : {}),
1646
+ ...(resolvedCompany ? { company_resolved: resolvedCompany } : {}),
1647
+ ...(candidates && candidates.length > 1 ? { other_candidates: candidates.filter((c) => c.azienda_id !== aziendaId) } : {}),
1648
+ ...(categoryArg ? { category: categoryArg, category_label: CATEGORY_LABELS[categoryArg] ?? null } : {}),
1649
+ documents: shaped,
1650
+ };
1651
+ }
1652
+
1248
1653
  export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@ import pack from './index.js';
10
10
 
11
11
 
12
12
  const server = new Server(
13
- { name: '@pipeworx/mcp-emarket-storage', version: '0.1.4' },
13
+ { name: '@pipeworx/mcp-emarket-storage', version: '0.1.5' },
14
14
  { capabilities: { tools: {} } },
15
15
  );
16
16