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"] +}