diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..6f4f924
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+node_modules
+npm-debug.log
+dist
+coverage
+.env
+.DS_Store
+.git
+.gitignore
+*.md
+.omc/
+.plans/
+scripts/
+docs/
+.github/
+planning/
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a4b1671
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,32 @@
+# Server Mode (stdio | http | both)
+# - stdio: For Claude Desktop integration (default)
+# - http: For HTTP server mode (web clients)
+# - both: Run both transports simultaneously
+SERVER_MODE=http
+
+# HTTP Server Configuration
+HTTP_PORT=3000
+HTTP_HOST=0.0.0.0
+
+# Rate Limiting (per IP address)
+RATE_LIMIT_PER_MINUTE=10
+RATE_LIMIT_PER_DAY=250
+
+# Trusted proxy IPs/prefixes for x-forwarded-for (comma-separated, e.g. "10.0.0.,172.17.")
+# Leave empty to always use the direct connection IP (safe default, no spoofing risk)
+# TRUSTED_PROXIES=
+
+# Allowed CORS origins (comma-separated). Empty = deny all cross-origin browser requests.
+# Example: ALLOWED_ORIGINS=https://app.example.com,https://dashboard.example.com
+# ALLOWED_ORIGINS=
+
+# Idle session TTL in milliseconds (sessions unused beyond this are evicted, default 1 hour)
+SESSION_TTL_MS=3600000
+
+# Request Configuration
+REQUEST_TIMEOUT_MS=30000
+MAX_RETRIES=3
+RETRY_BASE_DELAY_MS=1000
+
+# Logging
+LOG_LEVEL=info
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..e6c7fb5
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,4 @@
+# NPM configuration
+# If using a private registry, set NPM_TOKEN as environment variable during build
+min-release-age=7 # days
+ignore-scripts=true
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index 50cd3ec..aa0cc38 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,4 +4,5 @@ external_api_spec/*
long_cache/
ignore.js
.claude/
+.omc/
yaak/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c8e128a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+FROM node:22.22.0-alpine AS builder
+
+RUN npm install -g npm@11.7.0
+
+WORKDIR /app
+
+COPY package*.json .npmrc ./
+RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
+ npm ci --ignore-scripts
+
+COPY tsconfig.json tsconfig.docker.json ./
+COPY src ./src
+RUN npx tsc --project tsconfig.docker.json
+
+FROM node:22.22.0-alpine
+
+RUN npm install -g npm@11.7.0
+
+WORKDIR /app
+
+COPY package*.json .npmrc ./
+RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
+ npm ci --omit=dev --ignore-scripts
+
+COPY --from=builder /app/dist ./dist
+
+USER node
+
+CMD ["node", "dist/index.js"]
diff --git a/README.md b/README.md
index 2b09010..e0193c9 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,63 @@
-# @vespr/cardano-mcp
+# Cardano MCP
-MCP (Model Context Protocol) server that enables AI agents like Claude to query Cardano wallet balances and ADA prices via the VESPR API.
+MCP (Model Context Protocol) server that lets AI assistants query Cardano wallet balances, token prices, staking info, and more — powered by the [VESPR API](https://vespr.xyz).
-## Features
+## Client Compatibility
-- **Query wallet balance** - Get portfolio value in any supported fiat/crypto currency
-- **Transaction history** - View wallet transaction history with details
-- **Token information** - Get detailed token info including price, market cap, and risk rating
-- **Token price charts** - OHLCV candlestick data for any time period
-- **Trending tokens** - Discover trending tokens by volume, buys, or sells
-- **Staking information** - Check staking status, pool info, and rewards
-- **ADA handle resolution** - Resolve $handles to wallet addresses
-- **Asset metadata** - Retrieve on-chain CIP-25/CIP-68 metadata
-- **Batch asset lookup** - Get summary info for multiple assets at once
-- **Pool information** - Query stake pool metrics and performance
-- **Currency support** - 160+ fiat currencies and crypto options
+There are two ways to connect to this MCP server, depending on which AI client you use:
-## Prerequisites
+| Client | Transport | How to connect |
+|--------|-----------|----------------|
+| **Claude Code** | Streamable HTTP | Use the hosted URL or a local HTTP server |
+| **Claude Desktop** | stdio (subprocess) | Use `npx` or a local `node` command |
+| Other MCP clients | Check client docs | HTTP if supported, otherwise stdio |
-- Node.js 18 or later
-- VESPR API key
-- Claude Desktop (or any MCP-compatible client)
+> **Why two transports?** Claude Desktop only supports stdio-based MCP servers (subprocess with stdin/stdout). Claude Code and newer MCP clients support the [MCP Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) spec, which allows connecting to a remote server over HTTPS with no local installation needed.
-## Quick Start
+---
-### 1. Get a VESPR API Key
+## Option 1: Hosted MCP — no installation required
-Contact VESPR to obtain an API key for accessing the wallet and price APIs.
+The easiest way to get started. No API key needed, no software to install.
-### 2. Configure Claude Desktop
+**Supported clients:** Claude Code, any Streamable HTTP MCP client
-Add the MCP server to your Claude Desktop configuration:
+### Claude Code
+
+Add this to your project's `.mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "cardano": {
+ "type": "http",
+ "url": "https://mcp.vespr.xyz/mcp"
+ }
+ }
+}
+```
+
+Or add it globally via the CLI:
+
+```bash
+claude mcp add --transport http cardano https://mcp.vespr.xyz/mcp
+```
+
+That's it — no API key required when using the hosted server.
+
+---
+
+## Option 2: NPX — works with Claude Desktop
+
+Run the server as a local subprocess using `npx`. Requires a VESPR API key.
+
+### Get a VESPR API Key
+
+Contact [VESPR](https://vespr.xyz) to obtain an API key.
+
+### Claude Desktop
+
+Add this to your Claude Desktop config file:
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
@@ -50,182 +77,231 @@ Add the MCP server to your Claude Desktop configuration:
}
```
-### 3. Restart Claude Desktop
+Restart Claude Desktop after saving the config.
+
+---
+
+## Option 3: Self-hosted
+
+Run your own instance — useful if you want to customize the server or use it in a private environment.
+
+### Docker (recommended)
+
+```bash
+git clone https://github.com/vespr-wallet/cardano_mcp.git
+cd cardano_mcp
+VESPR_API_KEY=your-api-key docker compose up
+```
+
+The server starts on `http://localhost:3000`. Connect via Streamable HTTP:
+
+```json
+{
+ "mcpServers": {
+ "cardano": {
+ "type": "http",
+ "url": "http://localhost:3000/mcp",
+ "headers": {
+ "x-api-key": "${VESPR_API_KEY}"
+ }
+ }
+ }
+}
+```
+
+Or via stdio for Claude Desktop:
+
+```json
+{
+ "mcpServers": {
+ "cardano": {
+ "command": "node",
+ "args": ["/absolute/path/to/cardano_mcp/dist/index.js"],
+ "env": {
+ "VESPR_API_KEY": "your-api-key-here"
+ }
+ }
+ }
+}
+```
+
+### From source
+
+```bash
+git clone https://github.com/vespr-wallet/cardano_mcp.git
+cd cardano_mcp
+npm install
+npm run build
+VESPR_API_KEY=your-key node dist/index.js
+```
-After updating the config, restart Claude Desktop for changes to take effect.
+---
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
-| `VESPR_API_KEY` | **Yes** | - | Your VESPR API key |
-| `VESPR_API_URL` | No | `https://api.vespr.xyz` | VESPR API endpoint |
+| `VESPR_API_KEY` | Yes (self-hosted/npx) | — | Your VESPR API key. Not required when using `mcp.vespr.xyz`. |
+| `SERVER_MODE` | No | `stdio` | Transport mode: `stdio`, `http`, or `both` |
+| `HTTP_PORT` | No | `3000` | HTTP server port |
+| `HTTP_HOST` | No | `0.0.0.0` | HTTP server host |
+| `VESPR_API_URL` | No | `https://api.vespr.xyz` | VESPR API base URL |
| `REQUEST_TIMEOUT_MS` | No | `30000` | Request timeout in milliseconds |
| `MAX_RETRIES` | No | `3` | Maximum retry attempts |
| `RETRY_BASE_DELAY_MS` | No | `1000` | Base delay for exponential backoff |
+| `RATE_LIMIT_PER_MINUTE` | No | `10` | Max requests per minute per IP (HTTP mode) |
+| `RATE_LIMIT_PER_DAY` | No | `250` | Max requests per day per IP (HTTP mode) |
+
+---
-## Usage
+## What you can ask
-Once configured, you can ask Claude questions like:
+Once connected, ask your AI assistant questions like:
- "What's the balance of addr1qy8ac7qqy0vtulyl7wntmsxc6wex80gvcyjy33qffrhm7sh927ysx5sftuw0dlft05dz3c7revpf7jx0xnlcjz3g69mq4afdhv in USD?"
- "Show me the transaction history for this wallet"
- "What's the price and market cap of SNEK token?"
-- "Show me the price chart for VESPR token over the last week"
+- "Show me the VESPR token price chart for the last week"
- "What tokens are trending right now?"
-- "Is this wallet staking? What pool is it delegated to?"
+- "Is this wallet staking? What pool is it in and how much has it earned?"
- "What wallet address does $vespr resolve to?"
- "What are the best performing stake pools?"
- "What currencies are supported?"
+---
+
## Available Tools
| Tool | Description |
|------|-------------|
-| `get_wallet_balance` | Query wallet balance with ADA, tokens, and portfolio value |
-| `get_transaction_history` | Query wallet transaction history with amounts and directions |
-| `get_token_info` | Get detailed token information (price, market cap, supply, risk) |
-| `get_token_chart` | Get OHLCV price chart data for a token |
-| `get_trending_tokens` | Discover trending tokens by volume or trading activity |
-| `get_staking_info` | Query wallet staking status, pool info, and rewards |
-| `resolve_ada_handle` | Resolve an ADA handle ($handle) to a wallet address |
-| `get_asset_metadata` | Get on-chain CIP-25/CIP-68 metadata for an asset |
-| `get_asset_summary` | Batch lookup for multiple assets with categorization |
-| `get_pool_info` | Get stake pool information and performance metrics |
-| `get_supported_currencies` | List all supported fiat and crypto currencies |
-
-### get_wallet_balance
-
-Query Cardano wallet balance including ADA and native tokens with values in your chosen currency.
+| `get_wallet_balance` | Wallet balance — ADA, tokens, and portfolio value in any currency |
+| `get_transaction_history` | Transaction history with amounts and directions |
+| `get_token_info` | Token price, market cap, supply, and risk rating |
+| `get_token_chart` | OHLCV candlestick price data for any time period |
+| `get_trending_tokens` | Trending tokens by volume, buys, or sells |
+| `get_staking_info` | Staking status, pool info, and rewards |
+| `resolve_ada_handle` | Resolve a $handle to a wallet address |
+| `get_asset_metadata` | On-chain CIP-25/CIP-68 metadata for any asset |
+| `get_asset_summary` | Batch lookup for multiple assets |
+| `get_pool_info` | Stake pool metrics and performance |
+| `get_supported_currencies` | List of supported fiat and crypto currencies |
+
+### Tool parameters
+
+
+get_wallet_balance
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `address` | string | Yes | Cardano wallet address (bech32 format, addr1...) |
+| `address` | string | Yes | Cardano wallet address (bech32, addr1...) |
| `currency` | string | No | Currency for values (default: USD) |
-### get_transaction_history
+
-Query transaction history for a Cardano wallet address.
+
+get_transaction_history
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `address` | string | Yes | Cardano wallet address (bech32 format, addr1...) |
+| `address` | string | Yes | Cardano wallet address (bech32, addr1...) |
| `to_block` | number | No | Filter transactions up to this block height |
-### get_token_info
+
-Query detailed information about a Cardano native token including price, market cap, supply, and risk rating.
+
+get_token_info
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `unit` | string | Yes | Token unit identifier (policy ID + hex asset name) |
+| `unit` | string | Yes | Token unit (policy ID + hex asset name) |
| `currency` | string | No | Currency for price display (default: USD) |
-### get_token_chart
+
-Query OHLCV (Open, High, Low, Close, Volume) price chart data for a token.
+
+get_token_chart
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `unit` | string | Yes | Token unit identifier (policy ID + hex asset name) |
-| `period` | string | No | Chart period: 1H, 24H, 1W, 1M, 3M, 1Y, ALL (default: 24H) |
+| `unit` | string | Yes | Token unit (policy ID + hex asset name) |
+| `period` | string | No | `1H`, `24H`, `1W`, `1M`, `3M`, `1Y`, `ALL` (default: 24H) |
| `currency` | string | No | Currency for price display (default: ADA) |
-### get_trending_tokens
+
-Discover trending Cardano native tokens based on trading activity.
+
+get_trending_tokens
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `currency` | string | No | Currency for price display (default: USD) |
-| `sort` | string | No | Sort by: volume, buys, sells, unique_buyers, unique_sellers |
-| `period` | string | No | Time period: 1M, 5M, 30M, 1H, 4H, 1D |
-| `limit` | number | No | Number of tokens to return (default: 10, max: 100) |
+| `sort` | string | No | `volume`, `buys`, `sells`, `unique_buyers`, `unique_sellers` |
+| `period` | string | No | `1M`, `5M`, `30M`, `1H`, `4H`, `1D` |
+| `limit` | number | No | Number of results (default: 10, max: 100) |
-### get_staking_info
+
-Query staking status and rewards for a Cardano wallet address.
+
+get_staking_info
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `address` | string | Yes | Cardano wallet address (bech32 format, addr1...) |
+| `address` | string | Yes | Cardano wallet address (bech32, addr1...) |
-### resolve_ada_handle
+
-Resolve an ADA handle to its owner's wallet address.
+
+resolve_ada_handle
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `handle` | string | Yes | ADA handle (with or without $ prefix, e.g., 'myhandle' or '$myhandle') |
+| `handle` | string | Yes | ADA handle with or without $ prefix (e.g. `vespr` or `$vespr`) |
-### get_asset_metadata
+
-Retrieve on-chain metadata (CIP-25/CIP-68) for a Cardano native asset.
+
+get_asset_metadata
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `unit` | string | Yes | Asset unit identifier (policy ID + hex-encoded asset name) |
+| `unit` | string | Yes | Asset unit (policy ID + hex-encoded asset name) |
-### get_asset_summary
+
-Retrieve summary information for multiple Cardano native assets in a single batch request.
+
+get_asset_summary
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `units` | string[] | Yes | Array of asset unit identifiers (max 100 per request) |
+| `units` | string[] | Yes | Array of asset units (max 100 per request) |
-### get_pool_info
+
-Query information about a Cardano stake pool.
+
+get_pool_info
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
-| `pool_id` | string | Yes | Cardano stake pool ID (bech32 format, pool1...) |
+| `pool_id` | string | Yes | Stake pool ID (bech32, pool1...) |
-### get_supported_currencies
+
-Get the list of supported fiat and crypto currencies. No input parameters required.
+
+get_supported_currencies
-## Local Development
+No parameters.
-```bash
-# Clone the repository
-git clone https://github.com/vespr-wallet/cardano_mcp.git
-cd cardano_mcp
+
-# Install dependencies
-npm install
-
-# Build
-npm run build
-
-# Run locally
-VESPR_API_KEY=your-key node dist/index.js
-```
+---
-### Local Claude Desktop Config
-
-For local development, point to your local build:
-
-```json
-{
- "mcpServers": {
- "cardano": {
- "command": "node",
- "args": ["/absolute/path/to/cardano_mcp/dist/index.js"],
- "env": {
- "VESPR_API_KEY": "your-api-key-here"
- }
- }
- }
-}
-```
-
-### Running Tests
+## Development
```bash
-npm test # Run all tests
-npm run test:coverage # Run with coverage report
+npm install
+npm run build # compile TypeScript
+npm test # run tests
+npm run test:coverage # tests with coverage report
```
## License
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3e20e72
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+ mcp-server:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "3000:3000"
+ environment:
+ - SERVER_MODE=http
+ - HTTP_PORT=3000
+ - HTTP_HOST=0.0.0.0
+ - VESPR_API_KEY=${VESPR_API_KEY}
+ - RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-10}
+ - RATE_LIMIT_PER_DAY=${RATE_LIMIT_PER_DAY:-250}
+ - TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
+ - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
+ - SESSION_TTL_MS=${SESSION_TTL_MS:-3600000}
+ - LOG_LEVEL=${LOG_LEVEL:-info}
+ - REQUEST_TIMEOUT_MS=${REQUEST_TIMEOUT_MS:-30000}
+ - MAX_RETRIES=${MAX_RETRIES:-3}
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ restart: unless-stopped
diff --git a/package-lock.json b/package-lock.json
index b7b64b4..98a61fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,11 @@
"version": "0.1.1",
"license": "MIT",
"dependencies": {
+ "@fastify/cors": "^11.2.0",
+ "@fastify/rate-limit": "^10.3.0",
+ "@fastify/sensible": "^6.0.4",
"@modelcontextprotocol/sdk": "^1.0.0",
+ "fastify": "^5.7.2",
"lru-cache": "^11.2.4",
"zod": "^4.3.5"
},
@@ -1011,6 +1015,192 @@
"node": ">=18"
}
},
+ "node_modules/@fastify/ajv-compiler": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
+ "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.12.0",
+ "ajv-formats": "^3.0.1",
+ "fast-uri": "^3.0.0"
+ }
+ },
+ "node_modules/@fastify/cors": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
+ "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fastify-plugin": "^5.0.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
+ "node_modules/@fastify/error": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
+ "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/fast-json-stringify-compiler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
+ "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "fast-json-stringify": "^6.0.0"
+ }
+ },
+ "node_modules/@fastify/forwarded": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
+ "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@fastify/merge-json-schemas": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
+ "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@fastify/proxy-addr": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
+ "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/forwarded": "^3.0.0",
+ "ipaddr.js": "^2.1.0"
+ }
+ },
+ "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
+ "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@fastify/rate-limit": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
+ "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "fastify-plugin": "^5.0.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
+ "node_modules/@fastify/sensible": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz",
+ "integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "dequal": "^2.0.3",
+ "fastify-plugin": "^5.0.0",
+ "forwarded": "^0.2.0",
+ "http-errors": "^2.0.0",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ }
+ },
"node_modules/@hono/node-server": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.8.tgz",
@@ -1460,6 +1650,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lukeed/ms": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
+ "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
@@ -1512,6 +1711,12 @@
"@tybys/wasm-util": "^0.10.0"
}
},
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1967,6 +2172,12 @@
"win32"
]
},
+ "node_modules/abstract-logging": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
+ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -2082,6 +2293,35 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/avvio": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
+ "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/error": "^4.0.0",
+ "fastq": "^1.17.1"
+ }
+ },
"node_modules/babel-jest": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
@@ -2686,6 +2926,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -3028,6 +3277,12 @@
"express": ">= 4.11"
}
},
+ "node_modules/fast-decode-uri-component": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
+ "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3041,6 +3296,39 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-json-stringify": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz",
+ "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/merge-json-schemas": "^0.2.0",
+ "ajv": "^8.12.0",
+ "ajv-formats": "^3.0.1",
+ "fast-uri": "^3.0.0",
+ "json-schema-ref-resolver": "^3.0.0",
+ "rfdc": "^1.2.0"
+ }
+ },
+ "node_modules/fast-querystring": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
+ "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-decode-uri-component": "^1.0.1"
+ }
+ },
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -3057,6 +3345,76 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fastify": {
+ "version": "5.8.5",
+ "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
+ "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/ajv-compiler": "^4.0.5",
+ "@fastify/error": "^4.0.0",
+ "@fastify/fast-json-stringify-compiler": "^5.0.0",
+ "@fastify/proxy-addr": "^5.0.0",
+ "abstract-logging": "^2.0.1",
+ "avvio": "^9.0.0",
+ "fast-json-stringify": "^6.0.0",
+ "find-my-way": "^9.0.0",
+ "light-my-request": "^6.0.0",
+ "pino": "^9.14.0 || ^10.1.0",
+ "process-warning": "^5.0.0",
+ "rfdc": "^1.3.1",
+ "secure-json-parse": "^4.0.0",
+ "semver": "^7.6.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
+ "node_modules/fastify-plugin": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
+ "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/fastify/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -3101,6 +3459,20 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/find-my-way": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
+ "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-querystring": "^1.0.0",
+ "safe-regex2": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -4308,6 +4680,25 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-schema-ref-resolver": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
+ "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -4343,6 +4734,56 @@
"node": ">=6"
}
},
+ "node_modules/light-my-request": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
+ "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "process-warning": "^4.0.0",
+ "set-cookie-parser": "^2.6.0"
+ }
+ },
+ "node_modules/light-my-request/node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/light-my-request/node_modules/process-warning": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
+ "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -4650,6 +5091,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -4860,6 +5310,43 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pino": {
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
+ "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^3.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^4.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -4936,6 +5423,22 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -4981,6 +5484,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -5012,6 +5521,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -5064,6 +5582,31 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/ret": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
+ "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "license": "MIT"
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -5080,12 +5623,59 @@
"node": ">= 18"
}
},
+ "node_modules/safe-regex2": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
+ "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "ret": "~0.5.0"
+ },
+ "bin": {
+ "safe-regex2": "bin/safe-regex2.js"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/secure-json-parse": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
+ "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -5141,6 +5731,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -5263,6 +5859,15 @@
"node": ">=8"
}
},
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -5284,6 +5889,15 @@
"source-map": "^0.6.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -5577,6 +6191,18 @@
"node": "*"
}
},
+ "node_modules/thread-stream": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
+ "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -5597,6 +6223,15 @@
"node": ">=8.0"
}
},
+ "node_modules/toad-cache": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
+ "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
diff --git a/package.json b/package.json
index e37f363..b87cd3b 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,11 @@
"format:check": "prettier --check ."
},
"dependencies": {
+ "@fastify/cors": "^11.2.0",
+ "@fastify/rate-limit": "^10.3.0",
+ "@fastify/sensible": "^6.0.4",
"@modelcontextprotocol/sdk": "^1.0.0",
+ "fastify": "^5.7.2",
"lru-cache": "^11.2.4",
"zod": "^4.3.5"
},
diff --git a/scripts/stress-test.ts b/scripts/stress-test.ts
index 5ce6e48..bbc6611 100644
--- a/scripts/stress-test.ts
+++ b/scripts/stress-test.ts
@@ -91,10 +91,7 @@ async function runTest(name: string, fn: () => Promise): Promise Promise>,
-): Promise {
+async function runConcurrencyTest(scenario: string, tasks: Array<() => Promise>): Promise {
const wallStart = performance.now();
const times: number[] = [];
let successCount = 0;
@@ -175,9 +172,7 @@ async function runConcurrencyTests(): Promise {
results.push(
await runConcurrencyTest(
`ada_spot_price × ${CONCURRENT_REQUESTS} parallel (same key)`,
- Array.from({ length: CONCURRENT_REQUESTS }, () => () =>
- VesprApiRepository.getAdaSpotPrice(FiatCurrency.USD),
- ),
+ Array.from({ length: CONCURRENT_REQUESTS }, () => () => VesprApiRepository.getAdaSpotPrice(FiatCurrency.USD)),
),
);
@@ -185,8 +180,9 @@ async function runConcurrencyTests(): Promise {
results.push(
await runConcurrencyTest(
`get_token_info × ${CONCURRENT_REQUESTS} parallel (same key)`,
- Array.from({ length: CONCURRENT_REQUESTS }, () => () =>
- VesprApiRepository.getTokenInfo(TEST_CONFIG.tokenUnit, FiatCurrency.USD),
+ Array.from(
+ { length: CONCURRENT_REQUESTS },
+ () => () => VesprApiRepository.getTokenInfo(TEST_CONFIG.tokenUnit, FiatCurrency.USD),
),
),
);
@@ -195,29 +191,27 @@ async function runConcurrencyTests(): Promise {
results.push(
await runConcurrencyTest(
`get_trending_tokens × ${CONCURRENT_REQUESTS} parallel (same key)`,
- Array.from({ length: CONCURRENT_REQUESTS }, () => () =>
- VesprApiRepository.getTrendingTokens(FiatCurrency.USD, "1H"),
+ Array.from(
+ { length: CONCURRENT_REQUESTS },
+ () => () => VesprApiRepository.getTrendingTokens(FiatCurrency.USD, "1H"),
),
),
);
// Scenario D: 20 parallel — all tools mixed (warm LRU cache, keys pre-loaded by prior scenarios)
results.push(
- await runConcurrencyTest(
- `all tools mixed × ${CONCURRENT_REQUESTS} parallel (warm cache)`,
- [
- ...Array.from({ length: 5 }, () => () => VesprApiRepository.getAdaSpotPrice(FiatCurrency.USD)),
- ...Array.from({ length: 5 }, () => () =>
- VesprApiRepository.getTokenInfo(TEST_CONFIG.tokenUnit, FiatCurrency.USD),
- ),
- ...Array.from({ length: 5 }, () => () =>
- VesprApiRepository.getTokenChart(TEST_CONFIG.tokenUnit, "24H", CryptoCurrency.ADA),
- ),
- ...Array.from({ length: 5 }, () => () =>
- VesprApiRepository.getTrendingTokens(FiatCurrency.USD, "1H"),
- ),
- ],
- ),
+ await runConcurrencyTest(`all tools mixed × ${CONCURRENT_REQUESTS} parallel (warm cache)`, [
+ ...Array.from({ length: 5 }, () => () => VesprApiRepository.getAdaSpotPrice(FiatCurrency.USD)),
+ ...Array.from(
+ { length: 5 },
+ () => () => VesprApiRepository.getTokenInfo(TEST_CONFIG.tokenUnit, FiatCurrency.USD),
+ ),
+ ...Array.from(
+ { length: 5 },
+ () => () => VesprApiRepository.getTokenChart(TEST_CONFIG.tokenUnit, "24H", CryptoCurrency.ADA),
+ ),
+ ...Array.from({ length: 5 }, () => () => VesprApiRepository.getTrendingTokens(FiatCurrency.USD, "1H")),
+ ]),
);
// Scenario E: 50 parallel high-load wave
@@ -280,7 +274,9 @@ function printReport(individual: TestResult[], concurrent: ConcurrencyResult[]):
console.error(` ${status} ${r.scenario}`);
console.error(` Requests : ${r.successCount}/${r.totalRequests} (${passRate}%)`);
console.error(` Wall time: ${r.wallTimeMs}ms | Throughput: ${r.throughputRps} req/s`);
- console.error(` Latency : p50=${r.p50Ms}ms p95=${r.p95Ms}ms p99=${r.p99Ms}ms max=${r.maxResponseTimeMs}ms`);
+ console.error(
+ ` Latency : p50=${r.p50Ms}ms p95=${r.p95Ms}ms p99=${r.p99Ms}ms max=${r.maxResponseTimeMs}ms`,
+ );
}
const passed = individual.filter((r) => r.success && r.passedThreshold);
diff --git a/src/api/VesprApiClient.test.ts b/src/api/VesprApiClient.test.ts
index d65f63d..1b87433 100644
--- a/src/api/VesprApiClient.test.ts
+++ b/src/api/VesprApiClient.test.ts
@@ -2,6 +2,7 @@ import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals
import { VesprApiClient } from "./VesprApiClient.js";
import { FetchApiClient } from "../utils/api/FetchApiClient.js";
import { FiatCurrency, CryptoCurrency } from "../types/currency.js";
+import { config } from "../config.js";
describe("VesprApiClient", () => {
let client: VesprApiClient;
@@ -9,6 +10,7 @@ describe("VesprApiClient", () => {
let postSpy: jest.SpiedFunction;
beforeEach(() => {
+ config.apiKey = "test-api-key";
getSpy = jest.spyOn(FetchApiClient.prototype, "get");
postSpy = jest.spyOn(FetchApiClient.prototype, "post");
client = new VesprApiClient();
diff --git a/src/api/VesprApiClient.ts b/src/api/VesprApiClient.ts
index 0c0a937..e6d6f60 100644
--- a/src/api/VesprApiClient.ts
+++ b/src/api/VesprApiClient.ts
@@ -1,5 +1,6 @@
import { config } from "../config.js";
import { FetchApiClient } from "../utils/api/FetchApiClient.js";
+import { getCurrentApiKey } from "../utils/apiKeyContext.js";
import {
WalletDetailedResponseSchema,
AdaSpotPriceResponseSchema,
@@ -29,14 +30,18 @@ import {
import { FiatCurrency, CryptoCurrency } from "../types/currency.js";
export class VesprApiClient {
- private readonly client: FetchApiClient;
-
- constructor() {
- this.client = new FetchApiClient({
+ private getClient(): FetchApiClient {
+ const apiKey = getCurrentApiKey() || config.apiKey;
+ if (!apiKey) {
+ throw new Error(
+ "VESPR API key is required. Provide it via X-API-Key header (HTTP) or VESPR_API_KEY environment variable (stdio/npx).",
+ );
+ }
+ return new FetchApiClient({
baseUrl: config.apiBaseUrl,
headers: {
"Content-Type": "application/json",
- "x-api-key": config.apiKey,
+ "x-api-key": apiKey,
},
requestTimeoutMs: config.requestTimeoutMs,
maxRetries: config.maxRetries,
@@ -45,7 +50,7 @@ export class VesprApiClient {
}
async fetchWalletDetailed(address: string): Promise {
- return this.client.post({
+ return this.getClient().post({
path: "/v7/wallet/detailed",
body: { address },
schema: WalletDetailedResponseSchema,
@@ -54,7 +59,7 @@ export class VesprApiClient {
}
async getAdaSpotPrice(currency: FiatCurrency): Promise {
- return this.client.get({
+ return this.getClient().get({
path: `/v5/ada/spot?currency=${encodeURIComponent(currency)}`,
schema: AdaSpotPriceResponseSchema,
context: `ada-spot(${currency})`,
@@ -62,7 +67,7 @@ export class VesprApiClient {
}
async fetchTransactionHistory(address: string, toBlock?: number): Promise {
- return this.client.post({
+ return this.getClient().post({
path: "/v4/wallet/transactions",
body: { address, maybe_to_block: toBlock },
schema: TransactionHistoryResponseSchema,
@@ -71,7 +76,7 @@ export class VesprApiClient {
}
async fetchStakingInfo(address: string): Promise {
- return this.client.post({
+ return this.getClient().post({
path: "/v5/wallet/rewards/staking/info",
body: { address },
schema: StakingInfoResponseSchema,
@@ -83,7 +88,7 @@ export class VesprApiClient {
unit: string,
currency: FiatCurrency | CryptoCurrency = FiatCurrency.USD,
): Promise {
- return this.client.get({
+ return this.getClient().get({
path: `/v1/token/${encodeURIComponent(unit)}/info?currency=${encodeURIComponent(currency)}`,
schema: TokenInfoResponseSchema,
context: `token-info(${unit.slice(0, 20)}...)`,
@@ -99,7 +104,7 @@ export class VesprApiClient {
period,
currency,
});
- return this.client.get({
+ return this.getClient().get({
path: `/v1/token/${encodeURIComponent(unit)}/chart?${params}`,
schema: TokenChartResponseSchema,
context: `token-chart(${unit.slice(0, 20)}...)`,
@@ -113,7 +118,7 @@ export class VesprApiClient {
const params = new URLSearchParams({ currency });
if (period) params.set("period", period);
- return this.client.get({
+ return this.getClient().get({
path: `/v1/tokens/explore/trending?${params}`,
schema: TrendingTokensResponseSchema,
context: `trending-tokens(${currency})`,
@@ -124,7 +129,7 @@ export class VesprApiClient {
// Normalize handle - remove $ prefix if present
const normalizedHandle = handle.startsWith("$") ? handle.slice(1) : handle;
- return this.client.post({
+ return this.getClient().post({
path: "/v4/asset/handle_owner",
body: { handle: normalizedHandle.toLowerCase() },
schema: AdaHandleOwnerResponseSchema,
@@ -133,7 +138,7 @@ export class VesprApiClient {
}
async fetchAssetMetadata(unit: string): Promise {
- return this.client.get({
+ return this.getClient().get({
path: `/v4/asset/${encodeURIComponent(unit)}/metadata`,
schema: AssetMetadataResponseSchema,
context: `asset-metadata(${unit.slice(0, 20)}...)`,
@@ -141,7 +146,7 @@ export class VesprApiClient {
}
async fetchAssetSummary(units: string[]): Promise {
- return this.client.post({
+ return this.getClient().post({
path: "/v4/asset/summary",
body: { assets_unit: units },
schema: AssetSummaryResponseSchema,
@@ -150,7 +155,7 @@ export class VesprApiClient {
}
async fetchPoolInfo(poolId: string): Promise {
- return this.client.post({
+ return this.getClient().post({
path: "/v4/pool/info",
body: { pool_id_bech_32: poolId },
schema: PoolInfoResponseSchema,
diff --git a/src/config.ts b/src/config.ts
index 3acaa60..a98a6f1 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,3 +1,22 @@
+/**
+ * Server mode type
+ * - stdio: STDIO transport only (default, for Claude Desktop integration)
+ * - http: HTTP transport only (for web clients)
+ * - both: Both transports simultaneously (useful for local development)
+ */
+export type ServerMode = "stdio" | "http" | "both";
+
+/**
+ * Parse and validate SERVER_MODE environment variable
+ */
+function parseServerMode(): ServerMode {
+ const mode = process.env.SERVER_MODE?.toLowerCase();
+ if (mode === "http" || mode === "both") {
+ return mode;
+ }
+ return "stdio"; // Default for backward compatibility
+}
+
/**
* Server configuration from environment variables
*/
@@ -5,8 +24,8 @@ export const config = {
/** VESPR API base URL */
apiBaseUrl: process.env.VESPR_API_URL ?? "https://api.vespr.xyz",
- /** VESPR API Key */
- apiKey: process.env.VESPR_API_KEY!,
+ /** VESPR API Key (optional - users provide their own via X-API-Key header in HTTP mode) */
+ apiKey: process.env.VESPR_API_KEY,
/** Request timeout in milliseconds */
requestTimeoutMs: Number(process.env.REQUEST_TIMEOUT_MS) || 30000,
@@ -16,4 +35,25 @@ export const config = {
/** Base delay for exponential backoff (ms) */
retryBaseDelayMs: Number(process.env.RETRY_BASE_DELAY_MS) || 1000,
+
+ /** Server mode: stdio, http, or both */
+ serverMode: parseServerMode(),
+
+ /** HTTP server port */
+ httpPort: Number(process.env.HTTP_PORT) || 3000,
+
+ /** HTTP server host */
+ httpHost: process.env.HTTP_HOST ?? "0.0.0.0",
+
+ /** Rate limit: max requests per minute per IP */
+ rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE) || 10,
+
+ /** Rate limit: max requests per day per IP */
+ rateLimitPerDay: Number(process.env.RATE_LIMIT_PER_DAY) || 250,
+
+ /** Allowed CORS origins (comma-separated). Empty = deny all cross-origin requests */
+ allowedOrigins: (process.env.ALLOWED_ORIGINS ?? "").split(",").filter(Boolean),
+
+ /** Idle session TTL in milliseconds — sessions unused beyond this are evicted */
+ sessionTtlMs: Number(process.env.SESSION_TTL_MS) || 3_600_000,
};
diff --git a/src/index.ts b/src/index.ts
index a394a46..bceb86a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,18 +2,80 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerTools } from "./tools/index.js";
+import { config } from "./config.js";
+import { startServer } from "./server.js";
import { logger } from "./utils/logger.js";
-const server = new McpServer({
- name: "@vespr/cardano-mcp",
- version: "0.1.0",
-});
+const VERSION = "0.1.0";
+
+/**
+ * Create and configure the MCP server instance
+ */
+function createMcpServer(): McpServer {
+ const server = new McpServer({
+ name: "@vespr/cardano-mcp",
+ version: VERSION,
+ });
+
+ // Register all tools
+ registerTools(server);
+
+ return server;
+}
+
+/**
+ * Start STDIO transport for Claude Desktop integration
+ */
+async function startStdioTransport(server: McpServer): Promise {
+ const transport = new StdioServerTransport();
+ await server.connect(transport);
+ logger.info("server_started", { transport: "stdio", version: VERSION });
+}
+
+let httpServer: import("fastify").FastifyInstance | undefined;
-// Register all tools
-registerTools(server);
+/**
+ * Start HTTP transport for web clients
+ */
+async function startHttpTransport(): Promise {
+ httpServer = await startServer();
+}
-// Connect via stdio for Claude Desktop integration
-const transport = new StdioServerTransport();
-await server.connect(transport);
+/**
+ * Main entry point - starts server in configured mode
+ */
+async function main(): Promise {
+ const { serverMode } = config;
-logger.info("server_started", { transport: "stdio", version: "0.1.0" });
+ logger.info("server_initializing", { mode: serverMode, version: VERSION });
+
+ if (serverMode === "stdio" || serverMode === "both") {
+ const mcpServer = createMcpServer();
+ await startStdioTransport(mcpServer);
+ }
+
+ if (serverMode === "http" || serverMode === "both") {
+ await startHttpTransport();
+ }
+}
+
+function shutdown(signal: string): void {
+ logger.info("server_shutdown", { signal });
+ if (httpServer) {
+ httpServer.close().finally(() => process.exit(0));
+ } else {
+ process.exit(0);
+ }
+}
+
+// Handle graceful shutdown
+process.on("SIGINT", () => shutdown("SIGINT"));
+process.on("SIGTERM", () => shutdown("SIGTERM"));
+
+// Start the server
+main().catch((error) => {
+ logger.error("server_startup_failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ process.exit(1);
+});
diff --git a/src/middleware/rateLimit.test.ts b/src/middleware/rateLimit.test.ts
new file mode 100644
index 0000000..befd1f7
--- /dev/null
+++ b/src/middleware/rateLimit.test.ts
@@ -0,0 +1,91 @@
+import { jest, describe, beforeEach, afterEach, it, expect } from "@jest/globals";
+import { createDualWindowStore } from "./rateLimit.js";
+
+type Store = InstanceType>;
+
+function incr(store: Store, key: string): Promise<{ current: number; ttl: number }> {
+ return new Promise((resolve, reject) => {
+ store.incr(key, (err, result) => {
+ if (err) reject(err);
+ else resolve(result!);
+ });
+ });
+}
+
+describe("DualWindowStore", () => {
+ let Store: ReturnType;
+ let store: Store;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ Store = createDualWindowStore(3, 5);
+ store = new Store({});
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe("per-minute limit", () => {
+ it("allows requests under the limit", async () => {
+ const r1 = await incr(store, "1.2.3.4");
+ const r2 = await incr(store, "1.2.3.4");
+ const r3 = await incr(store, "1.2.3.4");
+ expect(r1.current).toBe(1);
+ expect(r2.current).toBe(2);
+ expect(r3.current).toBe(3);
+ });
+
+ it("blocks when per-minute limit exceeded", async () => {
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+
+ const result = await incr(store, "1.2.3.4");
+ expect(result.current).toBeGreaterThan(3); // > max → plugin blocks
+ expect(result.ttl).toBeGreaterThan(0);
+ });
+
+ it("resets after the minute window", async () => {
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+
+ jest.advanceTimersByTime(61_000);
+
+ const result = await incr(store, "1.2.3.4");
+ expect(result.current).toBe(1); // fresh window
+ });
+ });
+
+ describe("per-day limit", () => {
+ it("blocks when per-day limit exceeded", async () => {
+ // 3 requests — fills the minute window
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+
+ jest.advanceTimersByTime(61_000); // reset minute window
+
+ await incr(store, "1.2.3.4"); // 4th total
+ await incr(store, "1.2.3.4"); // 5th total — day limit reached
+
+ jest.advanceTimersByTime(61_000); // reset minute window again
+
+ const result = await incr(store, "1.2.3.4"); // 6th total — day blocked
+ expect(result.current).toBeGreaterThan(3);
+ expect(result.ttl).toBeGreaterThan(60_000); // retry window > 1 minute (day-scale)
+ });
+ });
+
+ describe("IP isolation", () => {
+ it("tracks different IPs independently", async () => {
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+ await incr(store, "1.2.3.4");
+
+ const result = await incr(store, "5.6.7.8");
+ expect(result.current).toBe(1);
+ });
+ });
+});
diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts
new file mode 100644
index 0000000..9b23f80
--- /dev/null
+++ b/src/middleware/rateLimit.ts
@@ -0,0 +1,71 @@
+import type { FastifyRateLimitStore } from "@fastify/rate-limit";
+
+const MINUTE_MS = 60_000;
+const DAY_MS = 86_400_000;
+const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
+
+interface Entry {
+ minuteTs: number[];
+ dayTs: number[];
+}
+
+/**
+ * Returns a @fastify/rate-limit-compatible store constructor that enforces
+ * both a per-minute and a per-day sliding-window limit per key.
+ *
+ * The plugin is configured with max=maxPerMinute. When the day budget is
+ * exhausted the store returns current=maxPerMinute+1, which causes the plugin
+ * to treat the request as over-limit regardless of the per-minute count.
+ */
+export function createDualWindowStore(
+ maxPerMinute: number,
+ maxPerDay: number,
+): new (options: unknown) => FastifyRateLimitStore {
+ return class implements FastifyRateLimitStore {
+ private entries = new Map();
+
+ constructor(_options: unknown) {
+ const interval = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
+ (interval as NodeJS.Timeout).unref?.();
+ }
+
+ incr(key: string, cb: (err: Error | null, result?: { current: number; ttl: number }) => void): void {
+ const now = Date.now();
+ let entry = this.entries.get(key);
+ if (!entry) {
+ entry = { minuteTs: [], dayTs: [] };
+ this.entries.set(key, entry);
+ }
+
+ entry.minuteTs = entry.minuteTs.filter((t) => now - t < MINUTE_MS);
+ entry.minuteTs.push(now);
+
+ entry.dayTs = entry.dayTs.filter((t) => now - t < DAY_MS);
+ entry.dayTs.push(now);
+
+ if (entry.dayTs.length > maxPerDay) {
+ const ttl = (entry.dayTs[0] ?? now) + DAY_MS - now;
+ cb(null, { current: maxPerMinute + 1, ttl });
+ return;
+ }
+
+ const ttl = (entry.minuteTs[0] ?? now) + MINUTE_MS - now;
+ cb(null, { current: entry.minuteTs.length, ttl });
+ }
+
+ child(_routeOptions: unknown): FastifyRateLimitStore {
+ return this;
+ }
+
+ private cleanup(): void {
+ const now = Date.now();
+ for (const [key, entry] of this.entries) {
+ entry.minuteTs = entry.minuteTs.filter((t) => now - t < MINUTE_MS);
+ entry.dayTs = entry.dayTs.filter((t) => now - t < DAY_MS);
+ if (entry.minuteTs.length === 0 && entry.dayTs.length === 0) {
+ this.entries.delete(key);
+ }
+ }
+ }
+ };
+}
diff --git a/src/server.ts b/src/server.ts
new file mode 100644
index 0000000..f53605d
--- /dev/null
+++ b/src/server.ts
@@ -0,0 +1,141 @@
+import Fastify, { FastifyInstance, FastifyRequest } from "fastify";
+import cors from "@fastify/cors";
+import sensible from "@fastify/sensible";
+import fastifyRateLimit from "@fastify/rate-limit";
+import { config } from "./config.js";
+import { logger } from "./utils/logger.js";
+import { registerHttpRoutes } from "./transports/http.js";
+import { registerStreamableHttpRoutes } from "./transports/streamableHttp.js";
+import { registerHttpTools } from "./tools/index.js";
+import { createDualWindowStore } from "./middleware/rateLimit.js";
+
+let serverStartTime: number | null = null;
+
+/**
+ * Create and configure Fastify HTTP server
+ * Outputs logs to stderr to avoid interfering with MCP protocol on stdout
+ */
+export function createServer(): FastifyInstance {
+ const server = Fastify({
+ logger: {
+ level: "info",
+ // Output to stderr (stdout reserved for MCP protocol)
+ stream: process.stderr,
+ },
+ disableRequestLogging: true, // We'll use our own structured logging
+ });
+
+ return server;
+}
+
+/**
+ * Register plugins and configure the server
+ */
+async function configureServer(server: FastifyInstance): Promise {
+ // CORS support — restrict to configured origins; deny all cross-origin if none are set
+ const allowedOrigins = config.allowedOrigins;
+ await server.register(cors, {
+ origin:
+ allowedOrigins.length > 0
+ ? (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => {
+ if (!origin || allowedOrigins.includes(origin)) {
+ cb(null, true);
+ } else {
+ cb(new Error("Not allowed by CORS"), false);
+ }
+ }
+ : false,
+ methods: ["GET", "POST", "DELETE", "OPTIONS"],
+ allowedHeaders: ["Content-Type", "Authorization", "x-api-key", "mcp-session-id"],
+ exposedHeaders: ["mcp-session-id"],
+ });
+
+ // Sensible defaults (error handling, 404 handling, etc.)
+ await server.register(sensible);
+
+ // Health check endpoint
+ server.get("/health", async () => {
+ const now = Date.now();
+ return {
+ status: "ok",
+ timestamp: new Date(now).toISOString(),
+ startedAt: serverStartTime ? new Date(serverStartTime).toISOString() : null,
+ uptimeMs: serverStartTime ? now - serverStartTime : null,
+ };
+ });
+
+ // Root endpoint
+ server.get("/", async () => {
+ return {
+ name: "@vespr/cardano-mcp",
+ version: "0.1.0",
+ transport: "http",
+ endpoints: {
+ health: "/health",
+ tools: "/mcp/tools",
+ execute: "/mcp/tools/:toolName",
+ mcp: "/mcp",
+ },
+ };
+ });
+
+ // Register HTTP tools with the registry
+ registerHttpTools();
+
+ const rateLimitKeyGenerator = (request: FastifyRequest): string =>
+ (request.headers["x-real-ip"] as string | undefined) ||
+ (request.headers["x-client-ip"] as string | undefined) ||
+ (request.headers["cf-connecting-ip"] as string | undefined) ||
+ (request.headers["do-connecting-ip"] as string | undefined) ||
+ (typeof request.headers["x-forwarded-for"] === "string"
+ ? request.headers["x-forwarded-for"].split(",")[0].trim()
+ : "") ||
+ request.ip;
+
+ await server.register(fastifyRateLimit, {
+ max: config.rateLimitPerMinute,
+ timeWindow: "1 minute",
+ hook: "preValidation",
+ store: createDualWindowStore(config.rateLimitPerMinute, config.rateLimitPerDay),
+ allowList: (request) => !request.url.startsWith("/mcp"),
+ keyGenerator: rateLimitKeyGenerator,
+ errorResponseBuilder: (_request, context) => ({
+ statusCode: 429,
+ error: "Too Many Requests",
+ message: `Rate limit exceeded. Retry in ${context.after}.`,
+ }),
+ });
+
+ await registerHttpRoutes(server);
+ await registerStreamableHttpRoutes(server, config.sessionTtlMs);
+}
+
+/**
+ * Start the HTTP server
+ */
+export async function startServer(): Promise {
+ serverStartTime = Date.now();
+ const server = createServer();
+ await configureServer(server);
+
+ const port = config.httpPort;
+ const host = config.httpHost;
+
+ await server.listen({ port, host });
+
+ logger.info("http_server_started", {
+ port,
+ host,
+ version: "0.1.0",
+ });
+
+ return server;
+}
+
+/**
+ * Gracefully shutdown the server
+ */
+export async function stopServer(server: FastifyInstance): Promise {
+ await server.close();
+ logger.info("http_server_stopped", {});
+}
diff --git a/src/tools/get_asset_metadata.ts b/src/tools/get_asset_metadata.ts
index b29a619..484ff85 100644
--- a/src/tools/get_asset_metadata.ts
+++ b/src/tools/get_asset_metadata.ts
@@ -42,6 +42,62 @@ function formatMetadata(metadata: Record, indent: number = 0):
return lines.join("\n");
}
+/**
+ * Handler for get_asset_metadata tool
+ */
+export async function getAssetMetadataHandler({ unit }: { unit: string }): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Validate input
+ if (!unit || unit.trim() === "") {
+ return {
+ content: [{ type: "text" as const, text: "Error: Asset unit cannot be empty." }],
+ isError: true,
+ };
+ }
+
+ try {
+ const trimmedUnit = unit.trim();
+ const response = await VesprApiRepository.getAssetMetadata(trimmedUnit);
+
+ const output = {
+ unit: trimmedUnit,
+ name: response.name,
+ has_metadata: response.onchain_metadata !== null,
+ onchain_metadata: response.onchain_metadata,
+ };
+
+ // Format human-readable output
+ let summary: string;
+ if (response.onchain_metadata) {
+ const metadataFormatted = formatMetadata(response.onchain_metadata);
+ summary = [`Asset: ${response.name}`, `Unit: ${trimmedUnit}`, "", "On-chain Metadata:", metadataFormatted].join(
+ "\n",
+ );
+ } else {
+ summary = [`Asset: ${response.name}`, `Unit: ${trimmedUnit}`, `Status: No on-chain metadata found`].join("\n");
+ }
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetAssetMetadata(server: McpServer): void {
server.registerTool(
"get_asset_metadata",
@@ -56,57 +112,6 @@ export function registerGetAssetMetadata(server: McpServer): void {
},
outputSchema: assetMetadataOutputSchema,
},
- async ({ unit }) => {
- // Validate input
- const trimmedUnit = unit?.trim() ?? "";
- if (!trimmedUnit) {
- return {
- content: [{ type: "text" as const, text: "Error: Asset unit cannot be empty." }],
- isError: true,
- };
- }
-
- try {
- const response = await VesprApiRepository.getAssetMetadata(trimmedUnit);
-
- const output = {
- unit: trimmedUnit,
- name: response.name,
- has_metadata: response.onchain_metadata !== null,
- onchain_metadata: response.onchain_metadata,
- };
-
- // Format human-readable output
- let summary: string;
- if (response.onchain_metadata) {
- const metadataFormatted = formatMetadata(response.onchain_metadata);
- summary = [`Asset: ${response.name}`, `Unit: ${trimmedUnit}`, "", "On-chain Metadata:", metadataFormatted].join(
- "\n",
- );
- } else {
- summary = [`Asset: ${response.name}`, `Unit: ${trimmedUnit}`, `Status: No on-chain metadata found`].join(
- "\n",
- );
- }
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getAssetMetadataHandler,
);
}
diff --git a/src/tools/get_asset_summary.ts b/src/tools/get_asset_summary.ts
index 243a008..dc10f17 100644
--- a/src/tools/get_asset_summary.ts
+++ b/src/tools/get_asset_summary.ts
@@ -126,6 +126,105 @@ function formatAssetSummary(
return lines.join("\n");
}
+/**
+ * Handler for get_asset_summary tool
+ */
+export async function getAssetSummaryHandler({ units }: { units: string[] }): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Validate input - empty array
+ if (!units || units.length === 0) {
+ return {
+ content: [{ type: "text" as const, text: "Error: Units array cannot be empty." }],
+ isError: true,
+ };
+ }
+
+ // Validate input - max limit
+ if (units.length > MAX_ASSETS_LIMIT) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: Maximum ${MAX_ASSETS_LIMIT} assets allowed per request. Received ${units.length}.`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ // Validate input - no empty strings
+ const trimmedUnits = units.map((u) => u.trim()).filter((u) => u !== "");
+ if (trimmedUnits.length === 0) {
+ return {
+ content: [{ type: "text" as const, text: "Error: All provided units are empty or whitespace." }],
+ isError: true,
+ };
+ }
+
+ try {
+ const response = await VesprApiRepository.getAssetSummary(trimmedUnits);
+
+ // Transform tokens to output format (including image for AI display)
+ const tokens = response.tokens.map((t) => ({
+ policy: t.policy,
+ hex_asset_name: t.hex_asset_name,
+ name: t.name,
+ ticker: t.ticker,
+ decimals: t.decimals,
+ verified: t.verified,
+ registered_name: t.registered_name,
+ image: t.image,
+ }));
+
+ // Transform NFTs to output format (including image for AI display)
+ const nfts = response.nfts.map((n) => ({
+ policy: n.policy,
+ hex_asset_name: n.hex_asset_name,
+ name: n.name,
+ registered_name: n.registered_name,
+ image: n.image,
+ }));
+
+ // Transform other NFTs to output format (including image for AI display)
+ const otherNfts = response.other_nfts.map((n) => ({
+ policy: n.policy,
+ hex_asset_name: n.hex_asset_name,
+ name: n.name,
+ registered_name: n.registered_name,
+ image: n.image,
+ }));
+
+ const output = {
+ queried_count: trimmedUnits.length,
+ tokens,
+ nfts,
+ other_nfts: otherNfts,
+ };
+
+ // Format human-readable output
+ const summary = formatAssetSummary(trimmedUnits.length, tokens, nfts, otherNfts);
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetAssetSummary(server: McpServer): void {
server.registerTool(
"get_asset_summary",
@@ -142,98 +241,6 @@ export function registerGetAssetSummary(server: McpServer): void {
},
outputSchema: assetSummaryOutputSchema,
},
- async ({ units }) => {
- // Validate input - empty array
- if (!units || units.length === 0) {
- return {
- content: [{ type: "text" as const, text: "Error: Units array cannot be empty." }],
- isError: true,
- };
- }
-
- // Validate input - max limit
- if (units.length > MAX_ASSETS_LIMIT) {
- return {
- content: [
- {
- type: "text" as const,
- text: `Error: Maximum ${MAX_ASSETS_LIMIT} assets allowed per request. Received ${units.length}.`,
- },
- ],
- isError: true,
- };
- }
-
- // Validate input - no empty strings
- const trimmedUnits = units.map((u) => u.trim()).filter((u) => u !== "");
- if (trimmedUnits.length === 0) {
- return {
- content: [{ type: "text" as const, text: "Error: All provided units are empty or whitespace." }],
- isError: true,
- };
- }
-
- try {
- const response = await VesprApiRepository.getAssetSummary(trimmedUnits);
-
- // Transform tokens to output format (including image for AI display)
- const tokens = response.tokens.map((t) => ({
- policy: t.policy,
- hex_asset_name: t.hex_asset_name,
- name: t.name,
- ticker: t.ticker,
- decimals: t.decimals,
- verified: t.verified,
- registered_name: t.registered_name,
- image: t.image,
- }));
-
- // Transform NFTs to output format (including image for AI display)
- const nfts = response.nfts.map((n) => ({
- policy: n.policy,
- hex_asset_name: n.hex_asset_name,
- name: n.name,
- registered_name: n.registered_name,
- image: n.image,
- }));
-
- // Transform other NFTs to output format (including image for AI display)
- const otherNfts = response.other_nfts.map((n) => ({
- policy: n.policy,
- hex_asset_name: n.hex_asset_name,
- name: n.name,
- registered_name: n.registered_name,
- image: n.image,
- }));
-
- const output = {
- queried_count: trimmedUnits.length,
- tokens,
- nfts,
- other_nfts: otherNfts,
- };
-
- // Format human-readable output
- const summary = formatAssetSummary(trimmedUnits.length, tokens, nfts, otherNfts);
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getAssetSummaryHandler,
);
}
diff --git a/src/tools/get_pool_info.ts b/src/tools/get_pool_info.ts
index 82ea119..11d689b 100644
--- a/src/tools/get_pool_info.ts
+++ b/src/tools/get_pool_info.ts
@@ -117,6 +117,49 @@ function transformResponse(response: PoolInfoResponse): z.infer;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Support both pool_id and poolId parameter names
+ const effectivePoolId = pool_id ?? poolId;
+
+ // Validate pool ID
+ if (!effectivePoolId || !isValidPoolId(effectivePoolId)) {
+ return {
+ content: [{ type: "text" as const, text: "Error: Invalid pool ID. Pool IDs must start with 'pool1' prefix." }],
+ isError: true,
+ };
+ }
+
+ try {
+ const response = await VesprApiRepository.getPoolInfo(effectivePoolId.trim());
+
+ const output = transformResponse(response);
+ const summary = formatHumanReadable(response);
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetPoolInfo(server: McpServer): void {
server.registerTool(
"get_pool_info",
@@ -129,41 +172,6 @@ export function registerGetPoolInfo(server: McpServer): void {
},
outputSchema: poolInfoOutputSchema,
},
- async ({ pool_id }) => {
- // Validate pool ID
- if (!isValidPoolId(pool_id)) {
- return {
- content: [
- { type: "text" as const, text: "Error: Invalid pool ID. Pool IDs must start with 'pool1' prefix." },
- ],
- isError: true,
- };
- }
-
- try {
- const response = await VesprApiRepository.getPoolInfo(pool_id.trim());
-
- const output = transformResponse(response);
- const summary = formatHumanReadable(response);
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getPoolInfoHandler,
);
}
diff --git a/src/tools/get_staking_info.ts b/src/tools/get_staking_info.ts
index 9be9183..adf613a 100644
--- a/src/tools/get_staking_info.ts
+++ b/src/tools/get_staking_info.ts
@@ -206,6 +206,48 @@ function transformResponse(response: StakingInfoResponse): z.infer;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ const trimmedAddress = address.trim();
+
+ // Validate address
+ if (!isValidCardanoAddress(trimmedAddress)) {
+ return {
+ content: [{ type: "text" as const, text: "Error: Invalid Cardano address." }],
+ isError: true,
+ };
+ }
+
+ try {
+ const response = await VesprApiRepository.getStakingInfo(trimmedAddress);
+
+ const output = transformResponse(response);
+ const summary = formatHumanReadable(response);
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetStakingInfo(server: McpServer): void {
server.registerTool(
"get_staking_info",
@@ -218,41 +260,6 @@ export function registerGetStakingInfo(server: McpServer): void {
},
outputSchema: stakingOutputSchema,
},
- async ({ address }) => {
- const trimmedAddress = address.trim();
-
- // Validate address
- if (!isValidCardanoAddress(trimmedAddress)) {
- return {
- content: [{ type: "text" as const, text: "Error: Invalid Cardano address." }],
- isError: true,
- };
- }
-
- try {
- const response = await VesprApiRepository.getStakingInfo(trimmedAddress);
-
- const output = transformResponse(response);
- const summary = formatHumanReadable(response);
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getStakingInfoHandler,
);
}
diff --git a/src/tools/get_supported_currencies.ts b/src/tools/get_supported_currencies.ts
index 9e4bb09..5e812a4 100644
--- a/src/tools/get_supported_currencies.ts
+++ b/src/tools/get_supported_currencies.ts
@@ -2,6 +2,35 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { SUPPORTED_FIAT_CURRENCIES, SUPPORTED_CRYPTO_CURRENCIES, SUPPORTED_CURRENCIES } from "../types/currency.js";
+/**
+ * Handler for get_supported_currencies tool
+ */
+export async function getSupportedCurrenciesHandler(): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent: { fiat: string[]; crypto: string[] };
+}> {
+ const output = {
+ fiat: SUPPORTED_FIAT_CURRENCIES,
+ crypto: SUPPORTED_CRYPTO_CURRENCIES,
+ };
+
+ const textSummary = [
+ `Supported Fiat Currencies (${SUPPORTED_FIAT_CURRENCIES.length}): ${SUPPORTED_FIAT_CURRENCIES.join(", ")}`,
+ "",
+ `Supported Crypto Currencies (${SUPPORTED_CRYPTO_CURRENCIES.length}): ${SUPPORTED_CRYPTO_CURRENCIES.join(", ")}`,
+ ].join("\n");
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: textSummary,
+ },
+ ],
+ structuredContent: output,
+ };
+}
+
export function registerGetSupportedCurrencies(server: McpServer): void {
server.registerTool(
"get_supported_currencies",
@@ -14,27 +43,6 @@ export function registerGetSupportedCurrencies(server: McpServer): void {
crypto: z.array(z.string()),
},
},
- async () => {
- const output = {
- fiat: SUPPORTED_FIAT_CURRENCIES,
- crypto: SUPPORTED_CRYPTO_CURRENCIES,
- };
-
- const textSummary = [
- `Supported Fiat Currencies (${SUPPORTED_FIAT_CURRENCIES.length}): ${SUPPORTED_FIAT_CURRENCIES.join(", ")}`,
- "",
- `Supported Crypto Currencies (${SUPPORTED_CRYPTO_CURRENCIES.length}): ${SUPPORTED_CRYPTO_CURRENCIES.join(", ")}`,
- ].join("\n");
-
- return {
- content: [
- {
- type: "text" as const,
- text: textSummary,
- },
- ],
- structuredContent: output,
- };
- },
+ getSupportedCurrenciesHandler,
);
}
diff --git a/src/tools/get_token_chart.ts b/src/tools/get_token_chart.ts
index 78e4282..758e8d6 100644
--- a/src/tools/get_token_chart.ts
+++ b/src/tools/get_token_chart.ts
@@ -4,10 +4,7 @@ import { VesprApiError } from "../types/errors.js";
import { formatWithCommas } from "../utils/formatting.js";
import VesprApiRepository from "../repository/VesprApiRepository.js";
import { CryptoCurrency, SupportedCurrency, SUPPORTED_CURRENCIES } from "../types/currency.js";
-import { ChartPeriodSchema, TokenChartIntervalSchema } from "../types/api/schemas.js";
-
-// Valid chart periods
-const CHART_PERIODS = ["1H", "24H", "1W", "1M", "3M", "1Y", "ALL"] as const;
+import { ChartPeriod, ChartPeriodSchema, TokenChartIntervalSchema } from "../types/api/schemas.js";
// Output schema for candle data
const candleOutputSchema = z.object({
@@ -43,6 +40,100 @@ function formatTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toISOString().replace("T", " ").slice(0, 19);
}
+/**
+ * Handler for get_token_chart tool
+ */
+export async function getTokenChartHandler({
+ unit,
+ period,
+ currency,
+}: {
+ unit: string;
+ period?: string;
+ currency?: string;
+}): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Validate unit is not empty
+ if (!unit || unit.trim() === "") {
+ return {
+ content: [{ type: "text" as const, text: "Error: Token unit identifier is required." }],
+ isError: true,
+ };
+ }
+
+ // Use default values if not specified
+ const effectivePeriod = period ?? "24H";
+ const effectiveCurrency = (currency as SupportedCurrency) ?? CryptoCurrency.ADA;
+
+ try {
+ const response = await VesprApiRepository.getTokenChart(unit, effectivePeriod as ChartPeriod, effectiveCurrency);
+
+ // Transform response for output
+ const output = {
+ interval: response.interval,
+ currency: response.currency,
+ candles: response.data.map((candle) => ({
+ timestamp: candle.timestamp,
+ open: candle.open,
+ high: candle.high,
+ low: candle.low,
+ close: candle.close,
+ volume: candle.volume,
+ })),
+ };
+
+ // Handle empty candle array
+ if (response.data.length === 0) {
+ const summary = [
+ `Token Chart (${effectivePeriod}) - 0 candles`,
+ `Currency: ${response.currency}`,
+ `No chart data available for this period.`,
+ ].join("\n");
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ }
+
+ // Calculate summary statistics
+ const candles = response.data;
+ const maxHigh = Math.max(...candles.map((c) => c.high));
+ const minLow = Math.min(...candles.map((c) => c.low));
+ const lastClose = candles[candles.length - 1].close;
+ const startTime = formatTimestamp(candles[0].timestamp);
+ const endTime = formatTimestamp(candles[candles.length - 1].timestamp);
+
+ // Format human-readable summary
+ const summary = [
+ `Token Chart (${effectivePeriod}) - ${candles.length} candles`,
+ `Currency: ${response.currency}`,
+ `Period: ${startTime} to ${endTime}`,
+ `High: ${formatPrice(maxHigh, response.currency)} | Low: ${formatPrice(minLow, response.currency)}`,
+ `Latest: ${formatPrice(lastClose, response.currency)}`,
+ ].join("\n");
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetTokenChart(server: McpServer): void {
server.registerTool(
"get_token_chart",
@@ -72,85 +163,6 @@ export function registerGetTokenChart(server: McpServer): void {
},
outputSchema: tokenChartOutputSchema,
},
- async ({ unit, period, currency }) => {
- // Validate unit is not empty
- if (!unit || unit.trim() === "") {
- return {
- content: [{ type: "text" as const, text: "Error: Token unit identifier is required." }],
- isError: true,
- };
- }
-
- // Use default values if not specified
- const effectivePeriod = period ?? "24H";
- const effectiveCurrency = (currency as SupportedCurrency) ?? CryptoCurrency.ADA;
-
- try {
- const response = await VesprApiRepository.getTokenChart(unit, effectivePeriod, effectiveCurrency);
-
- // Transform response for output
- const output = {
- interval: response.interval,
- currency: response.currency,
- candles: response.data.map((candle) => ({
- timestamp: candle.timestamp,
- open: candle.open,
- high: candle.high,
- low: candle.low,
- close: candle.close,
- volume: candle.volume,
- })),
- };
-
- // Handle empty candle array
- if (response.data.length === 0) {
- const summary = [
- `Token Chart (${effectivePeriod}) - 0 candles`,
- `Currency: ${response.currency}`,
- `No chart data available for this period.`,
- ].join("\n");
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- }
-
- // Calculate summary statistics
- const candles = response.data;
- const maxHigh = Math.max(...candles.map((c) => c.high));
- const minLow = Math.min(...candles.map((c) => c.low));
- const lastClose = candles[candles.length - 1].close;
- const startTime = formatTimestamp(candles[0].timestamp);
- const endTime = formatTimestamp(candles[candles.length - 1].timestamp);
-
- // Format human-readable summary
- const summary = [
- `Token Chart (${effectivePeriod}) - ${candles.length} candles`,
- `Currency: ${response.currency}`,
- `Period: ${startTime} to ${endTime}`,
- `High: ${formatPrice(maxHigh, response.currency)} | Low: ${formatPrice(minLow, response.currency)}`,
- `Latest: ${formatPrice(lastClose, response.currency)}`,
- ].join("\n");
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getTokenChartHandler,
);
}
diff --git a/src/tools/get_token_info.ts b/src/tools/get_token_info.ts
index 9912da0..2f42f13 100644
--- a/src/tools/get_token_info.ts
+++ b/src/tools/get_token_info.ts
@@ -45,6 +45,95 @@ function getRiskDescription(category: string | null | undefined): string {
return category;
}
+/**
+ * Handler for get_token_info tool
+ */
+export async function getTokenInfoHandler({ unit, currency }: { unit: string; currency?: string }): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Validate unit is not empty
+ if (!unit || unit.trim() === "") {
+ return {
+ content: [{ type: "text" as const, text: "Error: Token unit identifier is required." }],
+ isError: true,
+ };
+ }
+
+ if (currency && !SUPPORTED_CURRENCIES.includes(currency as SupportedCurrency)) {
+ return {
+ content: [{ type: "text" as const, text: "Error: Invalid currency. Must be one of the supported currencies." }],
+ isError: true,
+ };
+ }
+
+ const effectiveCurrency = (currency as SupportedCurrency) ?? FiatCurrency.USD;
+
+ try {
+ const response = await VesprApiRepository.getTokenInfo(unit, effectiveCurrency);
+ const data = response.data;
+
+ // Transform response for output (convert undefined to null for schema compliance)
+ const output = {
+ subject: data.subject,
+ name: data.name,
+ ticker: data.ticker ?? null,
+ description: data.description ?? null,
+ url: data.url ?? null,
+ decimals: data.decimals,
+ price: data.price ?? null,
+ circSupply: data.circSupply ?? null,
+ fdv: data.fdv ?? null,
+ mcap: data.mcap ?? null,
+ totalSupply: data.totalSupply,
+ riskCategory: data.riskCategory ?? null,
+ verified: data.verified,
+ currency: data.currency,
+ };
+
+ // Format human-readable summary
+ const ticker = data.ticker ? ` (${data.ticker})` : "";
+ const priceStr = data.price != null ? `${formatNumber(data.price)} ${data.currency}` : "N/A";
+ const mcapStr = data.mcap != null ? `${formatNumber(data.mcap, 0)} ${data.currency}` : "N/A";
+ const fdvStr = data.fdv != null ? `${formatNumber(data.fdv, 0)} ${data.currency}` : "N/A";
+ const circSupplyStr = data.circSupply != null ? formatNumber(data.circSupply, 0) : "N/A";
+ const totalSupplyStr = formatNumber(data.totalSupply, 0);
+ const riskStr = getRiskDescription(data.riskCategory);
+
+ const summary = [
+ `Token: ${data.name}${ticker}`,
+ `Price: ${priceStr}`,
+ `Market Cap: ${mcapStr}`,
+ `FDV: ${fdvStr}`,
+ `Circulating Supply: ${circSupplyStr}`,
+ `Total Supply: ${totalSupplyStr}`,
+ `Risk Rating: ${riskStr}`,
+ `Verified: ${data.verified ? "Yes" : "No"}`,
+ data.description ? `\nDescription: ${data.description}` : "",
+ data.url ? `URL: ${data.url}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetTokenInfo(server: McpServer): void {
server.registerTool(
"get_token_info",
@@ -66,82 +155,6 @@ export function registerGetTokenInfo(server: McpServer): void {
},
outputSchema: tokenInfoOutputSchema,
},
- async ({ unit, currency }) => {
- // Validate unit is not empty
- if (!unit || unit.trim() === "") {
- return {
- content: [{ type: "text" as const, text: "Error: Token unit identifier is required." }],
- isError: true,
- };
- }
-
- // Use default currency if not specified
- const effectiveCurrency = (currency as SupportedCurrency) ?? FiatCurrency.USD;
-
- try {
- const response = await VesprApiRepository.getTokenInfo(unit, effectiveCurrency);
- const data = response.data;
-
- // Transform response for output (convert undefined to null for schema compliance)
- const output = {
- subject: data.subject,
- name: data.name,
- ticker: data.ticker ?? null,
- description: data.description ?? null,
- url: data.url ?? null,
- decimals: data.decimals,
- price: data.price ?? null,
- circSupply: data.circSupply ?? null,
- fdv: data.fdv ?? null,
- mcap: data.mcap ?? null,
- totalSupply: data.totalSupply,
- riskCategory: data.riskCategory ?? null,
- verified: data.verified,
- currency: data.currency,
- };
-
- // Format human-readable summary
- const ticker = data.ticker ? ` (${data.ticker})` : "";
- const priceStr = data.price != null ? `${formatNumber(data.price)} ${data.currency}` : "N/A";
- const mcapStr = data.mcap != null ? `${formatNumber(data.mcap, 0)} ${data.currency}` : "N/A";
- const fdvStr = data.fdv != null ? `${formatNumber(data.fdv, 0)} ${data.currency}` : "N/A";
- const circSupplyStr = data.circSupply != null ? formatNumber(data.circSupply, 0) : "N/A";
- const totalSupplyStr = formatNumber(data.totalSupply, 0);
- const riskStr = getRiskDescription(data.riskCategory);
-
- const summary = [
- `Token: ${data.name}${ticker}`,
- `Price: ${priceStr}`,
- `Market Cap: ${mcapStr}`,
- `FDV: ${fdvStr}`,
- `Circulating Supply: ${circSupplyStr}`,
- `Total Supply: ${totalSupplyStr}`,
- `Risk Rating: ${riskStr}`,
- `Verified: ${data.verified ? "Yes" : "No"}`,
- data.description ? `\nDescription: ${data.description}` : "",
- data.url ? `URL: ${data.url}` : "",
- ]
- .filter(Boolean)
- .join("\n");
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getTokenInfoHandler,
);
}
diff --git a/src/tools/get_transaction_history.ts b/src/tools/get_transaction_history.ts
index 7f9fcf1..b710557 100644
--- a/src/tools/get_transaction_history.ts
+++ b/src/tools/get_transaction_history.ts
@@ -47,6 +47,88 @@ const historyOutputSchema = z.object({
transactions: z.array(transactionOutputSchema),
});
+/**
+ * Handler for get_transaction_history tool
+ */
+export async function getTransactionHistoryHandler({
+ address,
+ to_block,
+}: {
+ address: string;
+ to_block?: number;
+}): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Validate address
+ if (!isValidCardanoAddress(address)) {
+ return {
+ content: [{ type: "text" as const, text: "Error: Invalid Cardano address." }],
+ isError: true,
+ };
+ }
+
+ try {
+ const response = await VesprApiRepository.getTransactionHistory(address, to_block);
+
+ // Transform transactions for output
+ const transactions = response.transactions.map((tx) => {
+ const apiDirection = tx.direction as ApiDirection;
+ return {
+ txHash: tx.txHash,
+ timestamp: tx.timestamp,
+ blockHeight: tx.blockHeight,
+ direction: directionLabels[apiDirection] as "Received" | "Sent" | "Self Transfer" | "Multisig",
+ adaAmount: lovelaceToAda(tx.lovelace),
+ fee: lovelaceToAda(tx.txFee),
+ assetCount: tx.assets.length,
+ _apiDirection: apiDirection, // Keep for display formatting
+ };
+ });
+
+ // Build structured output (excluding internal _apiDirection)
+ const output = {
+ sinceBlock: response.sinceBlock,
+ toBlock: response.toBlock,
+ transactionCount: transactions.length,
+ transactions: transactions.map(({ _apiDirection, ...tx }) => tx),
+ };
+
+ // Format human-readable summary using direction symbols and amount prefixes
+ const summary = [
+ `Transaction History (blocks ${response.sinceBlock} - ${response.toBlock})`,
+ `Total: ${transactions.length} transaction(s)`,
+ "",
+ ...transactions
+ .slice(0, 10)
+ .map(
+ (tx) =>
+ `${directionSymbols[tx._apiDirection]} ${directionAmountPrefix[tx._apiDirection]}${tx.adaAmount} ADA | ${tx.direction} | ${tx.timestamp} | ${tx.assetCount} assets | ${tx.txHash.slice(0, 16)}...`,
+ ),
+ transactions.length > 10 ? `... and ${transactions.length - 10} more` : "",
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetTransactionHistory(server: McpServer): void {
server.registerTool(
"get_transaction_history",
@@ -65,75 +147,6 @@ export function registerGetTransactionHistory(server: McpServer): void {
},
outputSchema: historyOutputSchema,
},
- async ({ address, to_block }) => {
- // Validate address
- if (!isValidCardanoAddress(address)) {
- return {
- content: [{ type: "text" as const, text: "Error: Invalid Cardano address." }],
- isError: true,
- };
- }
-
- try {
- const response = await VesprApiRepository.getTransactionHistory(address, to_block);
-
- // Transform transactions for output
- const transactions = response.transactions.map((tx) => {
- const apiDirection = tx.direction as ApiDirection;
- return {
- txHash: tx.txHash,
- timestamp: tx.timestamp,
- blockHeight: tx.blockHeight,
- direction: directionLabels[apiDirection] as "Received" | "Sent" | "Self Transfer" | "Multisig",
- adaAmount: lovelaceToAda(tx.lovelace),
- fee: lovelaceToAda(tx.txFee),
- assetCount: tx.assets.length,
- _apiDirection: apiDirection, // Keep for display formatting
- };
- });
-
- // Build structured output (excluding internal _apiDirection)
- const output = {
- sinceBlock: response.sinceBlock,
- toBlock: response.toBlock,
- transactionCount: transactions.length,
- transactions: transactions.map(({ _apiDirection, ...tx }) => tx),
- };
-
- // Format human-readable summary using direction symbols and amount prefixes
- const summary = [
- `Transaction History (blocks ${response.sinceBlock} - ${response.toBlock})`,
- `Total: ${transactions.length} transaction(s)`,
- "",
- ...transactions
- .slice(0, 10)
- .map(
- (tx) =>
- `${directionSymbols[tx._apiDirection]} ${directionAmountPrefix[tx._apiDirection]}${tx.adaAmount} ADA | ${tx.direction} | ${tx.timestamp} | ${tx.assetCount} assets | ${tx.txHash.slice(0, 16)}...`,
- ),
- transactions.length > 10 ? `... and ${transactions.length - 10} more` : "",
- ]
- .filter(Boolean)
- .join("\n");
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getTransactionHistoryHandler,
);
}
diff --git a/src/tools/get_trending_tokens.ts b/src/tools/get_trending_tokens.ts
index 9cd287f..dc78e16 100644
--- a/src/tools/get_trending_tokens.ts
+++ b/src/tools/get_trending_tokens.ts
@@ -4,7 +4,7 @@ import { VesprApiError } from "../types/errors.js";
import { formatWithCommas } from "../utils/formatting.js";
import VesprApiRepository from "../repository/VesprApiRepository.js";
import { FiatCurrency, SupportedCurrency, SUPPORTED_CURRENCIES } from "../types/currency.js";
-import { TrendingPeriodSchema, TrendingTokenItem } from "../types/api/schemas.js";
+import { TrendingPeriod, TrendingPeriodSchema, TrendingTokenItem } from "../types/api/schemas.js";
// Output schema for trending token item
const trendingTokenOutputSchema = z.object({
@@ -79,6 +79,95 @@ function transformToken(token: TrendingTokenItem): z.infer;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Use default values if not specified
+ const effectiveCurrency = (currency as SupportedCurrency) ?? FiatCurrency.USD;
+ const effectiveLimit = limit ?? 10;
+
+ try {
+ const response = await VesprApiRepository.getTrendingTokens(
+ effectiveCurrency,
+ period as TrendingPeriod | undefined,
+ );
+
+ // Apply limit to results
+ const limitedData = response.data.slice(0, effectiveLimit);
+
+ // Transform for output - use the requested currency since API doesn't return it
+ const output = {
+ currency: effectiveCurrency,
+ period: period ?? null,
+ tokens: limitedData.map(transformToken),
+ };
+
+ // Handle empty results
+ if (limitedData.length === 0) {
+ const summary = [
+ `Trending Tokens${period ? ` (${period})` : ""}`,
+ `Currency: ${effectiveCurrency}`,
+ ``,
+ `No trending tokens found for the specified criteria.`,
+ ].join("\n");
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ }
+
+ // Format human-readable ranked list
+ const header = [`Trending Tokens${period ? ` (${period})` : ""}`, `Currency: ${effectiveCurrency}`, ``];
+
+ const tokenLines = limitedData.map((token, index) => {
+ const rank = index + 1;
+ const ticker = token.ticker ? ` (${token.ticker})` : "";
+ const verified = token.verified ? " [Verified]" : "";
+ const priceStr = formatPrice(token.ada_per_adjusted_unit, effectiveCurrency);
+ const changeStr = formatChange(token.period_ada_price_change_percentage);
+ const volumeStr = formatNumber(token.period_volume_ada, 0);
+
+ return [
+ `${rank}. ${token.name}${ticker}${verified}`,
+ ` Price: ${priceStr} ${effectiveCurrency} | Change: ${changeStr}`,
+ ` Volume: ${volumeStr} | Buys: ${token.period_buys_count} | Sells: ${token.period_sales_count}`,
+ ].join("\n");
+ });
+
+ const summary = [...header, ...tokenLines].join("\n");
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerGetTrendingTokens(server: McpServer): void {
server.registerTool(
"get_trending_tokens",
@@ -105,77 +194,6 @@ export function registerGetTrendingTokens(server: McpServer): void {
},
outputSchema: trendingTokensOutputSchema,
},
- async ({ currency, period, limit }) => {
- // Use default values if not specified
- const effectiveCurrency = (currency as SupportedCurrency) ?? FiatCurrency.USD;
- const effectiveLimit = limit ?? 10;
-
- try {
- const response = await VesprApiRepository.getTrendingTokens(effectiveCurrency, period);
-
- // Apply limit to results
- const limitedData = response.data.slice(0, effectiveLimit);
-
- // Transform for output - use the requested currency since API doesn't return it
- const output = {
- currency: effectiveCurrency,
- period: period ?? null,
- tokens: limitedData.map(transformToken),
- };
-
- // Handle empty results
- if (limitedData.length === 0) {
- const summary = [
- `Trending Tokens${period ? ` (${period})` : ""}`,
- `Currency: ${effectiveCurrency}`,
- ``,
- `No trending tokens found for the specified criteria.`,
- ].join("\n");
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- }
-
- // Format human-readable ranked list
- const header = [`Trending Tokens${period ? ` (${period})` : ""}`, `Currency: ${effectiveCurrency}`, ``];
-
- const tokenLines = limitedData.map((token, index) => {
- const rank = index + 1;
- const ticker = token.ticker ? ` (${token.ticker})` : "";
- const verified = token.verified ? " [Verified]" : "";
- const priceStr = formatPrice(token.ada_per_adjusted_unit, effectiveCurrency);
- const changeStr = formatChange(token.period_ada_price_change_percentage);
- const volumeStr = formatNumber(token.period_volume_ada, 0);
-
- return [
- `${rank}. ${token.name}${ticker}${verified}`,
- ` Price: ${priceStr} ${effectiveCurrency} | Change: ${changeStr}`,
- ` Volume: ${volumeStr} | Buys: ${token.period_buys_count} | Sells: ${token.period_sales_count}`,
- ].join("\n");
- });
-
- const summary = [...header, ...tokenLines].join("\n");
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ getTrendingTokensHandler,
);
}
diff --git a/src/tools/get_wallet_balance.ts b/src/tools/get_wallet_balance.ts
index 1b57f9e..0aa1fa2 100644
--- a/src/tools/get_wallet_balance.ts
+++ b/src/tools/get_wallet_balance.ts
@@ -5,7 +5,7 @@ import { lovelaceToAda, formatTokenAmount } from "../utils/cardano.js";
import { formatWithCommas } from "../utils/formatting.js";
import { isValidCardanoAddress } from "../utils/validation.js";
import VesprApiRepository from "../repository/VesprApiRepository.js";
-import { FiatCurrency, SUPPORTED_CURRENCIES } from "../types/currency.js";
+import { FiatCurrency, SupportedCurrency, SUPPORTED_CURRENCIES } from "../types/currency.js";
const tokenOutputSchema = z.object({
name: z.string().describe("The name of the token"),
@@ -24,6 +24,133 @@ const balanceOutputSchema = z.object({
type TokenOutput = z.infer;
type BalanceOutput = z.infer;
+/**
+ * Handler for get_wallet_balance tool
+ */
+export async function getWalletBalanceHandler({ address, currency }: { address: string; currency: string }): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: BalanceOutput;
+ isError?: boolean;
+}> {
+ // Validate address format before making API call
+ if (!isValidCardanoAddress(address)) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: "Error: Invalid Cardano address. Address should be a valid bech32 Shelley Era Wallet address.",
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ if (!SUPPORTED_CURRENCIES.includes(currency as SupportedCurrency)) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: "Error: Invalid currency. Currency must be one of the supported fiat currencies.",
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ try {
+ const [walletData, spotPrice] = await Promise.all([
+ VesprApiRepository.getDetailedWallet(address),
+ VesprApiRepository.getAdaSpotPrice(currency as SupportedCurrency),
+ ]);
+
+ const fiatSpotPrice = parseFloat(spotPrice.spot);
+
+ // Transform response to match our output schema
+ const adaBalance = lovelaceToAda(walletData.lovelace);
+ const stakingRewards = lovelaceToAda(walletData.rewards_lovelace);
+
+ const tokens: TokenOutput[] = [
+ {
+ name: "Cardano",
+ ticker: "ADA",
+ amount: adaBalance,
+ value: (parseFloat(adaBalance) * fiatSpotPrice).toFixed(2),
+ },
+ ...walletData.tokens.map((token) => {
+ const decimalsAdjustedAmount = formatTokenAmount(token.quantity, token.decimals);
+ const adaPerAdjustedUnit = token.ada_per_adjusted_unit ? parseFloat(token.ada_per_adjusted_unit) : null;
+ const adaWorth = adaPerAdjustedUnit ? parseFloat(decimalsAdjustedAmount) * adaPerAdjustedUnit : null;
+ const currencyWorth = adaWorth ? adaWorth * fiatSpotPrice : null;
+ const tokenOutput: TokenOutput = {
+ name: token.name || token.hex_asset_name,
+ ticker: token.ticker,
+ amount: decimalsAdjustedAmount,
+ value: currencyWorth ? currencyWorth.toFixed(2) : null,
+ };
+
+ return tokenOutput;
+ }),
+ ];
+
+ const portfolioValue = tokens.reduce((acc, token) => acc + (token.value ? parseFloat(token.value) : 0), 0);
+
+ const output: BalanceOutput = {
+ currency: currency as SupportedCurrency,
+ portfolio_value: portfolioValue.toFixed(2),
+ tokens,
+ handles: walletData.handles,
+ };
+
+ // Format human-readable text with commas
+ const formattedAda = formatWithCommas(adaBalance);
+ const formattedRewards = formatWithCommas(stakingRewards);
+ const tokenCount = tokens.length;
+ const handleCount = walletData.handles.length;
+
+ const textSummary = [
+ `Portfolio Value (ADA + Tokens): ${portfolioValue.toFixed(2)} ${currency}`,
+ `ADA Balance: ${formattedAda} ADA`,
+ `Staking Rewards: ${formattedRewards} ADA`,
+ `Tokens: ${tokenCount} token${tokenCount !== 1 ? "s" : ""}`,
+ `Handles: ${handleCount > 0 ? walletData.handles.join(", ") : "none"}`,
+ ].join("\n");
+
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: textSummary,
+ },
+ ],
+ structuredContent: output,
+ };
+ } catch (error) {
+ // Handle VesprApiError with user-friendly message
+ if (error instanceof VesprApiError) {
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: ${error.message}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ // Handle unexpected errors
+ return {
+ content: [
+ {
+ type: "text" as const,
+ text: `Error: An unexpected error occurred. ${error instanceof Error ? error.message : "Unknown error"}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+}
+
export function registerGetWalletBalance(server: McpServer): void {
server.registerTool(
"get_wallet_balance",
@@ -42,124 +169,6 @@ export function registerGetWalletBalance(server: McpServer): void {
},
outputSchema: balanceOutputSchema,
},
- async ({ address, currency }) => {
- // Validate address format before making API call
- if (!isValidCardanoAddress(address)) {
- return {
- content: [
- {
- type: "text" as const,
- text: "Error: Invalid Cardano address. Address should be a valid bech32 Shelley Era Wallet address.",
- },
- ],
- isError: true,
- };
- }
-
- if (!SUPPORTED_CURRENCIES.includes(currency)) {
- return {
- content: [
- {
- type: "text" as const,
- text: "Error: Invalid currency. Currency must be one of the supported fiat currencies.",
- },
- ],
- isError: true,
- };
- }
-
- try {
- const [walletData, spotPrice] = await Promise.all([
- VesprApiRepository.getDetailedWallet(address),
- VesprApiRepository.getAdaSpotPrice(currency),
- ]);
-
- const fiatSpotPrice = parseFloat(spotPrice.spot);
-
- // Transform response to match our output schema
- const adaBalance = lovelaceToAda(walletData.lovelace);
- const stakingRewards = lovelaceToAda(walletData.rewards_lovelace);
-
- const tokens: TokenOutput[] = [
- {
- name: "Cardano",
- ticker: "ADA",
- amount: adaBalance,
- value: (parseFloat(adaBalance) * fiatSpotPrice).toFixed(2),
- },
- ...walletData.tokens.map((token) => {
- const decimalsAdjustedAmount = formatTokenAmount(token.quantity, token.decimals);
- const adaPerAdjustedUnit = token.ada_per_adjusted_unit ? parseFloat(token.ada_per_adjusted_unit) : null;
- const adaWorth = adaPerAdjustedUnit ? parseFloat(decimalsAdjustedAmount) * adaPerAdjustedUnit : null;
- const currencyWorth = adaWorth ? adaWorth * fiatSpotPrice : null;
- const tokenOutput: TokenOutput = {
- name: token.name || token.hex_asset_name,
- ticker: token.ticker,
- amount: decimalsAdjustedAmount,
- value: currencyWorth ? currencyWorth.toFixed(2) : null,
- };
-
- return tokenOutput;
- }),
- ];
-
- const portfolioValue = tokens.reduce((acc, token) => acc + (token.value ? parseFloat(token.value) : 0), 0);
-
- const output: BalanceOutput = {
- currency,
- portfolio_value: portfolioValue.toFixed(2),
- tokens,
- handles: walletData.handles,
- };
-
- // Format human-readable text with commas
- const formattedAda = formatWithCommas(adaBalance);
- const formattedRewards = formatWithCommas(stakingRewards);
- const tokenCount = tokens.length;
- const handleCount = walletData.handles.length;
-
- const textSummary = [
- `Portfolio Value (ADA + Tokens): ${portfolioValue.toFixed(2)} ${currency}`,
- `ADA Balance: ${formattedAda} ADA`,
- `Staking Rewards: ${formattedRewards} ADA`,
- `Tokens: ${tokenCount} token${tokenCount !== 1 ? "s" : ""}`,
- `Handles: ${handleCount > 0 ? walletData.handles.join(", ") : "none"}`,
- ].join("\n");
-
- return {
- content: [
- {
- type: "text" as const,
- text: textSummary + "\n\n" + JSON.stringify(output, null, 2),
- },
- ],
- structuredContent: output,
- };
- } catch (error) {
- // Handle VesprApiError with user-friendly message
- if (error instanceof VesprApiError) {
- return {
- content: [
- {
- type: "text" as const,
- text: `Error: ${error.message}`,
- },
- ],
- isError: true,
- };
- }
-
- // Handle unexpected errors
- return {
- content: [
- {
- type: "text" as const,
- text: `Error: An unexpected error occurred. ${error instanceof Error ? error.message : "Unknown error"}`,
- },
- ],
- isError: true,
- };
- }
- },
+ getWalletBalanceHandler,
);
}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 1095de6..0a98729 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -1,16 +1,23 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
-import { registerGetAssetMetadata } from "./get_asset_metadata.js";
-import { registerGetAssetSummary } from "./get_asset_summary.js";
-import { registerGetPoolInfo } from "./get_pool_info.js";
-import { registerGetSupportedCurrencies } from "./get_supported_currencies.js";
-import { registerGetStakingInfo } from "./get_staking_info.js";
-import { registerGetTokenChart } from "./get_token_chart.js";
-import { registerGetTokenInfo } from "./get_token_info.js";
-import { registerGetTransactionHistory } from "./get_transaction_history.js";
-import { registerGetTrendingTokens } from "./get_trending_tokens.js";
-import { registerGetWalletBalance } from "./get_wallet_balance.js";
-import { registerResolveAdaHandle } from "./resolve_ada_handle.js";
+import { z } from "zod";
+import { httpToolRegistry, HttpToolResult } from "../transports/http.js";
+import { registerGetAssetMetadata, getAssetMetadataHandler } from "./get_asset_metadata.js";
+import { registerGetAssetSummary, getAssetSummaryHandler } from "./get_asset_summary.js";
+import { registerGetPoolInfo, getPoolInfoHandler } from "./get_pool_info.js";
+import { registerGetSupportedCurrencies, getSupportedCurrenciesHandler } from "./get_supported_currencies.js";
+import { registerGetStakingInfo, getStakingInfoHandler } from "./get_staking_info.js";
+import { registerGetTokenChart, getTokenChartHandler } from "./get_token_chart.js";
+import { registerGetTokenInfo, getTokenInfoHandler } from "./get_token_info.js";
+import { registerGetTransactionHistory, getTransactionHistoryHandler } from "./get_transaction_history.js";
+import { registerGetTrendingTokens, getTrendingTokensHandler } from "./get_trending_tokens.js";
+import { registerGetWalletBalance, getWalletBalanceHandler } from "./get_wallet_balance.js";
+import { registerResolveAdaHandle, resolveAdaHandleHandler } from "./resolve_ada_handle.js";
+import { SUPPORTED_CURRENCIES, FiatCurrency, CryptoCurrency } from "../types/currency.js";
+import { ChartPeriodSchema, TrendingPeriodSchema } from "../types/api/schemas.js";
+/**
+ * Register tools with MCP server (for STDIO transport)
+ */
export function registerTools(server: McpServer): void {
registerGetSupportedCurrencies(server);
registerGetWalletBalance(server);
@@ -24,3 +31,175 @@ export function registerTools(server: McpServer): void {
registerGetAssetSummary(server);
registerGetPoolInfo(server);
}
+
+/**
+ * Register tools with HTTP registry (for HTTP transport)
+ */
+export function registerHttpTools(): void {
+ // get_supported_currencies
+ httpToolRegistry.registerTool({
+ name: "get_supported_currencies",
+ title: "Get Supported Currencies",
+ description: "Get the list of supported fiat and crypto currencies for the available MCP tools",
+ inputSchema: {},
+ handler: getSupportedCurrenciesHandler as () => Promise,
+ });
+
+ // get_wallet_balance
+ httpToolRegistry.registerTool({
+ name: "get_wallet_balance",
+ title: "Get Wallet Balance",
+ description:
+ "Query Cardano wallet balance including ADA and native tokens. This will include the balance from all addresses associated with this wallet, not just the address provided.",
+ inputSchema: z.object({
+ address: z.string().describe("Cardano wallet address (bech32 format, addr1...)"),
+ currency: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ z.enum(SUPPORTED_CURRENCIES).default(FiatCurrency.USD),
+ )
+ .describe("The currency to use for the displayed data"),
+ }),
+ handler: getWalletBalanceHandler as (args: Record) => Promise,
+ });
+
+ // get_transaction_history
+ httpToolRegistry.registerTool({
+ name: "get_transaction_history",
+ title: "Get Transaction History",
+ description: "Query Cardano wallet transaction history",
+ inputSchema: z.object({
+ address: z.string().describe("Cardano wallet address (bech32 format, addr1...)"),
+ to_block: z
+ .preprocess((val) => (val === null || val === "" ? undefined : val), z.number().int().positive().optional())
+ .describe("Optional: filter transactions up to this block height"),
+ }),
+ handler: getTransactionHistoryHandler as (args: Record) => Promise,
+ });
+
+ // get_staking_info
+ httpToolRegistry.registerTool({
+ name: "get_staking_info",
+ title: "Get Staking Info",
+ description: "Query Cardano wallet staking information including rewards, pool delegation, and staking status",
+ inputSchema: z.object({
+ address: z.string().describe("Cardano wallet address (bech32 format, addr1...)"),
+ currency: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ z.enum(SUPPORTED_CURRENCIES).default(FiatCurrency.USD),
+ )
+ .describe("The currency to use for the displayed data"),
+ }),
+ handler: getStakingInfoHandler as (args: Record) => Promise,
+ });
+
+ // get_token_info
+ httpToolRegistry.registerTool({
+ name: "get_token_info",
+ title: "Get Token Info",
+ description: "Query detailed information about a Cardano native token including price, market cap, and metadata",
+ inputSchema: z.object({
+ unit: z.string().describe("Token unit (policy ID + asset name hex, e.g., '8f...abc')"),
+ currency: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ z.enum(SUPPORTED_CURRENCIES).default(FiatCurrency.USD),
+ )
+ .describe("The currency to use for the displayed data"),
+ }),
+ handler: getTokenInfoHandler as (args: Record) => Promise,
+ });
+
+ // get_token_chart
+ httpToolRegistry.registerTool({
+ name: "get_token_chart",
+ title: "Get Token Chart",
+ description: "Query price chart data for a Cardano native token over a specified time range",
+ inputSchema: z.object({
+ unit: z.string().describe("Token unit (policy ID + asset name hex)"),
+ period: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ ChartPeriodSchema.optional().default("24H"),
+ )
+ .describe("Chart period: 1H, 24H, 1W, 1M, 3M, 1Y, ALL (default: 24H)"),
+ currency: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ z.enum(SUPPORTED_CURRENCIES).optional().default(CryptoCurrency.ADA),
+ )
+ .describe("Currency for price display (default: ADA)"),
+ }),
+ handler: getTokenChartHandler as (args: Record) => Promise,
+ });
+
+ // get_trending_tokens
+ httpToolRegistry.registerTool({
+ name: "get_trending_tokens",
+ title: "Get Trending Tokens",
+ description: "Query trending Cardano native tokens based on trading activity",
+ inputSchema: z.object({
+ limit: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ z.number().int().min(1).max(100).default(10),
+ )
+ .describe("Number of tokens to return (1-100)"),
+ currency: z
+ .preprocess(
+ (val) => (val === null || val === "" ? undefined : val),
+ z.enum(SUPPORTED_CURRENCIES).default(FiatCurrency.USD),
+ )
+ .describe("The currency to use for the displayed data"),
+ period: z
+ .preprocess((val) => (val === null || val === "" ? undefined : val), TrendingPeriodSchema.optional())
+ .describe("Time period: 1M (1 min), 5M, 30M, 1H, 4H, 1D. Default varies by API."),
+ }),
+ handler: getTrendingTokensHandler as (args: Record) => Promise,
+ });
+
+ // resolve_ada_handle
+ httpToolRegistry.registerTool({
+ name: "resolve_ada_handle",
+ title: "Resolve ADA Handle",
+ description: "Resolve an ADA handle (e.g., $handle) to its associated Cardano wallet address",
+ inputSchema: z.object({
+ handle: z.string().describe("ADA handle to resolve (with or without $ prefix)"),
+ }),
+ handler: resolveAdaHandleHandler as (args: Record) => Promise,
+ });
+
+ // get_asset_metadata
+ httpToolRegistry.registerTool({
+ name: "get_asset_metadata",
+ title: "Get Asset Metadata",
+ description: "Query on-chain and off-chain metadata for a Cardano native asset",
+ inputSchema: z.object({
+ unit: z.string().describe("Asset unit (policy ID + asset name hex)"),
+ }),
+ handler: getAssetMetadataHandler as (args: Record) => Promise,
+ });
+
+ // get_asset_summary
+ httpToolRegistry.registerTool({
+ name: "get_asset_summary",
+ title: "Get Asset Summary",
+ description: "Query summary information for a Cardano native asset including supply, holders, and transactions",
+ inputSchema: z.object({
+ unit: z.string().describe("Asset unit (policy ID + asset name hex)"),
+ }),
+ handler: getAssetSummaryHandler as (args: Record) => Promise,
+ });
+
+ // get_pool_info
+ httpToolRegistry.registerTool({
+ name: "get_pool_info",
+ title: "Get Pool Info",
+ description: "Query detailed information about a Cardano stake pool",
+ inputSchema: z.object({
+ poolId: z.string().describe("Stake pool ID (bech32 format, pool1...)"),
+ }),
+ handler: getPoolInfoHandler as (args: Record) => Promise,
+ });
+}
diff --git a/src/tools/resolve_ada_handle.ts b/src/tools/resolve_ada_handle.ts
index 7e6cda3..a191c52 100644
--- a/src/tools/resolve_ada_handle.ts
+++ b/src/tools/resolve_ada_handle.ts
@@ -10,6 +10,63 @@ const handleOutputSchema = z.object({
found: z.boolean(),
});
+/**
+ * Handler for resolve_ada_handle tool
+ */
+export async function resolveAdaHandleHandler({ handle }: { handle: string }): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+ structuredContent?: z.infer;
+ isError?: boolean;
+}> {
+ // Validate input
+ if (!handle || handle.trim() === "" || handle === "$") {
+ return {
+ content: [{ type: "text" as const, text: "Error: Handle cannot be empty." }],
+ isError: true,
+ };
+ }
+
+ try {
+ const trimmedHandle = handle.trim();
+ const response = await VesprApiRepository.resolveAdaHandle(trimmedHandle);
+
+ // Normalize handle for display (without $)
+ const normalizedHandle = (trimmedHandle.startsWith("$") ? trimmedHandle.slice(1) : trimmedHandle).toLowerCase();
+
+ const output = {
+ handle: normalizedHandle,
+ owner: response.owner,
+ found: response.owner !== null,
+ };
+
+ // Format human-readable output
+ let summary: string;
+ if (response.owner) {
+ summary = [`Handle: $${normalizedHandle}`, `Owner: ${response.owner}`].join("\n");
+ } else {
+ summary = [`Handle: $${normalizedHandle}`, `Status: Not found (handle does not exist or is not registered)`].join(
+ "\n",
+ );
+ }
+
+ return {
+ content: [{ type: "text" as const, text: summary }],
+ structuredContent: output,
+ };
+ } catch (error) {
+ if (error instanceof VesprApiError) {
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error.message}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
+ isError: true,
+ };
+ }
+}
+
export function registerResolveAdaHandle(server: McpServer): void {
server.registerTool(
"resolve_ada_handle",
@@ -22,58 +79,6 @@ export function registerResolveAdaHandle(server: McpServer): void {
},
outputSchema: handleOutputSchema,
},
- async ({ handle }) => {
- const trimmedHandle = handle?.trim() ?? "";
-
- // Validate input
- if (!trimmedHandle || trimmedHandle === "$") {
- return {
- content: [{ type: "text" as const, text: "Error: Handle cannot be empty." }],
- isError: true,
- };
- }
-
- try {
- const response = await VesprApiRepository.resolveAdaHandle(trimmedHandle);
-
- // Normalize handle for display (without $)
- const normalizedHandle = (trimmedHandle.startsWith("$") ? trimmedHandle.slice(1) : trimmedHandle).toLowerCase();
-
- const output = {
- handle: normalizedHandle,
- owner: response.owner,
- found: response.owner !== null,
- };
-
- // Format human-readable output
- let summary: string;
- if (response.owner) {
- summary = [`Handle: $${normalizedHandle}`, `Owner: ${response.owner}`].join("\n");
- } else {
- summary = [
- `Handle: $${normalizedHandle}`,
- `Status: Not found (handle does not exist or is not registered)`,
- ].join("\n");
- }
-
- return {
- content: [{ type: "text" as const, text: summary }],
- structuredContent: output,
- };
- } catch (error) {
- if (error instanceof VesprApiError) {
- return {
- content: [{ type: "text" as const, text: `Error: ${error.message}` }],
- isError: true,
- };
- }
- return {
- content: [
- { type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` },
- ],
- isError: true,
- };
- }
- },
+ resolveAdaHandleHandler,
);
}
diff --git a/src/transports/http.test.ts b/src/transports/http.test.ts
new file mode 100644
index 0000000..9957de3
--- /dev/null
+++ b/src/transports/http.test.ts
@@ -0,0 +1,449 @@
+import { jest, describe, it, expect, beforeEach } from "@jest/globals";
+import { FastifyInstance } from "fastify";
+import { z } from "zod";
+import { HttpToolRegistry, httpToolRegistry, registerHttpRoutes, HttpToolResult } from "./http.js";
+
+// Mock logger to avoid console output during tests
+jest.mock("../utils/logger.js", () => ({
+ logger: {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ },
+}));
+
+describe("HttpToolRegistry", () => {
+ let registry: HttpToolRegistry;
+
+ beforeEach(() => {
+ registry = new HttpToolRegistry();
+ });
+
+ describe("registerTool", () => {
+ it("should register a tool", () => {
+ const tool = {
+ name: "test_tool",
+ title: "Test Tool",
+ description: "A test tool",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "test" }] }) as HttpToolResult,
+ };
+
+ registry.registerTool(tool);
+
+ expect(registry.getTool("test_tool")).toBeDefined();
+ expect(registry.getTool("test_tool")?.name).toBe("test_tool");
+ });
+
+ it("should overwrite existing tool with same name", () => {
+ const tool1 = {
+ name: "test_tool",
+ title: "Test Tool 1",
+ description: "First tool",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "first" }] }) as HttpToolResult,
+ };
+ const tool2 = {
+ name: "test_tool",
+ title: "Test Tool 2",
+ description: "Second tool",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "second" }] }) as HttpToolResult,
+ };
+
+ registry.registerTool(tool1);
+ registry.registerTool(tool2);
+
+ expect(registry.getTool("test_tool")?.title).toBe("Test Tool 2");
+ });
+ });
+
+ describe("getTool", () => {
+ it("should return undefined for non-existent tool", () => {
+ expect(registry.getTool("non_existent")).toBeUndefined();
+ });
+
+ it("should return the correct tool", () => {
+ const tool = {
+ name: "my_tool",
+ title: "My Tool",
+ description: "Description",
+ inputSchema: z.object({ param: z.string() }),
+ handler: async () => ({ content: [{ type: "text", text: "result" }] }) as HttpToolResult,
+ };
+
+ registry.registerTool(tool);
+
+ const retrieved = registry.getTool("my_tool");
+ expect(retrieved).toBeDefined();
+ expect(retrieved?.name).toBe("my_tool");
+ expect(retrieved?.title).toBe("My Tool");
+ });
+ });
+
+ describe("getAllTools", () => {
+ it("should return empty array when no tools registered", () => {
+ expect(registry.getAllTools()).toEqual([]);
+ });
+
+ it("should return all registered tools", () => {
+ const tool1 = {
+ name: "tool_a",
+ title: "Tool A",
+ description: "A",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "a" }] }) as HttpToolResult,
+ };
+ const tool2 = {
+ name: "tool_b",
+ title: "Tool B",
+ description: "B",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "b" }] }) as HttpToolResult,
+ };
+
+ registry.registerTool(tool1);
+ registry.registerTool(tool2);
+
+ const tools = registry.getAllTools();
+ expect(tools).toHaveLength(2);
+ expect(tools.map((t) => t.name)).toContain("tool_a");
+ expect(tools.map((t) => t.name)).toContain("tool_b");
+ });
+ });
+
+ describe("listTools", () => {
+ it("should return tool metadata without handlers", () => {
+ const tool = {
+ name: "list_test",
+ title: "List Test",
+ description: "For listing",
+ inputSchema: z.object({ input: z.string() }),
+ handler: async () => ({ content: [{ type: "text", text: "result" }] }) as HttpToolResult,
+ };
+
+ registry.registerTool(tool);
+
+ const listed = registry.listTools();
+ expect(listed).toHaveLength(1);
+ expect(listed[0]).toHaveProperty("name", "list_test");
+ expect(listed[0]).toHaveProperty("title", "List Test");
+ expect(listed[0]).toHaveProperty("description", "For listing");
+ expect(listed[0]).toHaveProperty("inputSchema");
+ expect(listed[0]).not.toHaveProperty("handler");
+ });
+
+ it("should handle empty inputSchema", () => {
+ const tool = {
+ name: "no_input",
+ title: "No Input",
+ description: "Tool with no input",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "done" }] }) as HttpToolResult,
+ };
+
+ registry.registerTool(tool);
+
+ const listed = registry.listTools();
+ expect(listed[0].inputSchema).toEqual({});
+ });
+ });
+});
+
+describe("registerHttpRoutes", () => {
+ let mockServer: Partial;
+ let registeredRoutes: Map;
+
+ beforeEach(() => {
+ registeredRoutes = new Map();
+
+ // Clear global registry before each test
+ httpToolRegistry.clear();
+
+ mockServer = {
+ get: jest.fn((path: string, handler: Function) => {
+ registeredRoutes.set(`GET ${path}`, { method: "GET", handler });
+ }) as unknown as FastifyInstance["get"],
+ post: jest.fn((path: string, handler: Function) => {
+ registeredRoutes.set(`POST ${path}`, { method: "POST", handler });
+ }) as unknown as FastifyInstance["post"],
+ };
+ });
+
+ it("should register GET /mcp/tools route", async () => {
+ await registerHttpRoutes(mockServer as FastifyInstance);
+
+ expect(mockServer.get).toHaveBeenCalledWith("/mcp/tools", expect.any(Function));
+ });
+
+ it("should register POST /mcp/tools/:toolName route", async () => {
+ await registerHttpRoutes(mockServer as FastifyInstance);
+
+ expect(mockServer.post).toHaveBeenCalledWith("/mcp/tools/:toolName", expect.any(Function));
+ });
+
+ describe("GET /mcp/tools handler", () => {
+ it("should return list of registered tools", async () => {
+ // Register a test tool in the global registry
+ httpToolRegistry.registerTool({
+ name: "test_list",
+ title: "Test List",
+ description: "Test description",
+ inputSchema: {},
+ handler: async () => ({ content: [{ type: "text", text: "test" }] }) as HttpToolResult,
+ });
+
+ await registerHttpRoutes(mockServer as FastifyInstance);
+
+ const route = registeredRoutes.get("GET /mcp/tools");
+ expect(route).toBeDefined();
+
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler({}, mockReply);
+
+ expect(mockReply.send).toHaveBeenCalledWith({
+ tools: expect.arrayContaining([
+ expect.objectContaining({
+ name: "test_list",
+ title: "Test List",
+ description: "Test description",
+ }),
+ ]),
+ count: 1,
+ });
+ });
+
+ it("should return empty list when no tools registered", async () => {
+ await registerHttpRoutes(mockServer as FastifyInstance);
+
+ const route = registeredRoutes.get("GET /mcp/tools");
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler({}, mockReply);
+
+ expect(mockReply.send).toHaveBeenCalledWith({
+ tools: [],
+ count: 0,
+ });
+ });
+ });
+
+ describe("POST /mcp/tools/:toolName handler", () => {
+ beforeEach(async () => {
+ // Register a test tool
+ httpToolRegistry.registerTool({
+ name: "echo_tool",
+ title: "Echo Tool",
+ description: "Echoes the input",
+ inputSchema: z.object({
+ message: z.string(),
+ }),
+ handler: async (args: Record) => ({
+ content: [{ type: "text", text: `Echo: ${args.message}` }],
+ structuredContent: { echoed: args.message },
+ }),
+ });
+
+ await registerHttpRoutes(mockServer as FastifyInstance);
+ });
+
+ it("should execute a tool and return result", async () => {
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+ expect(route).toBeDefined();
+
+ const mockRequest = {
+ params: { toolName: "echo_tool" },
+ body: { arguments: { message: "hello" } },
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.send).toHaveBeenCalledWith({
+ result: { echoed: "hello" },
+ });
+ });
+
+ it("should return 404 for non-existent tool", async () => {
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+
+ const mockRequest = {
+ params: { toolName: "non_existent_tool" },
+ body: { arguments: {} },
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.status).toHaveBeenCalledWith(404);
+ expect(mockReply.send).toHaveBeenCalledWith({
+ error: {
+ code: "TOOL_NOT_FOUND",
+ message: "Tool 'non_existent_tool' not found",
+ availableTools: expect.any(Array),
+ },
+ });
+ });
+
+ it("should return 400 for invalid request body", async () => {
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+
+ const mockRequest = {
+ params: { toolName: "echo_tool" },
+ body: { arguments: "not-an-object" }, // Invalid - should be object
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.status).toHaveBeenCalledWith(400);
+ expect(mockReply.send).toHaveBeenCalledWith({
+ error: {
+ code: "INVALID_REQUEST",
+ message: "Invalid request body",
+ details: expect.any(Array),
+ },
+ });
+ });
+
+ it("should return 400 for invalid tool arguments", async () => {
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+
+ const mockRequest = {
+ params: { toolName: "echo_tool" },
+ body: { arguments: { message: 123 } }, // Invalid - should be string
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.status).toHaveBeenCalledWith(400);
+ expect(mockReply.send).toHaveBeenCalledWith({
+ error: {
+ code: "INVALID_ARGUMENTS",
+ message: "Invalid tool arguments",
+ details: expect.any(Array),
+ },
+ });
+ });
+
+ it("should handle tool execution errors", async () => {
+ // Register a tool that throws an error
+ httpToolRegistry.registerTool({
+ name: "error_tool",
+ title: "Error Tool",
+ description: "Throws an error",
+ inputSchema: {},
+ handler: async () => {
+ throw new Error("Something went wrong");
+ },
+ });
+
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+
+ const mockRequest = {
+ params: { toolName: "error_tool" },
+ body: { arguments: {} },
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.status).toHaveBeenCalledWith(500);
+ expect(mockReply.send).toHaveBeenCalledWith({
+ error: {
+ code: "EXECUTION_ERROR",
+ message: "Something went wrong",
+ },
+ });
+ });
+
+ it("should return content array when no structuredContent", async () => {
+ httpToolRegistry.registerTool({
+ name: "text_only_tool",
+ title: "Text Only",
+ description: "Returns only text content",
+ inputSchema: {},
+ handler: async () => ({
+ content: [{ type: "text", text: "Just text" }],
+ }),
+ });
+
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+
+ const mockRequest = {
+ params: { toolName: "text_only_tool" },
+ body: { arguments: {} },
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.send).toHaveBeenCalledWith({
+ result: [{ type: "text", text: "Just text" }],
+ });
+ });
+
+ it("should use default empty object for missing arguments", async () => {
+ httpToolRegistry.registerTool({
+ name: "no_args_tool",
+ title: "No Args",
+ description: "Needs no arguments",
+ inputSchema: {},
+ handler: async () => ({
+ content: [{ type: "text", text: "Done" }],
+ structuredContent: { success: true },
+ }),
+ });
+
+ const route = registeredRoutes.get("POST /mcp/tools/:toolName");
+
+ const mockRequest = {
+ params: { toolName: "no_args_tool" },
+ body: {}, // No arguments field
+ };
+ const mockReply = {
+ send: jest.fn(),
+ status: jest.fn().mockReturnThis(),
+ };
+
+ await route!.handler(mockRequest, mockReply);
+
+ expect(mockReply.send).toHaveBeenCalledWith({
+ result: { success: true },
+ });
+ });
+ });
+});
+
+describe("Global httpToolRegistry", () => {
+ it("should be a singleton instance", () => {
+ expect(httpToolRegistry).toBeInstanceOf(HttpToolRegistry);
+ });
+});
diff --git a/src/transports/http.ts b/src/transports/http.ts
new file mode 100644
index 0000000..77baf50
--- /dev/null
+++ b/src/transports/http.ts
@@ -0,0 +1,251 @@
+import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
+import { z, ZodObject, ZodRawShape } from "zod";
+import { logger } from "../utils/logger.js";
+import { apiKeyContext } from "../utils/apiKeyContext.js";
+
+/**
+ * Tool definition for HTTP transport
+ */
+export interface HttpToolDefinition {
+ name: string;
+ title: string;
+ description: string;
+ inputSchema: ZodObject | Record;
+ handler: (args: Record) => Promise;
+}
+
+/**
+ * Result from tool execution
+ */
+export interface HttpToolResult {
+ content: Array<{ type: string; text: string }>;
+ structuredContent?: Record;
+ isError?: boolean;
+}
+
+/**
+ * Tool registry for HTTP transport
+ * Maintains a separate registry of tools that can be called via HTTP
+ */
+export class HttpToolRegistry {
+ private tools: Map = new Map();
+
+ /**
+ * Register a tool with the HTTP registry
+ */
+ registerTool(tool: HttpToolDefinition): void {
+ this.tools.set(tool.name, tool);
+ logger.info("http_tool_registered", { tool: tool.name });
+ }
+
+ /**
+ * Get a tool by name
+ */
+ getTool(name: string): HttpToolDefinition | undefined {
+ return this.tools.get(name);
+ }
+
+ /**
+ * Get all registered tools
+ */
+ getAllTools(): HttpToolDefinition[] {
+ return Array.from(this.tools.values());
+ }
+
+ /**
+ * Remove all registered tools (useful for test teardown)
+ */
+ clear(): void {
+ this.tools.clear();
+ }
+
+ /**
+ * List tool metadata (without handlers)
+ */
+ listTools(): Array<{
+ name: string;
+ title: string;
+ description: string;
+ inputSchema: Record;
+ }> {
+ return this.getAllTools().map((tool) => ({
+ name: tool.name,
+ title: tool.title,
+ description: tool.description,
+ inputSchema: tool.inputSchema instanceof z.ZodObject ? tool.inputSchema.shape : {},
+ }));
+ }
+}
+
+// Global registry instance
+export const httpToolRegistry = new HttpToolRegistry();
+
+/**
+ * HTTP request body schema for tool execution
+ */
+const executeToolBodySchema = z.object({
+ arguments: z.record(z.string(), z.unknown()).optional().default({}),
+});
+
+/**
+ * Request params for tool execution
+ */
+interface ExecuteToolParams {
+ toolName: string;
+}
+
+/**
+ * Register MCP HTTP routes with Fastify
+ */
+export async function registerHttpRoutes(server: FastifyInstance): Promise {
+ /**
+ * GET /mcp/tools - List all available tools
+ */
+ server.get("/mcp/tools", async (_request: FastifyRequest, reply: FastifyReply) => {
+ const startTime = Date.now();
+
+ try {
+ const tools = httpToolRegistry.listTools();
+
+ logger.info("http_list_tools", {
+ count: tools.length,
+ durationMs: Date.now() - startTime,
+ });
+
+ return reply.send({
+ tools,
+ count: tools.length,
+ });
+ } catch (error) {
+ logger.error("http_list_tools_error", {
+ error: error instanceof Error ? error.message : String(error),
+ durationMs: Date.now() - startTime,
+ });
+
+ return reply.status(500).send({
+ error: {
+ code: "INTERNAL_ERROR",
+ message: "Failed to list tools",
+ },
+ });
+ }
+ });
+
+ /**
+ * POST /mcp/tools/:toolName - Execute a tool
+ */
+ server.post<{
+ Params: ExecuteToolParams;
+ Body: z.infer;
+ }>("/mcp/tools/:toolName", async (request: FastifyRequest, reply: FastifyReply) => {
+ const startTime = Date.now();
+ const { toolName } = request.params as ExecuteToolParams;
+
+ try {
+ // Parse and validate request body
+ const bodyResult = executeToolBodySchema.safeParse(request.body);
+ if (!bodyResult.success) {
+ logger.warn("http_tool_invalid_body", {
+ tool: toolName,
+ issues: bodyResult.error.issues,
+ durationMs: Date.now() - startTime,
+ });
+
+ return reply.status(400).send({
+ error: {
+ code: "INVALID_REQUEST",
+ message: "Invalid request body",
+ details: bodyResult.error.issues,
+ },
+ });
+ }
+
+ // Find the tool
+ const tool = httpToolRegistry.getTool(toolName);
+ if (!tool) {
+ logger.warn("http_tool_not_found", {
+ tool: toolName,
+ durationMs: Date.now() - startTime,
+ });
+
+ return reply.status(404).send({
+ error: {
+ code: "TOOL_NOT_FOUND",
+ message: `Tool '${toolName}' not found`,
+ availableTools: httpToolRegistry.listTools().map((t) => t.name),
+ },
+ });
+ }
+
+ // Validate tool arguments if schema is defined
+ const args = bodyResult.data.arguments;
+ if (tool.inputSchema instanceof z.ZodObject) {
+ const argsResult = tool.inputSchema.safeParse(args);
+ if (!argsResult.success) {
+ logger.warn("http_tool_invalid_args", {
+ tool: toolName,
+ issues: argsResult.error.issues,
+ durationMs: Date.now() - startTime,
+ });
+
+ return reply.status(400).send({
+ error: {
+ code: "INVALID_ARGUMENTS",
+ message: "Invalid tool arguments",
+ details: argsResult.error.issues,
+ },
+ });
+ }
+ }
+
+ logger.info("http_tool_executing", {
+ tool: toolName,
+ hasArgs: Object.keys(args).length > 0,
+ });
+
+ const apiKey = (request.headers?.["x-api-key"] as string | undefined) || undefined;
+
+ const result = await apiKeyContext.run(apiKey, () => tool.handler(args));
+
+ logger.info("http_tool_executed", {
+ tool: toolName,
+ durationMs: Date.now() - startTime,
+ hasStructuredContent: !!result.structuredContent,
+ });
+
+ if (result.isError) {
+ return reply.status(502).send({
+ error: {
+ code: "TOOL_ERROR",
+ message: result.content.map((c) => c.text).join("\n"),
+ },
+ });
+ }
+
+ return reply.send({
+ result: result.structuredContent ?? result.content,
+ });
+ } catch (error) {
+ logger.error("http_tool_execution_error", {
+ tool: toolName,
+ error: error instanceof Error ? error.message : String(error),
+ durationMs: Date.now() - startTime,
+ });
+
+ // Check if it's a known API error type
+ const errorMessage = error instanceof Error ? error.message : "Tool execution failed";
+ const statusCode = errorMessage.includes("not found") ? 404 : 500;
+
+ return reply.status(statusCode).send({
+ error: {
+ code: statusCode === 404 ? "NOT_FOUND" : "EXECUTION_ERROR",
+ message: errorMessage,
+ },
+ });
+ }
+ });
+
+ logger.info("http_routes_registered", {
+ routes: ["/mcp/tools", "/mcp/tools/:toolName"],
+ });
+}
diff --git a/src/transports/streamableHttp.ts b/src/transports/streamableHttp.ts
new file mode 100644
index 0000000..a7f49c1
--- /dev/null
+++ b/src/transports/streamableHttp.ts
@@ -0,0 +1,178 @@
+import { randomUUID } from "crypto";
+import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import { registerTools } from "../tools/index.js";
+import { apiKeyContext } from "../utils/apiKeyContext.js";
+import { logger } from "../utils/logger.js";
+
+const VERSION = "0.1.0";
+const SESSION_CLEANUP_INTERVAL_MS = 60_000;
+
+interface Session {
+ transport: StreamableHTTPServerTransport;
+ apiKey?: string;
+ lastActiveAt: number;
+}
+
+const sessions = new Map();
+
+function getOrCreateSession(
+ sessionId: string | undefined,
+ apiKey: string | undefined,
+): { session: Session; isNew: boolean } {
+ if (sessionId && sessions.has(sessionId)) {
+ return { session: sessions.get(sessionId)!, isNew: false };
+ }
+
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (sid: string) => {
+ sessions.set(sid, { transport, apiKey, lastActiveAt: Date.now() });
+ logger.info("mcp_session_created", { sessionId: sid });
+ },
+ });
+
+ transport.onclose = () => {
+ const sid = transport.sessionId;
+ if (sid) {
+ sessions.delete(sid);
+ logger.info("mcp_session_closed", { sessionId: sid });
+ }
+ };
+
+ const mcpServer = new McpServer({
+ name: "@vespr/cardano-mcp",
+ version: VERSION,
+ });
+
+ registerTools(mcpServer);
+
+ mcpServer.connect(transport).catch((error) => {
+ logger.error("mcp_server_connect_error", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ });
+
+ const session: Session = { transport, apiKey, lastActiveAt: Date.now() };
+
+ return { session, isNew: true };
+}
+
+export async function registerStreamableHttpRoutes(server: FastifyInstance, sessionTtlMs: number): Promise {
+ const cleanupInterval = setInterval(() => {
+ const now = Date.now();
+ for (const [sid, session] of sessions.entries()) {
+ if (now - session.lastActiveAt > sessionTtlMs) {
+ session.transport.close().catch(() => {});
+ sessions.delete(sid);
+ logger.info("mcp_session_expired", { sessionId: sid });
+ }
+ }
+ }, SESSION_CLEANUP_INTERVAL_MS);
+
+ server.addHook("onClose", async () => {
+ clearInterval(cleanupInterval);
+ });
+
+ server.post("/mcp", async (request: FastifyRequest, reply: FastifyReply) => {
+ const sessionId = (request.headers["mcp-session-id"] as string) || undefined;
+ const apiKey = (request.headers["x-api-key"] as string) || undefined;
+
+ try {
+ if (sessionId && !sessions.has(sessionId)) {
+ return reply.status(404).send({
+ error: "Invalid or expired session ID",
+ });
+ }
+
+ const { session } = getOrCreateSession(sessionId, apiKey);
+ session.lastActiveAt = Date.now();
+
+ reply.hijack();
+
+ await apiKeyContext.run(session.apiKey, async () => {
+ await session.transport.handleRequest(request.raw, reply.raw, request.body);
+ });
+ } catch (error) {
+ logger.error("mcp_post_error", {
+ error: error instanceof Error ? error.message : String(error),
+ sessionId,
+ });
+
+ if (!reply.sent) {
+ return reply.status(500).send({
+ error: "Internal server error",
+ });
+ }
+ }
+ });
+
+ server.get("/mcp", async (request: FastifyRequest, reply: FastifyReply) => {
+ const sessionId = (request.headers["mcp-session-id"] as string) || undefined;
+
+ if (!sessionId || !sessions.has(sessionId)) {
+ return reply.status(404).send({
+ error: "Invalid or expired session ID",
+ });
+ }
+
+ const session = sessions.get(sessionId)!;
+ session.lastActiveAt = Date.now();
+
+ try {
+ reply.hijack();
+
+ await apiKeyContext.run(session.apiKey, async () => {
+ await session.transport.handleRequest(request.raw, reply.raw);
+ });
+ } catch (error) {
+ logger.error("mcp_get_error", {
+ error: error instanceof Error ? error.message : String(error),
+ sessionId,
+ });
+
+ if (!reply.sent) {
+ return reply.status(500).send({
+ error: "Internal server error",
+ });
+ }
+ }
+ });
+
+ server.delete("/mcp", async (request: FastifyRequest, reply: FastifyReply) => {
+ const sessionId = (request.headers["mcp-session-id"] as string) || undefined;
+
+ if (!sessionId || !sessions.has(sessionId)) {
+ return reply.status(404).send({
+ error: "Invalid or expired session ID",
+ });
+ }
+
+ const session = sessions.get(sessionId)!;
+
+ try {
+ reply.hijack();
+
+ await session.transport.handleRequest(request.raw, reply.raw);
+
+ sessions.delete(sessionId);
+ logger.info("mcp_session_deleted", { sessionId });
+ } catch (error) {
+ logger.error("mcp_delete_error", {
+ error: error instanceof Error ? error.message : String(error),
+ sessionId,
+ });
+
+ if (!reply.sent) {
+ return reply.status(500).send({
+ error: "Internal server error",
+ });
+ }
+ }
+ });
+
+ logger.info("streamable_http_routes_registered", {
+ routes: ["POST /mcp", "GET /mcp", "DELETE /mcp"],
+ });
+}
diff --git a/src/utils/apiKeyContext.ts b/src/utils/apiKeyContext.ts
new file mode 100644
index 0000000..707fa99
--- /dev/null
+++ b/src/utils/apiKeyContext.ts
@@ -0,0 +1,7 @@
+import { AsyncLocalStorage } from "async_hooks";
+
+export const apiKeyContext = new AsyncLocalStorage();
+
+export function getCurrentApiKey(): string | undefined {
+ return apiKeyContext.getStore();
+}
diff --git a/tsconfig.docker.json b/tsconfig.docker.json
new file mode 100644
index 0000000..af8cdbb
--- /dev/null
+++ b/tsconfig.docker.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
+}