@cyanheads/exchange-rates-mcp-server
Convert currencies, get FX rates, and query historical ECB exchange rate data via MCP. STDIO or Streamable HTTP.
7 Tools β’ 1 Opt-in Tool β’ 2 Resources
Overview
ECB reference exchange rates via Frankfurter β a keyless proxy covering ~30 currencies back to 1999-01-04. Convert amounts, disambiguate currency codes, and pull point-in-time or historical rates from any MCP client, with SQL analytics over long time-series when DataCanvas is enabled. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
| Tool | Description |
|---|
fx_list_currencies | List all ~30 ECB-supported ISO 4217 currencies with full names |
fx_get_rates | Snapshot of all rates for a base currency at latest or a historical date |
fx_get_rate | Exchange rate for a single currency pair at latest or a historical date |
fx_convert_currency | Convert an amount between two currencies at latest or a historical rate |
fx_get_timeseries | Historical daily rates for a currency pair over a date range |
fx_dataframe_describe | List DataCanvas tables and columns staged by a prior fx_get_timeseries call |
fx_dataframe_query | Run a read-only SQL SELECT against a staged DataCanvas table |
fx_dataframe_drop | Remove one staged DataCanvas table or view (opt-in, destructive) |
The three fx_dataframe_* tools need CANVAS_PROVIDER_TYPE=duckdb β unset, they're not advertised in tools/list at all, and fx_get_timeseries returns every range inline instead. fx_dataframe_drop additionally needs FX_ENABLE_CANVAS_DROP=true.
Resources
| Resource | Description |
|---|
fx://currencies | All supported currencies as a stable reference document |
fx://rates/latest/{base} | Latest rates snapshot for a base currency as a stable URI |
All resource data is also reachable via tools β use fx_list_currencies or fx_get_rates for programmatic access.
Capability reference
- No input parameters
- Returns
[{ code, name }] for all ~30 ECB-scoped currencies, sorted alphabetically by code
- ECB coverage shifts as currencies enter or exit scope β call this to validate a user-supplied code rather than hard-coding a list
base_currency required; date optional (default latest, ECB data from 1999-01-04, no future dates); optional symbols array narrows the response and must name at least one code
- Returns a
rates map (quote code β rate), the actual rate_date, and date_snapped: true when a weekend/holiday request snapped to the prior business day
- Naming the base currency in
symbols is valid β answered locally with a rate of 1 rather than sent upstream
- Typed failures:
invalid_date_format, unsupported_currency, date_out_of_range, upstream_no_data
base_currency, quote_currency required; date optional (default latest, ECB data from 1999-01-04, no future dates)
- Returns
rate, rate_date, and date_snapped: true when a weekend/holiday request snapped to the prior business day
- Cross-rates (neither side EUR) triangulate through EUR in one upstream call; a same-currency pair returns a rate of 1 without reaching the API, still dated to the real publication day
- Typed failures:
invalid_date_format, unsupported_currency, date_out_of_range, upstream_no_data
base_currency, quote_currency, amount (must be > 0) required; date optional (default latest, ECB data from 1999-01-04, no future dates)
- Handles EURβany, anyβEUR, and cross-rate pairs (e.g. USDβJPY) in a single upstream call
- Returns
quote_amount (rounded to 6 decimal places), rate, rate_date, date_snapped, plus rate_type and source provenance
- Typed failures:
invalid_date_format, unsupported_currency, date_out_of_range, upstream_no_data
base_currency, quote_currency, start_date, end_date required (ECB data from 1999-01-04, no future dates, start β€ end); optional canvas_id appends to an existing canvas
- Inline results page at 500 publication days β
rate_count is always the range total; truncated: true plus next_start_date continue the page
- Ranges over
FX_TIMESERIES_CANVAS_THRESHOLD_DAYS (default 90 days) spill to DataCanvas when configured β response carries spilled: true, canvas_id, table_name; without DataCanvas they're paged inline instead
- A same-currency pair returns a rate of 1 on each real ECB publication day in range, not a synthetic MonβFri loop
- An empty range (only weekends/holidays) returns
rate_count: 0 with an explanatory notice, distinguishable from an error
canvas_id required (from a prior fx_get_timeseries call)
- Returns each staged table's
kind, row_count, and column schema (name, type, nullable), plus expires_at
- Required first step before
fx_dataframe_query; needs CANVAS_PROVIDER_TYPE=duckdb β unregistered otherwise
canvas_not_found when the ID doesn't exist or has expired
canvas_id and a read-only SQL query required; row_limit optional (1β10,000, default 150)
- Supports aggregations, GROUP BY, window functions, and JOINs across tables from multiple
fx_get_timeseries calls
- Returns at most
row_limit rows; truncated: true plus a notice give the ORDER BY <column> LIMIT <n> OFFSET <m> shape for the next page β ORDER BY is required for stable paging
- Markdown table cells are escaped so pipes, angle brackets, and line breaks stay inside their cell;
structuredContent keeps raw values
- Needs
CANVAS_PROVIDER_TYPE=duckdb; typed failures: canvas_not_found, missing_table, invalid_query
canvas_id and exact table_name (from fx_dataframe_describe) required
- Removes one staged table or view; ECB rate data is untouched and the series can be re-staged via
fx_get_timeseries
- Returns
dropped: true/false depending on whether the table existed
- Disabled unless
FX_ENABLE_CANVAS_DROP=true β otherwise absent from tools/list, named with its enable hint only in the startup log and the HTTP landing page; also needs CANVAS_PROVIDER_TYPE=duckdb
fx://currencies resource
- No parameters; returns
currencies, count, source as application/json β the same payload as fx_list_currencies
- Listed as a single static resource
fx://rates/latest/{base} resource
base is an ISO 4217 currency code in the URI
- Returns
base_currency, rate_date, a rates map, rate_type, and source for the latest ECB fix
- Listed with four sample URIs (EUR, USD, GBP, JPY) as discovery hints
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
ECB-specific:
- Keyless access via Frankfurter β a Cloudflare-fronted ECB proxy; no API keys required
- Cross-rate triangulation: any pair works β USD β JPY is one upstream call, cross-rated through EUR on Frankfurter's side
- Weekend/holiday date semantics:
date_snapped surfaces when the API returns a different date than requested
- Identity pairs never reach the upstream API: a currency against itself returns a rate of 1, dated to the day the ECB actually published for that currency rather than to the calendar date requested
- Long time-series spill to DataCanvas (DuckDB) when enabled, for SQL aggregation over the full range
Agent-friendly output:
- Rate provenance on every response β
rate_type, source, rate_date, and date_snapped so agents can reason about trust and freshness
- Structured error contracts β typed
reason fields (unsupported_currency, date_out_of_range, invalid_query, β¦) let callers branch on failure type, not string parsing
- Bounded responses β inline time-series pages continue from
next_start_date, and SQL results cap at row_limit, so no call returns an unbounded payload
- Success-path
notice enrichment β explains an empty series, where to continue a paged series, or which tools read a staged one, so a legitimate zero-result never reads as a failure
Getting started
Public Hosted Instance
A public instance is available at https://exchange-rates.caseyjhand.com/mcp β no installation required. Point any MCP client at it via Streamable HTTP:
{
"mcpServers": {
"exchange-rates-mcp-server": {
"type": "streamable-http",
"url": "https://exchange-rates.caseyjhand.com/mcp"
}
}
}
Self-Hosted / Local
No API key required β Frankfurter is keyless. Add the following to your MCP client configuration file:
{
"mcpServers": {
"exchange-rates-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["@cyanheads/exchange-rates-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with npx (no Bun required):
{
"mcpServers": {
"exchange-rates-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@cyanheads/exchange-rates-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with Docker:
{
"mcpServers": {
"exchange-rates-mcp-server": {
"type": "stdio",
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MCP_TRANSPORT_TYPE=stdio",
"ghcr.io/cyanheads/exchange-rates-mcp-server:latest"
]
}
}
}
To enable DataCanvas for long time-series SQL analytics β which also registers fx_dataframe_describe and fx_dataframe_query, skipped from tools/list otherwise β add CANVAS_PROVIDER_TYPE=duckdb:
{
"mcpServers": {
"exchange-rates-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["@cyanheads/exchange-rates-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"CANVAS_PROVIDER_TYPE": "duckdb"
}
}
}
}
For Streamable HTTP, set the transport and start the server:
MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
Prerequisites
- Bun v1.4.0 or higher (or Node.js v24+).
- No API key β Frankfurter is free and keyless.
Installation
- Clone the repository:
git clone https://github.com/cyanheads/exchange-rates-mcp-server.git
- Navigate into the directory:
cd exchange-rates-mcp-server
- Install dependencies:
- Configure environment:
Configuration
All configuration is validated at startup via Zod schemas. Environment variables:
| Variable | Description | Default |
|---|
FRANKFURTER_BASE_URL | Frankfurter API base URL. Override for local testing or a self-hosted instance. | https://api.frankfurter.dev/v1 |
FX_TIMESERIES_CANVAS_THRESHOLD_DAYS | Day range above which fx_get_timeseries spills to DataCanvas, when one is configured. | 90 |
FX_ENABLE_CANVAS_DROP | Enable the destructive fx_dataframe_drop tool. Off by default: the tool is not registered and is absent from tools/list. | false |
CANVAS_PROVIDER_TYPE | Canvas engine. Set to duckdb to enable DataCanvas for fx_get_timeseries long-range spillover and to register the three fx_dataframe_* tools. At none they are skipped from tools/list. | none |
MCP_TRANSPORT_TYPE | Transport: stdio or http. | stdio |
MCP_HTTP_PORT | Port for HTTP server. | 3010 |
MCP_SESSION_MODE | HTTP session mode: auto, stateful, or stateless. The server declares stateless in code β no handler here asks the client for input mid-call, so nothing needs a session to resume β and setting this variable overrides that declaration. | stateless |
MCP_AUTH_MODE | Auth mode: none, jwt, or oauth. | none |
MCP_REQUEST_STATE_KEY | Opt-in key (β₯ 32 bytes, the same on every instance) that seals the requestState a handler returns between rounds. No tool here returns one. | β |
MCP_LOG_LEVEL | Log level (RFC 5424: debug, info, notice, warning, error). Also the floor for logs mirrored to the client. | info |
LOG_TOOL_FAILURE_PAYLOADS | Log each failed tool call's arguments and result (redacted by key name only). | false |
LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES | Per-payload cap for LOG_TOOL_FAILURE_PAYLOADS, in UTF-8 bytes. | 16384 |
OTEL_ENABLED | Enable OpenTelemetry instrumentation. | false |
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP base URL; traces go to /v1/traces, metrics to /v1/metrics. | β |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | Opt-in OTLP log export (e.g. http://localhost:4318/v1/logs); the base endpoint never enables it. | β |
See .env.example for the full list of optional overrides including storage, session, and telemetry vars.
Running the server
Local development
-
Build and run:
bun run rebuild
bun run start:stdio
bun run start:http
-
Run checks and tests:
bun run devcheck
bun run test
bun run lint:mcp
Docker
docker build -t exchange-rates-mcp-server .
docker run --rm -p 3010:3010 exchange-rates-mcp-server
The Dockerfile defaults to HTTP transport, stateless session mode, and logs to /var/log/exchange-rates-mcp-server. OpenTelemetry peer dependencies are installed by default β build with --build-arg OTEL_ENABLED=false to omit them. Production dependencies, DuckDB's native binding included, are cross-installed for the target platform in a separate deps stage, so multi-arch builds never run Bun under emulation and the runtime image carries only node_modules and dist/.
Project structure
| Directory | Purpose |
|---|
src/index.ts | createApp() entry point β registers tools, resources, and canvas accessor. |
src/config/ | Server-specific environment variable parsing and validation with Zod. |
src/mcp-server/tools/ | Tool definitions (*.tool.ts) β fx_* tools. |
src/mcp-server/resources/ | Resource definitions β fx://currencies and fx://rates/latest/{base}. |
src/services/frankfurter/ | Frankfurter HTTP client, retry logic, and domain types. |
src/services/canvas/ | Module-level DataCanvas accessor for fx_get_timeseries spillover. |
src/utils/ | Output helpers β Markdown table-cell escaping for fx_dataframe_query. |
tests/ | Unit and integration tests mirroring src/. |
docs/ | Design document and idea notes. |
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches β no
try/catch in tool logic
- Use
ctx.log for request-scoped logging, ctx.state for tenant-scoped storage
- Register new tools and resources via the barrels in
src/mcp-server/*/definitions/index.ts
- Wrap external API calls: validate raw β normalize to domain type β return output schema; never fabricate missing fields
- ECB rates are mid-market reference rates β preserve the
rate_type provenance in every response
Contributing
Issues are welcome. Run checks and tests before submitting:
bun run devcheck
bun run test
License
Apache-2.0 β see LICENSE for details.