@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 +50 -5
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +434 -13
- package/src/server.ts +1 -1
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
|
|
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
|
-
##
|
|
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
|
|
112
|
-
disclosures market-wide for that day
|
|
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
|
|
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.
|
|
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-
|
|
30
|
-
"sourceCommit": "
|
|
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.
|
|
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
|
|
863
|
-
*
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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)"
|
|
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
|
-
|
|
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}${
|
|
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
|
-
|
|
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