@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 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 1679+ 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 1679+ 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.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-291834a10f3874c627e789fe06b89ba136235d6cdc4364d5c0a2f4525dfe7910",
30
- "sourceCommit": "76aa8f5d9b5dc2f665563030647c68e661478864"
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.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
- throw err;
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) 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
+ }
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.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 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 {