@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
package/AGENTS.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** exchange-rates-mcp-server
4
- **Version:** 0.1.4
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.10.6`
4
+ **Version:** 0.2.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.11.0`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
8
8
  **Zod:** ^4.4.3
@@ -35,7 +35,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
35
35
  - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
36
36
  - **Use `ctx.log`** for request-scoped logging. No `console` calls.
37
37
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
38
- - **Check `ctx.elicit` / `ctx.sample`** for presence before calling.
38
+ - **Check `ctx.elicit`** for presence before calling.
39
39
  - **Secrets in env vars only** — never hardcoded.
40
40
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
41
41
 
@@ -144,9 +144,23 @@ export function getServerConfig() {
144
144
 
145
145
  `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
146
146
 
147
- ### Server instructions
147
+ For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
148
148
 
149
- `createApp({ instructions })` optional server-level orientation, sent to clients on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
149
+ ### Server identity and instructions
150
+
151
+ `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`):
152
+
153
+ ```ts
154
+ await createApp({
155
+ name: 'exchange-rates-mcp-server',
156
+ title: 'exchange-rates-mcp-server', // display name — the hyphenated repo name, never Title Case
157
+ instructions: 'Use fx_list_currencies first to disambiguate "dollars".', // session-level context
158
+ });
159
+ ```
160
+
161
+ `description` and `websiteUrl` are also accepted but stay unset here — `description` derives from `package.json`, and an explicit copy is drift.
162
+
163
+ `instructions` is optional server-level orientation, sent on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
150
164
 
151
165
  ---
152
166
 
@@ -157,13 +171,14 @@ Handlers receive a unified `ctx` object. Key properties:
157
171
  | Property | Description |
158
172
  |:---------|:------------|
159
173
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
160
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
161
- | `ctx.elicit` | Ask user for structured input. **Check for presence first:** `if (ctx.elicit) { ... }` |
162
- | `ctx.sample` | Request LLM completion from the client. **Check for presence first:** `if (ctx.sample) { ... }` |
174
+ | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
175
+ | `ctx.elicit` | Ask user for structured input — form call `(message, schema)` or `.url(message, url)` for an external link. **Check for presence first:** `if (ctx.elicit) { ... }` |
176
+ | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
177
+ | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
163
178
  | `ctx.signal` | `AbortSignal` for cancellation. |
164
179
  | `ctx.progress` | Task progress (present when `task: true`) — `.setTotal(n)`, `.increment()`, `.update(message)`. |
165
180
  | `ctx.requestId` | Unique request ID. |
166
- | `ctx.tenantId` | Tenant ID from JWT or `'default'` for stdio. |
181
+ | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
167
182
 
168
183
  ---
169
184
 
@@ -171,7 +186,7 @@ Handlers receive a unified `ctx` object. Key properties:
171
186
 
172
187
  Handlers throw — the framework catches, classifies, and formats.
173
188
 
174
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required descriptive metadata for the agent's next move ( 5 words, lint-validated); for the wire `data.recovery.hint` (mirrored into `content[]` text), pass explicitly at the throw site when dynamic context matters: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
189
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
175
190
 
176
191
  ```ts
177
192
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -183,7 +198,7 @@ errors: [
183
198
  ],
184
199
  async handler(input, ctx) {
185
200
  const item = await db.find(input.id);
186
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
201
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
187
202
  return item;
188
203
  }
189
204
  ```
@@ -222,8 +237,9 @@ src/
222
237
  canvas/
223
238
  canvas-accessor.ts # Module-level DataCanvas accessor (set via createApp setup())
224
239
  frankfurter/
225
- frankfurter-service.ts # Frankfurter HTTP clientgetRate, getRates, getTimeseries, getCurrencies
226
- types.ts # Domain types (ResolvedRate, RatesSnapshot, TimeseriesResult, …)
240
+ errors.ts # Structured service failuresreason/field on McpError.data, read via failureOf()
241
+ frankfurter-service.ts # Frankfurter HTTP client — getRate, getRates, getTimeSeries, listCurrencies
242
+ types.ts # Domain types (ResolvedRate, SeriesRow, TimeSeriesResult, …)
227
243
  mcp-server/
228
244
  tools/definitions/
229
245
  fx-convert-currency.tool.ts # Convert amount between two currencies
@@ -253,7 +269,7 @@ src/
253
269
 
254
270
  ## Skills
255
271
 
256
- Skills are modular instructions in `skills/` at the project root. Read them directly when a task matches — e.g., `skills/add-tool/SKILL.md` when adding a tool.
272
+ Skills are modular instructions in `skills/` at the project root. Read them directly when a task matches — e.g., `skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry.
257
273
 
258
274
  **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
259
275
 
@@ -273,7 +289,6 @@ Available skills:
273
289
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
274
290
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
275
291
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
276
- | `devcheck` | Lint, format, typecheck, audit |
277
292
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
278
293
  | `git-wrapup` | Land working-tree changes as a versioned commit + annotated tag — version bump, changelog, verify, tag. Local only. |
279
294
  | `release-and-publish` | Push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
@@ -281,12 +296,14 @@ Available skills:
281
296
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
282
297
  | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
283
298
  | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
299
+ | `techniques` | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns |
284
300
  | `api-auth` | Auth modes, scopes, JWT/OAuth |
285
301
  | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
286
302
  | `api-config` | AppConfig, parseConfig, env vars |
287
303
  | `api-context` | Context interface, logger, state, progress |
288
304
  | `api-errors` | McpError, JsonRpcErrorCode, error patterns |
289
305
  | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` |
306
+ | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in |
290
307
  | `api-services` | LLM, Speech, Graph services |
291
308
  | `api-testing` | createMockContext, test patterns |
292
309
  | `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers |
@@ -301,31 +318,34 @@ When you complete a skill's checklist, check the boxes and add a completion time
301
318
 
302
319
  ## Commands
303
320
 
304
- **Runtime:** Scripts use `tsx`both `npm run <cmd>` and `bun run <cmd>` work. `bun` is slightly faster for script invocation but not required.
321
+ **Runtime:** Scripts use Bun's native TypeScript execution — `bun run <cmd>` is the standard invocation. `npm run <cmd>` also works (npm delegates to bun).
305
322
 
306
323
  | Command | Purpose |
307
324
  |:--------|:--------|
308
- | `npm run build` | Compile TypeScript |
309
- | `npm run rebuild` | Clean + build |
310
- | `npm run clean` | Remove build artifacts |
311
- | `npm run devcheck` | Lint + format + typecheck + security + changelog sync |
325
+ | `bun run build` | Compile TypeScript |
326
+ | `bun run rebuild` | Clean + build |
327
+ | `bun run clean` | Remove build artifacts |
328
+ | `bun run devcheck` | Lint + format + typecheck + security + changelog sync |
312
329
  | `bun run audit:refresh` | Delete `bun.lock`, reinstall, and re-run `bun audit`. Use when `devcheck` flags a transitive advisory — Bun's `update` is sticky on transitive resolutions, so the advisory may be a stale-lockfile false positive. If it survives the refresh, it's real. |
313
- | `npm run tree` | Generate directory structure doc |
314
- | `npm run format` | Auto-fix formatting (safe fixes only) |
315
- | `npm run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
316
- | `npm test` | Run tests |
317
- | `npm run start:stdio` | Production mode (stdio) |
318
- | `npm run start:http` | Production mode (HTTP) |
319
- | `npm run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
320
- | `npm run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
321
- | `npm run bundle` | Build and pack as `.mcpb` for one-click Claude Desktop install |
322
- | `npm run publish-mcp` | Publish to MCP Registry via mcp-publisher |
330
+ | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
331
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
332
+ | `bun run list-skills` | Print the skill registry |
333
+ | `bun run tree` | Generate directory structure doc |
334
+ | `bun run format` | Auto-fix formatting (safe fixes only) |
335
+ | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
336
+ | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
337
+ | `bun run start:stdio` | Production mode (stdio) |
338
+ | `bun run start:http` | Production mode (HTTP) |
339
+ | `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
340
+ | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
341
+ | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
342
+ | `bun run publish-mcp` | Publish to MCP Registry via mcp-publisher |
323
343
 
324
344
  ---
325
345
 
326
346
  ## Bundling
327
347
 
328
- `npm run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
348
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on. This server uses DataCanvas, so its bundle ships without the DuckDB native — `@duckdb/node-api` is an optional peer loaded lazily, so `fx_dataframe_*` report an actionable install hint and every other tool works normally. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
329
349
 
330
350
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match.
331
351
 
@@ -343,14 +363,14 @@ Each per-version file opens with YAML frontmatter:
343
363
  ---
344
364
  summary: "One-line headline, ≤350 chars" # required — powers the rollup index
345
365
  breaking: false # optional — true flags breaking changes
346
- security: false # optional — true flags security fixes
366
+ security: false # optional — true ONLY for a source-code security fix, never a dependency CVE bump
347
367
  ---
348
368
 
349
369
  # 0.1.0 — YYYY-MM-DD
350
370
  ...
351
371
  ```
352
372
 
353
- `breaking: true` renders a `· ⚠️ Breaking` badge — use it when consumers must update code on upgrade (signature changes, removed APIs, config renames). `security: true` renders a `· 🛡️ Security` badge and pairs with a `## Security` body section. When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`.
373
+ `breaking: true` renders a `· ⚠️ Breaking` badge — use it when consumers must update code on upgrade (signature changes, removed APIs, config renames). `security: true` renders a `· 🛡️ Security` badge and pairs with a `## Security` body section — set it only for a security fix in this server's *own source code*, never for a routine dependency or transitive CVE bump (record those under `## Dependencies`). When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`.
354
374
 
355
375
  `agent-notes` is an optional free-form field for maintenance agents processing the release downstream. Content here won't appear in the rendered CHANGELOG — it's consumed by agents running the `maintenance` skill. Use it for adoption instructions that don't fit the human-facing sections: new files to create, fields to populate, one-time migration steps. Omit entirely when there's nothing to say.
356
376
 
package/CLAUDE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** exchange-rates-mcp-server
4
- **Version:** 0.1.4
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.10.6`
4
+ **Version:** 0.2.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.11.0`
6
6
  **Engines:** Bun ≥1.3.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
8
8
  **Zod:** ^4.4.3
@@ -35,7 +35,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
35
35
  - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
36
36
  - **Use `ctx.log`** for request-scoped logging. No `console` calls.
37
37
  - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly.
38
- - **Check `ctx.elicit` / `ctx.sample`** for presence before calling.
38
+ - **Check `ctx.elicit`** for presence before calling.
39
39
  - **Secrets in env vars only** — never hardcoded.
40
40
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
41
41
 
@@ -144,9 +144,23 @@ export function getServerConfig() {
144
144
 
145
145
  `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner.
146
146
 
147
- ### Server instructions
147
+ For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
148
148
 
149
- `createApp({ instructions })` optional server-level orientation, sent to clients on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
149
+ ### Server identity and instructions
150
+
151
+ `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`):
152
+
153
+ ```ts
154
+ await createApp({
155
+ name: 'exchange-rates-mcp-server',
156
+ title: 'exchange-rates-mcp-server', // display name — the hyphenated repo name, never Title Case
157
+ instructions: 'Use fx_list_currencies first to disambiguate "dollars".', // session-level context
158
+ });
159
+ ```
160
+
161
+ `description` and `websiteUrl` are also accepted but stay unset here — `description` derives from `package.json`, and an explicit copy is drift.
162
+
163
+ `instructions` is optional server-level orientation, sent on every `initialize` as session-level context. Use it for deployment guidance (connection aliases, regional notes, scope hints) instead of repeating the same context across tool descriptions. Client adoption is uneven, but there's no downside when set.
150
164
 
151
165
  ---
152
166
 
@@ -157,13 +171,14 @@ Handlers receive a unified `ctx` object. Key properties:
157
171
  | Property | Description |
158
172
  |:---------|:------------|
159
173
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
160
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
161
- | `ctx.elicit` | Ask user for structured input. **Check for presence first:** `if (ctx.elicit) { ... }` |
162
- | `ctx.sample` | Request LLM completion from the client. **Check for presence first:** `if (ctx.sample) { ... }` |
174
+ | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
175
+ | `ctx.elicit` | Ask user for structured input — form call `(message, schema)` or `.url(message, url)` for an external link. **Check for presence first:** `if (ctx.elicit) { ... }` |
176
+ | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
177
+ | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
163
178
  | `ctx.signal` | `AbortSignal` for cancellation. |
164
179
  | `ctx.progress` | Task progress (present when `task: true`) — `.setTotal(n)`, `.increment()`, `.update(message)`. |
165
180
  | `ctx.requestId` | Unique request ID. |
166
- | `ctx.tenantId` | Tenant ID from JWT or `'default'` for stdio. |
181
+ | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
167
182
 
168
183
  ---
169
184
 
@@ -171,7 +186,7 @@ Handlers receive a unified `ctx` object. Key properties:
171
186
 
172
187
  Handlers throw — the framework catches, classifies, and formats.
173
188
 
174
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required descriptive metadata for the agent's next move ( 5 words, lint-validated); for the wire `data.recovery.hint` (mirrored into `content[]` text), pass explicitly at the throw site when dynamic context matters: `ctx.fail('reason', msg, { recovery: { hint: '...' } })`. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
189
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble freely and don't need declaring.
175
190
 
176
191
  ```ts
177
192
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -183,7 +198,7 @@ errors: [
183
198
  ],
184
199
  async handler(input, ctx) {
185
200
  const item = await db.find(input.id);
186
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
201
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
187
202
  return item;
188
203
  }
189
204
  ```
@@ -222,8 +237,9 @@ src/
222
237
  canvas/
223
238
  canvas-accessor.ts # Module-level DataCanvas accessor (set via createApp setup())
224
239
  frankfurter/
225
- frankfurter-service.ts # Frankfurter HTTP clientgetRate, getRates, getTimeseries, getCurrencies
226
- types.ts # Domain types (ResolvedRate, RatesSnapshot, TimeseriesResult, …)
240
+ errors.ts # Structured service failuresreason/field on McpError.data, read via failureOf()
241
+ frankfurter-service.ts # Frankfurter HTTP client — getRate, getRates, getTimeSeries, listCurrencies
242
+ types.ts # Domain types (ResolvedRate, SeriesRow, TimeSeriesResult, …)
227
243
  mcp-server/
228
244
  tools/definitions/
229
245
  fx-convert-currency.tool.ts # Convert amount between two currencies
@@ -253,7 +269,7 @@ src/
253
269
 
254
270
  ## Skills
255
271
 
256
- Skills are modular instructions in `skills/` at the project root. Read them directly when a task matches — e.g., `skills/add-tool/SKILL.md` when adding a tool.
272
+ Skills are modular instructions in `skills/` at the project root. Read them directly when a task matches — e.g., `skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry.
257
273
 
258
274
  **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
259
275
 
@@ -273,7 +289,6 @@ Available skills:
273
289
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
274
290
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
275
291
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
276
- | `devcheck` | Lint, format, typecheck, audit |
277
292
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
278
293
  | `git-wrapup` | Land working-tree changes as a versioned commit + annotated tag — version bump, changelog, verify, tag. Local only. |
279
294
  | `release-and-publish` | Push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
@@ -281,12 +296,14 @@ Available skills:
281
296
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
282
297
  | `report-issue-framework` | File a bug or feature request against `@cyanheads/mcp-ts-core` via `gh` CLI |
283
298
  | `report-issue-local` | File a bug or feature request against this server's own repo via `gh` CLI |
299
+ | `techniques` | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns |
284
300
  | `api-auth` | Auth modes, scopes, JWT/OAuth |
285
301
  | `api-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in |
286
302
  | `api-config` | AppConfig, parseConfig, env vars |
287
303
  | `api-context` | Context interface, logger, state, progress |
288
304
  | `api-errors` | McpError, JsonRpcErrorCode, error patterns |
289
305
  | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` |
306
+ | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in |
290
307
  | `api-services` | LLM, Speech, Graph services |
291
308
  | `api-testing` | createMockContext, test patterns |
292
309
  | `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers |
@@ -301,31 +318,34 @@ When you complete a skill's checklist, check the boxes and add a completion time
301
318
 
302
319
  ## Commands
303
320
 
304
- **Runtime:** Scripts use `tsx`both `npm run <cmd>` and `bun run <cmd>` work. `bun` is slightly faster for script invocation but not required.
321
+ **Runtime:** Scripts use Bun's native TypeScript execution — `bun run <cmd>` is the standard invocation. `npm run <cmd>` also works (npm delegates to bun).
305
322
 
306
323
  | Command | Purpose |
307
324
  |:--------|:--------|
308
- | `npm run build` | Compile TypeScript |
309
- | `npm run rebuild` | Clean + build |
310
- | `npm run clean` | Remove build artifacts |
311
- | `npm run devcheck` | Lint + format + typecheck + security + changelog sync |
325
+ | `bun run build` | Compile TypeScript |
326
+ | `bun run rebuild` | Clean + build |
327
+ | `bun run clean` | Remove build artifacts |
328
+ | `bun run devcheck` | Lint + format + typecheck + security + changelog sync |
312
329
  | `bun run audit:refresh` | Delete `bun.lock`, reinstall, and re-run `bun audit`. Use when `devcheck` flags a transitive advisory — Bun's `update` is sticky on transitive resolutions, so the advisory may be a stale-lockfile false positive. If it survives the refresh, it's real. |
313
- | `npm run tree` | Generate directory structure doc |
314
- | `npm run format` | Auto-fix formatting (safe fixes only) |
315
- | `npm run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
316
- | `npm test` | Run tests |
317
- | `npm run start:stdio` | Production mode (stdio) |
318
- | `npm run start:http` | Production mode (HTTP) |
319
- | `npm run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
320
- | `npm run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
321
- | `npm run bundle` | Build and pack as `.mcpb` for one-click Claude Desktop install |
322
- | `npm run publish-mcp` | Publish to MCP Registry via mcp-publisher |
330
+ | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
331
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
332
+ | `bun run list-skills` | Print the skill registry |
333
+ | `bun run tree` | Generate directory structure doc |
334
+ | `bun run format` | Auto-fix formatting (safe fixes only) |
335
+ | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
336
+ | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
337
+ | `bun run start:stdio` | Production mode (stdio) |
338
+ | `bun run start:http` | Production mode (HTTP) |
339
+ | `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
340
+ | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
341
+ | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
342
+ | `bun run publish-mcp` | Publish to MCP Registry via mcp-publisher |
323
343
 
324
344
  ---
325
345
 
326
346
  ## Bundling
327
347
 
328
- `npm run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
348
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on. This server uses DataCanvas, so its bundle ships without the DuckDB native — `@duckdb/node-api` is an optional peer loaded lazily, so `fx_dataframe_*` report an actionable install hint and every other tool works normally. MCPB is stdio-only — HTTP and Cloudflare Workers deployments are unaffected. Consumers who don't need it can delete `manifest.json` and `.mcpbignore`; `lint:packaging` skips cleanly.
329
349
 
330
350
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match.
331
351
 
@@ -343,14 +363,14 @@ Each per-version file opens with YAML frontmatter:
343
363
  ---
344
364
  summary: "One-line headline, ≤350 chars" # required — powers the rollup index
345
365
  breaking: false # optional — true flags breaking changes
346
- security: false # optional — true flags security fixes
366
+ security: false # optional — true ONLY for a source-code security fix, never a dependency CVE bump
347
367
  ---
348
368
 
349
369
  # 0.1.0 — YYYY-MM-DD
350
370
  ...
351
371
  ```
352
372
 
353
- `breaking: true` renders a `· ⚠️ Breaking` badge — use it when consumers must update code on upgrade (signature changes, removed APIs, config renames). `security: true` renders a `· 🛡️ Security` badge and pairs with a `## Security` body section. When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`.
373
+ `breaking: true` renders a `· ⚠️ Breaking` badge — use it when consumers must update code on upgrade (signature changes, removed APIs, config renames). `security: true` renders a `· 🛡️ Security` badge and pairs with a `## Security` body section — set it only for a security fix in this server's *own source code*, never for a routine dependency or transitive CVE bump (record those under `## Dependencies`). When both are set, badges render `· ⚠️ Breaking · 🛡️ Security`.
354
374
 
355
375
  `agent-notes` is an optional free-form field for maintenance agents processing the release downstream. Content here won't appear in the rendered CHANGELOG — it's consumed by agents running the `maintenance` skill. Use it for adoption instructions that don't fit the human-facing sections: new files to create, fields to populate, one-time migration steps. Omit entirely when there's nothing to say.
356
376
 
package/Dockerfile CHANGED
@@ -4,7 +4,7 @@
4
4
  # This stage installs all dependencies (including dev), builds the TypeScript
5
5
  # source code into JavaScript, and prepares the production assets.
6
6
  # ==============================================================================
7
- FROM oven/bun:1.3 AS build
7
+ FROM oven/bun:1.3.14 AS build
8
8
 
9
9
  WORKDIR /usr/src/app
10
10
 
@@ -16,8 +16,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
16
16
  # Copy dependency manifests for optimized layer caching
17
17
  COPY package.json bun.lock ./
18
18
 
19
- # Install all dependencies (including dev dependencies for building)
20
- RUN bun install --frozen-lockfile
19
+ # Install all dependencies (including dev dependencies for building).
20
+ # The BuildKit cache mount persists Bun's global package cache across builds.
21
+ RUN --mount=type=cache,target=/root/.bun/install/cache \
22
+ bun install --frozen-lockfile --ignore-scripts
21
23
 
22
24
  # Copy the rest of the source code
23
25
  COPY . .
@@ -33,7 +35,7 @@ RUN bun run build
33
35
  # application. It uses a slim base image and only includes production
34
36
  # dependencies and build artifacts.
35
37
  # ==============================================================================
36
- FROM oven/bun:1.3-slim AS production
38
+ FROM oven/bun:1.3.14-slim AS production
37
39
 
38
40
  WORKDIR /usr/src/app
39
41
 
@@ -61,8 +63,9 @@ COPY --from=build /usr/src/app/node_modules ./node_modules
61
63
  # These are not bundled by default to keep the base image lean. Enable at build time
62
64
  # with: docker build --build-arg OTEL_ENABLED=true
63
65
  ARG OTEL_ENABLED=true
64
- RUN if [ "$OTEL_ENABLED" = "true" ]; then \
65
- bun add @hono/otel \
66
+ RUN --mount=type=cache,target=/root/.bun/install/cache \
67
+ if [ "$OTEL_ENABLED" = "true" ]; then \
68
+ bun add --omit=dev --ignore-scripts @hono/otel \
66
69
  @opentelemetry/instrumentation-http \
67
70
  @opentelemetry/exporter-metrics-otlp-http \
68
71
  @opentelemetry/exporter-trace-otlp-http \
package/LICENSE CHANGED
@@ -186,7 +186,7 @@ Apache License
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright 2025 Casey Hand @cyanheads
189
+ Copyright 2026 Casey Hand @cyanheads
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.1.4-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/exchange-rates-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/exchange-rates-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/exchange-rates-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^6.0.3-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.11-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.2.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/exchange-rates-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^1.29.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/exchange-rates-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/exchange-rates-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.3.14-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -29,17 +29,19 @@
29
29
 
30
30
  ## Tools
31
31
 
32
- Seven tools for working with ECB FX rate data — currency lookup and disambiguation, point-in-time rates and conversions, historical time-series retrieval, and SQL analytics over the DataCanvas workspace that long time-series calls produce:
32
+ Seven tools for working with ECB FX rate data — currency lookup and disambiguation, point-in-time rates and conversions, historical time-series retrieval, and SQL analytics over the DataCanvas workspace that long time-series calls produce.
33
+
34
+ The two `fx_dataframe_*` tools require DataCanvas. With `CANVAS_PROVIDER_TYPE` unset (the default) they are not advertised in `tools/list` at all, so a client never sees a tool it cannot call; the HTTP landing page still lists them as disabled cards hinting `CANVAS_PROVIDER_TYPE=duckdb`, so operators can tell they exist. In that mode `fx_get_timeseries` returns every range inline:
33
35
 
34
36
  | Tool | Description |
35
37
  |:-----|:------------|
36
38
  | `fx_list_currencies` | List all ~30 ECB-supported ISO 4217 currencies with full names. Use before converting to disambiguate "dollars" (USD vs AUD vs CAD vs HKD vs SGD). |
37
- | `fx_get_rates` | Snapshot of all available rates for a base currency at latest or a historical date. Optional `symbols` filter for smaller responses. |
39
+ | `fx_get_rates` | Snapshot of all available rates for a base currency at latest or a historical date. Optional `symbols` filter for smaller responses; listing the base itself returns a rate of 1 for it. |
38
40
  | `fx_get_rate` | Exchange rate for a single currency pair at latest or a historical date. Surfaces `date_snapped` when a weekend/holiday request returns the prior business-day rate. |
39
41
  | `fx_convert_currency` | Convert an amount between any two currencies at latest or a historical rate. Cross-rates are triangulated through EUR. Returns converted amount, rate used, rate date, and whether the date was snapped. |
40
- | `fx_get_timeseries` | Historical daily rates for a currency pair over a date range. Short ranges (≤90 days) are returned inline; long ranges spill to DataCanvas with a `canvas_id` for SQL follow-up. |
41
- | `fx_dataframe_describe` | List DataCanvas tables and their columns from a prior `fx_get_timeseries` call. Required first step before `fx_dataframe_query`. |
42
- | `fx_dataframe_query` | Run a read-only SQL SELECT against a DataCanvas table produced by `fx_get_timeseries`. Supports aggregations, GROUP BY, window functions, and JOINs across multiple registered tables. |
42
+ | `fx_get_timeseries` | Historical daily rates for a currency pair over a date range, never including a date outside it. Short ranges (≤90 days) are returned inline; when DataCanvas is enabled, long ranges spill to it with a `canvas_id` for SQL follow-up. |
43
+ | `fx_dataframe_describe` | List DataCanvas tables and their columns from a prior `fx_get_timeseries` call. Required first step before `fx_dataframe_query`. Needs `CANVAS_PROVIDER_TYPE=duckdb`. |
44
+ | `fx_dataframe_query` | Run a read-only SQL SELECT against a DataCanvas table produced by `fx_get_timeseries`. Supports aggregations, GROUP BY, window functions, and JOINs across multiple registered tables. Needs `CANVAS_PROVIDER_TYPE=duckdb`. |
43
45
 
44
46
  ### `fx_list_currencies`
45
47
 
@@ -56,6 +58,7 @@ Full rates snapshot for a base currency in one call.
56
58
 
57
59
  - Returns all available quote currencies at a given date (default: latest)
58
60
  - Optional `symbols` parameter narrows the response to specific quote currencies
61
+ - Naming the base currency in `symbols` is valid — it is answered locally with a rate of 1 rather than sent upstream, which keeps a self-quote from failing
59
62
  - Useful for seeding bulk comparison workflows or discovering what's available
60
63
 
61
64
  ---
@@ -66,6 +69,7 @@ Point-in-time exchange rate for a single pair.
66
69
 
67
70
  - Returns the rate, the actual rate date, and `date_snapped: true` when the API silently moved a weekend/holiday request to the prior business day
68
71
  - Cross-rates (neither side EUR) are triangulated in a single API call — no extra round trip
72
+ - A same-currency pair returns a rate of 1 without a self-quote reaching the API, but still reports the publication date the ECB actually had for that currency, so `rate_date` and `date_snapped` read the same as for any other pair
69
73
  - Use `fx_convert_currency` when you need the converted amount; use this tool when you only need the rate number
70
74
 
71
75
  ---
@@ -75,7 +79,7 @@ Point-in-time exchange rate for a single pair.
75
79
  Convert an amount between any two currencies.
76
80
 
77
81
  - Handles EUR ↔ any, any ↔ EUR, and cross-rate (USD → JPY via EUR) in one upstream call
78
- - Returns `converted_amount`, `rate`, `rate_date`, `date_snapped`, plus `rate_type` and `source` provenance on every response
82
+ - Returns `quote_amount`, `rate`, `rate_date`, `date_snapped`, plus `rate_type` and `source` provenance on every response
79
83
  - Historical conversions supported back to 1999-01-04 (ECB launch date)
80
84
 
81
85
  ---
@@ -87,7 +91,10 @@ Historical rate series and DataCanvas SQL analytics.
87
91
  `fx_get_timeseries` returns a date-keyed series (business days only — ECB publishes once per business day):
88
92
 
89
93
  - Short ranges (≤ `FX_TIMESERIES_CANVAS_THRESHOLD_DAYS`, default 90 days) → inline `rates` map + metadata
90
- - Long ranges → first N rows inline + `canvas_id`, `table_name`, and `truncated: true` — the full series is registered as a DuckDB-backed table
94
+ - Long ranges, **when DataCanvas is enabled** → first N rows inline + `canvas_id`, `table_name`, and `spilled: true` — the full series is registered as a DuckDB-backed table. Without `CANVAS_PROVIDER_TYPE=duckdb` a long range comes back inline with `spilled: false` and a `notice` saying the threshold was crossed but no canvas was configured
95
+ - Requesting the same currency on both sides returns a rate of 1 on each publication day in the range, taken from the ECB's real calendar rather than a synthetic Mon–Fri loop
96
+
97
+ The response never carries a date outside the requested range. Frankfurter snaps a range that opens on a weekend or bank holiday back to the prior publication day; those rows are dropped, so `start_date` and `end_date` always sit inside the window you asked for. A range covering only non-publication days therefore returns an empty `rates` map with `rate_count: 0` and a `notice` explaining that the ECB published nothing in that window — distinguishable from an error.
91
98
 
92
99
  Once a `canvas_id` is in hand:
93
100
 
@@ -115,7 +122,7 @@ Built on [`@cyanheads/mcp-ts-core`](https://www.npmjs.com/package/@cyanheads/mcp
115
122
 
116
123
  - Declarative tool and resource definitions — single file per primitive, framework handles registration and validation
117
124
  - Unified error handling — handlers throw, framework catches, classifies, and formats
118
- - Typed error contracts with recovery hints — `unsupported_currency`, `date_out_of_range`, `canvas_not_found`, `invalid_query`
125
+ - Typed error contracts with recovery hints — `unsupported_currency`, `date_out_of_range`, `canvas_not_found`, `missing_table`, `invalid_query`
119
126
  - Pluggable auth: `none`, `jwt`, `oauth`
120
127
  - Structured logging with optional OpenTelemetry tracing
121
128
  - STDIO and Streamable HTTP transports
@@ -123,17 +130,19 @@ Built on [`@cyanheads/mcp-ts-core`](https://www.npmjs.com/package/@cyanheads/mcp
123
130
  ECB FX–specific:
124
131
 
125
132
  - Keyless access via [Frankfurter](https://www.frankfurter.dev/) — a Cloudflare-fronted ECB proxy; no API keys required
126
- - Cross-rate triangulation: any pair works (USD → JPY fetches EUR/USD and EUR/JPY in one call, computes JPY/USD ratio)
133
+ - Cross-rate triangulation: any pair works USD → JPY is one upstream call, cross-rated through EUR on Frankfurter's side
127
134
  - Weekend/holiday date semantics: `date_snapped` flag surfaces when the API returns a different date than requested
128
135
  - ECB data covers ~30 major currencies from 1999-01-04 to present; `fx_list_currencies` always reflects the live set
129
- - DataCanvas integration: `fx_get_timeseries` spills long ranges to DuckDB for aggregations and trend analysis
136
+ - Identity pairs never surface an upstream rejection: `fx_get_rate`, `fx_get_rates`, and `fx_get_timeseries` all return a rate of 1 for a currency against itself, dated to the days the ECB actually published for that currency rather than to the calendar dates requested
137
+ - DataCanvas integration: when enabled, `fx_get_timeseries` spills long ranges to DuckDB for aggregations and trend analysis
130
138
  - Rate provenance on every response: `rate_type: "ECB reference (mid-market)"` and `source: "ECB via Frankfurter"` — explicitly mid-market, not tradeable bid/ask
131
139
 
132
140
  Agent-friendly output:
133
141
 
134
142
  - Rate provenance on every response — `rate_type`, `source`, `rate_date`, and `date_snapped` so agents can reason about trust and freshness
135
143
  - Structured error contracts — typed `reason` fields (`unsupported_currency`, `date_out_of_range`, `invalid_query`, …) let callers branch on failure type, not string parsing
136
- - Discriminated DataCanvas output — `canvas_id` and `truncated: true` signal when a time-series exceeds the inline limit and SQL follow-up is needed
144
+ - Discriminated DataCanvas output — `spilled: true` plus `canvas_id` signal when a time-series was staged for SQL follow-up rather than returned inline
145
+ - Success-path `notice` enrichment — explains an empty series or a long range that stayed inline, so a legitimate zero-result never reads as a failure
137
146
 
138
147
  ---
139
148
 
@@ -210,7 +219,7 @@ Or with Docker:
210
219
  }
211
220
  ```
212
221
 
213
- To enable DataCanvas for long time-series SQL analytics, add `CANVAS_PROVIDER_TYPE=duckdb`:
222
+ 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`:
214
223
 
215
224
  ```json
216
225
  {
@@ -276,8 +285,8 @@ All configuration is validated at startup via Zod schemas. Environment variables
276
285
  | Variable | Description | Default |
277
286
  |:---------|:------------|:--------|
278
287
  | `FRANKFURTER_BASE_URL` | Frankfurter API base URL. Override for local testing or a self-hosted instance. | `https://api.frankfurter.dev/v1` |
279
- | `FX_TIMESERIES_CANVAS_THRESHOLD_DAYS` | Day range above which `fx_get_timeseries` spills to DataCanvas instead of returning inline. | `90` |
280
- | `CANVAS_PROVIDER_TYPE` | Canvas engine. Set to `duckdb` to enable DataCanvas for `fx_get_timeseries` long-range spillover. | `none` |
288
+ | `FX_TIMESERIES_CANVAS_THRESHOLD_DAYS` | Day range above which `fx_get_timeseries` spills to DataCanvas, when one is configured. | `90` |
289
+ | `CANVAS_PROVIDER_TYPE` | Canvas engine. Set to `duckdb` to enable DataCanvas for `fx_get_timeseries` long-range spillover and to register the two `fx_dataframe_*` tools. At `none` they are skipped from `tools/list`. | `none` |
281
290
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
282
291
  | `MCP_HTTP_PORT` | Port for HTTP server. | `3010` |
283
292
  | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` |