@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.
- package/AGENTS.md +44 -17
- package/CLAUDE.md +44 -17
- package/README.md +90 -72
- package/changelog/0.4.x/0.4.0.md +22 -0
- package/changelog/0.4.x/0.4.1.md +27 -0
- package/changelog/template.md +9 -26
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.js +2 -3
- package/dist/mcp-server/tools/definitions/fx-dataframe-describe.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-drop.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-drop.tool.js +2 -3
- package/dist/mcp-server/tools/definitions/fx-dataframe-drop.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.d.ts +4 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.js +54 -22
- package/dist/mcp-server/tools/definitions/fx-dataframe-query.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.d.ts +2 -0
- package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.js +115 -86
- package/dist/mcp-server/tools/definitions/fx-get-timeseries.tool.js.map +1 -1
- package/dist/services/frankfurter/errors.d.ts +6 -1
- package/dist/services/frankfurter/errors.d.ts.map +1 -1
- package/dist/services/frankfurter/errors.js +6 -2
- package/dist/services/frankfurter/errors.js.map +1 -1
- package/dist/services/frankfurter/frankfurter-service.d.ts.map +1 -1
- package/dist/services/frankfurter/frankfurter-service.js +12 -4
- package/dist/services/frankfurter/frankfurter-service.js.map +1 -1
- package/dist/utils/escape-markdown-table-cell.d.ts +11 -0
- package/dist/utils/escape-markdown-table-cell.d.ts.map +1 -0
- package/dist/utils/escape-markdown-table-cell.js +19 -0
- package/dist/utils/escape-markdown-table-cell.js.map +1 -0
- package/package.json +8 -7
- 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.
|
|
5
|
-
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `0.
|
|
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.
|
|
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,
|
|
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:
|
|
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` =
|
|
413
|
-
- [ ] `.codex-plugin/mcp.json` updated — server name key
|
|
414
|
-
- [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; inline `mcpServers` entry
|
|
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.
|
|
5
|
-
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `0.
|
|
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.
|
|
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,
|
|
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:
|
|
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` =
|
|
413
|
-
- [ ] `.codex-plugin/mcp.json` updated — server name key
|
|
414
|
-
- [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; inline `mcpServers` entry
|
|
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
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/exchange-rates-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/@cyanheads/exchange-rates-mcp-server) [](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
@@ -27,123 +27,140 @@
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Overview
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
34
|
+
### Tools
|
|
35
35
|
|
|
36
36
|
| Tool | Description |
|
|
37
|
-
|
|
38
|
-
| `fx_list_currencies` | List all ~30 ECB-supported ISO 4217 currencies with full names
|
|
39
|
-
| `fx_get_rates` | Snapshot of all
|
|
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
|
|
42
|
-
| `fx_get_timeseries` | Historical daily rates for a currency pair over a date range
|
|
43
|
-
| `fx_dataframe_describe` | List DataCanvas tables and
|
|
44
|
-
| `fx_dataframe_query` | Run a read-only SQL SELECT against a DataCanvas table
|
|
45
|
-
| `fx_dataframe_drop` |
|
|
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
|
-
|
|
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
|
-
|
|
49
|
+
### Resources
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
+
## Capability reference
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
### `fx_list_currencies` <sub>tool</sub>
|
|
59
61
|
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
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
|
-
### `
|
|
68
|
-
|
|
69
|
-
Point-in-time exchange rate for a single pair.
|
|
68
|
+
### `fx_get_rates` <sub>tool</sub>
|
|
70
69
|
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
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
|
-
### `
|
|
77
|
+
### `fx_get_rate` <sub>tool</sub>
|
|
79
78
|
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
-
|
|
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`
|
|
95
|
+
### `fx_get_timeseries` <sub>tool</sub>
|
|
89
96
|
|
|
90
|
-
|
|
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
|
-
|
|
103
|
+
---
|
|
93
104
|
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
---
|
|
101
113
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
### `fx_dataframe_drop` <sub>tool</sub>
|
|
110
125
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
+
## Features
|
|
123
147
|
|
|
124
|
-
-
|
|
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
|
|
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`
|
|
136
|
-
-
|
|
137
|
-
-
|
|
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
|
|
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
|
-
-
|
|
146
|
-
- Success-path `notice` enrichment — explains an empty series
|
|
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`.
|
|
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
|
|
382
|
+
Issues are welcome. Run checks and tests before submitting:
|
|
365
383
|
|
|
366
384
|
```sh
|
|
367
385
|
bun run devcheck
|