@cyanheads/exchange-rates-mcp-server 0.1.4 → 0.2.0

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.
Files changed (49) hide show
  1. package/AGENTS.md +53 -33
  2. package/CLAUDE.md +53 -33
  3. package/Dockerfile +9 -6
  4. package/LICENSE +1 -1
  5. package/README.md +24 -15
  6. package/changelog/0.1.x/0.1.5.md +29 -0
  7. package/changelog/0.2.x/0.2.0.md +41 -0
  8. package/changelog/template.md +5 -3
  9. package/dist/index.js +18 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/mcp-server/resources/definitions/fx-rates-latest.resource.d.ts.map +1 -1
  12. package/dist/mcp-server/resources/definitions/fx-rates-latest.resource.js +4 -3
  13. package/dist/mcp-server/resources/definitions/fx-rates-latest.resource.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/fx-convert-currency.tool.d.ts +10 -0
  15. package/dist/mcp-server/tools/definitions/fx-convert-currency.tool.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/definitions/fx-convert-currency.tool.js +29 -5
  17. package/dist/mcp-server/tools/definitions/fx-convert-currency.tool.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.js +7 -4
  20. package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.d.ts +6 -1
  22. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.js +30 -10
  24. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/fx-get-rate.tool.d.ts +10 -0
  26. package/dist/mcp-server/tools/definitions/fx-get-rate.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/fx-get-rate.tool.js +29 -5
  28. package/dist/mcp-server/tools/definitions/fx-get-rate.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/fx-get-rates.tool.d.ts +11 -1
  30. package/dist/mcp-server/tools/definitions/fx-get-rates.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/fx-get-rates.tool.js +33 -7
  32. package/dist/mcp-server/tools/definitions/fx-get-rates.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.d.ts +13 -1
  34. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.js +93 -44
  36. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.js.map +1 -1
  37. package/dist/services/canvas/canvas-accessor.d.ts.map +1 -1
  38. package/dist/services/frankfurter/errors.d.ts +42 -0
  39. package/dist/services/frankfurter/errors.d.ts.map +1 -0
  40. package/dist/services/frankfurter/errors.js +64 -0
  41. package/dist/services/frankfurter/errors.js.map +1 -0
  42. package/dist/services/frankfurter/frankfurter-service.d.ts +42 -8
  43. package/dist/services/frankfurter/frankfurter-service.d.ts.map +1 -1
  44. package/dist/services/frankfurter/frankfurter-service.js +146 -67
  45. package/dist/services/frankfurter/frankfurter-service.js.map +1 -1
  46. package/dist/services/frankfurter/types.d.ts +14 -0
  47. package/dist/services/frankfurter/types.d.ts.map +1 -1
  48. package/package.json +12 -11
  49. package/server.json +5 -5
@@ -2,18 +2,32 @@
2
2
  * @fileoverview Frankfurter API service — wraps ECB rate data from api.frankfurter.dev.
3
3
  * @module services/frankfurter/frankfurter-service
4
4
  */
5
- import type { Currency, FrankfurterRateResponse, FrankfurterSeriesResponse, ResolvedRate, SeriesRow } from './types.js';
5
+ import type { Currency, FrankfurterRateResponse, ResolvedRate, TimeSeriesResult } from './types.js';
6
6
  /** ECB data start date. Requests before this return 404 from Frankfurter. */
7
7
  declare const ECB_START_DATE = "1999-01-04";
8
+ /**
9
+ * True when `value` is a real calendar date written as YYYY-MM-DD.
10
+ * Rejects both malformed shapes (`2024-6-1`) and impossible dates (`2024-02-31`).
11
+ */
12
+ export declare function isIsoDate(value: string): boolean;
8
13
  /** Retrieve the singleton FrankfurterService (lazy-init). */
9
14
  export declare function getFrankfurterService(): FrankfurterService;
10
15
  /** @internal — exposed for testing only */
11
16
  export declare function resetFrankfurterService(): void;
12
17
  declare class FrankfurterService {
13
18
  private readonly baseUrl;
19
+ private currencyCache?;
14
20
  constructor();
15
- /** Fetch all supported currencies as a sorted array. */
21
+ /** Fetch all supported currencies as a sorted array. Served from the day-long cache. */
16
22
  listCurrencies(): Promise<Currency[]>;
23
+ /** The supported currency set, fetched once per {@link CURRENCY_CACHE_TTL_MS}. */
24
+ private currencies;
25
+ /**
26
+ * Reject currency codes outside the ECB set before they reach the URL.
27
+ * Frankfurter answers an unknown base and an unknown symbol with the same
28
+ * bodiless 404, so membership is the only way to name the offending input.
29
+ */
30
+ private assertSupportedCurrencies;
17
31
  /**
18
32
  * Get the exchange rate for a currency pair on a given date.
19
33
  * Handles cross-rate triangulation through EUR automatically.
@@ -22,6 +36,13 @@ declare class FrankfurterService {
22
36
  * arbitrary base via its own math. We just set base={upper_base} and
23
37
  * symbols={upper_quote} and let Frankfurter handle the triangulation.
24
38
  *
39
+ * A self-pair is 1 by definition and a 422 upstream, so it is quoted against a
40
+ * proxy currency instead — the rate is still local, but its date comes from the
41
+ * day the ECB actually published for the base. That keeps an identity rate's
42
+ * `rateDate` and `dateSnapped` telling the same story as any other pair's, and
43
+ * a date the ECB never published for that currency fails rather than answering
44
+ * with a fabricated publication date.
45
+ *
25
46
  * @param base - ISO 4217 base currency code
26
47
  * @param quote - ISO 4217 quote currency code
27
48
  * @param date - ISO 8601 date string, or 'latest'
@@ -30,16 +51,29 @@ declare class FrankfurterService {
30
51
  /**
31
52
  * Fetch all rates for a base currency at latest or historical date.
32
53
  * Optionally filter to a specific set of symbols.
54
+ *
55
+ * A currency's rate against itself is 1 by definition, and Frankfurter rejects
56
+ * a request whose only symbol is the base (422 "bad currency pair"). The base
57
+ * is therefore stripped from the upstream `symbols` and injected locally.
33
58
  */
34
59
  getRates(base: string, date: string, symbols?: string[]): Promise<FrankfurterRateResponse>;
35
60
  /**
36
- * Fetch a historical rate series for a currency pair.
37
- * Returns raw API response plus derived rows suitable for canvas registration.
61
+ * Fetch a historical rate series for a currency pair, clipped to the requested
62
+ * window and suitable for canvas registration.
63
+ *
64
+ * Two upstream behaviours are absorbed here:
65
+ *
66
+ * - A range that opens on a weekend or a TARGET holiday is snapped back to the
67
+ * prior publication day, so the response can carry dates the caller never
68
+ * asked for. Those rows are dropped, which leaves an empty series whenever
69
+ * the window contains no publication day at all.
70
+ * - A self-pair (`base === quote`) is a 422 upstream. It is answered locally
71
+ * with a rate of 1 on the days the ECB actually published for that currency,
72
+ * read off a proxy quote rather than a hand-rolled Mon–Fri calendar — the
73
+ * server has no TARGET holiday table, and per-currency gaps exist too (ISK
74
+ * was suspended from the reference set for a decade).
38
75
  */
39
- getTimeSeries(base: string, quote: string, startDate: string, endDate: string): Promise<{
40
- raw: FrankfurterSeriesResponse;
41
- rows: SeriesRow[];
42
- }>;
76
+ getTimeSeries(base: string, quote: string, startDate: string, endDate: string): Promise<TimeSeriesResult>;
43
77
  private fetchJson;
44
78
  }
45
79
  export { ECB_START_DATE };
@@ -1 +1 @@
1
- {"version":3,"file":"frankfurter-service.d.ts","sourceRoot":"","sources":["../../../src/services/frankfurter/frankfurter-service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EACV,QAAQ,EAER,uBAAuB,EACvB,yBAAyB,EACzB,YAAY,EACZ,SAAS,EACV,MAAM,YAAY,CAAC;AAEpB,6EAA6E;AAC7E,QAAA,MAAM,cAAc,eAAe,CAAC;AAuBpC,6DAA6D;AAC7D,wBAAgB,qBAAqB,IAAI,kBAAkB,CAG1D;AAED,2CAA2C;AAC3C,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAED,cAAM,kBAAkB;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;;IAQjC,wDAAwD;IAClD,cAAc,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;IAS3C;;;;;;;;;;;OAWG;IACG,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAgD/E;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,uBAAuB,CAAC;IAiB1F;;;OAGG;IACG,aAAa,CACjB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QAAE,GAAG,EAAE,yBAAyB,CAAC;QAAC,IAAI,EAAE,SAAS,EAAE,CAAA;KAAE,CAAC;IA0BjE,OAAO,CAAC,SAAS;CAqClB;AAED,OAAO,EAAE,cAAc,EAAE,CAAC"}
1
+ {"version":3,"file":"frankfurter-service.d.ts","sourceRoot":"","sources":["../../../src/services/frankfurter/frankfurter-service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAMH,OAAO,KAAK,EACV,QAAQ,EAER,uBAAuB,EAEvB,YAAY,EAEZ,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAEpB,6EAA6E;AAC7E,QAAA,MAAM,cAAc,eAAe,CAAC;AAQpC;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIhD;AAkBD,6DAA6D;AAC7D,wBAAgB,qBAAqB,IAAI,kBAAkB,CAG1D;AAED,2CAA2C;AAC3C,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAED,cAAM,kBAAkB;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,aAAa,CAAC,CAA8D;IAEpF,cAEC;IAID,wFAAwF;IAClF,cAAc,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC,CAE1C;IAED,kFAAkF;YACpE,UAAU;IAaxB;;;;OAIG;YACW,yBAAyB;IAQvC;;;;;;;;;;;;;;;;;;OAkBG;IACG,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAgC9E;IAID;;;;;;;OAOG;IACG,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,uBAAuB,CAAC,CA0B/F;IAID;;;;;;;;;;;;;;;OAeG;IACG,aAAa,CACjB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,CAAC,CAyC3B;IAID,OAAO,CAAC,SAAS;CAsClB;AAED,OAAO,EAAE,cAAc,EAAE,CAAC"}
@@ -3,25 +3,37 @@
3
3
  * @module services/frankfurter/frankfurter-service
4
4
  */
5
5
  import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
6
- import { withRetry } from '@cyanheads/mcp-ts-core/utils';
6
+ import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
7
7
  import { getServerConfig } from '../../config/server-config.js';
8
+ import { invalidDateFormat, unsupportedCurrency, upstreamNoData } from './errors.js';
8
9
  /** ECB data start date. Requests before this return 404 from Frankfurter. */
9
10
  const ECB_START_DATE = '1999-01-04';
10
- /** YYYY-MM-DD format guard. */
11
+ /** YYYY-MM-DD shape guard — calendar validity is checked separately in {@link isIsoDate}. */
11
12
  const DATE_FORMAT_RE = /^\d{4}-\d{2}-\d{2}$/;
12
- /** ISO 4217 currency code guard: 1-4 uppercase letters (covers standard 3-letter codes + rare 4-letter ones). */
13
- const CURRENCY_CODE_RE = /^[A-Za-z]{1,4}$/;
14
- /** Validate a date string is YYYY-MM-DD. Throws on bad format. */
15
- function assertDateFormat(date, label) {
16
- if (!DATE_FORMAT_RE.test(date)) {
17
- throw new Error(`not found: ${label} "${date}" is not a valid YYYY-MM-DD date`);
18
- }
13
+ /** The ECB reference set changes at most a few times a decade — cache it for a day. */
14
+ const CURRENCY_CACHE_TTL_MS = 86_400_000;
15
+ /**
16
+ * True when `value` is a real calendar date written as YYYY-MM-DD.
17
+ * Rejects both malformed shapes (`2024-6-1`) and impossible dates (`2024-02-31`).
18
+ */
19
+ export function isIsoDate(value) {
20
+ if (!DATE_FORMAT_RE.test(value))
21
+ return false;
22
+ const parsed = new Date(`${value}T00:00:00Z`);
23
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
19
24
  }
20
- /** Validate a currency code is safe to embed in a URL. Throws on bad format. */
21
- function assertCurrencyFormat(code, label) {
22
- if (!CURRENCY_CODE_RE.test(code)) {
23
- throw new Error(`not found: ${label} "${code}" is not a valid ISO 4217 currency code`);
24
- }
25
+ /** Boundary guard for date inputs. Throws `invalid_date_format` naming the field at fault. */
26
+ function assertDateFormat(date, field) {
27
+ if (!isIsoDate(date))
28
+ throw invalidDateFormat(field, date);
29
+ }
30
+ /**
31
+ * A stand-in quote currency for a self-pair, which Frankfurter answers with a 422.
32
+ * Any second currency reveals the days the ECB actually published for the base;
33
+ * EUR is in the reference set for the whole ECB history, so it is the default.
34
+ */
35
+ function proxyQuoteFor(base) {
36
+ return base === 'EUR' ? 'USD' : 'EUR';
25
37
  }
26
38
  let _service;
27
39
  /** Retrieve the singleton FrankfurterService (lazy-init). */
@@ -35,16 +47,38 @@ export function resetFrankfurterService() {
35
47
  }
36
48
  class FrankfurterService {
37
49
  baseUrl;
50
+ currencyCache;
38
51
  constructor() {
39
52
  this.baseUrl = getServerConfig().frankfurterBaseUrl;
40
53
  }
41
54
  // ── Currencies ──────────────────────────────────────────────────────────────
42
- /** Fetch all supported currencies as a sorted array. */
55
+ /** Fetch all supported currencies as a sorted array. Served from the day-long cache. */
43
56
  async listCurrencies() {
57
+ return (await this.currencies()).list;
58
+ }
59
+ /** The supported currency set, fetched once per {@link CURRENCY_CACHE_TTL_MS}. */
60
+ async currencies() {
61
+ const cached = this.currencyCache;
62
+ if (cached && Date.now() - cached.fetchedAt <= CURRENCY_CACHE_TTL_MS)
63
+ return cached;
44
64
  const map = await this.fetchJson('/currencies');
45
- return Object.entries(map)
65
+ const list = Object.entries(map)
46
66
  .map(([code, name]) => ({ code, name }))
47
67
  .sort((a, b) => a.code.localeCompare(b.code));
68
+ const fresh = { codes: new Set(list.map((c) => c.code)), fetchedAt: Date.now(), list };
69
+ this.currencyCache = fresh;
70
+ return fresh;
71
+ }
72
+ /**
73
+ * Reject currency codes outside the ECB set before they reach the URL.
74
+ * Frankfurter answers an unknown base and an unknown symbol with the same
75
+ * bodiless 404, so membership is the only way to name the offending input.
76
+ */
77
+ async assertSupportedCurrencies(field, codes) {
78
+ const { codes: supported } = await this.currencies();
79
+ const unsupported = codes.filter((code) => !supported.has(code));
80
+ if (unsupported.length > 0)
81
+ throw unsupportedCurrency(field, unsupported);
48
82
  }
49
83
  // ── Rate (point-in-time) ────────────────────────────────────────────────────
50
84
  /**
@@ -55,6 +89,13 @@ class FrankfurterService {
55
89
  * arbitrary base via its own math. We just set base={upper_base} and
56
90
  * symbols={upper_quote} and let Frankfurter handle the triangulation.
57
91
  *
92
+ * A self-pair is 1 by definition and a 422 upstream, so it is quoted against a
93
+ * proxy currency instead — the rate is still local, but its date comes from the
94
+ * day the ECB actually published for the base. That keeps an identity rate's
95
+ * `rateDate` and `dateSnapped` telling the same story as any other pair's, and
96
+ * a date the ECB never published for that currency fails rather than answering
97
+ * with a fabricated publication date.
98
+ *
58
99
  * @param base - ISO 4217 base currency code
59
100
  * @param quote - ISO 4217 quote currency code
60
101
  * @param date - ISO 8601 date string, or 'latest'
@@ -62,33 +103,22 @@ class FrankfurterService {
62
103
  async getRate(base, quote, date) {
63
104
  const upper_base = base.toUpperCase();
64
105
  const upper_quote = quote.toUpperCase();
65
- assertCurrencyFormat(upper_base, 'base_currency');
66
- assertCurrencyFormat(upper_quote, 'quote_currency');
67
106
  if (date !== 'latest')
68
107
  assertDateFormat(date, 'date');
69
- if (upper_base === upper_quote) {
70
- // Identity rate — no API call needed
71
- const today = new Date().toISOString().slice(0, 10);
72
- return {
73
- baseCurrency: upper_base,
74
- quoteCurrency: upper_quote,
75
- rate: 1,
76
- rateDate: date === 'latest' ? today : date,
77
- dateSnapped: false,
78
- rateType: 'ECB reference (mid-market)',
79
- source: 'ECB via Frankfurter',
80
- };
81
- }
108
+ await this.assertSupportedCurrencies('base_currency', [upper_base]);
109
+ await this.assertSupportedCurrencies('quote_currency', [upper_quote]);
110
+ const identity = upper_base === upper_quote;
111
+ const quoted = identity ? proxyQuoteFor(upper_base) : upper_quote;
82
112
  // Frankfurter supports arbitrary base — it triangulates through EUR internally.
83
- // Single call: base={upper_base}&symbols={upper_quote}
113
+ // Single call: base={upper_base}&symbols={quoted}
84
114
  const endpoint = date === 'latest' ? '/latest' : `/${encodeURIComponent(date)}`;
85
- const params = new URLSearchParams({ base: upper_base, symbols: upper_quote });
115
+ const params = new URLSearchParams({ base: upper_base, symbols: quoted });
86
116
  const url = `${endpoint}?${params}`;
87
117
  const raw = await this.fetchJson(url);
88
- const rate = raw.rates[upper_quote];
89
- if (rate === undefined) {
90
- throw new Error(`not found: ${upper_quote} not in response rates`);
91
- }
118
+ const quotedRate = raw.rates[quoted];
119
+ if (quotedRate === undefined)
120
+ throw upstreamNoData(url);
121
+ const rate = identity ? 1 : quotedRate;
92
122
  const dateSnapped = date !== 'latest' && raw.date !== date;
93
123
  return {
94
124
  baseCurrency: upper_base,
@@ -104,47 +134,94 @@ class FrankfurterService {
104
134
  /**
105
135
  * Fetch all rates for a base currency at latest or historical date.
106
136
  * Optionally filter to a specific set of symbols.
137
+ *
138
+ * A currency's rate against itself is 1 by definition, and Frankfurter rejects
139
+ * a request whose only symbol is the base (422 "bad currency pair"). The base
140
+ * is therefore stripped from the upstream `symbols` and injected locally.
107
141
  */
108
- getRates(base, date, symbols) {
142
+ async getRates(base, date, symbols) {
109
143
  const upper_base = base.toUpperCase();
110
- assertCurrencyFormat(upper_base, 'base_currency');
144
+ const upper_symbols = symbols?.map((s) => s.toUpperCase()) ?? [];
111
145
  if (date !== 'latest')
112
146
  assertDateFormat(date, 'date');
113
- if (symbols) {
114
- for (const sym of symbols)
115
- assertCurrencyFormat(sym.toUpperCase(), 'symbol');
116
- }
147
+ await this.assertSupportedCurrencies('base_currency', [upper_base]);
148
+ if (upper_symbols.length > 0)
149
+ await this.assertSupportedCurrencies('symbols', upper_symbols);
150
+ const selfQuoted = upper_symbols.includes(upper_base);
151
+ const upstreamSymbols = upper_symbols.filter((code) => code !== upper_base);
117
152
  const endpoint = date === 'latest' ? '/latest' : `/${encodeURIComponent(date)}`;
118
153
  const params = new URLSearchParams({ base: upper_base });
119
- if (symbols && symbols.length > 0)
120
- params.set('symbols', symbols.map((s) => s.toUpperCase()).join(','));
154
+ /**
155
+ * When the base was the only symbol requested there is nothing left to ask
156
+ * for, so the filter is dropped — the unfiltered snapshot still carries the
157
+ * publication date the caller needs, and the rates map is rebuilt below.
158
+ */
159
+ if (upstreamSymbols.length > 0)
160
+ params.set('symbols', upstreamSymbols.join(','));
121
161
  const url = `${endpoint}?${params}`;
122
- return this.fetchJson(url);
162
+ const raw = await this.fetchJson(url);
163
+ if (!selfQuoted)
164
+ return raw;
165
+ return {
166
+ ...raw,
167
+ rates: upstreamSymbols.length > 0 ? { ...raw.rates, [upper_base]: 1 } : { [upper_base]: 1 },
168
+ };
123
169
  }
124
170
  // ── Time-series ─────────────────────────────────────────────────────────────
125
171
  /**
126
- * Fetch a historical rate series for a currency pair.
127
- * Returns raw API response plus derived rows suitable for canvas registration.
172
+ * Fetch a historical rate series for a currency pair, clipped to the requested
173
+ * window and suitable for canvas registration.
174
+ *
175
+ * Two upstream behaviours are absorbed here:
176
+ *
177
+ * - A range that opens on a weekend or a TARGET holiday is snapped back to the
178
+ * prior publication day, so the response can carry dates the caller never
179
+ * asked for. Those rows are dropped, which leaves an empty series whenever
180
+ * the window contains no publication day at all.
181
+ * - A self-pair (`base === quote`) is a 422 upstream. It is answered locally
182
+ * with a rate of 1 on the days the ECB actually published for that currency,
183
+ * read off a proxy quote rather than a hand-rolled Mon–Fri calendar — the
184
+ * server has no TARGET holiday table, and per-currency gaps exist too (ISK
185
+ * was suspended from the reference set for a decade).
128
186
  */
129
187
  async getTimeSeries(base, quote, startDate, endDate) {
130
188
  const upper_base = base.toUpperCase();
131
189
  const upper_quote = quote.toUpperCase();
132
- assertCurrencyFormat(upper_base, 'base_currency');
133
- assertCurrencyFormat(upper_quote, 'quote_currency');
134
190
  assertDateFormat(startDate, 'start_date');
135
191
  assertDateFormat(endDate, 'end_date');
136
- const params = new URLSearchParams({ base: upper_base, symbols: upper_quote });
192
+ await this.assertSupportedCurrencies('base_currency', [upper_base]);
193
+ await this.assertSupportedCurrencies('quote_currency', [upper_quote]);
194
+ const identity = upper_base === upper_quote;
195
+ const quoted = identity ? proxyQuoteFor(upper_base) : upper_quote;
196
+ const params = new URLSearchParams({ base: upper_base, symbols: quoted });
137
197
  const url = `/${encodeURIComponent(startDate)}..${encodeURIComponent(endDate)}?${params}`;
138
198
  const raw = await this.fetchJson(url);
139
199
  const rows = Object.entries(raw.rates)
200
+ .filter(([date]) => date >= startDate && date <= endDate)
140
201
  .sort(([a], [b]) => a.localeCompare(b))
141
- .map(([date, rateMap]) => ({
142
- date,
143
- rate: rateMap[upper_quote] ?? 0,
144
- base_currency: upper_base,
145
- quote_currency: upper_quote,
146
- }));
147
- return { raw, rows };
202
+ .flatMap(([date, rateMap]) => {
203
+ /**
204
+ * A day the quoted currency is missing from is not a publication day for
205
+ * this pair — dropping it is the only honest option, since any stand-in
206
+ * number would read as a real ECB rate downstream.
207
+ */
208
+ const quotedRate = rateMap[quoted];
209
+ if (quotedRate === undefined)
210
+ return [];
211
+ return [
212
+ {
213
+ date,
214
+ rate: identity ? 1 : quotedRate,
215
+ base_currency: upper_base,
216
+ quote_currency: upper_quote,
217
+ },
218
+ ];
219
+ });
220
+ return {
221
+ rows,
222
+ startDate: rows[0]?.date ?? startDate,
223
+ endDate: rows.at(-1)?.date ?? endDate,
224
+ };
148
225
  }
149
226
  // ── HTTP helpers ────────────────────────────────────────────────────────────
150
227
  fetchJson(path) {
@@ -155,7 +232,7 @@ class FrankfurterService {
155
232
  response = await fetch(url, {
156
233
  headers: {
157
234
  Accept: 'application/json',
158
- 'User-Agent': 'exchange-rates-mcp-server/0.1.1',
235
+ 'User-Agent': 'exchange-rates-mcp-server/0.1.5',
159
236
  },
160
237
  signal: AbortSignal.timeout(10_000),
161
238
  });
@@ -164,14 +241,16 @@ class FrankfurterService {
164
241
  throw serviceUnavailable(`Frankfurter API unreachable: ${err.message}`, { url }, { cause: err });
165
242
  }
166
243
  if (!response.ok) {
167
- if (response.status === 404) {
168
- const body = await response.text().catch(() => '');
169
- throw new Error(`not found: ${body || 'date out of range or unknown currency'}`);
170
- }
171
- throw serviceUnavailable(`Frankfurter API error ${response.status}`, {
172
- url,
173
- status: response.status,
174
- });
244
+ /**
245
+ * Currency codes are checked against the live ECB set before the request,
246
+ * so a 404 here means the ECB published no rates for the requested date —
247
+ * not an unknown currency. Both this and the 4xx codes from
248
+ * `httpErrorFromResponse` classify as non-transient, so a deterministic
249
+ * input failure is surfaced immediately instead of burning retries.
250
+ */
251
+ if (response.status === 404)
252
+ throw upstreamNoData(url);
253
+ throw await httpErrorFromResponse(response, { data: { url }, service: 'Frankfurter' });
175
254
  }
176
255
  return response.json();
177
256
  }, { maxRetries: 2, baseDelayMs: 500 });
@@ -1 +1 @@
1
- {"version":3,"file":"frankfurter-service.js","sourceRoot":"","sources":["../../../src/services/frankfurter/frankfurter-service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"frankfurter-service.js","sourceRoot":"","sources":["../../../src/services/frankfurter/frankfurter-service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAChF,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAWrF,6EAA6E;AAC7E,MAAM,cAAc,GAAG,YAAY,CAAC;AAEpC,6FAA6F;AAC7F,MAAM,cAAc,GAAG,qBAAqB,CAAC;AAE7C,uFAAuF;AACvF,MAAM,qBAAqB,GAAG,UAAU,CAAC;AAEzC;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC9C,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,GAAG,KAAK,YAAY,CAAC,CAAC;IAC9C,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,CAAC;AACxF,CAAC;AAED,8FAA8F;AAC9F,SAAS,gBAAgB,CAAC,IAAY,EAAE,KAAa;IACnD,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;QAAE,MAAM,iBAAiB,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;AAC7D,CAAC;AAED;;;;GAIG;AACH,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;AACxC,CAAC;AAED,IAAI,QAAwC,CAAC;AAE7C,6DAA6D;AAC7D,MAAM,UAAU,qBAAqB;IACnC,QAAQ,KAAK,IAAI,kBAAkB,EAAE,CAAC;IACtC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,2CAA2C;AAC3C,MAAM,UAAU,uBAAuB;IACrC,QAAQ,GAAG,SAAS,CAAC;AACvB,CAAC;AAED,MAAM,kBAAkB;IACL,OAAO,CAAS;IACzB,aAAa,CAA+D;IAEpF;QACE,IAAI,CAAC,OAAO,GAAG,eAAe,EAAE,CAAC,kBAAkB,CAAC;IACtD,CAAC;IAED,+EAA+E;IAE/E,wFAAwF;IACxF,KAAK,CAAC,cAAc;QAClB,OAAO,CAAC,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,IAAI,CAAC;IACxC,CAAC;IAED,kFAAkF;IAC1E,KAAK,CAAC,UAAU;QACtB,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC;QAClC,IAAI,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,SAAS,IAAI,qBAAqB;YAAE,OAAO,MAAM,CAAC;QAEpF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAAc,aAAa,CAAC,CAAC;QAC7D,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC;aAC7B,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;aACvC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QAChD,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,CAAC;QACvF,IAAI,CAAC,aAAa,GAAG,KAAK,CAAC;QAC3B,OAAO,KAAK,CAAC;IACf,CAAC;IAED;;;;OAIG;IACK,KAAK,CAAC,yBAAyB,CAAC,KAAa,EAAE,KAAe;QACpE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;QACrD,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;QACjE,IAAI,WAAW,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,mBAAmB,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;IAC5E,CAAC;IAED,+EAA+E;IAE/E;;;;;;;;;;;;;;;;;;OAkBG;IACH,KAAK,CAAC,OAAO,CAAC,IAAY,EAAE,KAAa,EAAE,IAAY;QACrD,MAAM,UAAU,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACtC,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QACxC,IAAI,IAAI,KAAK,QAAQ;YAAE,gBAAgB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACtD,MAAM,IAAI,CAAC,yBAAyB,CAAC,eAAe,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC;QACpE,MAAM,IAAI,CAAC,yBAAyB,CAAC,gBAAgB,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC;QAEtE,MAAM,QAAQ,GAAG,UAAU,KAAK,WAAW,CAAC;QAC5C,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;QAElE,gFAAgF;QAChF,kDAAkD;QAClD,MAAM,QAAQ,GAAG,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC;QAChF,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;QAC1E,MAAM,GAAG,GAAG,GAAG,QAAQ,IAAI,MAAM,EAAE,CAAC;QAEpC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAA0B,GAAG,CAAC,CAAC;QAC/D,MAAM,UAAU,GAAG,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACrC,IAAI,UAAU,KAAK,SAAS;YAAE,MAAM,cAAc,CAAC,GAAG,CAAC,CAAC;QACxD,MAAM,IAAI,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC;QAEvC,MAAM,WAAW,GAAG,IAAI,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC;QAE3D,OAAO;YACL,YAAY,EAAE,UAAU;YACxB,aAAa,EAAE,WAAW;YAC1B,IAAI;YACJ,QAAQ,EAAE,GAAG,CAAC,IAAI;YAClB,WAAW;YACX,QAAQ,EAAE,4BAA4B;YACtC,MAAM,EAAE,qBAAqB;SAC9B,CAAC;IACJ,CAAC;IAED,+EAA+E;IAE/E;;;;;;;OAOG;IACH,KAAK,CAAC,QAAQ,CAAC,IAAY,EAAE,IAAY,EAAE,OAAkB;QAC3D,MAAM,UAAU,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACtC,MAAM,aAAa,GAAG,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;QACjE,IAAI,IAAI,KAAK,QAAQ;YAAE,gBAAgB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACtD,MAAM,IAAI,CAAC,yBAAyB,CAAC,eAAe,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC;QACpE,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,IAAI,CAAC,yBAAyB,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC;QAE7F,MAAM,UAAU,GAAG,aAAa,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;QACtD,MAAM,eAAe,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC;QAE5E,MAAM,QAAQ,GAAG,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC;QAChF,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;QACzD;;;;WAIG;QACH,IAAI,eAAe,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,eAAe,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;QACjF,MAAM,GAAG,GAAG,GAAG,QAAQ,IAAI,MAAM,EAAE,CAAC;QACpC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAA0B,GAAG,CAAC,CAAC;QAE/D,IAAI,CAAC,UAAU;YAAE,OAAO,GAAG,CAAC;QAC5B,OAAO;YACL,GAAG,GAAG;YACN,KAAK,EAAE,eAAe,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,KAAK,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE;SAC5F,CAAC;IACJ,CAAC;IAED,+EAA+E;IAE/E;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,aAAa,CACjB,IAAY,EACZ,KAAa,EACb,SAAiB,EACjB,OAAe;QAEf,MAAM,UAAU,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACtC,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QACxC,gBAAgB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QAC1C,gBAAgB,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;QACtC,MAAM,IAAI,CAAC,yBAAyB,CAAC,eAAe,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC;QACpE,MAAM,IAAI,CAAC,yBAAyB,CAAC,gBAAgB,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC;QAEtE,MAAM,QAAQ,GAAG,UAAU,KAAK,WAAW,CAAC;QAC5C,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;QAElE,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;QAC1E,MAAM,GAAG,GAAG,IAAI,kBAAkB,CAAC,SAAS,CAAC,KAAK,kBAAkB,CAAC,OAAO,CAAC,IAAI,MAAM,EAAE,CAAC;QAC1F,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAA4B,GAAG,CAAC,CAAC;QAEjE,MAAM,IAAI,GAAgB,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;aAChD,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,IAAI,SAAS,IAAI,IAAI,IAAI,OAAO,CAAC;aACxD,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;aACtC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,EAAE;YAC3B;;;;eAIG;YACH,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;YACnC,IAAI,UAAU,KAAK,SAAS;gBAAE,OAAO,EAAE,CAAC;YACxC,OAAO;gBACL;oBACE,IAAI;oBACJ,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU;oBAC/B,aAAa,EAAE,UAAU;oBACzB,cAAc,EAAE,WAAW;iBAC5B;aACF,CAAC;QACJ,CAAC,CAAC,CAAC;QAEL,OAAO;YACL,IAAI;YACJ,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,SAAS;YACrC,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,OAAO;SACtC,CAAC;IACJ,CAAC;IAED,+EAA+E;IAEvE,SAAS,CAAI,IAAY;QAC/B,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,CAAC;QACrC,OAAO,SAAS,CACd,KAAK,IAAI,EAAE;YACT,IAAI,QAAkB,CAAC;YACvB,IAAI,CAAC;gBACH,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;oBAC1B,OAAO,EAAE;wBACP,MAAM,EAAE,kBAAkB;wBAC1B,YAAY,EAAE,iCAAiC;qBAChD;oBACD,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC;iBACpC,CAAC,CAAC;YACL,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,kBAAkB,CACtB,gCAAiC,GAAa,CAAC,OAAO,EAAE,EACxD,EAAE,GAAG,EAAE,EACP,EAAE,KAAK,EAAE,GAAY,EAAE,CACxB,CAAC;YACJ,CAAC;YAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;gBACjB;;;;;;mBAMG;gBACH,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;oBAAE,MAAM,cAAc,CAAC,GAAG,CAAC,CAAC;gBACvD,MAAM,MAAM,qBAAqB,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC,CAAC;YACzF,CAAC;YAED,OAAO,QAAQ,CAAC,IAAI,EAAgB,CAAC;QACvC,CAAC,EACD,EAAE,UAAU,EAAE,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE,CACpC,CAAC;IACJ,CAAC;CACF;AAED,OAAO,EAAE,cAAc,EAAE,CAAC"}
@@ -56,4 +56,18 @@ export interface SeriesRow extends Record<string, unknown> {
56
56
  quote_currency: string;
57
57
  rate: number;
58
58
  }
59
+ /**
60
+ * A time series clipped to the caller's requested window. Frankfurter's own
61
+ * `start_date`/`end_date` echo the window it *served*, which opens earlier than
62
+ * the requested one whenever the range starts on a non-publication day. These
63
+ * bounds never fall outside what the caller asked for.
64
+ */
65
+ export interface TimeSeriesResult {
66
+ /** Last publication date inside the requested window; the requested end when there is none. */
67
+ endDate: string;
68
+ /** Publication days inside the requested window, ascending by date. May be empty. */
69
+ rows: SeriesRow[];
70
+ /** First publication date inside the requested window; the requested start when there is none. */
71
+ startDate: string;
72
+ }
59
73
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/services/frankfurter/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,iDAAiD;AACjD,MAAM,WAAW,WAAW;IAC1B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED,4CAA4C;AAC5C,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wDAAwD;AACxD,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACnC;AAED,+DAA+D;AAC/D,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG;YAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACtD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,WAAW,EAAE,OAAO,CAAC;IACrB,+BAA+B;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,QAAQ,EAAE,4BAA4B,CAAC;IACvC,+BAA+B;IAC/B,MAAM,EAAE,qBAAqB,CAAC;CAC/B;AAED,uGAAuG;AACvG,MAAM,WAAW,SAAU,SAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACxD,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/services/frankfurter/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,iDAAiD;AACjD,MAAM,WAAW,WAAW;IAC1B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB;AAED,4CAA4C;AAC5C,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wDAAwD;AACxD,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;CACnC;AAED,+DAA+D;AAC/D,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE;QAAE,CAAC,IAAI,EAAE,MAAM,GAAG;YAAE,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACtD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,WAAW,EAAE,OAAO,CAAC;IACrB,+BAA+B;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,QAAQ,EAAE,4BAA4B,CAAC;IACvC,+BAA+B;IAC/B,MAAM,EAAE,qBAAqB,CAAC;CAC/B;AAED,uGAAuG;AACvG,MAAM,WAAW,SAAU,SAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACxD,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,+FAA+F;IAC/F,OAAO,EAAE,MAAM,CAAC;IAChB,qFAAqF;IACrF,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,kGAAkG;IAClG,SAAS,EAAE,MAAM,CAAC;CACnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyanheads/exchange-rates-mcp-server",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "mcpName": "io.github.cyanheads/exchange-rates-mcp-server",
5
5
  "description": "Convert currencies, get FX rates, and query historical ECB exchange rate data via MCP. STDIO or Streamable HTTP.",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "url": "https://github.com/cyanheads/exchange-rates-mcp-server/issues"
67
67
  },
68
68
  "homepage": "https://github.com/cyanheads/exchange-rates-mcp-server#readme",
69
- "author": "cyanheads <casey@caseyjhand.com> (https://github.com/cyanheads/exchange-rates-mcp-server#readme)",
69
+ "author": "Casey Hand <casey@caseyjhand.com> (https://caseyjhand.com)",
70
70
  "funding": [
71
71
  {
72
72
  "type": "github",
@@ -78,7 +78,7 @@
78
78
  }
79
79
  ],
80
80
  "license": "Apache-2.0",
81
- "packageManager": "bun@1.3.11",
81
+ "packageManager": "bun@1.3.14",
82
82
  "engines": {
83
83
  "bun": ">=1.3.0",
84
84
  "node": ">=24.0.0"
@@ -87,18 +87,19 @@
87
87
  "access": "public"
88
88
  },
89
89
  "dependencies": {
90
- "@cyanheads/mcp-ts-core": "^0.10.6",
91
- "@duckdb/node-api": "^1.5.3-r.3",
90
+ "@cyanheads/mcp-ts-core": "^0.11.0",
91
+ "@duckdb/node-api": "^1.5.5-r.1",
92
92
  "pino-pretty": "^13.1.3",
93
93
  "zod": "^4.4.3"
94
94
  },
95
95
  "devDependencies": {
96
- "@biomejs/biome": "^2.4.16",
97
- "@types/node": "^25.9.3",
96
+ "@biomejs/biome": "2.5.5",
97
+ "@socketsecurity/bun-security-scanner": "^1.1.2",
98
+ "@types/node": "26.1.1",
98
99
  "depcheck": "^1.4.7",
99
- "ignore": "^7.0.5",
100
- "tsc-alias": "^1.8.17",
101
- "typescript": "^6.0.3",
102
- "vitest": "^4.1.8"
100
+ "ignore": "^7.0.6",
101
+ "tsc-alias": "^1.9.1",
102
+ "typescript": "^7.0.2",
103
+ "vitest": "^4.1.10"
103
104
  }
104
105
  }
package/server.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "https://github.com/cyanheads/exchange-rates-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.1.4",
9
+ "version": "0.2.0",
10
10
  "remotes": [
11
11
  {
12
12
  "type": "streamable-http",
@@ -19,7 +19,7 @@
19
19
  "registryBaseUrl": "https://registry.npmjs.org",
20
20
  "identifier": "@cyanheads/exchange-rates-mcp-server",
21
21
  "runtimeHint": "bun",
22
- "version": "0.1.4",
22
+ "version": "0.2.0",
23
23
  "packageArguments": [
24
24
  {
25
25
  "type": "positional",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  {
42
42
  "name": "FX_TIMESERIES_CANVAS_THRESHOLD_DAYS",
43
- "description": "Day range above which fx_get_timeseries spills results to DataCanvas for SQL analytics.",
43
+ "description": "Day range above which fx_get_timeseries spills results to DataCanvas for SQL analytics, when DataCanvas is enabled (CANVAS_PROVIDER_TYPE=duckdb). Longer ranges return inline otherwise.",
44
44
  "format": "string",
45
45
  "isRequired": false,
46
46
  "default": "90"
@@ -62,7 +62,7 @@
62
62
  "registryBaseUrl": "https://registry.npmjs.org",
63
63
  "identifier": "@cyanheads/exchange-rates-mcp-server",
64
64
  "runtimeHint": "bun",
65
- "version": "0.1.4",
65
+ "version": "0.2.0",
66
66
  "packageArguments": [
67
67
  {
68
68
  "type": "positional",
@@ -83,7 +83,7 @@
83
83
  },
84
84
  {
85
85
  "name": "FX_TIMESERIES_CANVAS_THRESHOLD_DAYS",
86
- "description": "Day range above which fx_get_timeseries spills results to DataCanvas for SQL analytics.",
86
+ "description": "Day range above which fx_get_timeseries spills results to DataCanvas for SQL analytics, when DataCanvas is enabled (CANVAS_PROVIDER_TYPE=duckdb). Longer ranges return inline otherwise.",
87
87
  "format": "string",
88
88
  "isRequired": false,
89
89
  "default": "90"