@pipeworx/mcp-ecb 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +359 -1
- package/src/server.ts +5 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
European Central Bank Data Portal MCP — exchange rates, interest rates, monetary aggregates, HICP, bank lending. No auth.
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1855+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
@@ -71,19 +71,19 @@ directly, instead of just this one's:
|
|
|
71
71
|
}
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Both URLs reach the same gateway and the same
|
|
74
|
+
Both URLs reach the same gateway and the same 1855+ data sources. The
|
|
75
75
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
76
76
|
reaches all of them from either one.
|
|
77
77
|
|
|
78
78
|
## No MCP client? Call it over HTTP
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
curl -X POST https://gateway.pipeworx.io/v1/tools/
|
|
81
|
+
curl -X POST https://gateway.pipeworx.io/v1/tools/ecb_policy_rate \
|
|
82
82
|
-H 'Content-Type: application/json' \
|
|
83
|
-
-d '{
|
|
83
|
+
-d '{}'
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
No account needed for the first calls. Inspect any tool: `GET https://gateway.pipeworx.io/v1/tools/
|
|
86
|
+
No account needed for the first calls. Inspect any tool: `GET https://gateway.pipeworx.io/v1/tools/ecb_policy_rate`. Find one: `POST https://gateway.pipeworx.io/v1/tools/search_packs` with `{"query":"..."}`.
|
|
87
87
|
|
|
88
88
|
## Standalone (no gateway account)
|
|
89
89
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-ecb",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "ECB Data Portal MCP — European Central Bank statistical data",
|
|
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-ab883f34ae882027b585171d95c9b8c5f3467180a89e01691b2894af4f901431",
|
|
30
|
+
"sourceCommit": "5749f83c0ea115fab4c0c0ef04b66b05f2507d67"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/ecb",
|
|
4
4
|
"title": "Ecb",
|
|
5
5
|
"description": "ECB Data Portal MCP — European Central Bank statistical data",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.4",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/ecb",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-ecb",
|
package/src/index.ts
CHANGED
|
@@ -670,6 +670,254 @@ function pickMessage(node: unknown, depth: number): string | null {
|
|
|
670
670
|
function collapse(s: string): string {
|
|
671
671
|
return s.replace(/\s+/g, ' ').trim();
|
|
672
672
|
}
|
|
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
|
+
}
|
|
673
921
|
/**
|
|
674
922
|
* ECB Data Portal MCP — European Central Bank statistical data
|
|
675
923
|
*
|
|
@@ -687,6 +935,10 @@ function collapse(s: string): string {
|
|
|
687
935
|
* API docs: https://data.ecb.europa.eu/help/api/
|
|
688
936
|
*
|
|
689
937
|
* Tools:
|
|
938
|
+
* - ecb_policy_rate: current deposit facility rate / main refinancing rate /
|
|
939
|
+
* marginal lending rate, each with effective_since,
|
|
940
|
+
* previous_rate and decision history (FM flow, the three
|
|
941
|
+
* official "key ECB interest rates" — fleet #3206)
|
|
690
942
|
* - exchange_rate: EUR → currency rate (convenience over EXR flow)
|
|
691
943
|
* - hicp_inflation: Harmonised CPI YoY change (convenience over ICP flow)
|
|
692
944
|
* - get_data: generic SDMX query against any flow
|
|
@@ -704,7 +956,27 @@ async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response>
|
|
|
704
956
|
|
|
705
957
|
const BASE = 'https://data-api.ecb.europa.eu/service';
|
|
706
958
|
|
|
959
|
+
// The three official "key ECB interest rates", FM flow, confirmed against the
|
|
960
|
+
// live series. DFR is the ECB's primary policy lever since the 2024 corridor
|
|
961
|
+
// narrowing (deposits now drive short-term money-market rates); MRO and MLFR
|
|
962
|
+
// are published alongside it and move on the same Governing Council dates.
|
|
963
|
+
const POLICY_RATE_SERIES: { field: string; label: string; key: string }[] = [
|
|
964
|
+
{ field: 'deposit_facility_rate', label: 'Deposit facility rate', key: 'D.U2.EUR.4F.KR.DFR.LEV' },
|
|
965
|
+
{ field: 'main_refinancing_rate', label: 'Main refinancing operations rate (fixed-rate tenders)', key: 'D.U2.EUR.4F.KR.MRR_FR.LEV' },
|
|
966
|
+
{ field: 'marginal_lending_facility_rate', label: 'Marginal lending facility rate', key: 'D.U2.EUR.4F.KR.MLFR.LEV' },
|
|
967
|
+
];
|
|
968
|
+
|
|
707
969
|
const tools: McpToolExport['tools'] = [
|
|
970
|
+
{
|
|
971
|
+
name: 'ecb_policy_rate',
|
|
972
|
+
description:
|
|
973
|
+
"The European Central Bank's three official key interest rates — the deposit facility rate (the ECB's " +
|
|
974
|
+
'primary policy rate since the 2024 operational-framework reform), the main refinancing operations rate, ' +
|
|
975
|
+
'and the marginal lending facility rate. For each: the current rate, the date it took effect, the previous ' +
|
|
976
|
+
'rate and when that one took effect, plus recent decision history (FM flow, series DFR/MRR_FR/MLFR). ' +
|
|
977
|
+
"Use this for \"ECB interest rate\" / \"ECB deposit rate\" / \"has the ECB cut rates\" instead of guessing an FM series key.",
|
|
978
|
+
inputSchema: { type: 'object', properties: {} },
|
|
979
|
+
},
|
|
708
980
|
{
|
|
709
981
|
name: 'ecb_exchange_rate',
|
|
710
982
|
description:
|
|
@@ -777,6 +1049,8 @@ const tools: McpToolExport['tools'] = [
|
|
|
777
1049
|
|
|
778
1050
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
779
1051
|
switch (name) {
|
|
1052
|
+
case 'ecb_policy_rate':
|
|
1053
|
+
return policyRate();
|
|
780
1054
|
case 'ecb_exchange_rate':
|
|
781
1055
|
return exchangeRate(args);
|
|
782
1056
|
case 'ecb_hicp_inflation':
|
|
@@ -790,6 +1064,76 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
790
1064
|
}
|
|
791
1065
|
}
|
|
792
1066
|
|
|
1067
|
+
/** {rate, effective_since, previous_rate, previous_rate_effective_since, decision_history, data_as_of} from an ascending {period,value} series. */
|
|
1068
|
+
function computeRateChanges(obs: { period: string; value: number | null }[]) {
|
|
1069
|
+
const pts = obs.filter((o) => o.value !== null) as { period: string; value: number }[];
|
|
1070
|
+
if (pts.length === 0) return null;
|
|
1071
|
+
const changes: { rate: number; effective_since: string }[] = [{ rate: pts[0].value, effective_since: pts[0].period }];
|
|
1072
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1073
|
+
if (pts[i].value !== changes[changes.length - 1].rate) {
|
|
1074
|
+
changes.push({ rate: pts[i].value, effective_since: pts[i].period });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
const current = changes[changes.length - 1];
|
|
1078
|
+
const previous = changes.length > 1 ? changes[changes.length - 2] : null;
|
|
1079
|
+
return {
|
|
1080
|
+
rate: current.rate,
|
|
1081
|
+
effective_since: current.effective_since,
|
|
1082
|
+
previous_rate: previous?.rate ?? null,
|
|
1083
|
+
previous_rate_effective_since: previous?.effective_since ?? null,
|
|
1084
|
+
decision_history: changes.slice(-5).reverse(),
|
|
1085
|
+
data_as_of: pts[pts.length - 1].period,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function policyRate() {
|
|
1090
|
+
// ~6 years of daily observations — comfortably more than enough to see the
|
|
1091
|
+
// last several Governing Council rate decisions.
|
|
1092
|
+
const results = await Promise.all(
|
|
1093
|
+
POLICY_RATE_SERIES.map(async (s) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const data = await fetchData('FM', s.key, { start_period: '2020-01-01' });
|
|
1096
|
+
const series = data.series[0]?.observations ?? [];
|
|
1097
|
+
return { field: s.field, label: s.label, computed: computeRateChanges(series), fetchError: null as string | null };
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
// A fixed, verified-live key failing is an upstream fault, not a bad
|
|
1100
|
+
// query — surface it per-field rather than failing the whole call,
|
|
1101
|
+
// since the other two rates may well still be answerable.
|
|
1102
|
+
return { field: s.field, label: s.label, computed: null, fetchError: err instanceof Error ? err.message : String(err) };
|
|
1103
|
+
}
|
|
1104
|
+
}),
|
|
1105
|
+
);
|
|
1106
|
+
const missing = results.filter((r) => r.computed === null);
|
|
1107
|
+
if (missing.length === results.length) {
|
|
1108
|
+
return {
|
|
1109
|
+
error: 'no_data',
|
|
1110
|
+
message:
|
|
1111
|
+
'The ECB returned no observations for any of the key interest rate series (FM flow, DFR/MRR_FR/MLFR). ' +
|
|
1112
|
+
'The series shape may have changed upstream; try get_data with flow_ref "FM" directly.',
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
const primary = results.find((r) => r.field === 'deposit_facility_rate')!;
|
|
1116
|
+
const out: Record<string, unknown> = {
|
|
1117
|
+
unit: 'percent',
|
|
1118
|
+
// Aggregate fields first: the deposit facility rate is the ECB's primary
|
|
1119
|
+
// policy lever since the 2024 corridor reform, so it carries the
|
|
1120
|
+
// top-level {rate, effective_since, previous_rate, ...} shape directly —
|
|
1121
|
+
// a caller asking "the ECB rate" does not have to know there are three.
|
|
1122
|
+
...(primary.computed ?? {}),
|
|
1123
|
+
};
|
|
1124
|
+
for (const r of results) {
|
|
1125
|
+
out[r.field] = r.computed
|
|
1126
|
+
? { label: r.label, ...r.computed }
|
|
1127
|
+
: { label: r.label, error: 'no_data', message: r.fetchError ?? `The ECB returned no observations for ${r.label}.` };
|
|
1128
|
+
}
|
|
1129
|
+
out.note =
|
|
1130
|
+
"The ECB's three official key interest rates (deposit facility, main refinancing, marginal lending). " +
|
|
1131
|
+
'The deposit facility rate has been the primary signal of the ECB monetary-policy stance since the ' +
|
|
1132
|
+
"2024 operational-framework reform narrowed the corridor; top-level fields describe it. All three " +
|
|
1133
|
+
'normally change together, on the same Governing Council decision date.';
|
|
1134
|
+
return out;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
793
1137
|
async function exchangeRate(args: Record<string, unknown>) {
|
|
794
1138
|
const currency = reqStr(args, 'currency', '"USD"').toUpperCase();
|
|
795
1139
|
const freq = ((args.frequency as string) ?? 'D').toUpperCase();
|
|
@@ -836,9 +1180,23 @@ async function fetchData(
|
|
|
836
1180
|
if (opts.last_n) params.set('lastNObservations', String(opts.last_n));
|
|
837
1181
|
const url = `${BASE}/data/${encodeURIComponent(flow)}/${encodeURIComponent(key)}?${params}`;
|
|
838
1182
|
const res = await pwFetch(url, { headers: { Accept: 'application/json' } });
|
|
839
|
-
if (res.status === 404)
|
|
1183
|
+
if (res.status === 404) {
|
|
1184
|
+
throw new Error(
|
|
1185
|
+
`not_found: the ECB has no series matching key "${key}" in flow "${flow}". The key does not exist at that ` +
|
|
1186
|
+
'shape, not just empty for this period — check the dimension values, or call list_dataflows for valid flow_refs.',
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
840
1189
|
if (!res.ok) {
|
|
841
1190
|
const body = await res.text();
|
|
1191
|
+
// A malformed key (wrong dimension count) gets ECB's generic error page —
|
|
1192
|
+
// HTML, not SDMX JSON. Leaking it verbatim reads as a Pipeworx bug and
|
|
1193
|
+
// buries the one useful fact (the request was malformed) under markup.
|
|
1194
|
+
if (body.trimStart().startsWith('<')) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
`user_error: the ECB rejected key "${key}" for flow "${flow}" as malformed (HTTP ${res.status}). ` +
|
|
1197
|
+
'SDMX keys are dot-separated dimension values in a fixed order for that flow — check the dimension count.',
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
842
1200
|
throw new Error(`ECB error: ${res.status} ${body.slice(0, 200)}`);
|
|
843
1201
|
}
|
|
844
1202
|
// ECB answers a 200 with a ZERO-BYTE body during an outage, and res.json()
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-ecb', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-ecb', version: '0.1.4' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|