@cyanheads/exchange-rates-mcp-server 0.3.2 → 0.4.1

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 (35) hide show
  1. package/AGENTS.md +44 -17
  2. package/CLAUDE.md +44 -17
  3. package/README.md +90 -72
  4. package/changelog/0.4.x/0.4.0.md +22 -0
  5. package/changelog/0.4.x/0.4.1.md +27 -0
  6. package/changelog/template.md +9 -26
  7. package/dist/index.js +10 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.js +2 -3
  11. package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/fx-dataframe-drop.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/fx-dataframe-drop.tool.js +2 -3
  14. package/dist/mcp-server/tools/definitions/fx-dataframe-drop.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.d.ts +4 -1
  16. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.js +54 -22
  18. package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.d.ts +2 -0
  20. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.js +115 -86
  22. package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.js.map +1 -1
  23. package/dist/services/frankfurter/errors.d.ts +6 -1
  24. package/dist/services/frankfurter/errors.d.ts.map +1 -1
  25. package/dist/services/frankfurter/errors.js +6 -2
  26. package/dist/services/frankfurter/errors.js.map +1 -1
  27. package/dist/services/frankfurter/frankfurter-service.d.ts.map +1 -1
  28. package/dist/services/frankfurter/frankfurter-service.js +12 -4
  29. package/dist/services/frankfurter/frankfurter-service.js.map +1 -1
  30. package/dist/utils/escape-markdown-table-cell.d.ts +11 -0
  31. package/dist/utils/escape-markdown-table-cell.d.ts.map +1 -0
  32. package/dist/utils/escape-markdown-table-cell.js +19 -0
  33. package/dist/utils/escape-markdown-table-cell.js.map +1 -0
  34. package/package.json +8 -7
  35. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** exchange-rates-mcp-server
4
- **Version:** 0.3.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `0.12.8`
4
+ **Version:** 0.4.1
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `0.13.6`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
- **Zod:** ^4.6.0
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
11
11
 
@@ -163,6 +163,22 @@ await createApp({
163
163
 
164
164
  `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.
165
165
 
166
+ ### Session posture and shutdown
167
+
168
+ Two more `createApp()` options shape how the server runs rather than how it presents itself:
169
+
170
+ ```ts
171
+ await createApp({
172
+ sessionMode: 'stateless', // or { default: 'stateful', require: 'stateful' }
173
+ setup(core) { setCanvas(core.canvas); },
174
+ async teardown() { await stopMyWatcher(); },
175
+ });
176
+ ```
177
+
178
+ `sessionMode` declares the HTTP session posture in `src/` instead of leaving it to a deployment's `MCP_SESSION_MODE`, which still wins whenever it carries a meaningful value (an empty string and an unsubstituted `${…}` placeholder read as unset and fall through to the option). This server declares `stateless`: no handler calls `ctx.requestInput`, so nothing needs a session to resume. Add `require: 'stateful'` when a tool does ask the caller for input mid-handler: startup then fails with a `ConfigurationError` rather than serving a mode in which a 2025-era client can never answer the prompt. Stdio is never refused.
179
+
180
+ `teardown(core)` is the `setup()` counterpart — release a watcher, socket, or non-`unref()`'d timer there. It runs after the transport stops and before the logger closes, on every shutdown path, and a signal-triggered shutdown then exits the process explicitly (0, or 1 if a step never settles within the framework's 10 s ceiling). Nothing here declares one: `setup()` only hands `core.canvas` to the module accessor, and the canvas service is torn down by the framework.
181
+
166
182
  ---
167
183
 
168
184
  ## Context
@@ -187,7 +203,7 @@ Handlers receive a unified `ctx` object. Key properties:
187
203
 
188
204
  Handlers throw — the framework catches, classifies, and formats.
189
205
 
190
- **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`, `RequestCancelled`) bubble freely and don't need declaring.
206
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` 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 unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
191
207
 
192
208
  ```ts
193
209
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -246,14 +262,16 @@ src/
246
262
  fx-convert-currency.tool.ts # Convert amount between two currencies
247
263
  fx-get-rate.tool.ts # Exchange rate for a currency pair
248
264
  fx-get-rates.tool.ts # All rates for a base currency snapshot
249
- fx-get-timeseries.tool.ts # Historical daily rates; spills to DataCanvas for long ranges
265
+ fx-get-timeseries.tool.ts # Historical daily rates; inline pages of 500 days, spills to DataCanvas for long ranges
250
266
  fx-list-currencies.tool.ts # All supported ISO 4217 currencies
251
267
  fx-dataframe-describe.tool.ts # List DataCanvas tables from a prior timeseries call
252
- fx-dataframe-query.tool.ts # SQL SELECT against DataCanvas tables
268
+ fx-dataframe-query.tool.ts # SQL SELECT against DataCanvas tables, bounded by row_limit
253
269
  fx-dataframe-drop.tool.ts # Remove one staged table; opt-in via FX_ENABLE_CANVAS_DROP
254
270
  resources/definitions/
255
271
  fx-currencies.resource.ts # fx://currencies — stable currencies reference document
256
272
  fx-rates-latest.resource.ts # fx://rates/latest/{base} — latest rates snapshot
273
+ utils/
274
+ escape-markdown-table-cell.ts # Escapes a value for one GFM table cell (fx_dataframe_query format())
257
275
  ```
258
276
 
259
277
  ---
@@ -271,9 +289,9 @@ src/
271
289
 
272
290
  ## Skills
273
291
 
274
- 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.
292
+ Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it. Keep `skills/` free for skills meant for those agents.
275
293
 
276
- **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.
294
+ **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
277
295
 
278
296
  Available skills:
279
297
 
@@ -293,7 +311,7 @@ Available skills:
293
311
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
294
312
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
295
313
  | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
296
- | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixup commits autosquashed into the stack, PR body kept in sync. Release PR mode only |
314
+ | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
297
315
  | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
298
316
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
299
317
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
@@ -313,7 +331,7 @@ Available skills:
313
331
  | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
314
332
  | `api-workers` | Cloudflare Workers runtime |
315
333
 
316
- **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.
334
+ **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `framework-skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.
317
335
 
318
336
  When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).
319
337
 
@@ -329,7 +347,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
329
347
  | `bun run rebuild` | Clean + build |
330
348
  | `bun run clean` | Remove build artifacts |
331
349
  | `bun run devcheck` | Lint + format + typecheck + security + changelog sync |
332
- | `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. |
350
+ | `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update <name>`, then `bun dedupe` |
351
+ | `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update <name>`, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` |
333
352
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
334
353
  | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
335
354
  | `bun run list-skills` | Print the skill registry |
@@ -344,15 +363,17 @@ When you complete a skill's checklist, check the boxes and add a completion time
344
363
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
345
364
  | `bun run publish-mcp` | Publish to MCP Registry via mcp-publisher |
346
365
 
366
+ **CI is one file.** `.github/workflows/codeql.yml` (scaffolded) is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
367
+
347
368
  ---
348
369
 
349
370
  ## Bundling
350
371
 
351
- `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.
372
+ `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 (`framework-skills/`, `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.
352
373
 
353
- **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.
374
+ **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, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`.
354
375
 
355
- **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `skills/polish-docs-meta/references/readme.md`.
376
+ **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `framework-skills/polish-docs-meta/references/readme.md`.
356
377
 
357
378
  ---
358
379
 
@@ -383,6 +404,12 @@ security: false # optional — true ONLY for a source
383
404
 
384
405
  ---
385
406
 
407
+ ## Publishing
408
+
409
+ **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
410
+
411
+ ---
412
+
386
413
  ## Imports
387
414
 
388
415
  ```ts
@@ -409,7 +436,7 @@ import { getMyService } from '@/services/my-domain/my-service.js';
409
436
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
410
437
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
411
438
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
412
- - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = package name; `interface.shortDescription` from `package.json` description
413
- - [ ] `.codex-plugin/mcp.json` updated — server name key matches `package.json` name; env vars added for any required API keys
414
- - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; inline `mcpServers` entry with server name key, env vars for any required API keys
439
+ - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
440
+ - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
441
+ - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
415
442
  - [ ] `npm run devcheck` passes
package/CLAUDE.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** exchange-rates-mcp-server
4
- **Version:** 0.3.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `0.12.8`
4
+ **Version:** 0.4.1
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `0.13.6`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
- **Zod:** ^4.6.0
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
11
11
 
@@ -163,6 +163,22 @@ await createApp({
163
163
 
164
164
  `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.
165
165
 
166
+ ### Session posture and shutdown
167
+
168
+ Two more `createApp()` options shape how the server runs rather than how it presents itself:
169
+
170
+ ```ts
171
+ await createApp({
172
+ sessionMode: 'stateless', // or { default: 'stateful', require: 'stateful' }
173
+ setup(core) { setCanvas(core.canvas); },
174
+ async teardown() { await stopMyWatcher(); },
175
+ });
176
+ ```
177
+
178
+ `sessionMode` declares the HTTP session posture in `src/` instead of leaving it to a deployment's `MCP_SESSION_MODE`, which still wins whenever it carries a meaningful value (an empty string and an unsubstituted `${…}` placeholder read as unset and fall through to the option). This server declares `stateless`: no handler calls `ctx.requestInput`, so nothing needs a session to resume. Add `require: 'stateful'` when a tool does ask the caller for input mid-handler: startup then fails with a `ConfigurationError` rather than serving a mode in which a 2025-era client can never answer the prompt. Stdio is never refused.
179
+
180
+ `teardown(core)` is the `setup()` counterpart — release a watcher, socket, or non-`unref()`'d timer there. It runs after the transport stops and before the logger closes, on every shutdown path, and a signal-triggered shutdown then exits the process explicitly (0, or 1 if a step never settles within the framework's 10 s ceiling). Nothing here declares one: `setup()` only hands `core.canvas` to the module accessor, and the canvas service is torn down by the framework.
181
+
166
182
  ---
167
183
 
168
184
  ## Context
@@ -187,7 +203,7 @@ Handlers receive a unified `ctx` object. Key properties:
187
203
 
188
204
  Handlers throw — the framework catches, classifies, and formats.
189
205
 
190
- **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`, `RequestCancelled`) bubble freely and don't need declaring.
206
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` 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 unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
191
207
 
192
208
  ```ts
193
209
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -246,14 +262,16 @@ src/
246
262
  fx-convert-currency.tool.ts # Convert amount between two currencies
247
263
  fx-get-rate.tool.ts # Exchange rate for a currency pair
248
264
  fx-get-rates.tool.ts # All rates for a base currency snapshot
249
- fx-get-timeseries.tool.ts # Historical daily rates; spills to DataCanvas for long ranges
265
+ fx-get-timeseries.tool.ts # Historical daily rates; inline pages of 500 days, spills to DataCanvas for long ranges
250
266
  fx-list-currencies.tool.ts # All supported ISO 4217 currencies
251
267
  fx-dataframe-describe.tool.ts # List DataCanvas tables from a prior timeseries call
252
- fx-dataframe-query.tool.ts # SQL SELECT against DataCanvas tables
268
+ fx-dataframe-query.tool.ts # SQL SELECT against DataCanvas tables, bounded by row_limit
253
269
  fx-dataframe-drop.tool.ts # Remove one staged table; opt-in via FX_ENABLE_CANVAS_DROP
254
270
  resources/definitions/
255
271
  fx-currencies.resource.ts # fx://currencies — stable currencies reference document
256
272
  fx-rates-latest.resource.ts # fx://rates/latest/{base} — latest rates snapshot
273
+ utils/
274
+ escape-markdown-table-cell.ts # Escapes a value for one GFM table cell (fx_dataframe_query format())
257
275
  ```
258
276
 
259
277
  ---
@@ -271,9 +289,9 @@ src/
271
289
 
272
290
  ## Skills
273
291
 
274
- 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.
292
+ Skills are modular instructions in `framework-skills/` at the project root. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. The directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand these development skills to every agent that installs it. Keep `skills/` free for skills meant for those agents.
275
293
 
276
- **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.
294
+ **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
277
295
 
278
296
  Available skills:
279
297
 
@@ -293,7 +311,7 @@ Available skills:
293
311
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
294
312
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
295
313
  | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
296
- | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixup commits autosquashed into the stack, PR body kept in sync. Release PR mode only |
314
+ | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
297
315
  | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
298
316
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
299
317
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
@@ -313,7 +331,7 @@ Available skills:
313
331
  | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules |
314
332
  | `api-workers` | Cloudflare Workers runtime |
315
333
 
316
- **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.
334
+ **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `framework-skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase.
317
335
 
318
336
  When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`).
319
337
 
@@ -329,7 +347,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
329
347
  | `bun run rebuild` | Clean + build |
330
348
  | `bun run clean` | Remove build artifacts |
331
349
  | `bun run devcheck` | Lint + format + typecheck + security + changelog sync |
332
- | `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. |
350
+ | `bun run audit:fix` | `bun audit fix` — upgrade vulnerable packages to the lowest safe version within existing ranges (`--dry-run` previews, `--latest` rewrites ranges). First response when `devcheck` flags a transitive advisory; then `bun update <name>`, then `bun dedupe` |
351
+ | `bun run audit:refresh` | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update <name>`, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` |
333
352
  | `bun run lint:mcp` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
334
353
  | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity (run by devcheck) |
335
354
  | `bun run list-skills` | Print the skill registry |
@@ -344,15 +363,17 @@ When you complete a skill's checklist, check the boxes and add a completion time
344
363
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
345
364
  | `bun run publish-mcp` | Publish to MCP Registry via mcp-publisher |
346
365
 
366
+ **CI is one file.** `.github/workflows/codeql.yml` (scaffolded) is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
367
+
347
368
  ---
348
369
 
349
370
  ## Bundling
350
371
 
351
- `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.
372
+ `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 (`framework-skills/`, `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.
352
373
 
353
- **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.
374
+ **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, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`.
354
375
 
355
- **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `skills/polish-docs-meta/references/readme.md`.
376
+ **README install badges** (Claude Desktop `.mcpb`, Cursor, VS Code) and the `base64` / `encodeURIComponent` config-generation commands are ship-time concerns — run the `polish-docs-meta` skill, which carries the badge format, layout, and generation snippets in `framework-skills/polish-docs-meta/references/readme.md`.
356
377
 
357
378
  ---
358
379
 
@@ -383,6 +404,12 @@ security: false # optional — true ONLY for a source
383
404
 
384
405
  ---
385
406
 
407
+ ## Publishing
408
+
409
+ **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
410
+
411
+ ---
412
+
386
413
  ## Imports
387
414
 
388
415
  ```ts
@@ -409,7 +436,7 @@ import { getMyService } from '@/services/my-domain/my-service.js';
409
436
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
410
437
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports)
411
438
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
412
- - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = package name; `interface.shortDescription` from `package.json` description
413
- - [ ] `.codex-plugin/mcp.json` updated — server name key matches `package.json` name; env vars added for any required API keys
414
- - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; inline `mcpServers` entry with server name key, env vars for any required API keys
439
+ - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
440
+ - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, contact email, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
441
+ - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
415
442
  - [ ] `npm run devcheck` passes
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.3.2-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-^2.0.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.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.4.1-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-^2.0.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.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -27,123 +27,140 @@
27
27
 
28
28
  ---
29
29
 
30
- ## Tools
30
+ ## Overview
31
31
 
32
- Eight 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. Five are advertised by default; the three `fx_dataframe_*` tools need `CANVAS_PROVIDER_TYPE=duckdb`, and the destructive one among them additionally needs `FX_ENABLE_CANVAS_DROP=true`.
32
+ 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.
33
33
 
34
- The three `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:
34
+ ### Tools
35
35
 
36
36
  | Tool | Description |
37
- |:-----|:------------|
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). |
39
- | `fx_get_rates` | Snapshot of all available rates for a base currency at latest or a historical date. Surfaces `date_snapped` when a weekend/holiday request returns the prior business-day snapshot. Optional `symbols` filter for smaller responses; listing the base itself returns a rate of 1 for it. |
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. |
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. |
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`. |
45
- | `fx_dataframe_drop` | Permanently remove one staged table or view from a DataCanvas. Deletes staged analytical data only — ECB rate data is untouched and the series can be re-staged. Needs `CANVAS_PROVIDER_TYPE=duckdb` and `FX_ENABLE_CANVAS_DROP=true`; disabled otherwise. |
37
+ |:---|:---|
38
+ | `fx_list_currencies` | List all ~30 ECB-supported ISO 4217 currencies with full names |
39
+ | `fx_get_rates` | Snapshot of all rates for a base currency at latest or a historical date |
40
+ | `fx_get_rate` | Exchange rate for a single currency pair at latest or a historical date |
41
+ | `fx_convert_currency` | Convert an amount between two currencies at latest or a historical rate |
42
+ | `fx_get_timeseries` | Historical daily rates for a currency pair over a date range |
43
+ | `fx_dataframe_describe` | List DataCanvas tables and columns staged by a prior `fx_get_timeseries` call |
44
+ | `fx_dataframe_query` | Run a read-only SQL SELECT against a staged DataCanvas table |
45
+ | `fx_dataframe_drop` | Remove one staged DataCanvas table or view (opt-in, destructive) |
46
46
 
47
- ### `fx_list_currencies`
47
+ 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`.
48
48
 
49
- Enumerate all supported currencies before converting or querying.
49
+ ### Resources
50
50
 
51
- - Returns `[{ code, name }]` for all ~30 ECB-scoped currencies
52
- - ECB coverage fluctuates as currencies enter/exit scope — always call this tool to validate user-supplied codes rather than hard-coding a list
51
+ | Resource | Description |
52
+ |:---|:---|
53
+ | `fx://currencies` | All supported currencies as a stable reference document |
54
+ | `fx://rates/latest/{base}` | Latest rates snapshot for a base currency as a stable URI |
53
55
 
54
- ---
56
+ All resource data is also reachable via tools — use `fx_list_currencies` or `fx_get_rates` for programmatic access.
55
57
 
56
- ### `fx_get_rates`
58
+ ## Capability reference
57
59
 
58
- Full rates snapshot for a base currency in one call.
60
+ ### `fx_list_currencies` <sub>tool</sub>
59
61
 
60
- - Returns all available quote currencies at a given date (default: latest), the actual `rate_date`, and `date_snapped: true` when the API silently moved a weekend/holiday request to the prior business day — always `false` when `date` is omitted
61
- - Optional `symbols` parameter narrows the response to specific quote currencies; when sent it must name at least one code — omit it to get every currency
62
- - 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
63
- - Useful for seeding bulk comparison workflows or discovering what's available
62
+ - No input parameters
63
+ - Returns `[{ code, name }]` for all ~30 ECB-scoped currencies, sorted alphabetically by code
64
+ - ECB coverage shifts as currencies enter or exit scope — call this to validate a user-supplied code rather than hard-coding a list
64
65
 
65
66
  ---
66
67
 
67
- ### `fx_get_rate`
68
-
69
- Point-in-time exchange rate for a single pair.
68
+ ### `fx_get_rates` <sub>tool</sub>
70
69
 
71
- - 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
72
- - Cross-rates (neither side EUR) are triangulated in a single API call — no extra round trip
73
- - 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
74
- - Use `fx_convert_currency` when you need the converted amount; use this tool when you only need the rate number
70
+ - `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
71
+ - 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
72
+ - Naming the base currency in `symbols` is valid — answered locally with a rate of 1 rather than sent upstream
73
+ - Typed failures: `invalid_date_format`, `unsupported_currency`, `date_out_of_range`, `upstream_no_data`
75
74
 
76
75
  ---
77
76
 
78
- ### `fx_convert_currency`
77
+ ### `fx_get_rate` <sub>tool</sub>
79
78
 
80
- Convert an amount between any two currencies.
79
+ - `base_currency`, `quote_currency` required; `date` optional (default latest, ECB data from 1999-01-04, no future dates)
80
+ - Returns `rate`, `rate_date`, and `date_snapped: true` when a weekend/holiday request snapped to the prior business day
81
+ - 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
82
+ - Typed failures: `invalid_date_format`, `unsupported_currency`, `date_out_of_range`, `upstream_no_data`
83
+
84
+ ---
81
85
 
82
- - Handles EUR ↔ any, any ↔ EUR, and cross-rate (USD → JPY via EUR) in one upstream call
83
- - Returns `quote_amount`, `rate`, `rate_date`, `date_snapped`, plus `rate_type` and `source` provenance on every response
84
- - Historical conversions supported back to 1999-01-04 (ECB launch date)
86
+ ### `fx_convert_currency` <sub>tool</sub>
87
+
88
+ - `base_currency`, `quote_currency`, `amount` (must be > 0) required; `date` optional (default latest, ECB data from 1999-01-04, no future dates)
89
+ - Handles EUR↔any, any↔EUR, and cross-rate pairs (e.g. USD→JPY) in a single upstream call
90
+ - Returns `quote_amount` (rounded to 6 decimal places), `rate`, `rate_date`, `date_snapped`, plus `rate_type` and `source` provenance
91
+ - Typed failures: `invalid_date_format`, `unsupported_currency`, `date_out_of_range`, `upstream_no_data`
85
92
 
86
93
  ---
87
94
 
88
- ### `fx_get_timeseries` + `fx_dataframe_describe` / `fx_dataframe_query`
95
+ ### `fx_get_timeseries` <sub>tool</sub>
89
96
 
90
- Historical rate series and DataCanvas SQL analytics.
97
+ - `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
98
+ - Inline results page at 500 publication days — `rate_count` is always the range total; `truncated: true` plus `next_start_date` continue the page
99
+ - 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
100
+ - A same-currency pair returns a rate of 1 on each real ECB publication day in range, not a synthetic Mon–Fri loop
101
+ - An empty range (only weekends/holidays) returns `rate_count: 0` with an explanatory `notice`, distinguishable from an error
91
102
 
92
- `fx_get_timeseries` returns a date-keyed series (business days only — ECB publishes once per business day):
103
+ ---
93
104
 
94
- - Short ranges (≤ `FX_TIMESERIES_CANVAS_THRESHOLD_DAYS`, default 90 days) → inline `rates` map + metadata
95
- - 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
96
- - 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
105
+ ### `fx_dataframe_describe` <sub>tool</sub>
97
106
 
98
- 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.
107
+ - `canvas_id` required (from a prior `fx_get_timeseries` call)
108
+ - Returns each staged table's `kind`, `row_count`, and column schema (`name`, `type`, `nullable`), plus `expires_at`
109
+ - Required first step before `fx_dataframe_query`; needs `CANVAS_PROVIDER_TYPE=duckdb` — unregistered otherwise
110
+ - `canvas_not_found` when the ID doesn't exist or has expired
99
111
 
100
- Once a `canvas_id` is in hand:
112
+ ---
101
113
 
102
- 1. **`fx_dataframe_describe`** — list the tables and columns on the canvas (required before `fx_dataframe_query`)
103
- 2. **`fx_dataframe_query`** — run arbitrary SQL SELECT against the registered table; supports aggregations, GROUP BY, window functions, JOINs across tables from multiple `fx_get_timeseries` calls
114
+ ### `fx_dataframe_query` <sub>tool</sub>
104
115
 
105
- The canvas uses a session-scoped TTL. To continue working with a prior series, call `fx_get_timeseries` again with the same parameters to obtain a fresh `canvas_id`.
116
+ - `canvas_id` and a read-only SQL `query` required; `row_limit` optional (1–10,000, default 150)
117
+ - Supports aggregations, GROUP BY, window functions, and JOINs across tables from multiple `fx_get_timeseries` calls
118
+ - 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
119
+ - Markdown table cells are escaped so pipes, angle brackets, and line breaks stay inside their cell; `structuredContent` keeps raw values
120
+ - Needs `CANVAS_PROVIDER_TYPE=duckdb`; typed failures: `canvas_not_found`, `missing_table`, `invalid_query`
106
121
 
107
122
  ---
108
123
 
109
- ## Resources and prompts
124
+ ### `fx_dataframe_drop` <sub>tool</sub>
110
125
 
111
- | Type | Name | Description |
112
- |:-----|:-----|:------------|
113
- | Resource | `fx://currencies` | All supported currencies as a stable reference document. Injectable context for clients that support resources. |
114
- | Resource | `fx://rates/latest/{base}` | Latest rates snapshot for a base currency as a stable URI. |
126
+ - `canvas_id` and exact `table_name` (from `fx_dataframe_describe`) required
127
+ - Removes one staged table or view; ECB rate data is untouched and the series can be re-staged via `fx_get_timeseries`
128
+ - Returns `dropped: true`/`false` depending on whether the table existed
129
+ - Disabled unless `FX_ENABLE_CANVAS_DROP=true` — listed with its enable hint but uncallable otherwise; also needs `CANVAS_PROVIDER_TYPE=duckdb`
115
130
 
116
- All resource data is also reachable via tools. Use `fx_list_currencies` or `fx_get_rates` for programmatic access.
131
+ ---
132
+
133
+ ### `fx://currencies` <sub>resource</sub>
134
+
135
+ - No parameters; returns `currencies`, `count`, `source` as `application/json` — the same payload as `fx_list_currencies`
136
+ - Listed as a single static resource
117
137
 
118
138
  ---
119
139
 
120
- ## Features
140
+ ### `fx://rates/latest/{base}` <sub>resource</sub>
141
+
142
+ - `base` is an ISO 4217 currency code in the URI
143
+ - Returns `base_currency`, `rate_date`, a `rates` map, `rate_type`, and `source` for the latest ECB fix
144
+ - Listed with four sample URIs (EUR, USD, GBP, JPY) as discovery hints
121
145
 
122
- Built on [`@cyanheads/mcp-ts-core`](https://www.npmjs.com/package/@cyanheads/mcp-ts-core):
146
+ ## Features
123
147
 
124
- - Declarative tool and resource definitions — single file per primitive, framework handles registration and validation
125
- - Unified error handling — handlers throw, framework catches, classifies, and formats
126
- - Typed error contracts with recovery hints — `unsupported_currency`, `date_out_of_range`, `canvas_not_found`, `missing_table`, `invalid_query`
127
- - Pluggable auth: `none`, `jwt`, `oauth`
128
- - Structured logging with optional OpenTelemetry tracing
129
- - STDIO and Streamable HTTP transports
148
+ Built on [`@cyanheads/mcp-ts-core`](https://github.com/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.
130
149
 
131
- ECB FX–specific:
150
+ ECB-specific:
132
151
 
133
152
  - Keyless access via [Frankfurter](https://www.frankfurter.dev/) — a Cloudflare-fronted ECB proxy; no API keys required
134
153
  - Cross-rate triangulation: any pair works — USD → JPY is one upstream call, cross-rated through EUR on Frankfurter's side
135
- - Weekend/holiday date semantics: `date_snapped` flag surfaces when the API returns a different date than requested
136
- - ECB data covers ~30 major currencies from 1999-01-04 to present; `fx_list_currencies` always reflects the live set, and an `unsupported_currency` rejection lists that live set inline so a caller can correct the code without a second call
137
- - 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
138
- - DataCanvas integration: when enabled, `fx_get_timeseries` spills long ranges to DuckDB for aggregations and trend analysis
139
- - Rate provenance on every response: `rate_type: "ECB reference (mid-market)"` and `source: "ECB via Frankfurter"` — explicitly mid-market, not tradeable bid/ask
154
+ - Weekend/holiday date semantics: `date_snapped` surfaces when the API returns a different date than requested
155
+ - 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
156
+ - Long time-series spill to DataCanvas (DuckDB) when enabled, for SQL aggregation over the full range
140
157
 
141
158
  Agent-friendly output:
142
159
 
143
- - Rate provenance on every snapshot, rate, and conversion response — `rate_type`, `source`, `rate_date`, and `date_snapped` so agents can reason about trust and freshness
160
+ - Rate provenance on every response — `rate_type`, `source`, `rate_date`, and `date_snapped` so agents can reason about trust and freshness
144
161
  - Structured error contracts — typed `reason` fields (`unsupported_currency`, `date_out_of_range`, `invalid_query`, …) let callers branch on failure type, not string parsing
145
- - Discriminated DataCanvas output — `spilled: true` plus `canvas_id` signal when a time-series was staged for SQL follow-up rather than returned inline
146
- - 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
162
+ - 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
163
+ - 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
147
164
 
148
165
  ---
149
166
 
@@ -291,7 +308,7 @@ All configuration is validated at startup via Zod schemas. Environment variables
291
308
  | `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` |
292
309
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
293
310
  | `MCP_HTTP_PORT` | Port for HTTP server. | `3010` |
294
- | `MCP_SESSION_MODE` | HTTP session mode: `auto`, `stateful`, or `stateless`. `.env.example` and the Dockerfile both set `stateless` — no handler here asks the client for input mid-call, so nothing needs a session to resume. | `auto` (resolves to `stateful`) |
311
+ | `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` |
295
312
  | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` |
296
313
  | `MCP_LOG_LEVEL` | Log level (RFC 5424: `debug`, `info`, `notice`, `warning`, `error`). | `info` |
297
314
  | `OTEL_ENABLED` | Enable [OpenTelemetry instrumentation](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry). | `false` |
@@ -342,6 +359,7 @@ The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `
342
359
  | `src/mcp-server/resources/` | Resource definitions — `fx://currencies` and `fx://rates/latest/{base}`. |
343
360
  | `src/services/frankfurter/` | Frankfurter HTTP client, retry logic, and domain types. |
344
361
  | `src/services/canvas/` | Module-level DataCanvas accessor for `fx_get_timeseries` spillover. |
362
+ | `src/utils/` | Output helpers — Markdown table-cell escaping for `fx_dataframe_query`. |
345
363
  | `tests/` | Unit and integration tests mirroring `src/`. |
346
364
  | `docs/` | Design document and idea notes. |
347
365
 
@@ -361,7 +379,7 @@ See [`CLAUDE.md`](./CLAUDE.md) for development guidelines and architectural rule
361
379
 
362
380
  ## Contributing
363
381
 
364
- Issues and pull requests are welcome. Run checks and tests before submitting:
382
+ Issues are welcome. Run checks and tests before submitting:
365
383
 
366
384
  ```sh
367
385
  bun run devcheck