@pipeworx/mcp-ecb 0.1.1 → 0.1.3
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 +147 -2
- 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 1819+ 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 1819+ 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.3",
|
|
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-bfe61ace2c12fd6d1410588f76f6e3e05ed66708c4644931b3dc6a680afb929b",
|
|
30
|
+
"sourceCommit": "f5821587e59206a76fc200d1b9cbeaad15a549ab"
|
|
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.3",
|
|
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
|
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
|
|
|
452
452
|
),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
|
-
|
|
455
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
456
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
457
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
458
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
459
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
460
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
461
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
462
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
463
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
464
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
465
|
+
//
|
|
466
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
467
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
468
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
469
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
470
|
+
// already carries the timeout case — covers every pack that uses
|
|
471
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
472
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
473
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
474
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
475
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
476
|
+
//
|
|
477
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
478
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
479
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
480
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
481
|
+
// still ours, not a third party's outage.
|
|
482
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
483
|
+
throw new Error(
|
|
484
|
+
markInternalOrigin(
|
|
485
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
486
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
487
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
488
|
+
url,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
456
491
|
}
|
|
457
492
|
}
|
|
458
493
|
|
|
@@ -652,6 +687,10 @@ function collapse(s: string): string {
|
|
|
652
687
|
* API docs: https://data.ecb.europa.eu/help/api/
|
|
653
688
|
*
|
|
654
689
|
* Tools:
|
|
690
|
+
* - ecb_policy_rate: current deposit facility rate / main refinancing rate /
|
|
691
|
+
* marginal lending rate, each with effective_since,
|
|
692
|
+
* previous_rate and decision history (FM flow, the three
|
|
693
|
+
* official "key ECB interest rates" — fleet #3206)
|
|
655
694
|
* - exchange_rate: EUR → currency rate (convenience over EXR flow)
|
|
656
695
|
* - hicp_inflation: Harmonised CPI YoY change (convenience over ICP flow)
|
|
657
696
|
* - get_data: generic SDMX query against any flow
|
|
@@ -669,7 +708,27 @@ async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response>
|
|
|
669
708
|
|
|
670
709
|
const BASE = 'https://data-api.ecb.europa.eu/service';
|
|
671
710
|
|
|
711
|
+
// The three official "key ECB interest rates", FM flow, confirmed against the
|
|
712
|
+
// live series. DFR is the ECB's primary policy lever since the 2024 corridor
|
|
713
|
+
// narrowing (deposits now drive short-term money-market rates); MRO and MLFR
|
|
714
|
+
// are published alongside it and move on the same Governing Council dates.
|
|
715
|
+
const POLICY_RATE_SERIES: { field: string; label: string; key: string }[] = [
|
|
716
|
+
{ field: 'deposit_facility_rate', label: 'Deposit facility rate', key: 'D.U2.EUR.4F.KR.DFR.LEV' },
|
|
717
|
+
{ field: 'main_refinancing_rate', label: 'Main refinancing operations rate (fixed-rate tenders)', key: 'D.U2.EUR.4F.KR.MRR_FR.LEV' },
|
|
718
|
+
{ field: 'marginal_lending_facility_rate', label: 'Marginal lending facility rate', key: 'D.U2.EUR.4F.KR.MLFR.LEV' },
|
|
719
|
+
];
|
|
720
|
+
|
|
672
721
|
const tools: McpToolExport['tools'] = [
|
|
722
|
+
{
|
|
723
|
+
name: 'ecb_policy_rate',
|
|
724
|
+
description:
|
|
725
|
+
"The European Central Bank's three official key interest rates — the deposit facility rate (the ECB's " +
|
|
726
|
+
'primary policy rate since the 2024 operational-framework reform), the main refinancing operations rate, ' +
|
|
727
|
+
'and the marginal lending facility rate. For each: the current rate, the date it took effect, the previous ' +
|
|
728
|
+
'rate and when that one took effect, plus recent decision history (FM flow, series DFR/MRR_FR/MLFR). ' +
|
|
729
|
+
"Use this for \"ECB interest rate\" / \"ECB deposit rate\" / \"has the ECB cut rates\" instead of guessing an FM series key.",
|
|
730
|
+
inputSchema: { type: 'object', properties: {} },
|
|
731
|
+
},
|
|
673
732
|
{
|
|
674
733
|
name: 'ecb_exchange_rate',
|
|
675
734
|
description:
|
|
@@ -742,6 +801,8 @@ const tools: McpToolExport['tools'] = [
|
|
|
742
801
|
|
|
743
802
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
744
803
|
switch (name) {
|
|
804
|
+
case 'ecb_policy_rate':
|
|
805
|
+
return policyRate();
|
|
745
806
|
case 'ecb_exchange_rate':
|
|
746
807
|
return exchangeRate(args);
|
|
747
808
|
case 'ecb_hicp_inflation':
|
|
@@ -755,6 +816,76 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
755
816
|
}
|
|
756
817
|
}
|
|
757
818
|
|
|
819
|
+
/** {rate, effective_since, previous_rate, previous_rate_effective_since, decision_history, data_as_of} from an ascending {period,value} series. */
|
|
820
|
+
function computeRateChanges(obs: { period: string; value: number | null }[]) {
|
|
821
|
+
const pts = obs.filter((o) => o.value !== null) as { period: string; value: number }[];
|
|
822
|
+
if (pts.length === 0) return null;
|
|
823
|
+
const changes: { rate: number; effective_since: string }[] = [{ rate: pts[0].value, effective_since: pts[0].period }];
|
|
824
|
+
for (let i = 1; i < pts.length; i++) {
|
|
825
|
+
if (pts[i].value !== changes[changes.length - 1].rate) {
|
|
826
|
+
changes.push({ rate: pts[i].value, effective_since: pts[i].period });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const current = changes[changes.length - 1];
|
|
830
|
+
const previous = changes.length > 1 ? changes[changes.length - 2] : null;
|
|
831
|
+
return {
|
|
832
|
+
rate: current.rate,
|
|
833
|
+
effective_since: current.effective_since,
|
|
834
|
+
previous_rate: previous?.rate ?? null,
|
|
835
|
+
previous_rate_effective_since: previous?.effective_since ?? null,
|
|
836
|
+
decision_history: changes.slice(-5).reverse(),
|
|
837
|
+
data_as_of: pts[pts.length - 1].period,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function policyRate() {
|
|
842
|
+
// ~6 years of daily observations — comfortably more than enough to see the
|
|
843
|
+
// last several Governing Council rate decisions.
|
|
844
|
+
const results = await Promise.all(
|
|
845
|
+
POLICY_RATE_SERIES.map(async (s) => {
|
|
846
|
+
try {
|
|
847
|
+
const data = await fetchData('FM', s.key, { start_period: '2020-01-01' });
|
|
848
|
+
const series = data.series[0]?.observations ?? [];
|
|
849
|
+
return { field: s.field, label: s.label, computed: computeRateChanges(series), fetchError: null as string | null };
|
|
850
|
+
} catch (err) {
|
|
851
|
+
// A fixed, verified-live key failing is an upstream fault, not a bad
|
|
852
|
+
// query — surface it per-field rather than failing the whole call,
|
|
853
|
+
// since the other two rates may well still be answerable.
|
|
854
|
+
return { field: s.field, label: s.label, computed: null, fetchError: err instanceof Error ? err.message : String(err) };
|
|
855
|
+
}
|
|
856
|
+
}),
|
|
857
|
+
);
|
|
858
|
+
const missing = results.filter((r) => r.computed === null);
|
|
859
|
+
if (missing.length === results.length) {
|
|
860
|
+
return {
|
|
861
|
+
error: 'no_data',
|
|
862
|
+
message:
|
|
863
|
+
'The ECB returned no observations for any of the key interest rate series (FM flow, DFR/MRR_FR/MLFR). ' +
|
|
864
|
+
'The series shape may have changed upstream; try get_data with flow_ref "FM" directly.',
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const primary = results.find((r) => r.field === 'deposit_facility_rate')!;
|
|
868
|
+
const out: Record<string, unknown> = {
|
|
869
|
+
unit: 'percent',
|
|
870
|
+
// Aggregate fields first: the deposit facility rate is the ECB's primary
|
|
871
|
+
// policy lever since the 2024 corridor reform, so it carries the
|
|
872
|
+
// top-level {rate, effective_since, previous_rate, ...} shape directly —
|
|
873
|
+
// a caller asking "the ECB rate" does not have to know there are three.
|
|
874
|
+
...(primary.computed ?? {}),
|
|
875
|
+
};
|
|
876
|
+
for (const r of results) {
|
|
877
|
+
out[r.field] = r.computed
|
|
878
|
+
? { label: r.label, ...r.computed }
|
|
879
|
+
: { label: r.label, error: 'no_data', message: r.fetchError ?? `The ECB returned no observations for ${r.label}.` };
|
|
880
|
+
}
|
|
881
|
+
out.note =
|
|
882
|
+
"The ECB's three official key interest rates (deposit facility, main refinancing, marginal lending). " +
|
|
883
|
+
'The deposit facility rate has been the primary signal of the ECB monetary-policy stance since the ' +
|
|
884
|
+
"2024 operational-framework reform narrowed the corridor; top-level fields describe it. All three " +
|
|
885
|
+
'normally change together, on the same Governing Council decision date.';
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
758
889
|
async function exchangeRate(args: Record<string, unknown>) {
|
|
759
890
|
const currency = reqStr(args, 'currency', '"USD"').toUpperCase();
|
|
760
891
|
const freq = ((args.frequency as string) ?? 'D').toUpperCase();
|
|
@@ -801,9 +932,23 @@ async function fetchData(
|
|
|
801
932
|
if (opts.last_n) params.set('lastNObservations', String(opts.last_n));
|
|
802
933
|
const url = `${BASE}/data/${encodeURIComponent(flow)}/${encodeURIComponent(key)}?${params}`;
|
|
803
934
|
const res = await pwFetch(url, { headers: { Accept: 'application/json' } });
|
|
804
|
-
if (res.status === 404)
|
|
935
|
+
if (res.status === 404) {
|
|
936
|
+
throw new Error(
|
|
937
|
+
`not_found: the ECB has no series matching key "${key}" in flow "${flow}". The key does not exist at that ` +
|
|
938
|
+
'shape, not just empty for this period — check the dimension values, or call list_dataflows for valid flow_refs.',
|
|
939
|
+
);
|
|
940
|
+
}
|
|
805
941
|
if (!res.ok) {
|
|
806
942
|
const body = await res.text();
|
|
943
|
+
// A malformed key (wrong dimension count) gets ECB's generic error page —
|
|
944
|
+
// HTML, not SDMX JSON. Leaking it verbatim reads as a Pipeworx bug and
|
|
945
|
+
// buries the one useful fact (the request was malformed) under markup.
|
|
946
|
+
if (body.trimStart().startsWith('<')) {
|
|
947
|
+
throw new Error(
|
|
948
|
+
`user_error: the ECB rejected key "${key}" for flow "${flow}" as malformed (HTTP ${res.status}). ` +
|
|
949
|
+
'SDMX keys are dot-separated dimension values in a fixed order for that flow — check the dimension count.',
|
|
950
|
+
);
|
|
951
|
+
}
|
|
807
952
|
throw new Error(`ECB error: ${res.status} ${body.slice(0, 200)}`);
|
|
808
953
|
}
|
|
809
954
|
// 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.3' },
|
|
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 {
|