@pipeworx/mcp-emarket-storage 0.1.4 → 0.1.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/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 1878+ 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 1878+ 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.6",
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-5c645dc46698ad0a98a7fe7c1b219859f4a852bd450e964af83f9c24afd95130",
30
+ "sourceCommit": "29fb434ca65fe0f61191f19c3502f583f5fb99d7"
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.6",
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
@@ -295,12 +295,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
295
295
  // internal-host-class.ts; no-op for every third-party upstream, which is why
296
296
  // this touches 481 packs' error text and changes none of it.
297
297
  return markInternalOrigin(
298
- `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
298
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
299
299
  res.url,
300
300
  res.status,
301
301
  );
302
302
  }
303
303
 
304
+ /**
305
+ * Facts about a FAILED response that live in its headers, not its body, appended
306
+ * so the gateway's classifier (which only ever sees this string) can tell a limit
307
+ * from a wall from a refusal (fleet #3477). Both are standard, both are absent on
308
+ * almost every response, and neither is invented: `Retry-After` is the server
309
+ * saying "come back later" (the one thing that makes a 403 a throttle), and
310
+ * Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
311
+ */
312
+ function responseHints(res: Response): string {
313
+ const hints: string[] = [];
314
+ const retryAfter = res.headers.get('retry-after');
315
+ if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
316
+ if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
317
+ return hints.length ? ` (${hints.join('; ')})` : '';
318
+ }
319
+
304
320
  /**
305
321
  * Just the upstream's own explanation — no name, no status.
306
322
  *
@@ -671,6 +687,254 @@ function collapse(s: string): string {
671
687
  return s.replace(/\s+/g, ' ').trim();
672
688
  }
673
689
 
690
+ // ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
691
+ //
692
+ // `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
693
+ // multi-megabyte upstream body is held TWICE at peak: once as the raw string
694
+ // and once as the object graph (which for an array of small objects runs
695
+ // several times the byte size). Nothing in `workers/gateway` can see that
696
+ // peak — it happens inside the pack, before any gateway code holds a payload —
697
+ // which is why the gateway-side payload caps of #3293 and #3320 did not move
698
+ // the 503/1102 rate at all.
699
+ //
700
+ // Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
701
+ // statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
702
+ // gnomad_gene BRCA1 2,732,706 B
703
+ // Five of those in flight at the quality runner's width is ~40 MB of parse
704
+ // peak against a 128 MB isolate; Cloudflare kills the isolate and every
705
+ // request in flight returns 503/1102, which is why the victims look random.
706
+ //
707
+ // `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
708
+ // returns one enormous top-level JSON array with no server-side paging. It
709
+ // parses elements off the stream and DISCARDS the consumed bytes, so peak
710
+ // retained memory is one element plus one chunk rather than the whole body,
711
+ // and it cancels the body as soon as it has what it was asked for.
712
+
713
+ /** Default ceiling for a single upstream JSON body read into an isolate.
714
+ * Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
715
+ * below the point where a handful of concurrent reads exhausts 128 MB. */
716
+ const DEFAULT_MAX_JSON_BYTES = 1_500_000;
717
+
718
+ interface JsonArrayPrefix<T> {
719
+ /** The decoded elements, at most `maxItems` of them. */
720
+ items: T[];
721
+ /** True when the upstream array had more elements than we read. */
722
+ truncated: boolean;
723
+ /** Bytes actually pulled off the socket — the number that bounds memory. */
724
+ bytesRead: number;
725
+ /** `Content-Length` if the upstream declared one, else null. */
726
+ declaredBytes: number | null;
727
+ }
728
+
729
+ /**
730
+ * Read the FIRST `maxItems` elements of a top-level JSON array off a response
731
+ * stream without ever materialising the whole body.
732
+ *
733
+ * Use this instead of `parseJson` when the upstream returns one big array and
734
+ * offers no `limit`/`offset` of its own. Peak retained memory is one element
735
+ * plus the current chunk; the body is cancelled as soon as `maxItems` is
736
+ * reached, so the remaining megabytes are never transferred at all.
737
+ *
738
+ * const { items, truncated } = await readJsonArrayPrefix<Cube>(
739
+ * res, 'StatCan WDS', { maxItems: limit + offset });
740
+ *
741
+ * `maxBytes` is a second, independent backstop for a pathological element (a
742
+ * single 50 MB object): hitting it stops the read and sets `truncated`, rather
743
+ * than throwing, because a partial answer plus an explicit truncated flag is
744
+ * more useful to a caller than an error.
745
+ */
746
+ async function readJsonArrayPrefix<T>(
747
+ res: Response,
748
+ name: string,
749
+ opts: { maxItems: number; maxBytes?: number },
750
+ ): Promise<JsonArrayPrefix<T>> {
751
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
752
+ const declaredRaw = Number(res.headers.get('content-length'));
753
+ const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
754
+
755
+ // No streaming body available (a mock, or an already-buffered response):
756
+ // fall back to the whole-text path so behaviour stays correct, just unbounded.
757
+ if (!res.body) {
758
+ const all = await parseJson<T[]>(res, name);
759
+ if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
760
+ return {
761
+ items: all.slice(0, opts.maxItems),
762
+ truncated: all.length > opts.maxItems,
763
+ bytesRead: declaredBytes ?? 0,
764
+ declaredBytes,
765
+ };
766
+ }
767
+
768
+ const reader = res.body.getReader();
769
+ const decoder = new TextDecoder();
770
+ const items: T[] = [];
771
+
772
+ let buf = '';
773
+ let scan = 0;
774
+ let bytesRead = 0;
775
+ let started = false; // consumed the opening '['
776
+ let arrayEnded = false;
777
+ let depth = 0;
778
+ let inStr = false;
779
+ let esc = false;
780
+ let elemStart = -1;
781
+ let truncated = false;
782
+
783
+ const push = (text: string) => {
784
+ const t = text.trim();
785
+ if (!t) return;
786
+ items.push(JSON.parse(t) as T);
787
+ };
788
+
789
+ try {
790
+ outer: while (items.length < opts.maxItems && !arrayEnded) {
791
+ const { value, done } = await reader.read();
792
+ if (done) break;
793
+ bytesRead += value.byteLength;
794
+ buf += decoder.decode(value, { stream: true });
795
+ if (bytesRead > maxBytes) { truncated = true; break; }
796
+
797
+ for (; scan < buf.length; scan++) {
798
+ const ch = buf[scan];
799
+
800
+ if (inStr) {
801
+ if (esc) { esc = false; continue; }
802
+ if (ch === '\\') { esc = true; continue; }
803
+ if (ch === '"') inStr = false;
804
+ continue;
805
+ }
806
+
807
+ if (!started) {
808
+ if (ch === '[') { started = true; continue; }
809
+ if (ch.trim() === '') continue;
810
+ throw new Error(
811
+ `upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
812
+ 'This reader is only for upstreams that answer with one top-level array.',
813
+ );
814
+ }
815
+
816
+ if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
817
+
818
+ if (ch === '{' || ch === '[') {
819
+ if (depth === 0 && elemStart < 0) elemStart = scan;
820
+ depth++;
821
+ continue;
822
+ }
823
+
824
+ if (ch === '}' || ch === ']') {
825
+ if (depth > 0) {
826
+ depth--;
827
+ if (depth === 0 && elemStart >= 0) {
828
+ push(buf.slice(elemStart, scan + 1));
829
+ elemStart = -1;
830
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
831
+ }
832
+ continue;
833
+ }
834
+ // depth 0 and a closing bracket: the end of the top-level array.
835
+ if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
836
+ arrayEnded = true;
837
+ break outer;
838
+ }
839
+
840
+ if (ch === ',' && depth === 0) {
841
+ if (elemStart >= 0) {
842
+ push(buf.slice(elemStart, scan));
843
+ elemStart = -1;
844
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
845
+ }
846
+ continue;
847
+ }
848
+
849
+ // A bare scalar element (number / true / false / null).
850
+ if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
851
+ }
852
+
853
+ // Drop everything already consumed. THIS is what bounds memory: without
854
+ // it `buf` grows to the full body and the reader is streaming in name only.
855
+ if (elemStart >= 0) {
856
+ buf = buf.slice(elemStart);
857
+ scan -= elemStart;
858
+ elemStart = 0;
859
+ } else {
860
+ buf = buf.slice(scan);
861
+ scan = 0;
862
+ }
863
+ }
864
+ } finally {
865
+ // Cancel rather than drain: the point is that the remaining megabytes are
866
+ // never transferred. A cancel on an already-finished body is a no-op.
867
+ await reader.cancel().catch(() => {});
868
+ }
869
+
870
+ return { items, truncated, bytesRead, declaredBytes };
871
+ }
872
+
873
+ /**
874
+ * `parseJson`, with a hard ceiling on how many bytes are read.
875
+ *
876
+ * For an upstream whose body is not a single array (so `readJsonArrayPrefix`
877
+ * does not apply) but which can still answer with megabytes. Unlike the array
878
+ * reader this one THROWS when the cap is hit, because a truncated prefix of an
879
+ * arbitrary JSON document is not parseable and there is no partial answer to
880
+ * hand back. A declared `Content-Length` over the cap is refused before a
881
+ * single chunk is read.
882
+ */
883
+ async function parseJsonBounded<T>(
884
+ res: Response,
885
+ name: string,
886
+ maxBytes: number = DEFAULT_MAX_JSON_BYTES,
887
+ ): Promise<T> {
888
+ const declaredRaw = Number(res.headers.get('content-length'));
889
+ const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
890
+
891
+ const tooBig = (n: number) =>
892
+ new Error(
893
+ `upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
894
+ 'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
895
+ 'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
896
+ 'a specific id, or a lower limit) and retry.',
897
+ );
898
+
899
+ if (declared !== null && declared > maxBytes) {
900
+ await res.body?.cancel().catch(() => {});
901
+ throw tooBig(declared);
902
+ }
903
+ if (!res.body) return parseJson<T>(res, name);
904
+
905
+ const reader = res.body.getReader();
906
+ const decoder = new TextDecoder();
907
+ let raw = '';
908
+ let bytesRead = 0;
909
+ try {
910
+ for (;;) {
911
+ const { value, done } = await reader.read();
912
+ if (done) break;
913
+ bytesRead += value.byteLength;
914
+ if (bytesRead > maxBytes) throw tooBig(bytesRead);
915
+ raw += decoder.decode(value, { stream: true });
916
+ }
917
+ raw += decoder.decode();
918
+ } finally {
919
+ await reader.cancel().catch(() => {});
920
+ }
921
+
922
+ if (!raw.trim()) {
923
+ throw new Error(
924
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
925
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
926
+ );
927
+ }
928
+ try {
929
+ return JSON.parse(raw) as T;
930
+ } catch {
931
+ throw new Error(
932
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
933
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
934
+ );
935
+ }
936
+ }
937
+
674
938
 
675
939
  // Reusable entity-resolution helpers for MCP packs. SELF-CONTAINED — no internal
676
940
  // imports — so publish-pack.sh can inline it into standalone pack builds the same
@@ -858,9 +1122,25 @@ function significantNameTokens(name: string): string[] {
858
1122
  * Drupal View; this pack parses its HTML with targeted regex (Cloudflare
859
1123
  * Workers have no DOMParser — same approach as spain-tenders/gdacs).
860
1124
  *
1125
+ * ⚠️ TWO SEPARATE ARCHIVES, SAME SITE, SAME FILTERS. comunicati-finanziari
1126
+ * (emarket_search_disclosures) is the issuer's own PRESS-RELEASE stream;
1127
+ * emarketstorage.it/it/documenti (emarket_search_documents) is the actual
1128
+ * regulated-FILING archive — annual/half-year reports and ESEF/XBRL ZIP
1129
+ * packages. Both expose the same azienda ids, category codes, pagination and
1130
+ * date-filter quirk, but a category filter on one does NOT return the same
1131
+ * rows as the other: verified live 2026-10-19, Interpump's category=100 on
1132
+ * comunicati-finanziari returns only a press release ("IP - 2025 Financial
1133
+ * Results"), while the identical filter on documenti returns "IP - Annual
1134
+ * Financial Report 2025" plus its ESEF XBRL ZIP
1135
+ * (/sites/default/files/xbrl/2026-04/20260402_181127.zip). Use
1136
+ * emarket_search_documents when you actually need the filing, not an
1137
+ * announcement about it.
1138
+ *
861
1139
  * ⚠️ 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).
1140
+ * title, an issuer, a timestamp, and a link to the underlying PDF (or, on
1141
+ * emarket_search_documents, occasionally an ESEF/XBRL ZIP — `has_xbrl` flags
1142
+ * those). There is no per-fact structured-data extraction (no "net income =
1143
+ * X" lookup).
864
1144
  *
865
1145
  * ⚠️ eMARKET-COVERED ISSUERS ONLY — NOT EVERY ITALIAN LISTED COMPANY. Italy
866
1146
  * has a SECOND OAM, 1INFO (a separate, keyless JSON API covering ~314
@@ -896,13 +1176,28 @@ function significantNameTokens(name: string): string[] {
896
1176
  const UA = 'Pipeworx/1.0 (+https://pipeworx.io; support@pipeworx.io)';
897
1177
  const ORIGIN = 'https://www.emarketstorage.it';
898
1178
  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> {
1179
+ // A SEPARATE Drupal view, same filters/markup/category codes/pagination as
1180
+ // LISTING_PATH, but a different underlying archive: comunicati-finanziari is
1181
+ // the press-release stream (an issuer's OWN announcements about its results),
1182
+ // while documenti is the actual regulated-FILING archive — annual-report PDFs
1183
+ // and ESEF/XBRL ZIP packages included. Verified live 2026-10-19: Interpump's
1184
+ // category=100 (1.1 Relazioni finanziarie annuali) on comunicati-finanziari
1185
+ // returns 2 press releases ("IP - 2025 Financial Results") with no annual
1186
+ // report anywhere; the SAME azienda_id + category on documenti returns the
1187
+ // actual "IP - Annual Financial Report 2025" PDF plus its ESEF XBRL ZIP
1188
+ // (/sites/default/files/xbrl/2026-04/20260402_181127.zip). Both routes share
1189
+ // the azienda id space (1309 = Interpump Group on both) and the same
1190
+ // date-filter exclusive-of-day-to quirk, so the fetch/paging/date machinery
1191
+ // below is shared; only the listing path and the ESEF-icon markup differ.
1192
+ const DOCUMENTI_PATH = '/it/documenti';
1193
+ 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).
1194
+
1195
+ async function pwFetch(url: string | URL, init?: RequestInit, timeoutMs?: number): Promise<Response> {
902
1196
  return fetchWithTimeout(
903
1197
  url,
904
1198
  { ...init, headers: { Accept: 'text/html', 'User-Agent': UA, ...(init?.headers ?? {}) } },
905
1199
  'eMarket Storage (Teleborsa OAM)',
1200
+ timeoutMs,
906
1201
  );
907
1202
  }
908
1203
 
@@ -932,7 +1227,7 @@ const tools: McpToolExport['tools'] = [
932
1227
  {
933
1228
  name: 'emarket_search_disclosures',
934
1229
  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.`,
1230
+ `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
1231
  inputSchema: {
937
1232
  type: 'object',
938
1233
  properties: {
@@ -961,6 +1256,23 @@ const tools: McpToolExport['tools'] = [
961
1256
  required: ['id', 'date'],
962
1257
  },
963
1258
  },
1259
+ {
1260
+ name: 'emarket_search_documents',
1261
+ description:
1262
+ `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}.`,
1263
+ inputSchema: {
1264
+ type: 'object',
1265
+ properties: {
1266
+ 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.' },
1267
+ azienda_id: { type: ['string', 'number'], description: "Teleborsa's internal issuer id, from a prior emarket_search_disclosures or emarket_search_documents result." },
1268
+ 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.' },
1269
+ 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.' },
1270
+ 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.' },
1271
+ limit: { type: ['number', 'string'], description: `Number of documents to return (1-${PAGE_SIZE}). Default 10.` },
1272
+ offset: { type: ['number', 'string'], description: 'Result offset for pagination. Default 0.' },
1273
+ },
1274
+ },
1275
+ },
964
1276
  ];
965
1277
 
966
1278
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -970,6 +1282,8 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
970
1282
  return await searchDisclosures(args);
971
1283
  case 'emarket_get_disclosure':
972
1284
  return await getDisclosure(args);
1285
+ case 'emarket_search_documents':
1286
+ return await searchDocuments(args);
973
1287
  default:
974
1288
  return { error: `Unknown tool: ${name}` };
975
1289
  }
@@ -1016,6 +1330,7 @@ interface RawRow {
1016
1330
  company: string;
1017
1331
  title: string;
1018
1332
  documentUrl: string | null;
1333
+ hasXbrl: boolean;
1019
1334
  publishedAtLocal: string | null;
1020
1335
  }
1021
1336
 
@@ -1038,20 +1353,33 @@ function extractRows(html: string): RawRow[] {
1038
1353
  return rows;
1039
1354
  }
1040
1355
 
1356
+ // Shared row shape across BOTH comunicati-finanziari (press releases) and
1357
+ // documenti (the regulated-filing archive). The documenti route's ESEF/XBRL
1358
+ // rows differ from every other row in two ways, both handled here:
1359
+ // - the document link is /sites/default/files/xbrl/…/*.zip, not the
1360
+ // /sites/default/files/comunicati/…/*.pdf path every other row uses;
1361
+ // - the title link's own text is preceded by an icon marker,
1362
+ // <span class="icon-esef" title="…"></span>, which the old title regex
1363
+ // (expecting no nested tag before the text) silently failed to match —
1364
+ // verified live 2026-10-19, the row parsed with title: null even though
1365
+ // the row itself (and its ZIP) were present and fetchable.
1041
1366
  function parseRow(block: string): RawRow | null {
1042
1367
  const protoM = /data-protocollo="(\d+)"/.exec(block);
1043
1368
  if (!protoM) return null;
1044
1369
  const aziendaM = /comunicati-finanziari\?azienda=(\d+)/.exec(block);
1045
- const pdfM = /href="(\/sites\/default\/files\/comunicati\/[^"]+\.pdf)"/.exec(block);
1370
+ const pdfM = /href="(\/sites\/default\/files\/(?:comunicati|xbrl)\/[^"]+\.(?:pdf|zip))"/i.exec(block);
1046
1371
  const timeM = /<time[^>]*>([^<]+)<\/time>/.exec(block);
1047
1372
  const companyM = /class="news-azienda"><a[^>]*>([^<]*)<\/a>/.exec(block);
1048
- const titleM = /class="news-title"><a[^>]*>([^<]*)<\/a>/.exec(block);
1373
+ // Allow an optional leading <span class="icon-esef" …></span> before the
1374
+ // actual title text (present on every ESEF/XBRL row on the documenti route).
1375
+ const titleM = /class="news-title"><a[^>]*>(?:<span[^>]*class="icon-esef"[^>]*><\/span>)?([^<]*)<\/a>/.exec(block);
1049
1376
  return {
1050
1377
  protocollo: protoM[1],
1051
1378
  aziendaId: aziendaM ? aziendaM[1] : null,
1052
1379
  company: companyM ? stripMarkup(companyM[1]).trim() : '',
1053
1380
  title: titleM ? stripMarkup(titleM[1]).trim() : '',
1054
1381
  documentUrl: pdfM ? `${ORIGIN}${pdfM[1]}` : null,
1382
+ hasXbrl: /icon-esef/.test(block) || /\.zip"/i.test(block),
1055
1383
  publishedAtLocal: timeM ? isoFromItalianDateTime(timeM[1].trim()) : null,
1056
1384
  };
1057
1385
  }
@@ -1074,8 +1402,8 @@ function parseListing(html: string): { rows: RawRow[]; hasNext: boolean; lastPag
1074
1402
  return { rows, hasNext, lastPageIndex: lastM ? Number(lastM[1]) : null };
1075
1403
  }
1076
1404
 
1077
- async function fetchListing(params: URLSearchParams): Promise<{ rows: RawRow[]; hasNext: boolean; lastPageIndex: number | null }> {
1078
- const res = await pwFetch(`${ORIGIN}${LISTING_PATH}?${params.toString()}`);
1405
+ async function fetchListing(params: URLSearchParams, path: string = LISTING_PATH, timeoutMs?: number): Promise<{ rows: RawRow[]; hasNext: boolean; lastPageIndex: number | null }> {
1406
+ const res = await pwFetch(`${ORIGIN}${path}?${params.toString()}`, undefined, timeoutMs);
1079
1407
  if (!res.ok) throw await httpError(res, 'eMarket Storage (Teleborsa OAM)');
1080
1408
  return parseListing(await res.text());
1081
1409
  }
@@ -1106,6 +1434,7 @@ function shapeRow(r: RawRow, categoryArg: string | undefined): Record<string, un
1106
1434
  published_at_local: r.publishedAtLocal,
1107
1435
  timezone: 'Europe/Rome',
1108
1436
  document_url: r.documentUrl,
1437
+ has_xbrl: r.hasXbrl,
1109
1438
  };
1110
1439
  }
1111
1440
 
@@ -1233,11 +1562,26 @@ async function getDisclosure(args: Record<string, unknown>): Promise<unknown> {
1233
1562
  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
1563
 
1235
1564
  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.
1565
+ // Bounds the worst case to a handful of SEQUENTIAL page fetches — and each
1566
+ // one gets a SHORT per-call timeout, not the shared 25s default. Both
1567
+ // numbers were sized against measured reality, not guessed: the busiest
1568
+ // single market-wide day found live (2026-03-20, annual-results season)
1569
+ // was 3 pages deep (page=2 was the last), and an ordinary day is 1-2.
1570
+ // MAX_PAGES=8 is already 2.6x that measured peak. The reason to shorten the
1571
+ // per-fetch timeout rather than rely on MAX_PAGES alone: a design partner
1572
+ // reported ~230s stalls in production (fleet #3300) — measured against the
1573
+ // OLD numbers (MAX_PAGES=15 × the shared 25s default = up to 375s
1574
+ // sequential worst case), which is exactly the order of magnitude they hit.
1575
+ // These are small HTML page fetches that normally return in well under a
1576
+ // second (verified live throughout this fix); 10s is generous slack for a
1577
+ // slow-but-alive upstream while keeping the new worst case (8 × 10s = 80s)
1578
+ // well inside any reasonable client timeout.
1579
+ const MAX_PAGES = 8;
1580
+ const PAGE_TIMEOUT_MS = 10_000;
1237
1581
  for (let page = 0; page < MAX_PAGES; page++) {
1238
1582
  const params = new URLSearchParams({ data_from: date, data_to: dateTo });
1239
1583
  if (page > 0) params.set('page', String(page));
1240
- const { rows, hasNext } = await fetchListing(params);
1584
+ const { rows, hasNext } = await fetchListing(params, LISTING_PATH, PAGE_TIMEOUT_MS);
1241
1585
  const hit = rows.find((r) => r.protocollo === id);
1242
1586
  if (hit) return shapeRow(hit, undefined);
1243
1587
  if (!hasNext) break;
@@ -1245,4 +1589,81 @@ async function getDisclosure(args: Record<string, unknown>): Promise<unknown> {
1245
1589
  return { error: 'disclosure not found for that id/date', id, date };
1246
1590
  }
1247
1591
 
1592
+ async function searchDocuments(args: Record<string, unknown>): Promise<unknown> {
1593
+ const companyQuery = strArg(args.company);
1594
+ const aziendaIdArg = strArg(args.azienda_id);
1595
+ const categoryArg = strArg(args.category);
1596
+ const dateFrom = strArg(args.date_from);
1597
+ const dateToArg = strArg(args.date_to);
1598
+ const limit = clampInt(args.limit, 10, 1, PAGE_SIZE);
1599
+ const offset = clampInt(args.offset, 0, 0, 100000);
1600
+
1601
+ // Same INCLUSIVE date_to / exclusive-upstream-bound handling as
1602
+ // emarket_search_disclosures (verified live 2026-10-19 that this route has
1603
+ // the identical off-by-one quirk) — see that tool's implementation comment
1604
+ // for the measured proof.
1605
+ const inclusiveDateTo = dateToArg ?? dateFrom;
1606
+ const upstreamDateTo = inclusiveDateTo ? addOneDay(inclusiveDateTo) : undefined;
1607
+
1608
+ let aziendaId: string | undefined = aziendaIdArg;
1609
+ let resolvedCompany: string | undefined;
1610
+ let candidates: { azienda_id: string; company: string }[] | undefined;
1611
+
1612
+ if (!aziendaId && companyQuery) {
1613
+ const issuers = await getAziendaMap();
1614
+ const ranked = rankMatches(companyQuery, issuers, (e) => e.name);
1615
+ if (ranked.length === 0) {
1616
+ return {
1617
+ found: false,
1618
+ reason: 'no_match',
1619
+ message: `"${companyQuery}" does not match any eMarket Storage issuer.`,
1620
+ 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.",
1621
+ };
1622
+ }
1623
+ const top = ranked[0];
1624
+ const tiedAtTop = ranked.filter((r) => r.score === top.score);
1625
+ if (tiedAtTop.length > 1) {
1626
+ return {
1627
+ found: false,
1628
+ reason: 'ambiguous_match',
1629
+ message: `"${companyQuery}" matches more than one eMarket Storage issuer equally well — pass azienda_id or a more specific company name.`,
1630
+ candidates: tiedAtTop.slice(0, 10).map((r) => ({ azienda_id: r.item.id, company: r.item.name })),
1631
+ };
1632
+ }
1633
+ aziendaId = top.item.id;
1634
+ resolvedCompany = top.item.name;
1635
+ candidates = ranked.slice(0, 5).map((r) => ({ azienda_id: r.item.id, company: r.item.name }));
1636
+ }
1637
+
1638
+ const page = Math.floor(offset / PAGE_SIZE);
1639
+ const withinPageOffset = offset - page * PAGE_SIZE;
1640
+
1641
+ const params = new URLSearchParams();
1642
+ if (dateFrom) params.set('data_from', dateFrom);
1643
+ if (upstreamDateTo) params.set('data_to', upstreamDateTo);
1644
+ if (aziendaId) params.set('azienda', aziendaId);
1645
+ if (categoryArg) params.set('categoria', categoryArg);
1646
+ if (page > 0) params.set('page', String(page));
1647
+
1648
+ const { rows, hasNext, lastPageIndex } = await fetchListing(params, DOCUMENTI_PATH);
1649
+ const pageSlice = rows.slice(withinPageOffset, withinPageOffset + limit);
1650
+ const shaped = pageSlice.map((r) => shapeRow(r, categoryArg));
1651
+
1652
+ return {
1653
+ count: shaped.length,
1654
+ limit,
1655
+ offset,
1656
+ has_more: hasNext || withinPageOffset + limit < rows.length,
1657
+ page_size: PAGE_SIZE,
1658
+ ...(lastPageIndex !== null ? { at_least_total: lastPageIndex * PAGE_SIZE } : {}),
1659
+ ...(dateFrom ? { date_from: dateFrom } : {}),
1660
+ ...(inclusiveDateTo ? { date_to: inclusiveDateTo, upstream_date_to_exclusive: upstreamDateTo } : {}),
1661
+ ...(aziendaId ? { azienda_id: aziendaId } : {}),
1662
+ ...(resolvedCompany ? { company_resolved: resolvedCompany } : {}),
1663
+ ...(candidates && candidates.length > 1 ? { other_candidates: candidates.filter((c) => c.azienda_id !== aziendaId) } : {}),
1664
+ ...(categoryArg ? { category: categoryArg, category_label: CATEGORY_LABELS[categoryArg] ?? null } : {}),
1665
+ documents: shaped,
1666
+ };
1667
+ }
1668
+
1248
1669
  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.6' },
14
14
  { capabilities: { tools: {} } },
15
15
  );
16
16