@pipeworx/mcp-ecb 0.1.2 → 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 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 1683+ live data sources.
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 1683+ data sources. The
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/ecb_exchange_rate \
81
+ curl -X POST https://gateway.pipeworx.io/v1/tools/ecb_policy_rate \
82
82
  -H 'Content-Type: application/json' \
83
- -d '{"currency":"USD"}'
83
+ -d '{}'
84
84
  ```
85
85
 
86
- No account needed for the first calls. Inspect any tool: `GET https://gateway.pipeworx.io/v1/tools/ecb_exchange_rate`. Find one: `POST https://gateway.pipeworx.io/v1/tools/search_packs` with `{"query":"..."}`.
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.2",
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-6da2a22652c47fe27e8653b84d0dd4934e8b20677f51818b817ca676fbd0a2e3",
30
- "sourceCommit": "4fa4c64448be06f4a2ee4cee19f756f3558a7cad"
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.2",
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
@@ -687,6 +687,10 @@ function collapse(s: string): string {
687
687
  * API docs: https://data.ecb.europa.eu/help/api/
688
688
  *
689
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)
690
694
  * - exchange_rate: EUR → currency rate (convenience over EXR flow)
691
695
  * - hicp_inflation: Harmonised CPI YoY change (convenience over ICP flow)
692
696
  * - get_data: generic SDMX query against any flow
@@ -704,7 +708,27 @@ async function pwFetch(url: string | URL, init?: RequestInit): Promise<Response>
704
708
 
705
709
  const BASE = 'https://data-api.ecb.europa.eu/service';
706
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
+
707
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
+ },
708
732
  {
709
733
  name: 'ecb_exchange_rate',
710
734
  description:
@@ -777,6 +801,8 @@ const tools: McpToolExport['tools'] = [
777
801
 
778
802
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
779
803
  switch (name) {
804
+ case 'ecb_policy_rate':
805
+ return policyRate();
780
806
  case 'ecb_exchange_rate':
781
807
  return exchangeRate(args);
782
808
  case 'ecb_hicp_inflation':
@@ -790,6 +816,76 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
790
816
  }
791
817
  }
792
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
+
793
889
  async function exchangeRate(args: Record<string, unknown>) {
794
890
  const currency = reqStr(args, 'currency', '"USD"').toUpperCase();
795
891
  const freq = ((args.frequency as string) ?? 'D').toUpperCase();
@@ -836,9 +932,23 @@ async function fetchData(
836
932
  if (opts.last_n) params.set('lastNObservations', String(opts.last_n));
837
933
  const url = `${BASE}/data/${encodeURIComponent(flow)}/${encodeURIComponent(key)}?${params}`;
838
934
  const res = await pwFetch(url, { headers: { Accept: 'application/json' } });
839
- if (res.status === 404) throw new Error(`ECB: no data for flow=${flow} key=${key}`);
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
+ }
840
941
  if (!res.ok) {
841
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
+ }
842
952
  throw new Error(`ECB error: ${res.status} ${body.slice(0, 200)}`);
843
953
  }
844
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.2' },
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 result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
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 {