From 36db8108c1784609b6a8dcf329c097ba719e4327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:55:48 -0400 Subject: [PATCH 1/7] feat: align with MCP 2026-07-28 --- README.md | 82 ++++++----- bin/cli.js | 4 +- bun.lock | 195 +++++++----------------- docs/mcp-2026-07-28-upgrade.md | 82 +++++++++++ package.json | 14 +- src/core/prompts.ts | 42 +++--- src/core/resources.ts | 12 +- src/core/tools.ts | 98 ++++++------- src/index.ts | 10 +- src/server/http-server.ts | 261 +++++++++++---------------------- src/server/protocol.ts | 12 ++ src/server/server.ts | 73 ++++----- src/server/stdio-server.ts | 20 +++ test/mcp-2026.test.ts | 176 ++++++++++++++++++++++ 14 files changed, 598 insertions(+), 483 deletions(-) create mode 100644 docs/mcp-2026-07-28-upgrade.md create mode 100644 src/server/protocol.ts create mode 100644 src/server/stdio-server.ts create mode 100644 test/mcp-2026.test.ts diff --git a/README.md b/README.md index cdbac53..8b9929d 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg) ![EVM Networks](https://img.shields.io/badge/Networks-60+-green) ![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-3178C6) -![MCP](https://img.shields.io/badge/MCP-1.22.0+-blue) +![MCP](https://img.shields.io/badge/MCP-2026--07--28-blue) ![Viem](https://img.shields.io/badge/Viem-2.39.3+-green) -A comprehensive Model Context Protocol (MCP) server that provides blockchain services across 60+ EVM-compatible networks. This server enables AI agents to interact with Ethereum, Optimism, Arbitrum, Base, Polygon, and many other EVM chains with a unified interface through 22 tools and 10 AI-guided prompts. +A comprehensive Model Context Protocol (MCP) server that provides blockchain services across 60+ EVM-compatible networks. This server enables AI agents to interact with Ethereum, Optimism, Arbitrum, Base, Polygon, and many other EVM chains with a unified interface through 25 tools and 10 AI-guided prompts. ## 📋 Contents @@ -257,16 +257,17 @@ Get your free API key from: ### Server Configuration -The server uses the following default configuration: +The HTTP server uses the following default configuration: - **Default Chain ID**: 1 (Ethereum Mainnet) -- **Server Port**: 3001 -- **Server Host**: 0.0.0.0 (accessible from any network interface) +- **Server Port**: `3001` (`MCP_PORT`) +- **Server Host**: `127.0.0.1` (`MCP_HOST`) +- **Allowed Host headers**: Localhost hostnames (`MCP_ALLOWED_HOSTS`, comma-separated) +- **Allowed Origin hostnames**: Localhost hostnames (`MCP_ALLOWED_ORIGINS`, comma-separated) -These values are hardcoded in the application. If you need to modify them, you can edit the following files: +When binding to a non-local interface, explicitly configure the public hostnames accepted by `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`. Values may be hostnames or origin URLs; validation is port-agnostic. -- For chain configuration: `src/core/chains.ts` -- For server configuration: `src/server/http-server.ts` +Chain defaults and RPC endpoints are configured in `src/core/chains.ts`. ## 🚀 Usage @@ -294,7 +295,7 @@ bun start bun dev ``` -Or start the HTTP server with SSE for web applications: +Or start the stateless Streamable HTTP server for web applications: ```bash # Start the HTTP server @@ -336,10 +337,6 @@ For a more portable configuration that you can share with your team or use acros "evm-mcp-server": { "command": "npx", "args": ["-y", "@mcpdotdirect/evm-mcp-server"] - }, - "evm-mcp-http": { - "command": "npx", - "args": ["-y", "@mcpdotdirect/evm-mcp-server", "--http"] } } } @@ -351,32 +348,45 @@ Place this file in your project's `.cursor` directory (create it if it doesn't e 2. Version control your MCP setup 3. Use different server configurations for different projects -### Example: HTTP Mode with SSE +### Example: Streamable HTTP Mode -If you're developing a web application and want to connect to the HTTP server with Server-Sent Events (SSE), you can use this configuration: +The HTTP entrypoint uses MCP `2026-07-28` stateless Streamable HTTP on `POST /mcp`. It does not mint `Mcp-Session-Id` values, and `GET /mcp` or `DELETE /mcp` return `405 Method Not Allowed`. HTTP is modern-only; stdio additionally serves legacy MCP `2025-11-25` clients through the SDK's version negotiation. -```json -{ - "mcpServers": { - "evm-mcp-sse": { - "url": "http://localhost:3001/sse" - } - } -} -``` +Modern HTTP clients must send: -This connects directly to the HTTP server's SSE endpoint, which is useful for: +- `Accept: application/json, text/event-stream` +- `MCP-Protocol-Version: 2026-07-28` +- `Mcp-Method: ` +- `Mcp-Name: ` for `tools/call`, `resources/read`, and `prompts/get` +- `params._meta.io.modelcontextprotocol/protocolVersion` +- `params._meta.io.modelcontextprotocol/clientCapabilities` -- Web applications that need to connect to the MCP server from the browser -- Environments where running local commands isn't ideal -- Sharing a single MCP server instance among multiple users or applications +Clients should also send `params._meta.io.modelcontextprotocol/clientInfo`. The final specification makes client identity optional, so the server accepts a request when that field is absent. -To use this configuration: +Example discovery request: -1. Create a `.cursor` directory in your project root if it doesn't exist -2. Save the above JSON as `mcp.json` in the `.cursor` directory -3. Restart Cursor or open your project -4. Cursor will detect the configuration and offer to enable the server(s) +```bash +curl -X POST http://127.0.0.1:3001/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + --data '{ + "jsonrpc": "2.0", + "id": "discover-1", + "method": "server/discover", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "example-client", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }' +``` ### Example: Using the MCP Server in Cursor @@ -632,7 +642,9 @@ mcp-evm-server/ ├── src/ │ ├── index.ts # Main stdio server entry point │ ├── server/ # Server-related files -│ │ ├── http-server.ts # HTTP server with SSE +│ │ ├── http-server.ts # Stateless Streamable HTTP server +│ │ ├── protocol.ts # Shared protocol metadata +│ │ ├── stdio-server.ts # SDK-native dual-era stdio entry │ │ └── server.ts # General server setup │ ├── core/ │ │ ├── chains.ts # Chain definitions and utilities @@ -662,7 +674,7 @@ To modify or extend the server: 2. Register new tools in `src/core/tools.ts` 3. Register new resources in `src/core/resources.ts` 4. Add new network support in `src/core/chains.ts` -5. To change server configuration, edit the hardcoded values in `src/server/http-server.ts` +5. Configure the HTTP listener with `MCP_PORT`, `MCP_HOST`, `MCP_ALLOWED_HOSTS`, and `MCP_ALLOWED_ORIGINS` ## 📄 License diff --git a/bin/cli.js b/bin/cli.js index 5770319..97f5b91 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -13,7 +13,7 @@ const require = createRequire(import.meta.url); const args = process.argv.slice(2); const httpMode = args.includes('--http') || args.includes('-h'); -console.log(`Starting EVM MCP Server in ${httpMode ? 'HTTP' : 'stdio'} mode...`); +console.error(`Starting EVM MCP Server in ${httpMode ? 'HTTP' : 'stdio'} mode...`); // Determine which file to execute const scriptPath = resolve(__dirname, '../build', httpMode ? 'http-server.js' : 'index.js'); @@ -49,4 +49,4 @@ try { console.error('Please try reinstalling the package or contact the maintainers.'); console.error(error); process.exit(1); -} \ No newline at end of file +} diff --git a/bun.lock b/bun.lock index 1179d0d..10ec6d7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,11 @@ "": { "name": "mcp-evm-server", "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", - "express": "^4.21.2", - "viem": "^2.39.3", - "zod": "^3.24.3", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "express": "^5.2.1", + "viem": "^2.55.10", + "zod": "^4.2.0", }, "devDependencies": { "@types/bun": "latest", @@ -30,9 +31,15 @@ "@conventional-changelog/git-client": ["@conventional-changelog/git-client@1.0.1", "", { "dependencies": { "@types/semver": "^7.5.5", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw=="], + "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], + "@hutson/parse-repository-url": ["@hutson/parse-repository-url@5.0.0", "", {}, "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.22.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + + "@modelcontextprotocol/node": ["@modelcontextprotocol/node@2.0.0", "", { "dependencies": { "@hono/node-server": "^1.19.9" }, "peerDependencies": { "@modelcontextprotocol/server": "^2.0.0", "hono": "^4.11.4" }, "optionalPeers": ["hono"] }, "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg=="], + + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -76,21 +83,15 @@ "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], - "abitype": ["abitype@1.1.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], - "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], - "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], @@ -102,7 +103,7 @@ "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], - "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -140,18 +141,12 @@ "cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], - "cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], - - "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -172,25 +167,15 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "eventsource": ["eventsource@3.0.5", "", { "dependencies": { "eventsource-parser": "^3.0.0" } }, "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw=="], - - "eventsource-parser": ["eventsource-parser@3.0.0", "", {}, "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA=="], - - "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], - - "express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -210,11 +195,13 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="], + "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], @@ -226,99 +213,77 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], - "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], - - "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="], - - "mime-types": ["mime-types@3.0.0", "", { "dependencies": { "mime-db": "^1.53.0" } }, "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "ox": ["ox@0.9.6", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg=="], + "ox": ["ox@0.14.33", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "router": ["router@2.1.0", "", { "dependencies": { "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -344,7 +309,7 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], @@ -356,92 +321,34 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "viem": ["viem@2.39.3", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.1.0", "isows": "1.0.7", "ox": "0.9.6", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-s11rPQRvUEdc5qHK3xT4fIk4qvgPAaLwaTFq+EbFlcJJD+Xn3R4mc9H6B6fquEiHl/mdsdbG/uKCnYpoNtHNHw=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "viem": ["viem@2.55.10", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.33", "ws": "8.21.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="], - - "@modelcontextprotocol/sdk/express": ["express@5.0.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.0.1", "content-disposition": "^1.0.0", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "^1.2.1", "debug": "4.3.6", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "^2.0.0", "fresh": "2.0.0", "http-errors": "2.0.0", "merge-descriptors": "^2.0.0", "methods": "~1.1.2", "mime-types": "^3.0.0", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "~1.3.3", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "router": "^2.0.0", "safe-buffer": "5.2.1", "send": "^1.1.0", "serve-static": "^2.1.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "^2.0.0", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ=="], - - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - - "body-parser/raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], - - "router/path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], - - "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - - "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.1.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.5.2", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ=="], - - "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], - - "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "@modelcontextprotocol/sdk/express/debug": ["debug@4.3.6", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg=="], - - "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], - - "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "@modelcontextprotocol/sdk/express/send": ["send@1.1.0", "", { "dependencies": { "debug": "^4.3.5", "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^0.5.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA=="], - - "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.1.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.0.0" } }, "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA=="], - - "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.0", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw=="], - - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "@modelcontextprotocol/sdk/express/body-parser/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], - - "@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.5.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag=="], - - "@modelcontextprotocol/sdk/express/body-parser/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "@modelcontextprotocol/sdk/express/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@modelcontextprotocol/sdk/express/finalhandler/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@modelcontextprotocol/sdk/express/send/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "@modelcontextprotocol/sdk/express/send/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "@modelcontextprotocol/sdk/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "send/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "send/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "@modelcontextprotocol/sdk/express/body-parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@modelcontextprotocol/sdk/express/finalhandler/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "body-parser/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "@modelcontextprotocol/sdk/express/send/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], } } diff --git a/docs/mcp-2026-07-28-upgrade.md b/docs/mcp-2026-07-28-upgrade.md new file mode 100644 index 0000000..2b9db78 --- /dev/null +++ b/docs/mcp-2026-07-28-upgrade.md @@ -0,0 +1,82 @@ +# MCP 2026-07-28 Upgrade + +Branch: `ccbbccbb/mcp-2026-07-28-upgrade` + +This repository targets the final MCP `2026-07-28` specification through the released TypeScript SDK v2 packages. The release-candidate compatibility adapter has been removed. + +## Authoritative Sources + +- Specification: https://modelcontextprotocol.io/specification/2026-07-28 +- Changelog: https://modelcontextprotocol.io/specification/2026-07-28/changelog +- SDK v1-to-v2 migration: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md +- SDK `2026-07-28` support: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/support-2026-07-28.md + +## Final Alignment + +- Replaced `@modelcontextprotocol/sdk` v1 with: + - `@modelcontextprotocol/server` v2 + - `@modelcontextprotocol/node` v2 + - Zod v4.2 or newer +- Replaced the local JSON-RPC adapter with SDK-native `createMcpHandler` and `serveStdio`. +- Kept stdio dual-era: + - MCP `2026-07-28` through `server/discover` + - MCP `2025-11-25` through the legacy `initialize` handshake +- Kept HTTP modern-only and stateless: + - one `POST /mcp` request per exchange + - no `Mcp-Session-Id` + - request-scoped JSON or SSE responses +- Added SDK-provided validation for the final standard headers: + - `MCP-Protocol-Version` + - `Mcp-Method` + - `Mcp-Name` +- Added Host and Origin validation before the HTTP MCP handler. +- Configured all static list capabilities with `listChanged: false`; resource subscriptions remain disabled. +- Configured one-hour public cache hints for discovery, list operations, resource templates, and resource reads. +- Migrated MCP-bound schemas to Zod 4 object schemas so the SDK emits JSON Schema 2020-12. +- Kept process diagnostics on `stderr`, including the npm CLI startup line, so stdio `stdout` contains protocol messages only. + +## Final-Spec Differences from the RC + +The repository no longer carries RC behavior for the following changes: + +- `HeaderMismatch` is `-32020`, not `-32001`. +- `UnsupportedProtocolVersion` is `-32022`, not `-32004`. +- `clientInfo` is optional request metadata. +- Server identity is emitted as `_meta.io.modelcontextprotocol/serverInfo` on every modern result, not as `serverInfo` in the `server/discover` result body. +- Request-scoped SSE and `subscriptions/listen` are owned by the official SDK instead of a repository-local transport implementation. + +## Compatibility Decisions + +- HTTP remains strict `2026-07-28` to preserve the RC branch's modern-only deployment decision. +- Stdio serves both modern and legacy clients because local hosts commonly require gradual negotiation. +- The static tool, prompt, and resource surfaces do not advertise change notifications. +- `wait_for_transaction` remains a normal synchronous tool. The Tasks extension is not advertised. +- The server does not implement MCP OAuth. It uses environment-configured RPC and wallet credentials. + +## Verification + +The automated MCP integration tests cover: + +- final `server/discover` shape and server identity metadata +- optional `clientInfo` +- deterministic tool listing and closed no-argument schemas +- cache hints on discovery, list, and resource results +- resource reads and a read-only tool call +- final `HeaderMismatch` and `UnsupportedProtocolVersion` error codes + +Release checks: + +```bash +bunx tsc --noEmit +bun run test:mcp +bun run build +bun run build:http +``` + +## Follow-up Work + +These are enhancements, not compliance blockers: + +- Add `outputSchema` and `structuredContent` to high-value read tools while preserving text content for older clients. +- Adopt the Tasks extension only if transaction confirmation regularly exceeds practical request timeouts. +- Add MCP OAuth before exposing wallet-backed write tools through a shared remote deployment. diff --git a/package.json b/package.json index 1919e8d..5067669 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "module": "src/index.ts", "type": "module", "version": "2.0.4", - "description": "MCP server for interacting with EVM-compatible blockchains - supports 22 tools and 10 prompts across 60+ networks", + "description": "MCP server for interacting with EVM-compatible blockchains - supports 25 tools and 10 prompts across 60+ networks", "bin": { "evm-mcp-server": "./bin/cli.js" }, @@ -28,7 +28,8 @@ "release": "npm publish", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md", - "inspect": "npx @modelcontextprotocol/inspector node build/index.js" + "inspect": "npx @modelcontextprotocol/inspector node build/index.js", + "test:mcp": "bun test test/mcp-2026.test.ts" }, "devDependencies": { "@types/bun": "latest", @@ -40,10 +41,11 @@ "typescript": "^5.8.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", - "express": "^4.21.2", - "viem": "^2.39.3", - "zod": "^3.24.3" + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "express": "^5.2.1", + "viem": "^2.55.10", + "zod": "^4.2.0" }, "keywords": [ "mcp", diff --git a/src/core/prompts.ts b/src/core/prompts.ts index ada7df8..247c59f 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; /** @@ -24,13 +24,13 @@ export function registerEVMPrompts(server: McpServer) { "prepare_transfer", { description: "Safely prepare and execute a token transfer with validation checks", - argsSchema: { + argsSchema: z.object({ tokenType: z.enum(["native", "erc20"]).describe("Token type: 'native' for ETH/MATIC or 'erc20' for contract tokens"), recipient: z.string().describe("Recipient address or ENS name"), amount: z.string().describe("Amount to transfer (in ether for native, token units for ERC20)"), network: z.string().optional().describe("Network name (default: ethereum)"), tokenAddress: z.string().optional().describe("Token contract address (required for ERC20)") - } + }) }, ({ tokenType, recipient, amount, network = "ethereum", tokenAddress }) => ({ messages: [{ @@ -89,10 +89,10 @@ ${tokenType === "native" ? ` "diagnose_transaction", { description: "Analyze transaction status, failures, and provide debugging insights", - argsSchema: { + argsSchema: z.object({ txHash: z.string().describe("Transaction hash to diagnose (0x...)"), network: z.string().optional().describe("Network name (default: ethereum)") - } + }) }, ({ txHash, network = "ethereum" }) => ({ messages: [{ @@ -173,11 +173,11 @@ Provide structured diagnosis: "analyze_wallet", { description: "Get comprehensive overview of wallet assets, balances, and activity", - argsSchema: { + argsSchema: z.object({ address: z.string().describe("Wallet address or ENS name to analyze"), network: z.string().optional().describe("Network name (default: ethereum)"), tokens: z.string().optional().describe("Comma-separated token addresses to check") - } + }) }, ({ address, network = "ethereum", tokens }) => { const tokenList = tokens ? tokens.split(',').map(t => t.trim()) : []; @@ -250,11 +250,11 @@ Provide analysis with clear sections: "audit_approvals", { description: "Review token approvals and identify security risks from unlimited spend", - argsSchema: { + argsSchema: z.object({ address: z.string().optional().describe("Wallet to audit (default: configured wallet)"), tokenAddress: z.string().describe("Token contract address to check approvals for"), network: z.string().optional().describe("Network name (default: ethereum)") - } + }) }, ({ address, tokenAddress, network = "ethereum" }) => ({ messages: [{ @@ -344,11 +344,11 @@ For each spender: "fetch_and_analyze_abi", { description: "Fetch contract ABI from block explorer and provide comprehensive analysis", - argsSchema: { + argsSchema: z.object({ contractAddress: z.string().describe("Contract address to analyze"), network: z.string().optional().describe("Network name (default: ethereum)"), findFunction: z.string().optional().describe("Specific function to analyze (e.g., 'swap', 'mint')") - } + }) }, ({ contractAddress, network = "ethereum", findFunction }) => ({ messages: [{ @@ -457,11 +457,11 @@ Look for: "explore_contract", { description: "Analyze contract functions and state without requiring full ABI", - argsSchema: { + argsSchema: z.object({ contractAddress: z.string().describe("Contract address to explore"), network: z.string().optional().describe("Network name (default: ethereum)"), fetchAbi: z.string().optional().describe("Set to 'true' to auto-fetch ABI (requires ETHERSCAN_API_KEY)") - } + }) }, ({ contractAddress, network = "ethereum", fetchAbi }) => ({ messages: [{ @@ -571,13 +571,13 @@ For each contract type: "interact_with_contract", { description: "Safely execute write operations on a smart contract with validation and confirmation", - argsSchema: { + argsSchema: z.object({ contractAddress: z.string().describe("Contract address to interact with"), functionName: z.string().describe("Function to call (e.g., 'mint', 'swap', 'stake')"), args: z.string().optional().describe("Comma-separated function arguments"), value: z.string().optional().describe("ETH value to send (for payable functions)"), network: z.string().optional().describe("Network name (default: ethereum)") - } + }) }, ({ contractAddress, functionName, args, value, network = "ethereum" }) => { const argsList = args ? args.split(',').map(a => a.trim()) : []; @@ -744,9 +744,9 @@ For a token mint operation: "explain_evm_concept", { description: "Explain EVM and blockchain concepts with examples", - argsSchema: { + argsSchema: z.object({ concept: z.string().describe("Concept to explain (gas, nonce, smart contracts, MEV, etc)") - } + }) }, ({ concept }) => ({ messages: [{ @@ -822,9 +822,9 @@ Provide explanation in sections: "compare_networks", { description: "Compare multiple EVM networks on key metrics and characteristics", - argsSchema: { + argsSchema: z.object({ networks: z.string().describe("Comma-separated network names (ethereum,polygon,arbitrum)") - } + }) }, ({ networks }) => { const networkList = networks.split(',').map(n => n.trim()); @@ -948,9 +948,9 @@ Help user choose based on: "check_network_status", { description: "Check current network health and conditions", - argsSchema: { + argsSchema: z.object({ network: z.string().optional().describe("Network name (default: ethereum)") - } + }) }, ({ network = "ethereum" }) => ({ messages: [{ diff --git a/src/core/resources.ts b/src/core/resources.ts index 0e97523..78fe59c 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { getSupportedNetworks } from "./chains.js"; /** @@ -17,13 +17,21 @@ export function registerEVMResources(server: McpServer) { server.registerResource( "supported_networks", "evm://networks", - { description: "Get list of all supported EVM networks and their configuration", mimeType: "application/json" }, + { + description: "Get list of all supported EVM networks and their configuration", + mimeType: "application/json", + cacheHint: { + ttlMs: 60 * 60 * 1000, + cacheScope: "public" + } + }, async (uri) => { try { const networks = getSupportedNetworks(); return { contents: [{ uri: uri.href, + mimeType: "application/json", text: JSON.stringify({ supportedNetworks: networks }, null, 2) }] }; diff --git a/src/core/tools.ts b/src/core/tools.ts index f10ed2d..b8f5f17 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import { getSupportedNetworks, getRpcUrl } from "./chains.js"; import * as services from "./services/index.js"; @@ -34,7 +34,7 @@ export function registerEVMTools(server: McpServer) { "get_wallet_address", { description: "Get the address of the configured wallet. Use this to verify which wallet is active.", - inputSchema: {}, + inputSchema: z.strictObject({}), annotations: { title: "Get Wallet Address", readOnlyHint: true, @@ -72,9 +72,9 @@ export function registerEVMTools(server: McpServer) { "get_chain_info", { description: "Get information about an EVM network: chain ID, current block number, and RPC endpoint", - inputSchema: { + inputSchema: z.object({ network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base') or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Chain Info", readOnlyHint: true, @@ -108,7 +108,7 @@ export function registerEVMTools(server: McpServer) { "get_supported_networks", { description: "Get a list of all supported EVM networks", - inputSchema: {}, + inputSchema: z.strictObject({}), annotations: { title: "Get Supported Networks", readOnlyHint: true, @@ -136,9 +136,9 @@ export function registerEVMTools(server: McpServer) { "get_gas_price", { description: "Get current gas prices (base fee, standard, and fast) for a network", - inputSchema: { + inputSchema: z.object({ network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Gas Prices", readOnlyHint: true, @@ -183,10 +183,10 @@ export function registerEVMTools(server: McpServer) { "resolve_ens_name", { description: "Resolve an ENS name to an Ethereum address", - inputSchema: { + inputSchema: z.object({ ensName: z.string().describe("ENS name to resolve (e.g., 'vitalik.eth')"), network: z.string().optional().describe("Network name or chain ID. ENS resolution works best on Ethereum mainnet. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Resolve ENS Name", readOnlyHint: true, @@ -230,10 +230,10 @@ export function registerEVMTools(server: McpServer) { "lookup_ens_address", { description: "Lookup the ENS name for an Ethereum address (reverse resolution)", - inputSchema: { + inputSchema: z.object({ address: z.string().describe("Ethereum address to lookup"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Lookup ENS Address", readOnlyHint: true, @@ -275,10 +275,10 @@ export function registerEVMTools(server: McpServer) { "get_block", { description: "Get block details by block number or hash", - inputSchema: { + inputSchema: z.object({ blockIdentifier: z.string().describe("Block number (as string) or block hash"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Block", readOnlyHint: true, @@ -311,9 +311,9 @@ export function registerEVMTools(server: McpServer) { "get_latest_block", { description: "Get the latest block from the network", - inputSchema: { + inputSchema: z.object({ network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Latest Block", readOnlyHint: true, @@ -343,10 +343,10 @@ export function registerEVMTools(server: McpServer) { "get_balance", { description: "Get the native token balance (ETH, MATIC, etc.) for an address", - inputSchema: { + inputSchema: z.object({ address: z.string().describe("The wallet address or ENS name"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Native Token Balance", readOnlyHint: true, @@ -381,11 +381,11 @@ export function registerEVMTools(server: McpServer) { "get_token_balance", { description: "Get the ERC20 token balance for an address", - inputSchema: { + inputSchema: z.object({ address: z.string().describe("The wallet address or ENS name"), tokenAddress: z.string().describe("The ERC20 token contract address"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get ERC20 Token Balance", readOnlyHint: true, @@ -426,12 +426,12 @@ export function registerEVMTools(server: McpServer) { "get_allowance", { description: "Check the allowance granted to a spender for a token. This tells you how much of a token an address can spend on your behalf.", - inputSchema: { + inputSchema: z.object({ tokenAddress: z.string().describe("The ERC20 token contract address"), spenderAddress: z.string().describe("The address allowed to spend the token (usually a contract address)"), ownerAddress: z.string().optional().describe("The owner address (defaults to the configured wallet)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Token Allowance", readOnlyHint: true, @@ -493,10 +493,10 @@ export function registerEVMTools(server: McpServer) { "get_transaction", { description: "Get transaction details by transaction hash", - inputSchema: { + inputSchema: z.object({ txHash: z.string().describe("Transaction hash (0x...)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Transaction", readOnlyHint: true, @@ -522,10 +522,10 @@ export function registerEVMTools(server: McpServer) { "get_transaction_receipt", { description: "Get transaction receipt (confirmation status, gas used, logs). Use this to check if a transaction has been confirmed.", - inputSchema: { + inputSchema: z.object({ txHash: z.string().describe("Transaction hash (0x...)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get Transaction Receipt", readOnlyHint: true, @@ -554,11 +554,11 @@ export function registerEVMTools(server: McpServer) { "wait_for_transaction", { description: "Wait for a transaction to be confirmed (mined). Polls the network until confirmation.", - inputSchema: { + inputSchema: z.object({ txHash: z.string().describe("Transaction hash (0x...)"), confirmations: z.number().optional().describe("Number of block confirmations required. Defaults to 1."), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Wait For Transaction", readOnlyHint: true, @@ -605,10 +605,10 @@ export function registerEVMTools(server: McpServer) { "get_contract_abi", { description: "Fetch a contract's full ABI from Etherscan/block explorers. Use this to understand verified contracts before interacting. Requires ETHERSCAN_API_KEY. Supports 30+ EVM networks. Works best with verified contracts on block explorers.", - inputSchema: { + inputSchema: z.object({ contractAddress: z.string().describe("The contract address (0x...)"), network: z.string().optional().describe("Network name or chain ID. Defaults to ethereum. Supported: ethereum, polygon, arbitrum, optimism, base, avalanche, gnosis, fantom, bsc, celo, scroll, linea, zksync, manta, blast, and testnets (sepolia, mumbai, arbitrum-sepolia, optimism-sepolia, base-sepolia, avalanche-fuji)") - }, + }), annotations: { title: "Get Contract ABI", readOnlyHint: true, @@ -649,13 +649,13 @@ export function registerEVMTools(server: McpServer) { "read_contract", { description: "Call read-only functions on a smart contract. Automatically fetches ABI from block explorer if not provided (requires ETHERSCAN_API_KEY). Falls back to common functions if contract is not verified. Use this to query contract state and data.", - inputSchema: { + inputSchema: z.object({ contractAddress: z.string().describe("The contract address"), functionName: z.string().describe("Function name (e.g., 'name', 'symbol', 'balanceOf', 'totalSupply', 'owner')"), args: z.array(z.string()).optional().describe("Function arguments as strings (e.g., ['0xAddress'] for balanceOf)"), abiJson: z.string().optional().describe("Full contract ABI as JSON string (optional - will auto-fetch verified contract ABI if not provided)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Read Smart Contract", readOnlyHint: true, @@ -754,14 +754,14 @@ export function registerEVMTools(server: McpServer) { "write_contract", { description: "Execute state-changing functions on a smart contract. Automatically fetches ABI from block explorer if not provided (requires ETHERSCAN_API_KEY). Use this to call any write function on verified contracts. Requires wallet to be configured (via private key or mnemonic).", - inputSchema: { + inputSchema: z.object({ contractAddress: z.string().describe("The contract address"), functionName: z.string().describe("Function name to call (e.g., 'mint', 'swap', 'stake', 'approve')"), args: z.array(z.string()).optional().describe("Function arguments as strings (e.g., ['0xAddress', '1000000'])"), value: z.string().optional().describe("ETH value to send with transaction in ether (e.g., '0.1' for payable functions)"), abiJson: z.string().optional().describe("Full contract ABI as JSON string (optional - will auto-fetch verified contract ABI if not provided)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Write to Smart Contract", readOnlyHint: false, @@ -867,7 +867,7 @@ export function registerEVMTools(server: McpServer) { "multicall", { description: "Batch multiple contract read calls into a single RPC request. Significantly reduces latency and RPC usage when querying multiple functions. Uses the Multicall3 contract deployed on all major networks. Perfect for portfolio analysis, price aggregation, and querying multiple contract states efficiently.", - inputSchema: { + inputSchema: z.object({ calls: z.array(z.object({ contractAddress: z.string().describe("The contract address"), functionName: z.string().describe("Function name to call"), @@ -876,7 +876,7 @@ export function registerEVMTools(server: McpServer) { })).describe("Array of contract calls to batch together"), allowFailure: z.boolean().optional().describe("If true, returns partial results even if some calls fail. Defaults to true."), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Multicall (Batch Read)", readOnlyHint: true, @@ -995,11 +995,11 @@ export function registerEVMTools(server: McpServer) { "transfer_native", { description: "Transfer native tokens (ETH, MATIC, etc.) to an address. Uses the configured wallet.", - inputSchema: { + inputSchema: z.object({ to: z.string().describe("Recipient address or ENS name"), amount: z.string().describe("Amount to send in ether (e.g., '0.5' for 0.5 ETH)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Transfer Native Tokens", readOnlyHint: false, @@ -1039,12 +1039,12 @@ export function registerEVMTools(server: McpServer) { "transfer_erc20", { description: "Transfer ERC20 tokens to an address. Uses the configured wallet.", - inputSchema: { + inputSchema: z.object({ tokenAddress: z.string().describe("The ERC20 token contract address"), to: z.string().describe("Recipient address or ENS name"), amount: z.string().describe("Amount to send (in token units, accounting for decimals)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Transfer ERC20 Tokens", readOnlyHint: false, @@ -1087,12 +1087,12 @@ export function registerEVMTools(server: McpServer) { "approve_token_spending", { description: "Approve a spender (contract) to spend tokens on your behalf. Required before interacting with DEXes, lending protocols, etc.", - inputSchema: { + inputSchema: z.object({ tokenAddress: z.string().describe("The ERC20 token contract address"), spenderAddress: z.string().describe("The address that will be allowed to spend tokens (usually a contract)"), amount: z.string().describe("Amount to approve (in token units). Use '0' to revoke approval."), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Approve Token Spending", readOnlyHint: false, @@ -1137,11 +1137,11 @@ export function registerEVMTools(server: McpServer) { "get_nft_info", { description: "Get information about an ERC721 NFT including metadata URI", - inputSchema: { + inputSchema: z.object({ contractAddress: z.string().describe("The NFT contract address"), tokenId: z.string().describe("The NFT token ID"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get NFT Info", readOnlyHint: true, @@ -1177,12 +1177,12 @@ export function registerEVMTools(server: McpServer) { "get_erc1155_balance", { description: "Get ERC1155 token balance for an address", - inputSchema: { + inputSchema: z.object({ contractAddress: z.string().describe("The ERC1155 contract address"), tokenId: z.string().describe("The token ID"), address: z.string().describe("The owner address or ENS name"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, + }), annotations: { title: "Get ERC1155 Balance", readOnlyHint: true, @@ -1223,9 +1223,9 @@ export function registerEVMTools(server: McpServer) { "sign_message", { description: "Sign an arbitrary message using the configured wallet. Useful for authentication (SIWE), meta-transactions, and off-chain signatures. The signature can be verified on-chain or off-chain.", - inputSchema: { + inputSchema: z.object({ message: z.string().describe("The message to sign (plain text or hex-encoded data)") - }, + }), annotations: { title: "Sign Message", readOnlyHint: false, @@ -1262,12 +1262,12 @@ export function registerEVMTools(server: McpServer) { "sign_typed_data", { description: "Sign structured data (EIP-712) using the configured wallet. Used for gasless transactions, meta-transactions, permit signatures, and protocol-specific signatures. The signature follows the EIP-712 standard.", - inputSchema: { + inputSchema: z.object({ domainJson: z.string().describe("EIP-712 domain as JSON string with fields: name, version, chainId, verifyingContract, salt (all optional)"), typesJson: z.string().describe("EIP-712 types definition as JSON string (exclude EIP712Domain type - it's added automatically)"), primaryType: z.string().describe("The primary type name (e.g., 'Mail', 'Permit', 'MetaTransaction')"), messageJson: z.string().describe("The message data to sign as JSON string") - }, + }), annotations: { title: "Sign Typed Data (EIP-712)", readOnlyHint: false, diff --git a/src/index.ts b/src/index.ts index 0e48074..a779045 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,9 @@ -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import startServer from "./server/server.js"; +import { runStdioServer } from "./server/stdio-server.js"; // Start the server async function main() { try { - const server = await startServer(); - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("EVM MCP Server running on stdio"); + await runStdioServer(); } catch (error) { console.error("Error starting MCP server:", error); process.exit(1); @@ -17,4 +13,4 @@ async function main() { main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/src/server/http-server.ts b/src/server/http-server.ts index cb20194..ab34eb1 100644 --- a/src/server/http-server.ts +++ b/src/server/http-server.ts @@ -1,220 +1,129 @@ -import { randomUUID } from "node:crypto"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import startServer from "./server.js"; import express, { Request, Response } from "express"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpHandler } from "@modelcontextprotocol/server"; +import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node"; +import { getSupportedNetworks } from "../core/chains.js"; +import createServer from "./server.js"; +import { CACHE_SCOPE, CACHE_TTL_MS, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "./protocol.js"; -// Environment variables const PORT = parseInt(process.env.MCP_PORT || "3001", 10); -const HOST = process.env.MCP_HOST || "0.0.0.0"; +const HOST = process.env.MCP_HOST || "127.0.0.1"; +const LOCAL_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"]; -console.error(`Configured to listen on ${HOST}:${PORT}`); - -// Setup Express -const app = express(); -app.use(express.json({ limit: '10mb' })); // Prevent DoS attacks with huge payloads - -// Track active transports by session ID with cleanup -const transports = new Map(); -const sessionTimestamps = new Map(); -const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - -// Cleanup stale sessions periodically -setInterval(() => { - const now = Date.now(); - for (const [sessionId, timestamp] of sessionTimestamps.entries()) { - if (now - timestamp > SESSION_TIMEOUT_MS) { - console.error(`Cleaning up stale session: ${sessionId}`); - const transport = transports.get(sessionId); - if (transport) { - transport.close().catch(err => - console.error(`Error closing stale session ${sessionId}:`, err) - ); - } - transports.delete(sessionId); - sessionTimestamps.delete(sessionId); - } - } -}, 5 * 60 * 1000); // Check every 5 minutes - -// Initialize the MCP server -let server: McpServer | null = null; -startServer().then(s => { - server = s; - console.error("MCP Server initialized successfully"); -}).catch(error => { - console.error("Failed to initialize server:", error); - process.exit(1); -}); - -// Handle all MCP requests through POST /mcp -app.post("/mcp", async (req: Request, res: Response) => { - console.error(`Received POST /mcp request from ${req.ip}`); - - if (!server) { - console.error("Server not initialized yet"); - res.status(503).json({ error: "Server not initialized" }); - return; - } +function configuredHostnames(variableName: string, fallback: string[]): string[] { + const configured = process.env[variableName] + ?.split(",") + .map(value => value.trim()) + .filter(Boolean); - // Check for existing session - const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports.has(sessionId)) { - // Reuse existing transport for this session - transport = transports.get(sessionId)!; - sessionTimestamps.set(sessionId, Date.now()); // Update last activity - console.error(`Reusing transport for session: ${sessionId}`); - } else if (!sessionId) { - // New session - create transport with session ID generator - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - console.error(`Session initialized: ${newSessionId}`); - transports.set(newSessionId, transport); - sessionTimestamps.set(newSessionId, Date.now()); - }, - onsessionclosed: (closedSessionId) => { - console.error(`Session closed: ${closedSessionId}`); - transports.delete(closedSessionId); - sessionTimestamps.delete(closedSessionId); - } - }); - - // Connect the transport to the server - await server.connect(transport); - console.error("New transport connected to server"); - } else { - // Invalid session ID provided - console.error(`Invalid session ID: ${sessionId}`); - res.status(404).json({ error: "Session not found" }); - return; + if (!configured?.length) { + return fallback; } - // Handle the request - try { - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error(`Error handling request: ${error}`); - if (!res.headersSent) { - res.status(500).json({ error: `Internal server error: ${error}` }); + return configured.map(value => { + try { + return new URL(value).hostname; + } catch { + return value; } - } -}); - -// Handle GET requests for SSE streams (server-to-client notifications) -app.get("/mcp", async (req: Request, res: Response) => { - console.error(`Received GET /mcp request from ${req.ip}`); - - if (!server) { - res.status(503).json({ error: "Server not initialized" }); - return; - } + }); +} - const sessionId = req.headers["mcp-session-id"] as string | undefined; +function isLocalHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} - if (!sessionId || !transports.has(sessionId)) { - res.status(400).json({ error: "Invalid or missing session ID" }); - return; - } +const defaultHostnames = isLocalHost(HOST) ? LOCAL_HOSTNAMES : [HOST]; +const allowedHostnames = configuredHostnames("MCP_ALLOWED_HOSTS", defaultHostnames); +const allowedOriginHostnames = configuredHostnames("MCP_ALLOWED_ORIGINS", defaultHostnames); +const validateHost = hostHeaderValidation(allowedHostnames); +const validateOrigin = originValidation(allowedOriginHostnames); - const transport = transports.get(sessionId)!; +console.error(`Configured to listen on ${HOST}:${PORT}`); - try { - await transport.handleRequest(req, res); - } catch (error) { - console.error(`Error handling SSE request: ${error}`); - if (!res.headersSent) { - res.status(500).json({ error: `Internal server error: ${error}` }); - } +const app = express(); +const mcpHandler = createMcpHandler(createServer, { + legacy: "reject" +}); +const nodeHandler = toNodeHandler(mcpHandler, { + onerror: (error) => { + console.error("MCP Node adapter error:", error); } }); -// Handle DELETE requests to close sessions -app.delete("/mcp", async (req: Request, res: Response) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (!sessionId || !transports.has(sessionId)) { - res.status(404).json({ error: "Session not found" }); +app.all("/mcp", (req: Request, res: Response) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) { return; } - const transport = transports.get(sessionId)!; - - try { - await transport.handleRequest(req, res); - } catch (error) { - console.error(`Error closing session: ${error}`); - if (!res.headersSent) { - res.status(500).json({ error: `Internal server error: ${error}` }); - } - } + void nodeHandler(req, res); }); -// Health check endpoint app.get("/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok", - server: server ? "initialized" : "initializing", - activeSessions: transports.size, - sessionIds: Array.from(transports.keys()) + protocol: `MCP ${MODERN_PROTOCOL_VERSION}`, + transport: "Streamable HTTP", + stateless: true }); }); -// Root endpoint for basic info app.get("/", (_req: Request, res: Response) => { res.status(200).json({ - name: "EVM MCP Server", - version: "2.0.0", - protocol: "MCP 2025-06-18", + name: SERVER_INFO.name, + version: SERVER_INFO.version, + protocol: `MCP ${MODERN_PROTOCOL_VERSION}`, transport: "Streamable HTTP", endpoints: { mcp: "/mcp", health: "/health" }, - status: server ? "ready" : "initializing", - activeSessions: transports.size + cache: { + ttlMs: CACHE_TTL_MS, + cacheScope: CACHE_SCOPE + }, + status: "ready", + stateless: true }); }); -// Handle process termination gracefully -process.on('SIGINT', async () => { - console.error('Shutting down server...'); - - // Close all active transports - for (const [sessionId, transport] of transports) { - console.error(`Closing transport for session: ${sessionId}`); - await transport.close(); - } - transports.clear(); - - process.exit(0); +const httpServer = app.listen(PORT, HOST, () => { + console.error(`EVM MCP Server v${SERVER_INFO.version} running at http://${HOST}:${PORT}`); + console.error(`MCP endpoint: http://${HOST}:${PORT}/mcp`); + console.error(`Health check: http://${HOST}:${PORT}/health`); + console.error(`Protocol: MCP ${MODERN_PROTOCOL_VERSION} (stateless Streamable HTTP)`); + console.error(`Supported networks: ${getSupportedNetworks().length} networks`); +}).on("error", (error: Error) => { + console.error("HTTP server error:", error); + process.exit(1); }); -process.on('SIGTERM', async () => { - console.error('Received SIGTERM, shutting down...'); +httpServer.timeout = 120000; +httpServer.keepAliveTimeout = 65000; + +let isShuttingDown = false; - for (const [sessionId, transport] of transports) { - console.error(`Closing transport for session: ${sessionId}`); - await transport.close(); +async function shutdown(signal: string): Promise { + if (isShuttingDown) { + return; } - transports.clear(); + isShuttingDown = true; + + console.error(`${signal} received, shutting down...`); + await mcpHandler.close(); + httpServer.close((error) => { + if (error) { + console.error("Error closing HTTP server:", error); + process.exit(1); + } - process.exit(0); -}); + process.exit(0); + }); +} -// Start the HTTP server -const httpServer = app.listen(PORT, HOST, () => { - console.error(`EVM MCP Server running at http://${HOST}:${PORT}`); - console.error(`MCP endpoint: http://${HOST}:${PORT}/mcp`); - console.error(`Health check: http://${HOST}:${PORT}/health`); - console.error(`Protocol: MCP 2025-06-18 (Streamable HTTP)`); -}).on('error', (err: Error) => { - console.error(`Server error: ${err}`); - process.exit(1); +process.on("SIGINT", () => { + void shutdown("SIGINT"); }); -// Set server timeout to prevent hanging connections -httpServer.timeout = 120000; // 2 minutes -httpServer.keepAliveTimeout = 65000; // 65 seconds +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); diff --git a/src/server/protocol.ts b/src/server/protocol.ts new file mode 100644 index 0000000..fcd9deb --- /dev/null +++ b/src/server/protocol.ts @@ -0,0 +1,12 @@ +export const SERVER_INFO = { + name: "evm-mcp-server", + version: "2.0.4" +} as const; + +export const MODERN_PROTOCOL_VERSION = "2026-07-28"; +export const LEGACY_PROTOCOL_VERSION = "2025-11-25"; +export const CACHE_TTL_MS = 60 * 60 * 1000; +export const CACHE_SCOPE = "public"; + +export const SERVER_INSTRUCTIONS = + "Use the EVM tools to inspect supported chains, resolve ENS names, read balances and contract data, and prepare or submit transactions with the configured wallet. Always ask the user to confirm write operations before invoking transfer or approval tools."; diff --git a/src/server/server.ts b/src/server/server.ts index fe86134..c6c27cc 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,52 +1,43 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerEVMResources } from "../core/resources.js"; import { registerEVMTools } from "../core/tools.js"; import { registerEVMPrompts } from "../core/prompts.js"; -import { getSupportedNetworks } from "../core/chains.js"; +import { CACHE_SCOPE, CACHE_TTL_MS, SERVER_INFO, SERVER_INSTRUCTIONS } from "./protocol.js"; -// Create and start the MCP server -async function startServer() { - try { - // Create a new MCP server instance with capabilities - const server = new McpServer( - { - name: "evm-mcp-server", - version: "2.0.0" +// Create the MCP server used by the stdio and per-request HTTP serving entries. +function createServer() { + const cacheHint = { + ttlMs: CACHE_TTL_MS, + cacheScope: CACHE_SCOPE + } as const; + + const server = new McpServer( + SERVER_INFO, + { + capabilities: { + tools: { listChanged: false }, + resources: { listChanged: false, subscribe: false }, + prompts: { listChanged: false } }, - { - capabilities: { - tools: { - listChanged: true - }, - resources: { - subscribe: false, - listChanged: true - }, - prompts: { - listChanged: true - }, - logging: {} - } + instructions: SERVER_INSTRUCTIONS, + cacheHints: { + "server/discover": cacheHint, + "tools/list": cacheHint, + "prompts/list": cacheHint, + "resources/list": cacheHint, + "resources/templates/list": cacheHint, + "resources/read": cacheHint } - ); - - // Register all resources, tools, and prompts - registerEVMResources(server); - registerEVMTools(server); - registerEVMPrompts(server); + } + ); - // Log server information - console.error(`EVM MCP Server v2.0.0 initialized`); - console.error(`Protocol: MCP 2025-06-18`); - console.error(`Supported networks: ${getSupportedNetworks().length} networks`); - console.error("Server is ready to handle requests"); + // Register all resources, tools, and prompts. + registerEVMResources(server); + registerEVMTools(server); + registerEVMPrompts(server); - return server; - } catch (error) { - console.error("Failed to initialize server:", error); - process.exit(1); - } + return server; } // Export the server creation function -export default startServer; +export default createServer; diff --git a/src/server/stdio-server.ts b/src/server/stdio-server.ts new file mode 100644 index 0000000..a4de983 --- /dev/null +++ b/src/server/stdio-server.ts @@ -0,0 +1,20 @@ +import { serveStdio, type StdioServerHandle } from "@modelcontextprotocol/server/stdio"; +import { getSupportedNetworks } from "../core/chains.js"; +import createServer from "./server.js"; +import { LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "./protocol.js"; + +/** + * Serve MCP over stdio with SDK-native negotiation for modern and legacy clients. + */ +export function runStdioServer(): StdioServerHandle { + console.error(`EVM MCP Server v${SERVER_INFO.version} running on stdio`); + console.error(`Protocol: MCP ${MODERN_PROTOCOL_VERSION} (modern), MCP ${LEGACY_PROTOCOL_VERSION} (legacy)`); + console.error(`Supported networks: ${getSupportedNetworks().length} networks`); + + return serveStdio(createServer, { + legacy: "serve", + onerror: (error) => { + console.error("MCP stdio error:", error); + } + }); +} diff --git a/test/mcp-2026.test.ts b/test/mcp-2026.test.ts new file mode 100644 index 0000000..b393b39 --- /dev/null +++ b/test/mcp-2026.test.ts @@ -0,0 +1,176 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createMcpHandler, SERVER_INFO_META_KEY } from "@modelcontextprotocol/server"; +import createServer from "../src/server/server.js"; +import { CACHE_SCOPE, CACHE_TTL_MS, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "../src/server/protocol.js"; + +const handler = createMcpHandler(createServer, { + legacy: "reject" +}); + +const CLIENT_META = { + "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {} +}; + +type JsonRpcResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result?: Record; + error?: { + code: number; + message: string; + data?: unknown; + }; +}; + +async function modernRequest( + id: string, + method: string, + params: Record = {}, + options: { + name?: string; + protocolVersion?: string; + meta?: Record; + } = {} +): Promise<{ status: number; body: JsonRpcResponse }> { + const protocolVersion = options.protocolVersion ?? MODERN_PROTOCOL_VERSION; + const headers = new Headers({ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": method + }); + + if (options.name) { + headers.set("Mcp-Name", options.name); + } + + const response = await handler.fetch(new Request("http://test.local/mcp", { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id, + method, + params: { + ...params, + _meta: options.meta ?? CLIENT_META + } + }) + })); + + return { + status: response.status, + body: await response.json() as JsonRpcResponse + }; +} + +afterAll(async () => { + await handler.close(); +}); + +describe("MCP 2026-07-28 SDK integration", () => { + test("discovers protocol metadata and stamps server identity on the result", async () => { + const { status, body } = await modernRequest("discover", "server/discover"); + + expect(status).toBe(200); + expect(body.result).toEqual(expect.objectContaining({ + resultType: "complete", + supportedVersions: [MODERN_PROTOCOL_VERSION], + ttlMs: CACHE_TTL_MS, + cacheScope: CACHE_SCOPE, + _meta: { + [SERVER_INFO_META_KEY]: SERVER_INFO + } + })); + expect(body.result).not.toHaveProperty("serverInfo"); + }); + + test("accepts requests without optional clientInfo metadata", async () => { + const { status, body } = await modernRequest("tools", "tools/list"); + + expect(status).toBe(200); + expect(body.error).toBeUndefined(); + }); + + test("lists tools with deterministic order and closed no-argument schemas", async () => { + const { body } = await modernRequest("tools", "tools/list"); + const { body: repeatedBody } = await modernRequest("tools-repeat", "tools/list"); + const tools = body.result?.tools as Array>; + const names = tools.map(tool => tool.name as string); + const repeatedNames = (repeatedBody.result?.tools as Array>) + .map(tool => tool.name as string); + const walletTool = tools.find(tool => tool.name === "get_wallet_address"); + + expect(names).toEqual(repeatedNames); + expect(walletTool?.inputSchema).toEqual(expect.objectContaining({ + type: "object", + properties: {}, + additionalProperties: false + })); + expect(body.result).toEqual(expect.objectContaining({ + ttlMs: CACHE_TTL_MS, + cacheScope: CACHE_SCOPE + })); + }); + + test("reads static resources with cache hints", async () => { + const { body } = await modernRequest( + "read", + "resources/read", + { uri: "evm://networks" }, + { name: "evm://networks" } + ); + const contents = body.result?.contents as Array>; + + expect(body.result).toEqual(expect.objectContaining({ + resultType: "complete", + ttlMs: CACHE_TTL_MS, + cacheScope: CACHE_SCOPE + })); + expect(contents[0]).toEqual(expect.objectContaining({ + uri: "evm://networks", + mimeType: "application/json" + })); + }); + + test("calls read-only tools without wallet or RPC credentials", async () => { + const { body } = await modernRequest( + "call", + "tools/call", + { + name: "get_supported_networks", + arguments: {} + }, + { name: "get_supported_networks" } + ); + + expect(body.result).toEqual(expect.objectContaining({ + resultType: "complete", + content: [expect.objectContaining({ type: "text" })] + })); + }); + + test("uses the final HeaderMismatch error code", async () => { + const { status, body } = await modernRequest("mismatch", "tools/list", {}, { + protocolVersion: "1900-01-01" + }); + + expect(status).toBe(400); + expect(body.error?.code).toBe(-32020); + }); + + test("uses the final UnsupportedProtocolVersion error code", async () => { + const unsupportedVersion = "1900-01-01"; + const { status, body } = await modernRequest("unsupported", "server/discover", {}, { + protocolVersion: unsupportedVersion, + meta: { + ...CLIENT_META, + "io.modelcontextprotocol/protocolVersion": unsupportedVersion + } + }); + + expect(status).toBe(400); + expect(body.error?.code).toBe(-32022); + }); +}); From a1067d94d368d1066d2a886d4edb370cbb224811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:59:08 -0400 Subject: [PATCH 2/7] fix: harden MCP authorization and tool contracts --- README.md | 449 ++++++++------ bun.lock | 9 +- docs/mcp-2026-07-28-upgrade.md | 63 +- package.json | 6 +- src/core/chains.ts | 72 +-- src/core/prompts.ts | 793 +++++++++++-------------- src/core/resources.ts | 2 +- src/core/services/balance.ts | 81 +-- src/core/services/clients.ts | 13 +- src/core/services/contracts.ts | 33 +- src/core/services/index.ts | 3 +- src/core/services/tokens.ts | 107 +--- src/core/services/transactions.ts | 34 +- src/core/services/transfer.ts | 238 +------- src/core/services/utils.ts | 43 -- src/core/services/wallet.ts | 6 +- src/core/tools.ts | 946 +++++++++++++++++++++--------- src/server/auth.ts | 436 ++++++++++++++ src/server/http-app.ts | 183 ++++++ src/server/http-server.ts | 70 +-- src/server/protocol.ts | 2 +- src/server/request-state.ts | 120 ++++ src/server/server.ts | 7 +- src/server/stdio-server.ts | 6 +- test/auth.test.ts | 441 ++++++++++++++ test/http-auth.test.ts | 373 ++++++++++++ test/mcp-2026.test.ts | 549 ++++++++++++++++- 27 files changed, 3505 insertions(+), 1580 deletions(-) delete mode 100644 src/core/services/utils.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/http-app.ts create mode 100644 src/server/request-state.ts create mode 100644 test/auth.test.ts create mode 100644 test/http-auth.test.ts diff --git a/README.md b/README.md index 8b9929d..4af440d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # EVM MCP Server ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg) -![EVM Networks](https://img.shields.io/badge/Networks-60+-green) +![EVM Networks](https://img.shields.io/badge/Networks-55-green) ![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-3178C6) ![MCP](https://img.shields.io/badge/MCP-2026--07--28-blue) -![Viem](https://img.shields.io/badge/Viem-2.39.3+-green) +![Viem](https://img.shields.io/badge/Viem-2.55.10+-green) -A comprehensive Model Context Protocol (MCP) server that provides blockchain services across 60+ EVM-compatible networks. This server enables AI agents to interact with Ethereum, Optimism, Arbitrum, Base, Polygon, and many other EVM chains with a unified interface through 25 tools and 10 AI-guided prompts. +A comprehensive Model Context Protocol (MCP) server that provides blockchain services across 55 distinct EVM-compatible chains. This server enables AI agents to interact with Ethereum, Optimism, Arbitrum, Base, Polygon, and many other EVM chains with a unified interface through 25 tools and 10 AI-guided prompts. ## 📋 Contents @@ -33,64 +33,65 @@ A comprehensive Model Context Protocol (MCP) server that provides blockchain ser The MCP EVM Server leverages the Model Context Protocol to provide blockchain services to AI agents. It supports a wide range of services including: - Reading blockchain state (balances, transactions, blocks, etc.) -- Interacting with smart contracts with **automatic ABI fetching** from block explorers -- Transferring tokens (native, ERC20, ERC721, ERC1155) -- Querying token metadata and balances -- Chain-specific services across 60+ EVM networks (34 mainnets + 26 testnets) -- **ENS name resolution** for all address parameters (use human-readable names like 'vitalik.eth' instead of addresses) +- Interacting with smart contracts with **automatic ABI fetching** through Etherscan v2 where supported +- Transferring native and ERC20 tokens +- Querying ERC20, ERC721, and ERC1155 data +- Chain-specific services across 55 EVM chains (31 mainnets + 24 testnets) +- **ENS name resolution** for supported balance and transfer address parameters - **AI-friendly prompts** that guide agents through complex workflows -All services are exposed through a consistent interface of MCP tools, resources, and prompts, making it easy for AI agents to discover and use blockchain functionality. **Every tool that accepts Ethereum addresses also supports ENS names**, automatically resolving them to addresses behind the scenes. The server includes intelligent ABI fetching, eliminating the need to know contract ABIs in advance. +All services are exposed through a consistent interface of MCP tools, resources, and prompts, making it easy for AI agents to discover and use blockchain functionality. The API reference identifies which address parameters accept ENS names. Raw contract interaction tools require resolved hexadecimal addresses. For verified contracts on chains supported by Etherscan v2, the server can fetch an ABI when one is not supplied. ## ✨ Features ### Blockchain Data Access -- **Multi-chain support** for 60+ EVM-compatible networks (34 mainnets + 26 testnets) -- **Chain information** including blockNumber, chainId, and RPCs +- **Multi-chain support** for 55 EVM-compatible chains (31 mainnets + 24 testnets) +- **Chain information** including block number, chain ID, and RPC endpoint - **Block data** access by number, hash, or latest -- **Transaction details** and receipts with decoded logs -- **Address balances** for native tokens and all token standards -- **ENS resolution** for human-readable Ethereum addresses (use 'vitalik.eth' instead of '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045') +- **Transaction details** and receipts with logs +- **Address balances** for native, ERC20, and ERC1155 tokens +- **ENS resolution** for supported address parameters (use 'vitalik.eth' instead of '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045') ### Token services - **ERC20 Tokens** - - Get token metadata (name, symbol, decimals, supply) - Check token balances - Transfer tokens between addresses - Approve spending allowances - **NFTs (ERC721)** - - Get collection and token metadata - - Verify token ownership - - Transfer NFTs between addresses - - Retrieve token URIs and count holdings + - Get collection name and symbol + - Retrieve token URIs (the server does not fetch off-chain metadata documents) - **Multi-tokens (ERC1155)** - - Get token balances and metadata - - Transfer tokens with quantity - - Access token URIs + - Get token balances by owner and token ID ### Smart Contract Interactions - **Read contract state** through view/pure functions -- **Write to contracts** - Execute any state-changing function with automatic ABI fetching -- **Contract verification** to distinguish from EOAs -- **Event logs** retrieval and filtering -- **Automatic ABI fetching** from Etherscan v2 API across all 60+ networks (no need to know ABIs in advance) -- **ABI parsing and validation** with function discovery +- **Write to contracts** - Execute ABI-described state-changing functions with an optional Etherscan v2 ABI fetch +- **Automatic ABI fetching** through the Etherscan v2 API where the selected chain is supported by Etherscan +- **ABI JSON parsing and basic array-shape checking** with readable-function discovery ### Comprehensive Transaction Support -- **Flexible Wallet Support** - Configure with Private Key or Mnemonic (BIP-39) with HD path support +- **Flexible Wallet Support** - Configure with a private key or mnemonic-derived HD account - **Native token transfers** across all supported networks -- **Gas estimation** for transaction planning +- **Current gas price information** for transaction planning - **Transaction status** and receipt information +- **Bounded confirmation waiting** with a configurable 1–90 second timeout - **Error handling** with descriptive messages +### MCP-Native Safety and Results + +- **Structured tool results** - All 25 tools advertise an output schema and return successful JSON results in `structuredContent` +- **Legacy-readable output** - The same successful JSON is retained as pretty-printed text content +- **Enforced operation confirmation** - Wallet-backed writes and signatures use MCP `input_required` confirmation before accessing the configured wallet +- **Scoped remote authorization** - HTTP deployments support the `mcp`, `evm:write`, and `evm:sign` OAuth scopes + ### Message Signing Capabilities - **Personal Message Signing** - Sign arbitrary messages for authentication and verification @@ -102,23 +103,23 @@ All services are exposed through a consistent interface of MCP tools, resources, ### AI-Guided Workflows (Prompts) - **Transaction preparation** - Guidance for planning and executing transfers -- **Wallet analysis** - Tools for analyzing wallet activity and holdings +- **Wallet analysis** - Guidance for native and explicitly requested ERC20 balances - **Smart contract exploration** - Interactive ABI fetching and contract analysis - **Contract interaction** - Safe execution of write operations on smart contracts - **Network information** - Learning about EVM networks and comparisons -- **Approval auditing** - Reviewing and managing token approvals +- **Approval auditing** - Assessing a known owner-to-spender token allowance - **Error diagnosis** - Troubleshooting transaction failures ## 🌐 Supported Networks -### Mainnets +### Mainnets (31) - Ethereum (ETH) - Optimism (OP) - Arbitrum (ARB) - Arbitrum Nova - Base -- Polygon (MATIC) +- Polygon (POL) - Polygon zkEVM - Avalanche (AVAX) - Binance Smart Chain (BSC) @@ -145,7 +146,7 @@ All services are exposed through a consistent interface of MCP tools, resources, - Flow - Lumia -### Testnets +### Testnets (24) - Sepolia - Optimism Sepolia @@ -211,17 +212,19 @@ export EVM_PRIVATE_KEY="0x..." # Your private key in hex format (with or without **Option 2: Mnemonic Phrase (Recommended for HD Wallets)** ```bash -export EVM_MNEMONIC="word1 word2 word3 ... word12" # Your 12 or 24 word BIP-39 mnemonic +export EVM_MNEMONIC="word1 word2 word3 ..." # Your mnemonic phrase export EVM_ACCOUNT_INDEX="0" # Optional: Account index for HD wallet derivation (default: 0) ``` The mnemonic option supports hierarchical deterministic (HD) wallet derivation: -- Uses BIP-39 standard mnemonic phrases (12 or 24 words) -- Supports BIP-44 derivation path: `m/44'/60'/0'/0/{accountIndex}` +- Passes the configured phrase to Viem's mnemonic account derivation +- Uses derivation path `m/44'/60'/{accountIndex}'/0/0` - `EVM_ACCOUNT_INDEX` allows you to derive different accounts from the same mnemonic - Default account index is 0 (first account) +The server does not pre-validate mnemonic word count or checksum. Validate the phrase before configuring it. + **Wallet is used for:** - Transferring native tokens (`transfer_native` tool) @@ -246,14 +249,14 @@ export ETHERSCAN_API_KEY="your-api-key-here" This API key is optional but required for: -- Automatic ABI fetching from block explorers (`get_contract_abi` tool) -- Auto-fetching ABIs when reading contracts (`read_contract` tool with `abiJson` parameter) -- The `fetch_and_analyze_abi` prompt +- Automatic ABI fetching from Etherscan v2 (`get_contract_abi` tool) +- Auto-fetching ABIs when reading contracts (`read_contract` tool without an `abiJson` parameter) +- Workflows generated by the `fetch_and_analyze_abi` prompt Get your free API key from: - [Etherscan](https://etherscan.io/apis) - For Ethereum and compatible chains -- The same key works across all 60+ EVM networks via the Etherscan v2 API +- The same key is sent to the Etherscan v2 API for chains that Etherscan supports ### Server Configuration @@ -267,6 +270,45 @@ The HTTP server uses the following default configuration: When binding to a non-local interface, explicitly configure the public hostnames accepted by `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`. Values may be hostnames or origin URLs; validation is port-agnostic. +#### HTTP OAuth + +The HTTP process is an OAuth resource server; it does not issue access tokens. OAuth is optional only when `MCP_HOST` is local (`127.0.0.1`, `localhost`, or `::1`). If `MCP_OAUTH_ISSUER_URL` is set, OAuth is enabled even for a local bind. A non-local bind fails during startup unless OAuth is fully configured. + +Required when OAuth is enabled: + +| Variable | Purpose | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `MCP_OAUTH_ISSUER_URL` | Exact HTTPS authorization-server issuer URL with no query or fragment | +| `MCP_PUBLIC_URL` | Exact externally reachable MCP endpoint; it must have the `/mcp` path with no query or fragment, and remote use requires HTTPS | +| `MCP_OAUTH_CLIENT_ID` | Resource-server client ID used for RFC 7662 token introspection | +| `MCP_OAUTH_CLIENT_SECRET` | Resource-server client secret used for RFC 7662 token introspection | + +Optional OAuth variables: + +| Variable | Default | Purpose | +| ----------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `MCP_OAUTH_METADATA_URL` | RFC 8414 URL derived from the issuer | Override authorization-server metadata discovery | +| `MCP_OAUTH_INTROSPECTION_URL` | Metadata `introspection_endpoint` | Override the RFC 7662 introspection endpoint | +| `MCP_OAUTH_AUDIENCE` | `MCP_PUBLIC_URL` | Expected token audience or resource | +| `MCP_OAUTH_SCOPES` | none | Add scopes advertised alongside the minimal built-in `mcp` scope | +| `MCP_OAUTH_REQUIRED_SCOPES` | none | Add scopes required for every request alongside the built-in `mcp` scope | + +The introspection response must identify an active, unexpired token for this server's audience/resource. Every authenticated MCP request requires `mcp` plus any additional globally required scopes. `write_contract`, both transfer tools, and `approve_token_spending` additionally require `evm:write`; `sign_message` and `sign_typed_data` require `evm:sign`. + +Authorization-server metadata must advertise the authorization-code response type and PKCE `S256`; the issuer, metadata URL, and all authorization-server endpoints must use HTTPS. Metadata and token-introspection requests reject redirects and time out after 10 seconds. + +Example remote configuration: + +```bash +export MCP_HOST="0.0.0.0" +export MCP_ALLOWED_HOSTS="mcp.example.com" +export MCP_ALLOWED_ORIGINS="https://app.example.com" +export MCP_OAUTH_ISSUER_URL="https://auth.example.com/" +export MCP_PUBLIC_URL="https://mcp.example.com/mcp" +export MCP_OAUTH_CLIENT_ID="evm-mcp-resource-server" +export MCP_OAUTH_CLIENT_SECRET="..." +``` + Chain defaults and RPC endpoints are configured in `src/core/chains.ts`. ## 🚀 Usage @@ -363,6 +405,8 @@ Modern HTTP clients must send: Clients should also send `params._meta.io.modelcontextprotocol/clientInfo`. The final specification makes client identity optional, so the server accepts a request when that field is absent. +When OAuth is enabled, clients must also send `Authorization: Bearer `. The server publishes MCP protected-resource metadata and returns standards-based `WWW-Authenticate` challenges for missing, invalid, or insufficiently scoped tokens. + Example discovery request: ```bash @@ -421,9 +465,9 @@ main(); 2. With the file open in Cursor, you can ask Cursor to: - "Check the current ETH balance of vitalik.eth" - - "Look up the price of USDC on Ethereum" + - "Show me the current gas price on Ethereum" - "Show me the latest block on Optimism" - - "Check if 0x1234... is a contract address" + - "Read the name() function from the contract at 0x1234..." 3. Cursor will use the MCP server to execute these operations and return the results directly in your conversation. @@ -443,106 +487,131 @@ claude ### Example: Getting a Token Balance with ENS -```javascript -// Example of using the MCP client to check a token balance using ENS -const mcp = new McpClient("http://localhost:3000"); - -const result = await mcp.invokeTool("get-token-balance", { - tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC on Ethereum - ownerAddress: "vitalik.eth", // ENS name instead of address - network: "ethereum", -}); - -console.log(result); -// { -// tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", -// owner: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", -// network: "ethereum", -// raw: "1000000000", -// formatted: "1000", -// symbol: "USDC", -// decimals: 6 -// } +Use the following object as the `params` of a `tools/call` request: + +```json +{ + "name": "get_token_balance", + "arguments": { + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "address": "vitalik.eth", + "network": "ethereum" + } +} +``` + +A successful result includes the same JSON as text and as typed structured content: + +```json +{ + "structuredContent": { + "network": "ethereum", + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "address": "vitalik.eth", + "balance": { + "raw": "1000000000", + "formatted": "1000", + "symbol": "USDC", + "decimals": 6 + } + } +} ``` ### Example: Resolving an ENS Name -```javascript -// Example of using the MCP client to resolve an ENS name to an address -const mcp = new McpClient("http://localhost:3000"); - -const result = await mcp.invokeTool("resolve-ens", { - ensName: "vitalik.eth", - network: "ethereum", -}); - -console.log(result); -// { -// ensName: "vitalik.eth", -// normalizedName: "vitalik.eth", -// resolvedAddress: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", -// network: "ethereum" -// } +```json +{ + "name": "resolve_ens_name", + "arguments": { + "ensName": "vitalik.eth", + "network": "ethereum" + } +} ``` ### Example: Batch Multiple Calls with Multicall -```javascript -// Example of using multicall to batch multiple contract reads in a single RPC call -const mcp = new McpClient("http://localhost:3000"); - -const result = await mcp.invokeTool("multicall", { - network: "ethereum", - calls: [ - { - contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC - functionName: "balanceOf", - args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"], - }, - { - contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC - functionName: "symbol", - }, - { - contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC - functionName: "decimals", - }, - ], -}); - -console.log(result); -// { -// network: "ethereum", -// totalCalls: 3, -// successfulCalls: 3, -// failedCalls: 0, -// results: [ -// { contractAddress: "0xA0b...", functionName: "balanceOf", result: "1000000000", status: "success" }, -// { contractAddress: "0xA0b...", functionName: "symbol", result: "USDC", status: "success" }, -// { contractAddress: "0xA0b...", functionName: "decimals", result: "6", status: "success" } -// ] -// } +```json +{ + "name": "multicall", + "arguments": { + "network": "ethereum", + "calls": [ + { + "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "functionName": "balanceOf", + "args": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"] + }, + { + "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "functionName": "symbol" + }, + { + "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "functionName": "decimals" + } + ] + } +} +``` + +### Structured Results and Wallet Confirmation + +Every tool advertises an `outputSchema`. Successful calls return the schema-described value twice: + +- `structuredContent` contains native JSON for clients. +- `content[0].text` contains the same JSON as pretty-printed text for compatibility. + +Values that originate as JavaScript bigint values are encoded as decimal strings in both representations. + +The following wallet-backed tools enforce confirmation through MCP multi-round-trip `input_required` results: + +- `write_contract` +- `transfer_native` +- `transfer_erc20` +- `approve_token_spending` +- `sign_message` +- `sign_typed_data` + +The first invocation describes the exact operation and requests a boolean `confirm` input. No wallet action occurs until the client returns an accepted response with `confirm: true`; declining or cancelling terminates the operation. Clients should display this protocol-level request instead of adding a separate conversational confirmation. + +Confirmation continuation state is HMAC integrity-protected and binds the complete tool arguments. It expires after five minutes, is process-local and single-use, and is also bound to the authenticated bearer token for HTTP requests. An expired, replayed, cross-process, or differently authenticated continuation requires a new confirmation. + +For example, the initial transfer call uses ordinary `tools/call` parameters: + +```json +{ + "name": "transfer_native", + "arguments": { + "to": "vitalik.eth", + "amount": "0.01", + "network": "ethereum" + } +} ``` +An MCP `2026-07-28` client must advertise form elicitation support to complete the confirmation round trip. Legacy stdio clients use the SDK compatibility bridge. + ## 📚 API Reference ### Tools -The server provides 25 focused MCP tools for agents. **All tools that accept address parameters support both Ethereum addresses and ENS names.** +The server provides 25 focused MCP tools for agents. ENS support is noted for each relevant address parameter below; raw contract interaction tools require resolved hexadecimal addresses. #### Wallet Information -| Tool Name | Description | Key Parameters | -| -------------------- | --------------------------------------------------------------- | -------------- | -| `get_wallet_address` | Get the address of the configured wallet (from EVM_PRIVATE_KEY) | none | +| Tool Name | Description | Key Parameters | +| -------------------- | ---------------------------------------- | -------------- | +| `get_wallet_address` | Get the configured wallet address | none | #### Network Information -| Tool Name | Description | Key Parameters | -| ------------------------ | ----------------------------------- | -------------- | -| `get_chain_info` | Get network information | `network` | -| `get_supported_networks` | List all supported EVM networks | none | -| `get_gas_price` | Get current gas prices on a network | `network` | +| Tool Name | Description | Key Parameters | +| ------------------------ | ------------------------------------------------------------- | -------------- | +| `get_chain_info` | Get network information | `network` | +| `get_supported_networks` | List 87 configured names and aliases for 55 distinct EVM chains | none | +| `get_gas_price` | Get current gas prices on a network | `network` | #### ENS Services @@ -553,30 +622,32 @@ The server provides 25 focused MCP tools for agents. **All tools that accept add #### Block & Transaction Information -| Tool Name | Description | Key Parameters | -| ------------------------- | --------------------------------- | --------------------------------------- | -| `get_block` | Get block data | `blockNumber` or `blockHash`, `network` | -| `get_latest_block` | Get latest block data | `network` | -| `get_transaction` | Get transaction details | `txHash`, `network` | -| `get_transaction_receipt` | Get transaction receipt with logs | `txHash`, `network` | -| `wait_for_transaction` | Wait for transaction confirmation | `txHash`, `confirmations`, `network` | +| Tool Name | Description | Key Parameters | +| ------------------------- | --------------------------------- | ------------------------------------------------------ | +| `get_block` | Get block data | `blockIdentifier`, `network` | +| `get_latest_block` | Get latest block data | `network` | +| `get_transaction` | Get transaction details | `txHash`, `network` | +| `get_transaction_receipt` | Get transaction receipt with logs | `txHash`, `network` | +| `wait_for_transaction` | Wait for transaction confirmation | `txHash`, `confirmations`, `timeoutSeconds`, `network` | + +`wait_for_transaction.timeoutSeconds` accepts an integer from 1 through 90 and defaults to 90. If the transaction is still pending, call the tool again or query `get_transaction_receipt`. The tool remains synchronous because the released MCP TypeScript SDK v2 removed its experimental Tasks server runtime; this server does not advertise or implement Tasks. #### Balance & Token Information -| Tool Name | Description | Key Parameters | -| ------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------- | -| `get_balance` | Get native token balance | `address` (address/ENS), `network` | -| `get_token_balance` | Check ERC20 token balance | `tokenAddress` (address/ENS), `ownerAddress` (address/ENS), `network` | -| `get_allowance` | Check token spending allowance | `tokenAddress` (address/ENS), `ownerAddress` (address/ENS), `spenderAddress` (address/ENS), `network` | +| Tool Name | Description | Key Parameters | +| ------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `get_balance` | Get native token balance | `address` (address/ENS), `network` | +| `get_token_balance` | Check ERC20 token balance | `tokenAddress` (address/ENS), `address` (address/ENS), `network` | +| `get_allowance` | Check token spending allowance | `tokenAddress`, `spenderAddress`, `ownerAddress` (optional; configured wallet by default), `network` | #### Smart Contract Interactions | Tool Name | Description | Key Parameters | | ------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `get_contract_abi` | Fetch contract ABI from block explorer (60+ networks) | `contractAddress` (address/ENS), `network` | +| `get_contract_abi` | Fetch a verified contract ABI through Etherscan v2 where supported | `contractAddress`, `network` | | `read_contract` | Read smart contract state (auto-fetches ABI if needed) | `contractAddress`, `functionName`, `args[]`, `abiJson` (optional), `network` | | `write_contract` | Execute state-changing functions (auto-fetches ABI if needed) | `contractAddress`, `functionName`, `args[]`, `value` (optional), `abiJson` (optional), `network` | -| `multicall` | Batch multiple read calls into a single RPC request (uses Multicall3) | `calls[]` (array of contract calls), `allowFailure` (optional), `network` | +| `multicall` | Batch contract reads through Viem/Multicall3; large batches may be split and require a configured deployment | `calls[]` (array of contract calls), `allowFailure` (optional), `network` | #### Token Transfers @@ -588,10 +659,10 @@ The server provides 25 focused MCP tools for agents. **All tools that accept add #### NFT Services -| Tool Name | Description | Key Parameters | -| --------------------- | ------------------------- | -------------------------------------------------------------------------------- | -| `get_nft_info` | Get NFT (ERC721) metadata | `tokenAddress` (address/ENS), `tokenId`, `network` | -| `get_erc1155_balance` | Check ERC1155 balance | `tokenAddress` (address/ENS), `tokenId`, `ownerAddress` (address/ENS), `network` | +| Tool Name | Description | Key Parameters | +| --------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- | +| `get_nft_info` | Get ERC721 collection name, symbol, and token URI | `contractAddress`, `tokenId`, `network` | +| `get_erc1155_balance` | Check ERC1155 balance | `contractAddress` (address/ENS), `tokenId`, `address` (address/ENS), `network` | #### Message Signing @@ -600,67 +671,75 @@ The server provides 25 focused MCP tools for agents. **All tools that accept add | `sign_message` | Sign arbitrary messages for authentication and verification (SIWE, off-chain signatures) | `message` | | `sign_typed_data` | Sign EIP-712 structured data for gasless transactions, permits, and meta-transactions | `domainJson`, `typesJson`, `primaryType`, `messageJson` | -### Resources +### Prompts -The server exposes blockchain data through the following MCP resource URIs. All resource URIs that accept addresses also support ENS names, which are automatically resolved to addresses. +The server registers 10 prompts that generate task-specific instructions. Retrieving a prompt does not itself read blockchain state or execute a wallet operation. -#### Blockchain Resources +| Prompt Name | Description | Key Parameters | +| ------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `prepare_transfer` | Guide a validated native or ERC20 transfer workflow | `tokenType`, `recipient`, `amount`, `tokenAddress` (optional; required for ERC20), `network` | +| `diagnose_transaction` | Guide transaction status, receipt, and gas-based diagnosis | `txHash`, `network` | +| `analyze_wallet` | Summarize native and explicitly requested ERC20 balances | `address`, `tokens` (optional, comma-separated), `network` | +| `audit_approvals` | Assess one owner-to-spender ERC20 allowance | `tokenAddress`, `spenderAddress`, `address` (optional; configured wallet by default), `network` | +| `fetch_and_analyze_abi` | Guide verified-contract ABI fetching and analysis | `contractAddress`, `findFunction` (optional), `network` | +| `explore_contract` | Guide contract exploration with optional ABI fetching | `contractAddress`, `fetchAbi` (optional), `network` | +| `interact_with_contract` | Guide a validated contract write with MCP confirmation | `contractAddress`, `functionName`, `args` (optional JSON array string), `value` (optional), `network` | +| `explain_evm_concept` | Explain an EVM or blockchain concept | `concept` | +| `compare_networks` | Compare named EVM networks | `networks` (comma-separated) | +| `check_network_status` | Guide a current network-condition check | `network` | -| Resource URI Pattern | Description | -| ------------------------------------------- | ---------------------------------------- | -| `evm://{network}/chain` | Chain information for a specific network | -| `evm://chain` | Ethereum mainnet chain information | -| `evm://{network}/block/{blockNumber}` | Block data by number | -| `evm://{network}/block/latest` | Latest block data | -| `evm://{network}/address/{address}/balance` | Native token balance | -| `evm://{network}/tx/{txHash}` | Transaction details | -| `evm://{network}/tx/{txHash}/receipt` | Transaction receipt with logs | +### Resources -#### Token Resources +The server exposes 87 configured names and aliases for its 55 distinct chains as a static MCP resource. Supported numeric chain IDs are also accepted as tool inputs. -| Resource URI Pattern | Description | -| ---------------------------------------------------------------------- | ------------------------------ | -| `evm://{network}/token/{tokenAddress}` | ERC20 token information | -| `evm://{network}/token/{tokenAddress}/balanceOf/{address}` | ERC20 token balance | -| `evm://{network}/nft/{tokenAddress}/{tokenId}` | NFT (ERC721) token information | -| `evm://{network}/nft/{tokenAddress}/{tokenId}/isOwnedBy/{address}` | NFT ownership verification | -| `evm://{network}/erc1155/{tokenAddress}/{tokenId}/uri` | ERC1155 token URI | -| `evm://{network}/erc1155/{tokenAddress}/{tokenId}/balanceOf/{address}` | ERC1155 token balance | +| Resource URI | Description | +| ---------------- | ---------------------------------------------- | +| `evm://networks` | Accepted EVM network names and alias identifiers | ## 🔒 Security Considerations -- **Private keys** are used only for transaction signing and are never stored by the server -- Consider implementing additional authentication mechanisms for production use -- Use HTTPS for the HTTP server in production environments +- Wallet secrets are read from the process environment for address derivation, transaction signing, and message signing; the application does not write them to persistent storage +- The six wallet-backed transaction, approval, and signing tools enforce exact-operation MCP confirmation before accessing the wallet +- Confirmation continuation state is HMAC integrity-protected, expires after five minutes, and is process-local and single-use; HTTP state is additionally bound to the authenticated bearer token +- Local HTTP may run without authorization; non-local HTTP fails closed unless OAuth is configured +- Remote access tokens require the baseline `mcp` scope, plus `evm:write` or `evm:sign` for privileged operations +- Use HTTPS for all non-local HTTP deployments - Implement rate limiting to prevent abuse -- For high-value services, consider adding confirmation steps +- Keep wallet secrets, OAuth introspection credentials, and RPC credentials in a secure secret manager ## 📁 Project Structure ``` -mcp-evm-server/ +evm-mcp-server/ +├── bin/ +│ └── cli.js # Published stdio/HTTP command-line entry point ├── src/ │ ├── index.ts # Main stdio server entry point │ ├── server/ # Server-related files +│ │ ├── auth.ts # HTTP OAuth metadata and token introspection +│ │ ├── http-app.ts # Testable Express middleware assembly │ │ ├── http-server.ts # Stateless Streamable HTTP server │ │ ├── protocol.ts # Shared protocol metadata -│ │ ├── stdio-server.ts # SDK-native dual-era stdio entry -│ │ └── server.ts # General server setup -│ ├── core/ -│ │ ├── chains.ts # Chain definitions and utilities -│ │ ├── resources.ts # MCP resources implementation -│ │ ├── tools.ts # MCP tools implementation -│ │ ├── prompts.ts # MCP prompts implementation -│ │ └── services/ # Core blockchain services -│ │ ├── index.ts # Operation exports -│ │ ├── balance.ts # Balance services -│ │ ├── transfer.ts # Token transfer services -│ │ ├── utils.ts # Utility functions -│ │ ├── tokens.ts # Token metadata services -│ │ ├── contracts.ts # Contract interactions -│ │ ├── transactions.ts # Transaction services -│ │ └── blocks.ts # Block services -│ │ └── clients.ts # RPC client utilities +│ │ ├── request-state.ts # Integrity-protected confirmation continuation state +│ │ ├── server.ts # General server setup +│ │ └── stdio-server.ts # SDK-native dual-era stdio entry +│ └── core/ +│ ├── chains.ts # Chain definitions and utilities +│ ├── resources.ts # MCP resources implementation +│ ├── tools.ts # MCP tools implementation +│ ├── prompts.ts # MCP prompts implementation +│ └── services/ # Core blockchain services +│ ├── abi.ts # Etherscan ABI fetching and parsing +│ ├── balance.ts # Balance services +│ ├── blocks.ts # Block services +│ ├── clients.ts # RPC client utilities +│ ├── contracts.ts # Contract interactions +│ ├── ens.ts # ENS resolution +│ ├── index.ts # Operation exports +│ ├── tokens.ts # Token information services +│ ├── transactions.ts # Transaction services +│ ├── transfer.ts # Token transfer services +│ └── wallet.ts # Wallet derivation and signing ├── package.json ├── tsconfig.json └── README.md @@ -674,7 +753,7 @@ To modify or extend the server: 2. Register new tools in `src/core/tools.ts` 3. Register new resources in `src/core/resources.ts` 4. Add new network support in `src/core/chains.ts` -5. Configure the HTTP listener with `MCP_PORT`, `MCP_HOST`, `MCP_ALLOWED_HOSTS`, and `MCP_ALLOWED_ORIGINS` +5. Configure the HTTP listener and OAuth resource server with the documented `MCP_*` environment variables ## 📄 License diff --git a/bun.lock b/bun.lock index 10ec6d7..afffd75 100644 --- a/bun.lock +++ b/bun.lock @@ -3,8 +3,9 @@ "configVersion": 0, "workspaces": { "": { - "name": "mcp-evm-server", + "name": "@mcpdotdirect/evm-mcp-server", "dependencies": { + "@modelcontextprotocol/express": "^2.0.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "express": "^5.2.1", @@ -37,6 +38,8 @@ "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + "@modelcontextprotocol/express": ["@modelcontextprotocol/express@2.0.0", "", { "dependencies": { "cors": "^2.8.5" }, "peerDependencies": { "@modelcontextprotocol/server": "^2.0.0", "express": "^4.18.0 || ^5.0.0" } }, "sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ=="], + "@modelcontextprotocol/node": ["@modelcontextprotocol/node@2.0.0", "", { "dependencies": { "@hono/node-server": "^1.19.9" }, "peerDependencies": { "@modelcontextprotocol/server": "^2.0.0", "hono": "^4.11.4" }, "optionalPeers": ["hono"] }, "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg=="], "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], @@ -143,6 +146,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -241,6 +246,8 @@ "normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/docs/mcp-2026-07-28-upgrade.md b/docs/mcp-2026-07-28-upgrade.md index 2b9db78..a755f94 100644 --- a/docs/mcp-2026-07-28-upgrade.md +++ b/docs/mcp-2026-07-28-upgrade.md @@ -1,7 +1,5 @@ # MCP 2026-07-28 Upgrade -Branch: `ccbbccbb/mcp-2026-07-28-upgrade` - This repository targets the final MCP `2026-07-28` specification through the released TypeScript SDK v2 packages. The release-candidate compatibility adapter has been removed. ## Authoritative Sources @@ -16,6 +14,7 @@ This repository targets the final MCP `2026-07-28` specification through the rel - Replaced `@modelcontextprotocol/sdk` v1 with: - `@modelcontextprotocol/server` v2 - `@modelcontextprotocol/node` v2 + - `@modelcontextprotocol/express` v2 - Zod v4.2 or newer - Replaced the local JSON-RPC adapter with SDK-native `createMcpHandler` and `serveStdio`. - Kept stdio dual-era: @@ -30,9 +29,28 @@ This repository targets the final MCP `2026-07-28` specification through the rel - `Mcp-Method` - `Mcp-Name` - Added Host and Origin validation before the HTTP MCP handler. +- Added MCP OAuth resource-server support for HTTP: + - localhost can run without authorization + - non-local binds fail closed unless OAuth is configured + - authorization-server metadata discovery and RFC 7662 token introspection + - baseline `mcp`, wallet-write `evm:write`, and signing `evm:sign` scopes - Configured all static list capabilities with `listChanged: false`; resource subscriptions remain disabled. -- Configured one-hour public cache hints for discovery, list operations, resource templates, and resource reads. +- Configured one-hour public cache hints for discovery, static list operations, resource templates, and the public `evm://networks` resource. Unannotated future resources retain conservative cache defaults. - Migrated MCP-bound schemas to Zod 4 object schemas so the SDK emits JSON Schema 2020-12. +- Added an `outputSchema` to all 25 tools. Every successful tool call returns equivalent JSON in both `structuredContent` and a pretty-printed text content block; bigint values are represented as decimal strings. +- Added native MCP multi-round-trip confirmation to the six wallet-backed operations: + - `write_contract` + - `transfer_native` + - `transfer_erc20` + - `approve_token_spending` + - `sign_message` + - `sign_typed_data` +- Integrity-protected confirmation continuation state with the SDK HMAC codec: + - binds the complete tool arguments and current HTTP bearer token + - expires after five minutes + - is consumed once per process before wallet access + - rejects tampering, argument changes, cross-token use, and replay +- Bounded `wait_for_transaction` with `timeoutSeconds` from 1 through 90, defaulting to 90 seconds so it returns before the 120-second HTTP transport timeout. - Kept process diagnostics on `stderr`, including the npm CLI startup line, so stdio `stdout` contains protocol messages only. ## Final-Spec Differences from the RC @@ -47,11 +65,35 @@ The repository no longer carries RC behavior for the following changes: ## Compatibility Decisions -- HTTP remains strict `2026-07-28` to preserve the RC branch's modern-only deployment decision. +- HTTP remains strict `2026-07-28` to preserve the existing modern-only deployment decision. - Stdio serves both modern and legacy clients because local hosts commonly require gradual negotiation. - The static tool, prompt, and resource surfaces do not advertise change notifications. -- `wait_for_transaction` remains a normal synchronous tool. The Tasks extension is not advertised. -- The server does not implement MCP OAuth. It uses environment-configured RPC and wallet credentials. +- `wait_for_transaction` remains a bounded synchronous tool. The Tasks extension is not advertised because the released v2 SDK removed its experimental server runtime; the extension currently has no supported TypeScript runtime integration to adopt. +- Wallet-backed operations are not executed until the client accepts the tool's MCP `input_required` confirmation. Prompts and server instructions do not request a second conversational confirmation. +- OAuth is HTTP-only. Stdio continues to obtain wallet and RPC credentials from its environment. + +## HTTP OAuth Configuration + +The HTTP process acts as an OAuth resource server; it does not issue access tokens. With the default local `MCP_HOST=127.0.0.1`, omitting `MCP_OAUTH_ISSUER_URL` keeps OAuth disabled. Setting it enables OAuth locally. Binding to a non-local interface requires OAuth and aborts startup if the configuration is incomplete. + +Required when OAuth is enabled: + +- `MCP_OAUTH_ISSUER_URL`: exact HTTPS authorization-server issuer without a query or fragment +- `MCP_PUBLIC_URL`: exact externally reachable MCP endpoint with the `/mcp` path and no query or fragment; non-local deployments require HTTPS +- `MCP_OAUTH_CLIENT_ID`: RFC 7662 introspection client ID +- `MCP_OAUTH_CLIENT_SECRET`: RFC 7662 introspection client secret + +Optional overrides: + +- `MCP_OAUTH_METADATA_URL`: authorization-server metadata URL; defaults to the RFC 8414 URL derived from the issuer, including correct well-known path insertion for issuers with a path +- `MCP_OAUTH_INTROSPECTION_URL`: introspection endpoint when metadata does not publish `introspection_endpoint` +- `MCP_OAUTH_AUDIENCE`: expected token audience/resource; defaults to `MCP_PUBLIC_URL` +- `MCP_OAUTH_SCOPES`: additional advertised scopes; the minimal built-in `mcp` scope is always advertised +- `MCP_OAUTH_REQUIRED_SCOPES`: additional scopes required for every MCP request; the baseline `mcp` scope is always required + +Introspection must return an active token with a client identity, expiration, the expected audience/resource, and appropriate scopes. In addition to the baseline scope, transaction and approval tools require `evm:write`; signing tools require `evm:sign`. + +Authorization-server metadata must support the authorization-code response type and PKCE `S256`. The issuer, metadata URL, and every authorization-server endpoint must use HTTPS. Metadata discovery and introspection reject redirects and use a 10-second deadline. ## Verification @@ -60,9 +102,13 @@ The automated MCP integration tests cover: - final `server/discover` shape and server identity metadata - optional `clientInfo` - deterministic tool listing and closed no-argument schemas +- output schemas and structured tool results +- confirmation requests and declines for all six wallet-backed operations, plus shared-helper coverage for argument binding, tamper rejection, and single-use replay prevention +- bounded transaction waiting - cache hints on discovery, list, and resource results - resource reads and a read-only tool call - final `HeaderMismatch` and `UnsupportedProtocolVersion` error codes +- local authorization opt-out, remote fail-closed behavior, OAuth metadata validation, RFC 7662 introspection, audience checks, and scopes Release checks: @@ -77,6 +123,5 @@ bun run build:http These are enhancements, not compliance blockers: -- Add `outputSchema` and `structuredContent` to high-value read tools while preserving text content for older clients. -- Adopt the Tasks extension only if transaction confirmation regularly exceeds practical request timeouts. -- Add MCP OAuth before exposing wallet-backed write tools through a shared remote deployment. +- Revisit the Tasks extension only after the official SDK provides a released server runtime for the final extension protocol. +- Add deployment-specific rate limiting, audit logging, secret management, and authorization-server operational guidance before hosting a shared production endpoint. diff --git a/package.json b/package.json index 5067669..5166014 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,8 @@ { "name": "@mcpdotdirect/evm-mcp-server", - "module": "src/index.ts", "type": "module", "version": "2.0.4", - "description": "MCP server for interacting with EVM-compatible blockchains - supports 25 tools and 10 prompts across 60+ networks", + "description": "MCP server with 25 tools and 10 prompts across 55 EVM chains", "bin": { "evm-mcp-server": "./bin/cli.js" }, @@ -29,7 +28,7 @@ "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md", "inspect": "npx @modelcontextprotocol/inspector node build/index.js", - "test:mcp": "bun test test/mcp-2026.test.ts" + "test:mcp": "bun test test/mcp-2026.test.ts test/auth.test.ts test/http-auth.test.ts" }, "devDependencies": { "@types/bun": "latest", @@ -41,6 +40,7 @@ "typescript": "^5.8.2" }, "dependencies": { + "@modelcontextprotocol/express": "^2.0.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "express": "^5.2.1", diff --git a/src/core/chains.ts b/src/core/chains.ts index 75115d3..ad0a614 100644 --- a/src/core/chains.ts +++ b/src/core/chains.ts @@ -61,7 +61,6 @@ import { } from 'viem/chains'; // Default configuration values -export const DEFAULT_RPC_URL = 'https://eth.llamarpc.com'; export const DEFAULT_CHAIN_ID = 1; // Map chain IDs to chains @@ -288,49 +287,34 @@ export const rpcUrlMap: Record = { * @returns The resolved chain ID */ export function resolveChainId(chainIdentifier: number | string): number { - if (typeof chainIdentifier === 'number') { + if (typeof chainIdentifier === 'string') { + const normalizedIdentifier = chainIdentifier.toLowerCase(); + const namedChainId = networkNameMap[normalizedIdentifier]; + if (namedChainId !== undefined) { + return namedChainId; + } + + if (/^\d+$/.test(normalizedIdentifier)) { + const numericChainId = Number(normalizedIdentifier); + if (Number.isSafeInteger(numericChainId) && chainMap[numericChainId]) { + return numericChainId; + } + } + } else if (Number.isSafeInteger(chainIdentifier) && chainMap[chainIdentifier]) { return chainIdentifier; } - - // Convert to lowercase for case-insensitive matching - const networkName = chainIdentifier.toLowerCase(); - - // Check if the network name is in our map - const chainId = networkNameMap[networkName]; - if (chainId !== undefined) { - return chainId; - } - - // Try parsing as a number - const parsedId = parseInt(networkName); - if (!isNaN(parsedId)) { - return parsedId; - } - - // Default to mainnet if not found - return DEFAULT_CHAIN_ID; + + throw new Error(`Unsupported network: ${chainIdentifier}`); } /** * Returns the chain configuration for the specified chain ID or network name * @param chainIdentifier Chain ID (number) or network name (string) * @returns The chain configuration - * @throws Error if the network is not supported (when string is provided) + * @throws Error if the network or chain ID is not supported */ export function getChain(chainIdentifier: number | string = DEFAULT_CHAIN_ID): Chain { - if (typeof chainIdentifier === 'string') { - const networkName = chainIdentifier.toLowerCase(); - // Try to get from direct network name mapping first - if (networkNameMap[networkName]) { - return chainMap[networkNameMap[networkName]] || mainnet; - } - - // If not found, throw an error - throw new Error(`Unsupported network: ${chainIdentifier}`); - } - - // If it's a number, return the chain from chainMap - return chainMap[chainIdentifier] || mainnet; + return chainMap[resolveChainId(chainIdentifier)]; } /** @@ -339,19 +323,21 @@ export function getChain(chainIdentifier: number | string = DEFAULT_CHAIN_ID): C * @returns The RPC URL for the specified chain */ export function getRpcUrl(chainIdentifier: number | string = DEFAULT_CHAIN_ID): string { - const chainId = typeof chainIdentifier === 'string' - ? resolveChainId(chainIdentifier) - : chainIdentifier; - - return rpcUrlMap[chainId] || DEFAULT_RPC_URL; + return rpcUrlMap[resolveChainId(chainIdentifier)]; } /** - * Get a list of supported networks - * @returns Array of supported network names (excluding short aliases) + * Get the configured network names and aliases. + * @returns Array of supported network names and aliases */ export function getSupportedNetworks(): string[] { return Object.keys(networkNameMap) - .filter(name => name.length > 2) // Filter out short aliases .sort(); -} +} + +/** + * Get the number of distinct configured chain IDs. + */ +export function getSupportedChainCount(): number { + return Object.keys(chainMap).length; +} diff --git a/src/core/prompts.ts b/src/core/prompts.ts index 247c59f..b00900c 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -25,11 +25,19 @@ export function registerEVMPrompts(server: McpServer) { { description: "Safely prepare and execute a token transfer with validation checks", argsSchema: z.object({ - tokenType: z.enum(["native", "erc20"]).describe("Token type: 'native' for ETH/MATIC or 'erc20' for contract tokens"), + tokenType: z.enum(["native", "erc20"]).describe("Token type: 'native' for ETH/POL or 'erc20' for contract tokens"), recipient: z.string().describe("Recipient address or ENS name"), - amount: z.string().describe("Amount to transfer (in ether for native, token units for ERC20)"), + amount: z.string().describe("Amount to transfer (in whole native-token units or ERC20 token units)"), network: z.string().optional().describe("Network name (default: ethereum)"), tokenAddress: z.string().optional().describe("Token contract address (required for ERC20)") + }).superRefine(({ tokenType, tokenAddress }, ctx) => { + if (tokenType === "erc20" && !tokenAddress) { + ctx.addIssue({ + code: "custom", + path: ["tokenAddress"], + message: "tokenAddress is required when tokenType is 'erc20'" + }); + } }) }, ({ tokenType, recipient, amount, network = "ethereum", tokenAddress }) => ({ @@ -43,42 +51,41 @@ export function registerEVMPrompts(server: McpServer) { ## Validation & Checks Before executing any transfer: -1. **Wallet Verification**: Call \`get_wallet_address\` to confirm the sending wallet +1. **Wallet Verification**: Call \`get_wallet_address\` with no arguments to identify the sending wallet 2. **Balance Check**: ${tokenType === "native" - ? "- Call `get_balance` to verify native token balance" - : "- Call `get_token_balance` with tokenAddress=${tokenAddress} to verify balance"} -3. **Gas Analysis**: Call \`get_gas_price\` to assess current network costs -${tokenType === "erc20" ? `4. **Approval Check**: Call \`get_allowance\` to verify approval (if needed for protocols)` : ""} + ? `- Call \`get_balance\` with address set to the sending wallet and network="${network}"` + : `- Call \`get_token_balance\` with address set to the sending wallet, tokenAddress="${tokenAddress}", and network="${network}"`} +3. **Gas Analysis**: Call \`get_gas_price\` with network="${network}" to assess current network gas conditions ## Execution Steps ${tokenType === "native" ? ` -1. Summarize: sender address, recipient, amount, and estimated gas cost -2. Request confirmation from user -3. Call \`transfer_native\` with to="${recipient}", amount="${amount}", network="${network}" -4. Return transaction hash to user -5. Call \`wait_for_transaction\` to confirm completion +1. Summarize: sender address, recipient, amount, and current gas conditions +2. Call \`transfer_native\` with to="${recipient}", amount="${amount}", network="${network}" +3. Let the tool enforce the exact-operation MCP confirmation; do not ask a duplicate conversational confirmation +4. After acceptance and execution, record the returned transaction hash +5. Call \`wait_for_transaction\` with txHash="[hash from transfer_native]", timeoutSeconds between 1 and 90, and network="${network}" +6. If the bounded wait times out, report the transaction as unconfirmed rather than assuming success or failure ` : ` -1. Check if approval is needed: - - If allowance < amount: Call \`approve_token_spending\` first - - Then proceed with transfer -2. Summarize: sender, recipient, token, amount, decimals, gas estimate -3. Request confirmation -4. Call \`transfer_erc20\` with tokenAddress, recipient, amount -5. Wait for confirmation with \`wait_for_transaction\` +1. Summarize: sender, recipient, token, amount, decimals, and current gas conditions +2. Call \`transfer_erc20\` with tokenAddress="${tokenAddress}", to="${recipient}", amount="${amount}", network="${network}" +3. Let the tool enforce the exact-operation MCP confirmation; do not ask a duplicate conversational confirmation +4. After acceptance and execution, record the returned transaction hash +5. Call \`wait_for_transaction\` with txHash="[hash from transfer_erc20]", timeoutSeconds between 1 and 90, and network="${network}" +6. If the bounded wait times out, report the transaction as unconfirmed rather than assuming success or failure `} ## Output Format -- **Transaction Hash**: Clear hex value -- **Status**: Pending or Confirmed -- **Cost Estimate**: Gas price and total cost -- **User Confirmation**: Always ask before sending +- **Transaction Hash**: Clear hex value if execution occurred +- **Submission State**: Submitted once a transaction hash is returned +- **Confirmation Result**: Confirmed or Failed when \`wait_for_transaction\` succeeds; Unconfirmed if the bounded wait times out +- **Gas Conditions**: Current gas price; note that this server does not estimate total transaction cost +- **MCP Confirmation**: Report whether the tool's protocol-level confirmation was accepted, declined, or cancelled; a decline or cancellation produces no transaction hash ## Safety Considerations - Never send more than available balance - Double-check recipient address - Warn about high gas prices -- Explain any approval requirements ` } }] @@ -106,59 +113,56 @@ ${tokenType === "native" ? ` ## Investigation Process ### 1. Gather Transaction Data -- Call \`get_transaction\` to fetch transaction details -- Call \`get_transaction_receipt\` to get status and gas used -- Note: both calls are read-only and free +- Call \`get_transaction\` with txHash="${txHash}", network="${network}" to fetch transaction details +- Call \`get_transaction_receipt\` with txHash="${txHash}", network="${network}" to get a mined transaction's status, gas used, and logs +- If the receipt call reports that no receipt exists yet, classify the transaction only as unconfirmed +- Both calls are read-only and consume no on-chain gas, although RPC usage may be metered ### 2. Status Assessment -Determine transaction state: -- **Pending**: Not yet mined (check mempool conditions) -- **Confirmed**: Successfully executed (status='success') -- **Failed**: Execution failed (status='failed') -- **Replaced**: Transaction was dropped/replaced (check nonce) +Determine only the state supported by the returned data: +- **Confirmed**: A receipt exists with status='success' +- **Reverted**: A receipt exists with status='reverted' +- **Unconfirmed**: The transaction exists but a receipt is not yet available +- **Unavailable**: The transaction lookup fails; do not infer whether it was dropped or replaced ### 3. Failure Analysis -If transaction failed, investigate: - -**Out of Gas**: -- Compare gasUsed vs gasLimit in receipt -- If gasUsed >= gasLimit, suggest increasing gas limit +If the receipt reports a revert, inspect only the available transaction and receipt fields: -**Contract Revert**: -- Check function called and parameters -- Verify sufficient balance/approvals -- Look for require/revert statements in contract +**Gas Usage**: +- Compare receipt gasUsed with the transaction gas limit +- Treat equality or near-equality as a possible out-of-gas indicator, not a proven cause -**Invalid Nonce**: -- Compare transaction nonce with account's current nonce -- Suggest pending transactions may need replacement +**Call Data**: +- Report the destination and raw transaction input +- Do not claim a function name or decoded parameters unless they are independently available +- A standard receipt does not contain the revert reason or Solidity source location -**Other Issues**: +**Observable Issues**: - Check sender/recipient addresses are valid -- Verify function parameters are correct type -- Look for access control restrictions +- Report the transaction value, gas fields, receipt status, and emitted logs +- State clearly when the available data cannot establish the root cause ### 4. Gas Analysis -- Calculate gas cost: gasUsed * gasPrice -- Compare to current gas prices (call \`get_gas_price\`) -- Assess if overpaid or underpaid +- Calculate actual gas cost from receipt gasUsed and effectiveGasPrice when both fields are available +- Call \`get_gas_price\` with network="${network}" only for current network context +- Do not label the historical transaction overpaid or underpaid from a current gas quote alone ## Output Format Provide structured diagnosis: -- **Status**: Pending/Confirmed/Failed with reason +- **Status**: Confirmed/Reverted/Unconfirmed/Unavailable, based only on returned data - **Transaction Hash**: The hash analyzed - **From/To**: Addresses involved -- **Function**: What was called +- **Call Data**: Raw input or method selector when available - **Gas Analysis**: Used vs limit, cost -- **Issue (if failed)**: Root cause and explanation -- **Recommended Actions**: Next steps to resolve +- **Evidence**: Receipt status, block, and logs when available +- **Limitations**: Information that would require simulation, tracing, source code, or transaction-history indexing +- **Recommended Actions**: Evidence-based next steps only ## Important Notes -- Be specific about error messages and codes -- Provide actionable recommendations -- Link issues to specific contract behavior -- Suggest solutions (retry, increase gas, fix parameters, etc.) +- Preserve exact error messages and returned field values +- Do not infer replacement status, current account nonce, revert reason, or contract source behavior +- Recommend retrying only after the cause is known and corrected ` } }] @@ -172,7 +176,7 @@ Provide structured diagnosis: server.registerPrompt( "analyze_wallet", { - description: "Get comprehensive overview of wallet assets, balances, and activity", + description: "Summarize native and explicitly requested ERC20 balances for a wallet", argsSchema: z.object({ address: z.string().describe("Wallet address or ENS name to analyze"), network: z.string().optional().describe("Network name (default: ethereum)"), @@ -188,25 +192,27 @@ Provide structured diagnosis: type: "text", text: `# Wallet Analysis -**Objective**: Provide complete asset overview for ${address} on ${network} +**Objective**: Summarize requested balances for ${address} on ${network} ## Information Gathering ### 1. Address Resolution -- If input contains '.eth', call \`resolve_ens_name\` to get address -- Otherwise use as direct address -- Provide both resolved address and ENS name if applicable +- If "${address}" is a name, call \`resolve_ens_name\` with ensName="${address}", network="${network}" +- Otherwise use it as a direct address +- For a direct address, call \`lookup_ens_address\` with address="${address}", network="${network}" only if reverse ENS information is useful +- Preserve the original input and any returned resolved address or ENS name ### 2. Native Token Balance -- Call \`get_balance\` to fetch native token (ETH/MATIC/etc) balance -- Report both wei and ether/human-readable formats -- Note: Free read-only call +- Call \`get_balance\` with address="${address}", network="${network}" +- Report both raw wei and human-readable formats +- This read-only call consumes no on-chain gas, although RPC usage may be metered ### 3. Token Balances ${tokenList.length > 0 - ? `- Call \`get_token_balance\` for each token:\n${tokenList.map(t => ` * ${t}`).join('\n')}` - : `- If specific tokens provided: call \`get_token_balance\` for each -- Include token symbol and decimals if available`} + ? `- Call \`get_token_balance\` for each requested token:\n${tokenList.map(t => ` * address="${address}", tokenAddress="${t}", network="${network}"`).join('\n')} +- Include the symbol, decimals, raw balance, and formatted balance returned by each call` + : `- No token addresses were provided, so do not claim to enumerate ERC20 holdings +- Explain that token discovery requires an external indexer or an explicit list of token addresses`} ## Output Format @@ -218,9 +224,8 @@ Provide analysis with clear sections: - Network: [network] **Native Token Balance** -- Ether: [formatted amount] +- Formatted Native Amount: [balance.formatted field] - Wei: [raw amount] -- In USD (if price available): [estimated value] **Token Holdings** (if requested) - Token: [address] @@ -229,16 +234,16 @@ Provide analysis with clear sections: - Decimals: [decimals] **Summary** -- Total assets value (if prices available) -- Primary holdings -- Notable observations +- Native balance +- Requested ERC20 balances +- Coverage limitations, including any tokens not requested ## Key Considerations - Show both formatted and raw amounts - Include token decimals for precision - Note if wallet has low/no balance -- Highlight any unusual patterns -- Be clear about what data was available vs not +- Do not claim USD value, complete holdings, transaction history, or wallet activity +- Be clear about which token addresses were checked and what data was unavailable ` } }] @@ -249,87 +254,82 @@ Provide analysis with clear sections: server.registerPrompt( "audit_approvals", { - description: "Review token approvals and identify security risks from unlimited spend", + description: "Assess one ERC20 allowance for a specific owner, token, and spender", argsSchema: z.object({ address: z.string().optional().describe("Wallet to audit (default: configured wallet)"), tokenAddress: z.string().describe("Token contract address to check approvals for"), + spenderAddress: z.string().describe("Spender contract address to assess"), network: z.string().optional().describe("Network name (default: ethereum)") }) }, - ({ address, tokenAddress, network = "ethereum" }) => ({ + ({ address, tokenAddress, spenderAddress, network = "ethereum" }) => ({ messages: [{ role: "user", content: { type: "text", text: `# Token Approval Audit -**Objective**: Check and analyze token approvals to identify security risks +**Objective**: Assess the allowance granted by ${address ?? "the configured wallet"} to ${spenderAddress} for token ${tokenAddress} on ${network} ## Approval Analysis ### 1. Get Configured Wallet (if needed) -- If no address provided: call \`get_wallet_address\` to get the configured wallet -- Use that as the owner for approval checks +- If no owner address was provided, call \`get_wallet_address\` with no arguments to identify the configured wallet -### 2. Check Current Approvals +### 2. Check Current Allowance - Call \`get_allowance\` with: * tokenAddress: ${tokenAddress} - * ownerAddress: [wallet address from step 1] - * spenderAddress: [contract being analyzed] -- Note the allowance amount returned + * ownerAddress: ${address ?? "[wallet address from step 1]"} + * spenderAddress: ${spenderAddress} + * network: ${network} +- Treat the returned allowance as a raw token base-unit integer ### 3. Interpret Results **Allowance = 0** - No approval set - User must approve before spender can use tokens -- Safe state +- No exposure through this allowance **Allowance < Max Value** -- Limited approval (safest approach) +- Limited approval - Spender can only use up to this amount -- Tokens are protected +- The amount alone does not establish whether the spender is trustworthy or actively used **Allowance = Max uint256 (unlimited)** -- Dangerous! Spender has unlimited access +- The spender has effectively unlimited allowance - Common but risky pattern -- Should be revoked if not actively used +- Recommend review or revocation only in light of user-provided or external context -## Security Assessment +## Allowance Assessment -For each approval found: -1. **Risk Level**: Low/Medium/High based on: - - Is it unlimited (high risk)? - - How trusted is the spender? - - Is it actively used? +For this allowance: +1. **Exposure Level**: None/Limited/Unlimited based only on the numeric allowance 2. **Recommendations**: - - Revoke unknown/untrusted spenders - - Lower limits on high-risk approvals - - Keep active approvals but monitor - - Remove expired/legacy approvals + - Consider setting the allowance to zero if the user no longer needs it + - Prefer a limited amount when the intended operation permits it + - Ask for external context before making claims about spender reputation or current usage ## Output Format **Token Approval Audit Report** -For each spender: -- **Spender Address**: [contract address] -- **Current Allowance**: [amount or "Unlimited"] -- **Risk Level**: Low/Medium/High -- **Status**: Active/Unused -- **Recommendation**: Keep/Reduce/Revoke +- **Owner Address**: [wallet address] +- **Spender Address**: ${spenderAddress} +- **Current Allowance (raw base units)**: [integer] +- **Exposure Level**: None/Limited/Unlimited +- **Recommendation**: Keep/Reduce/Revoke/Review, with rationale limited to the observed allowance **Summary** -- Total dangerous approvals: [count] - Recommendations: [action items] -- Overall risk: Safe/Moderate/High +- Missing context: spender reputation, approval history, and current usage are not available from this tool ## Important Notes -- Unlimited approvals are a major attack vector +- Unlimited allowance increases the amount exposed to the spender - Only approve what's necessary -- Regularly audit and revoke unused approvals -- Be especially careful with new/unknown contracts +- This workflow checks one token/spender pair; it does not enumerate all approvals +- Do not claim an approval is active, expired, legacy, trusted, or malicious from allowance data alone ` } }] @@ -343,7 +343,7 @@ For each spender: server.registerPrompt( "fetch_and_analyze_abi", { - description: "Fetch contract ABI from block explorer and provide comprehensive analysis", + description: "Fetch a verified contract ABI and summarize its exposed interface", argsSchema: z.object({ contractAddress: z.string().describe("Contract address to analyze"), network: z.string().optional().describe("Network name (default: ethereum)"), @@ -357,96 +357,84 @@ For each spender: type: "text", text: `# ABI Fetch and Analysis -**Objective**: Retrieve and analyze contract ABI from block explorer +**Objective**: Retrieve the contract ABI from a block explorer and summarize the interface it exposes ## Prerequisites -- Contract must be verified on block explorer (Etherscan/Polygonscan/etc) +- Contract must be verified and supported by the Etherscan v2 API - ETHERSCAN_API_KEY environment variable required -- Supports 30+ EVM networks via unified Etherscan v2 API -- Read-only, no gas cost +- Works on configured chains supported by Etherscan v2 +- Read-only and consumes no on-chain gas; explorer API usage may be metered ## Fetching Process ### 1. Fetch the ABI - Call \`get_contract_abi\` with contractAddress="${contractAddress}", network="${network}" -- Returns full ABI array with all functions, events, state variables -- Includes metadata about each function (inputs, outputs, mutability) +- Use the returned ABI array to inspect functions, events, errors, constructors, fallback handlers, and receive handlers +- Function entries expose names, inputs, outputs, and state mutability; they do not include Solidity source or modifiers ### 2. Parse and Categorize Organize functions by type: -**View/Pure Functions** (Read-only, free): -- Check current state -- Query data without state change -- Safe to call +**View/Pure Functions**: +- Read-only at the ABI level +- Consume no transaction gas when called through \`read_contract\`, although RPC usage may be metered **State-Changing Functions**: -- Payable: require ETH value -- Nonpayable: modify contract state -- Cost gas, need signer - -**Admin Functions**: -- Often restricted (onlyOwner, etc) -- Control contract behavior -- High risk if compromised +- Payable: can accept native-token value +- Nonpayable: cannot accept native-token value +- Require a signer and an on-chain transaction ### 3. Analyze Structure - Count functions by type -- Identify events and their usage +- List event and custom-error signatures - Look for special functions (constructor, fallback, receive) -- Check for custom errors +- Identify overloaded function names and preserve their full signatures ${findFunction ? `### 4. Find Specific Function - Search for "${findFunction}" in ABI -- Document: inputs, outputs, mutability -- Explain what it does -- Note any access controls` : `### 4. Key Functions -- Identify most important/used functions -- Explain inputs and outputs -- Note special requirements`} +- Document its exact inputs, outputs, and state mutability +- Describe only behavior implied directly by its signature +- State that access controls and implementation behavior are unknown from ABI alone` : `### 4. Interface Highlights +- Group recognizable function signatures by likely purpose +- Describe inputs and outputs without inventing implementation behavior +- Treat importance and contract type as interface-level heuristics`} ## Function Analysis Format For important functions provide: - **Name**: Function name - **Type**: View/Pure/Payable/Nonpayable -- **Inputs**: Parameter names and types with descriptions +- **Inputs**: Parameter names and ABI types - **Outputs**: Return values and types -- **Access**: Public/External/Restricted -- **Purpose**: What it does -- **Usage**: How to call it +- **Access Controls**: Unknown from ABI unless supplied by an external source +- **Likely Purpose**: A clearly labeled signature-based heuristic +- **Invocation Shape**: Arguments and whether native-token value is accepted -## Security Analysis +## Interface Heuristics -Look for: -- **Proxy Patterns**: Is this a proxy contract? -- **Access Controls**: Who can call what? -- **Special Functions**: Initialization, upgrade paths -- **Obvious Issues**: Reentrancy risks, overflow/underflow patterns -- **Standard Compliance**: Is it ERC20/721/1155 compatible? +- Note signatures commonly associated with ERC20, ERC721, ERC1155, proxy administration, or initialization +- Describe these as compatibility or pattern indicators, not proof of implementation or standards compliance +- Do not claim vulnerabilities, source-level access controls, reentrancy safety, arithmetic safety, or upgrade behavior from ABI alone ## Output Format -**Contract Analysis Report** +**Contract Interface Report** -- **Contract Type**: Identified purpose (Token/DEX/Lending/etc) +- **Likely Interface Type**: Signature-based heuristic with confidence and caveats - **Network**: Where deployed -- **Verified**: Yes (since we fetched ABI) +- **Explorer ABI Available**: Yes - **Function Count**: Total functions by type **Function Categories**: - View/Pure: [list of read functions] - Write: [list of state-changing functions] -- Admin: [restricted functions] +- Pattern Indicators: [standard or administrative-looking signatures, clearly labeled as heuristics] **Key Functions**: -[Detailed analysis of important functions] - -**Security Notes**: -[Vulnerabilities, patterns, recommendations] +[Exact signatures and ABI-derived invocation details] -**How to Interact**: -[Step-by-step guide for common operations] +**Limitations**: +- ABI does not provide source code, modifiers, access-control rules, business logic, vulnerability status, or runtime state ` } }] @@ -456,7 +444,7 @@ Look for: server.registerPrompt( "explore_contract", { - description: "Analyze contract functions and state without requiring full ABI", + description: "Inspect a contract interface and selected state through verified ABI or supported common reads", argsSchema: z.object({ contractAddress: z.string().describe("Contract address to explore"), network: z.string().optional().describe("Network name (default: ethereum)"), @@ -470,93 +458,74 @@ Look for: type: "text", text: `# Contract Exploration -**Objective**: Understand what contract ${contractAddress} does and how to use it +**Objective**: Inspect the exposed interface and selected readable state of ${contractAddress} on ${network} ## Exploration Strategy ${fetchAbi === 'true' ? `### With Full ABI (Fetched) -1. Call \`get_contract_abi\` to fetch verified ABI -2. Parse all available functions -3. Call \`read_contract\` for important state functions -4. Build comprehensive understanding +1. Call \`get_contract_abi\` with contractAddress="${contractAddress}", network="${network}" +2. Parse the returned function signatures, events, and errors +3. For a selected no-argument view function, call \`read_contract\` with contractAddress="${contractAddress}", functionName="[function name]", network="${network}" +4. For a view function with parameters, include args=["[argument strings]"] based on its ABI ` - : `### Without Full ABI (Probing) -1. Test common function signatures -2. Call \`read_contract\` with standard functions: - - name(), symbol(), decimals(), totalSupply() - - owner(), paused(), version() - - balanceOf(), allowance(), totalSupply() -3. Infer contract type from successful calls + : `### Common Read Probes +1. Be aware that \`read_contract\` first attempts to fetch a verified ABI automatically +2. If ABI fetch is unavailable, its built-in fallback supports only name, symbol, decimals, totalSupply, balanceOf, and allowance +3. Probe no-argument functions with exact calls such as: + - contractAddress="${contractAddress}", functionName="name", network="${network}" + - contractAddress="${contractAddress}", functionName="symbol", network="${network}" + - contractAddress="${contractAddress}", functionName="decimals", network="${network}" + - contractAddress="${contractAddress}", functionName="totalSupply", network="${network}" +4. Do not call balanceOf or allowance without the required address arguments +5. Treat successful signatures as interface clues, not proof of contract behavior `} -## Detection Process +## Interface Assessment -### 1. Identify Contract Type -Based on available functions, determine: -- **Token**: Has name, symbol, decimals, totalSupply, balanceOf -- **NFT/ERC721**: Has tokenURI, ownerOf, name, symbol -- **NFT/ERC1155**: Has uri, balanceOf, balanceOfBatch -- **Staking**: Has stake, unstake, reward, claim functions -- **DEX**: Has swap, liquidity, pair functions -- **Other**: Analyze unique functions +### 1. Identify Signature Patterns +Based only on functions that appear in a fetched ABI or succeed as reads: +- **ERC20-like**: name, symbol, decimals, totalSupply, balanceOf, allowance +- **ERC721-like**: ownerOf, tokenURI, name, symbol +- **ERC1155-like**: uri, balanceOf, balanceOfBatch +- **Other recognizable interfaces**: describe as tentative signature matches ### 2. Gather Key Information -For each contract type: - -**Token (ERC20)**: -- name, symbol, decimals, totalSupply -- If owner, supply cap, minting rules -- If tax/fee mechanism - -**NFT (ERC721)**: -- name, symbol, totalSupply -- baseURI, tokenURI patterns -- royalty info if available - -**Staking/Farming**: -- Pool info, APY, reward token -- Lockup periods, early withdrawal penalties -- Reward distribution mechanism +- Report exact values returned by successful read calls +- List relevant ABI signatures and state mutability when a full ABI is available +- Do not infer minting rules, fees, royalties, APY, lockups, ownership restrictions, or upgrade behavior unless a specific read returns that information -### 3. Security Assessment -- Check for pause functions (risk of rug) -- Look for upgrade mechanisms (upgradeable proxy) -- Identify admin-only functions -- Note unusual patterns +### 3. Limits of Interface Inspection +- Function names can indicate a possible pattern but not implementation behavior +- ABI data does not reveal Solidity modifiers, source code, vulnerabilities, or who is authorized to call a function +- Failed probes do not prove that a capability is absent ## Output Format **Contract Overview** - Address: [address] -- Type: [identified type] +- Likely Interface: [signature-based heuristic, or unknown] - Network: [network] -- Verified: [yes/if ABI was fetched] +- Explorer ABI Available: [yes/no] -**Key Properties** -[Type-specific details discovered] +**Observed Values** +[Only values returned by successful read calls] **Available Functions** - Read-only: [list] - State-changing: [list] -- Admin: [list if any] - -**How to Use** -[Step-by-step guide for primary use case] - -**Security Notes** -[Observations and recommendations] +- Pattern indicators: [clearly labeled interface heuristics] **Limitations** -[What couldn't be determined without full ABI] +[What could not be determined from the ABI and selected reads] ## When to Use ABI Fetch - Need complete function list - Want detailed parameter information - Exploring unfamiliar/complex contracts -- Security due diligence -- Learn contract architecture +- Need event and custom-error signatures +- Need a reliable invocation shape before a read or write ` } }] @@ -570,17 +539,31 @@ For each contract type: server.registerPrompt( "interact_with_contract", { - description: "Safely execute write operations on a smart contract with validation and confirmation", + description: "Safely execute smart contract writes with validation and tool-enforced MCP confirmation", argsSchema: z.object({ contractAddress: z.string().describe("Contract address to interact with"), functionName: z.string().describe("Function to call (e.g., 'mint', 'swap', 'stake')"), - args: z.string().optional().describe("Comma-separated function arguments"), - value: z.string().optional().describe("ETH value to send (for payable functions)"), + args: z.string() + .optional() + .refine((value) => { + if (value === undefined) { + return true; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) && parsed.every(argument => typeof argument === "string"); + } catch { + return false; + } + }, "args must be a JSON array of strings") + .describe("Function arguments as a JSON array of strings"), + value: z.string().optional().describe("Native-token value to send (for payable functions)"), network: z.string().optional().describe("Network name (default: ethereum)") }) }, ({ contractAddress, functionName, args, value, network = "ethereum" }) => { - const argsList = args ? args.split(',').map(a => a.trim()) : []; + const argsList = args ? JSON.parse(args) as string[] : []; return { messages: [{ role: "user", @@ -593,39 +576,41 @@ For each contract type: ## Prerequisites Check ### 1. Wallet Verification -- Call \`get_wallet_address\` to confirm the wallet that will execute this transaction +- Call \`get_wallet_address\` with no arguments to identify the wallet that will execute this transaction - Verify this is the correct wallet for this operation ### 2. Contract Analysis -- Call \`get_contract_abi\` to fetch and analyze the contract ABI -- Verify the function exists and understand its parameters +- Call \`get_contract_abi\` with contractAddress="${contractAddress}", network="${network}" +- Verify that ${functionName} exists and record its exact inputs and state mutability - Check function type: * **View/Pure**: Read-only (use \`read_contract\` instead) - * **Nonpayable**: State-changing, no ETH required - * **Payable**: State-changing, can accept ETH + * **Nonpayable**: State-changing, no native-token value accepted + * **Payable**: State-changing, can accept native-token value +- The ABI does not reveal source-level access controls or implementation behavior ### 3. Function Parameter Validation For function: **${functionName}** ${argsList.length > 0 ? `Arguments provided: ${argsList.join(', ')}` : 'No arguments provided'} - Verify parameter types match the ABI -- Validate addresses are checksummed +- Validate address syntax and network - Check numeric values are in correct units -- Resolve any ENS names to addresses if needed +- If an address argument is an ENS name, call \`resolve_ens_name\` with ensName="[name]", network="${network}" and substitute the returned address before invoking \`write_contract\` ### 4. Pre-execution Checks **Balance Check**: -- Call \`get_balance\` to verify sufficient native token balance -- Account for gas costs + value (if payable) +- Call \`get_balance\` with address="[wallet address from step 1]", network="${network}" +- Verify the native balance covers the explicit value, if any +- Do not claim that the remaining balance is sufficient for gas because this server does not estimate transaction gas usage -**Gas Estimation**: -- Call \`get_gas_price\` to estimate transaction cost -- Calculate total cost: (gas_price * estimated_gas) + value +**Gas Conditions**: +- Call \`get_gas_price\` with network="${network}" to report current network gas conditions +- Do not present a full transaction cost estimate because this server does not estimate gas usage **State Verification** (if applicable): -- Use \`read_contract\` to check current contract state -- Verify conditions are met (e.g., allowances, balances, ownership) +- For a specific view function identified in the ABI, call \`read_contract\` with contractAddress="${contractAddress}", functionName="[view function]", args=["[argument strings]"] when required, and network="${network}" +- Report only the returned state; do not infer unavailable access-control or business-logic conditions ## Execution Process @@ -635,19 +620,12 @@ Before executing, show: - **Network**: ${network} - **Function**: ${functionName} - **Arguments**: ${argsList.length > 0 ? argsList.join(', ') : 'None'} -${value ? `- **Value**: ${value} ETH` : ''} +${value ? `- **Native-Token Value**: ${value}` : ''} - **From**: [wallet address from step 1] -- **Estimated Gas Cost**: [from gas estimation] -- **Total Cost**: [gas + value] - -### 2. Request User Confirmation -⚠️ **IMPORTANT**: Always ask user to confirm before executing write operations -- Clearly state what will happen -- Show all costs involved -- Explain any risks or irreversible actions +- **Current Gas Conditions**: [from get_gas_price] +- **Transaction Cost**: Not estimated -### 3. Execute Transaction -Only after user confirms: +### 2. Invoke the Transaction Tool \`\`\` Call write_contract with: - contractAddress: "${contractAddress}" @@ -657,82 +635,77 @@ ${value ? `- value: "${value}"` : ''} - network: "${network}" \`\`\` -### 4. Monitor Transaction +The \`write_contract\` tool returns an MCP \`input_required\` request describing the exact operation before it accesses the wallet. Let the client display and answer that protocol-level request; do not ask a duplicate conversational confirmation. The operation executes only after the client accepts with \`confirm: true\`. A decline or cancellation is terminal. + +### 3. Monitor Transaction After execution: 1. Return transaction hash to user -2. Call \`wait_for_transaction\` to monitor confirmation -3. Call \`get_transaction_receipt\` to verify success -4. If failed, call \`diagnose_transaction\` to understand why +2. Call \`wait_for_transaction\` with txHash="[hash from write_contract]", timeoutSeconds between 1 and 90, and network="${network}" +3. Call \`get_transaction_receipt\` with txHash="[hash from write_contract]", network="${network}" for the raw mined receipt +4. If the receipt reports a revert, use MCP \`prompts/get\` with name="diagnose_transaction" and arguments={ txHash: "[hash from write_contract]", network: "${network}" }, then follow the returned workflow +5. If the bounded wait times out, report the transaction as unconfirmed rather than assuming success or failure ## Output Format **Pre-Execution Summary**: - Contract details - Function and parameters -- Cost breakdown -- Risk assessment +- Native-token value, if any +- Current gas conditions and the absence of a full cost estimate -**Confirmation Request**: -"Ready to execute ${functionName} on ${contractAddress}. This will cost approximately [X] ETH. Proceed? (yes/no)" +**MCP Confirmation**: +- Report whether the tool-generated confirmation was accepted, declined, or cancelled; a decline or cancellation produces no transaction **Execution Result**: -- Transaction Hash: [hash] -- Status: Pending/Confirmed/Failed +- Transaction Hash: [hash, if submitted] +- Submission State: Submitted +- Confirmation Result: Confirmed/Failed when the wait succeeds, or Unconfirmed after a timeout - Block Number: [if confirmed] -- Gas Used: [actual gas used] -- Total Cost: [final cost] +- Gas Used: [if a receipt is available] ## Safety Considerations ### Critical Checks -- ✅ Verify contract is verified on block explorer -- ✅ Check function parameters are correct type and format -- ✅ Ensure sufficient balance for gas + value -- ✅ Validate addresses (no typos, correct network) -- ✅ Understand what the function does before calling +- Confirm a verified ABI was fetched +- Check function parameters against the ABI +- Verify the explicit native-token value does not exceed the wallet balance +- Validate addresses and network +- Explain that an ABI signature does not prove implementation behavior ### Common Risks - **Irreversible**: Most blockchain transactions cannot be undone - **Gas Loss**: Failed transactions still consume gas - **Approval Risks**: Be careful with unlimited approvals -- **Reentrancy**: Some functions may be vulnerable -- **Access Control**: Verify you have permission to call this function +- **Unknown Implementation**: ABI data does not establish internal logic or access controls ### Red Flags -🚨 Stop and warn user if: -- Contract is not verified -- Function requires admin/owner privileges you don't have -- Unusually high gas estimate -- Suspicious parameter values -- Contract has known vulnerabilities +Stop and warn the user if: +- The ABI cannot be fetched +- The function is absent from the ABI +- Arguments do not match the ABI +- The current network gas price is unexpectedly high for the user's stated tolerance +- Parameter values conflict with the user's request ## Error Handling If transaction fails: -1. Get the revert reason from receipt -2. Check common issues: - - Insufficient balance/allowance - - Access control (onlyOwner, etc.) - - Invalid parameters - - Contract paused - - Slippage (for DEX operations) -3. Provide actionable fix suggestions -4. Offer to retry with corrected parameters +1. Preserve the exact tool error and receipt status +2. Do not claim that a standard receipt contains a revert reason +3. State that a precise cause may require simulation, tracing, source code, or protocol-specific context that these tools do not provide +4. Suggest a retry only after the cause is established and corrected ## Example Workflow For a token mint operation: -1. ✅ Verify wallet -2. ✅ Fetch contract ABI -3. ✅ Check mint function exists and is callable -4. ✅ Verify sufficient ETH for gas -5. ✅ Show summary: "Minting 1 NFT will cost ~0.002 ETH" -6. ⏸️ Wait for user confirmation -7. ✅ Execute write_contract -8. ✅ Monitor transaction -9. ✅ Confirm success and return token ID - -**Remember**: Always prioritize user safety and transparency! +1. Verify the wallet +2. Fetch the contract ABI +3. Check that the mint signature exists and validate its arguments +4. Check the wallet's native balance without claiming a gas-usage estimate +5. Show the operation summary and current gas conditions +6. Invoke \`write_contract\` +7. Let the tool enforce MCP confirmation before execution +8. Monitor the transaction with its returned hash +9. Report receipt data; do not claim a token ID unless it is independently decoded ` } }] @@ -821,7 +794,7 @@ Provide explanation in sections: server.registerPrompt( "compare_networks", { - description: "Compare multiple EVM networks on key metrics and characteristics", + description: "Compare current RPC-observable data across multiple EVM networks", argsSchema: z.object({ networks: z.string().describe("Comma-separated network names (ethereum,polygon,arbitrum)") }) @@ -835,108 +808,60 @@ Provide explanation in sections: type: "text", text: `# Network Comparison -**Objective**: Compare ${networkList.join(', ')} on key metrics - -## Comparison Metrics - -### 1. Network Health (Current) -For each network, call: -- \`get_chain_info\` for chain ID and current block -- \`get_gas_price\` for current gas costs -- \`get_latest_block\` for block time and recent activity - -### 2. Key Characteristics -Compare across these dimensions: - -**Architecture**: -- Execution layer (Rollup/Sidechain/L1) -- Consensus mechanism -- Finality -- Decentralization level - -**Performance**: -- Block time (seconds per block) -- Transactions per second (TPS) -- Confirmation time -- Throughput - -**Costs**: -- Current gas prices (in gwei) -- Average transaction cost -- Cost to deploy contract -- Price trends - -**Security**: -- Validator count / decentralization -- Mainnet maturity -- Track record -- Security audits - -**Ecosystem**: -- Major protocols deployed -- Liquidity depth -- Developer activity -- Community size +**Objective**: Compare current RPC-observable data for ${networkList.join(', ')} + +## Data Collection + +For each network, make separate calls with the network argument set explicitly: +${networkList.map(network => `- \`get_chain_info\` with network="${network}" +- \`get_gas_price\` with network="${network}" +- \`get_latest_block\` with network="${network}" +- \`get_block\` with blockIdentifier="[latest block number minus 1]", network="${network}" if an observed one-block interval is useful`).join('\n')} + +## Supported Comparisons + +- Chain ID and current block height from \`get_chain_info\` +- Latest block hash, number, timestamp, and transaction count when present +- One observed interval between the latest and immediately preceding block, if both calls succeed +- Current node gas-price estimate and, when available, priority-fee estimate from \`get_gas_price\` +- Latest block base fee from the latest block's baseFeePerGas field, when present +- Convert wei values to gwei for readability while preserving the raw values ## Comparison Table -Create table with: +Create a table with: - Network name -- Block time -- TPS capacity -- Current gas (gwei) -- Est. tx cost (USD) -- Security level -- Best for +- Chain ID +- Current block +- Latest block timestamp +- Latest block transaction count +- Observed one-block interval, clearly labeled as a single sample +- Current gas-price estimate in wei and gwei +- Priority-fee estimate in wei and gwei, when available +- Latest block base fee, if available ## Analysis -For each network: -- **Strengths**: What it does well -- **Weaknesses**: Limitations -- **Best Use Cases**: When to use -- **Trade-offs**: Speed vs cost vs security - -## Recommendations - -Provide guidance: -- For small frequent transactions: [network] -- For large one-time transfers: [network] -- For DeFi/trading: [network] -- For NFTs: [network] -- For cost optimization: [network] +- Compare only values returned by the tools +- A lower current gas quote does not establish lower average transaction cost +- A single block interval does not establish throughput, TPS, confirmation time, finality, or network health +- Do not rank security, decentralization, validators, ecosystem, liquidity, developer activity, or protocol suitability because these tools do not provide that data +- Do not estimate USD costs, deployment costs, transaction costs, historical averages, or trends ## Output Format -**Network Comparison Analysis** +**Current Network Measurements** [Comparison table] -**Network Profiles** - For each network: -- Overview -- Current metrics -- Strengths -- Weaknesses -- Best use cases - -**Recommendations** - -Based on user needs: -- Speed priority: [suggestion] -- Cost priority: [suggestion] -- Security priority: [suggestion] -- Overall best: [suggestion] - -**Decision Matrix** - -Help user choose based on: -- Transaction frequency -- Transaction size -- Budget constraints -- Required finality -- Ecosystem needs +- Exact returned measurements +- Calls that failed or fields that were absent +- Caveats on the one-block sample + +**Limited Comparison** +- Identify the lowest current gas-price estimate only as a point-in-time observation +- Do not make a general network recommendation from these measurements alone ` } }] @@ -947,7 +872,7 @@ Help user choose based on: server.registerPrompt( "check_network_status", { - description: "Check current network health and conditions", + description: "Check current RPC reachability, latest-block data, and gas conditions", argsSchema: z.object({ network: z.string().optional().describe("Network name (default: ethereum)") }) @@ -959,95 +884,61 @@ Help user choose based on: type: "text", text: `# Network Status Check -**Objective**: Assess health and current conditions of ${network} +**Objective**: Check current RPC-observable conditions for ${network} ## Status Assessment ### 1. Gather Current Data Call these read-only tools: -- \`get_chain_info\` for chain ID and current block number -- \`get_latest_block\` for block details and timing -- \`get_gas_price\` for current gas prices +- \`get_chain_info\` with network="${network}" for chain ID and current block number +- \`get_latest_block\` with network="${network}" for the latest block +- \`get_gas_price\` with network="${network}" for the node gas-price estimate and any available priority-fee estimate +- If an observed block interval is useful, call \`get_block\` with blockIdentifier="[latest block number minus 1]", network="${network}" -### 2. Network Health Analysis +### 2. Current Observations **Block Production**: - Current block number -- Block timing (normal ~12-15 sec for Ethereum) -- Consistent vs irregular blocks -- Any gaps or delays +- Latest block hash and timestamp +- Latest block transaction count when present +- Difference between the latest and previous block timestamps, clearly labeled as one observed interval +- Do not extrapolate consistency, throughput, or a historical block-time average from one interval **Gas Market**: -- Base fee level (in gwei) -- Priority fee level -- Gas price trend (up/down/stable) -- Congestion level +- Use the latest block's baseFeePerGas field for the actual latest-block base fee, when present +- Use \`get_gas_price\`'s gasPricePerGas field as the node's current transaction gas-price estimate +- Report priorityFeePerGas when the node provides it +- Preserve wei values and optionally convert them to gwei +- Do not infer a trend, historical comparison, or congestion category -**Overall Status**: -- Operational: Yes/No -- Issues detected: Yes/No -- Performance: Normal/Degraded/Critical - -### 3. Congestion Assessment - -Evaluate: -- Current gas prices vs average -- Pending transaction count -- Memory pool size -- Are transactions backing up? +**RPC Reachability**: +- If the calls succeed, report that the configured RPC endpoint responded +- If a call fails, preserve the exact error +- Do not equate RPC reachability with full network health or finality ## Output Format **Network Status Report: ${network}** -**Overall Status** -- Operational Status: [Online/Degraded/Offline] +**RPC Observation** +- RPC Responded: [yes/no for each call] - Current Block: [number] -- Network Time: [timestamp] -- Last Updated: [when] - -**Performance Metrics** -- Block Time: [seconds] (normal: 12-15s) -- Gas Base Fee: [gwei] -- Priority Fee: [gwei] -- Total Cost for Standard Tx: [estimate USD] - -**Congestion Level** -- Level: [Low/Moderate/High/Critical] -- Current vs Historical: [comparison] -- Trend: [increasing/stable/decreasing] - -**Network Activity** -- Blocks per minute: [rate] -- Recent block details: [hash, time, tx count] -- Network security: [indicators] - -**Recommendations** - -For **sending transactions now**: -- Best for: [low-value / high-value / time-critical] -- Gas setting: [standard / fast / extreme] -- Estimated cost: [range] -- Estimated wait time: [minutes] - -**If Congested**: -- Consider using: [alternative networks] -- Wait time: [estimated minutes] -- Cost to expedite: [gas increase needed] - -**If Issues Detected**: -- Known issues: [list if any] -- Expected duration: [if known] -- Recommended action: [wait / use alternate / etc] - -## Key Metrics - -Reference points for interpretation: -- Ethereum normal block: 12-15 seconds -- Polygon normal: 2 seconds -- Arbitrum normal: <1 second -- Normal gas: 20-50 gwei -- High congestion: 100+ gwei +- Latest Block Timestamp: [timestamp] +- Observed At: [current response time, if available] + +**Latest Block** +- Hash: [hash] +- Transaction Count: [if present] +- Observed Previous-Block Interval: [seconds, if the previous block was fetched] +- Base Fee: [wei and gwei, if present] + +**Current Gas Data** +- Node Gas-Price Estimate: [wei and gwei] +- Priority-Fee Estimate: [wei and gwei, if available] + +**Limitations** +- No mempool size, pending transaction count, historical trend, average fee, USD price, transaction gas estimate, validator/security data, incident status, or expected recovery time is available +- Do not recommend standard/fast/extreme fee settings or estimate transaction cost and confirmation time from these calls ` } }] diff --git a/src/core/resources.ts b/src/core/resources.ts index 78fe59c..eb9b4d5 100644 --- a/src/core/resources.ts +++ b/src/core/resources.ts @@ -18,7 +18,7 @@ export function registerEVMResources(server: McpServer) { "supported_networks", "evm://networks", { - description: "Get list of all supported EVM networks and their configuration", + description: "Get the configured names and aliases for all supported EVM networks", mimeType: "application/json", cacheHint: { ttlMs: 60 * 60 * 1000, diff --git a/src/core/services/balance.ts b/src/core/services/balance.ts index 0221b9d..e4839ea 100644 --- a/src/core/services/balance.ts +++ b/src/core/services/balance.ts @@ -1,8 +1,6 @@ import { formatEther, formatUnits, - type Address, - type Abi, getContract } from 'viem'; import { getPublicClient } from './clients.js'; @@ -34,24 +32,6 @@ const erc20Abi = [ } ] as const; -// Standard ERC721 ABI (minimal for reading) -const erc721Abi = [ - { - inputs: [{ type: 'address', name: 'owner' }], - name: 'balanceOf', - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - }, - { - inputs: [{ type: 'uint256', name: 'tokenId' }], - name: 'ownerOf', - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' - } -] as const; - // Standard ERC1155 ABI (minimal for reading) const erc1155Abi = [ { @@ -67,7 +47,7 @@ const erc1155Abi = [ ] as const; /** - * Get the ETH balance for an address + * Get the native-token balance for an address * @param addressOrEns Ethereum address or ENS name * @param network Network name or chain ID * @returns Balance in wei and ether @@ -135,63 +115,6 @@ export async function getERC20Balance( }; } -/** - * Check if an address owns a specific NFT - * @param tokenAddressOrEns NFT contract address or ENS name - * @param ownerAddressOrEns Owner address or ENS name - * @param tokenId Token ID to check - * @param network Network name or chain ID - * @returns True if the address owns the NFT - */ -export async function isNFTOwner( - tokenAddressOrEns: string, - ownerAddressOrEns: string, - tokenId: bigint, - network = 'ethereum' -): Promise { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network); - const ownerAddress = await resolveAddress(ownerAddressOrEns, network); - - try { - const actualOwner = await readContract({ - address: tokenAddress, - abi: erc721Abi, - functionName: 'ownerOf', - args: [tokenId] - }, network) as Address; - - return actualOwner.toLowerCase() === ownerAddress.toLowerCase(); - } catch (error: any) { - console.error(`Error checking NFT ownership: ${error.message}`); - return false; - } -} - -/** - * Get the number of NFTs owned by an address for a specific collection - * @param tokenAddressOrEns NFT contract address or ENS name - * @param ownerAddressOrEns Owner address or ENS name - * @param network Network name or chain ID - * @returns Number of NFTs owned - */ -export async function getERC721Balance( - tokenAddressOrEns: string, - ownerAddressOrEns: string, - network = 'ethereum' -): Promise { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network); - const ownerAddress = await resolveAddress(ownerAddressOrEns, network); - - return readContract({ - address: tokenAddress, - abi: erc721Abi, - functionName: 'balanceOf', - args: [ownerAddress] - }, network) as Promise; -} - /** * Get the balance of an ERC1155 token for an address * @param tokenAddressOrEns ERC1155 contract address or ENS name @@ -216,4 +139,4 @@ export async function getERC1155Balance( functionName: 'balanceOf', args: [ownerAddress, tokenId] }, network) as Promise; -} \ No newline at end of file +} diff --git a/src/core/services/clients.ts b/src/core/services/clients.ts index c74c125..50a4691 100644 --- a/src/core/services/clients.ts +++ b/src/core/services/clients.ts @@ -4,8 +4,7 @@ import { http, type PublicClient, type WalletClient, - type Hex, - type Address + type Hex } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { getChain, getRpcUrl } from '../chains.js'; @@ -53,13 +52,3 @@ export function getWalletClient(privateKey: Hex, network = 'ethereum'): WalletCl transport: http(rpcUrl) }); } - -/** - * Get an Ethereum address from a private key - * @param privateKey The private key in hex format (with or without 0x prefix) - * @returns The Ethereum address derived from the private key - */ -export function getAddressFromPrivateKey(privateKey: Hex): Address { - const account = privateKeyToAccount(privateKey); - return account.address; -} \ No newline at end of file diff --git a/src/core/services/contracts.ts b/src/core/services/contracts.ts index 650d054..6baad46 100644 --- a/src/core/services/contracts.ts +++ b/src/core/services/contracts.ts @@ -2,12 +2,9 @@ import { type Address, type Hash, type Hex, - type ReadContractParameters, - type GetLogsParameters, - type Log + type ReadContractParameters } from 'viem'; import { getPublicClient, getWalletClient } from './clients.js'; -import { resolveAddress } from './ens.js'; /** * Read from a contract for a specific network @@ -30,30 +27,8 @@ export async function writeContract( } /** - * Get logs for a specific network - */ -export async function getLogs(params: GetLogsParameters, network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.getLogs(params); -} - -/** - * Check if an address is a contract - * @param addressOrEns Address or ENS name to check - * @param network Network name or chain ID - * @returns True if the address is a contract, false if it's an EOA - */ -export async function isContract(addressOrEns: string, network = 'ethereum'): Promise { - // Resolve ENS name to address if needed - const address = await resolveAddress(addressOrEns, network); - - const client = getPublicClient(network); - const code = await client.getBytecode({ address }); - return code !== undefined && code !== '0x'; -} - -/** - * Batch multiple contract read calls into a single RPC request using Multicall3 + * Batch contract reads through Viem and Multicall3. + * Viem may split large batches, and the selected chain needs a configured deployment. * @param contracts Array of contract calls to batch * @param allowFailure If true, returns partial results even if some calls fail * @param network Network name or chain ID @@ -75,4 +50,4 @@ export async function multicall( contracts: contracts as any, allowFailure }); -} \ No newline at end of file +} diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 14108ed..a9aaaa2 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -9,7 +9,6 @@ export * from './tokens.js'; export * from './ens.js'; export * from './abi.js'; export * from './wallet.js'; -export { utils as helpers } from './utils.js'; // Re-export common types for convenience export type { @@ -19,4 +18,4 @@ export type { Block, TransactionReceipt, Log -} from 'viem'; \ No newline at end of file +} from 'viem'; diff --git a/src/core/services/tokens.ts b/src/core/services/tokens.ts index e0d8306..4885baa 100644 --- a/src/core/services/tokens.ts +++ b/src/core/services/tokens.ts @@ -1,44 +1,6 @@ -import { - type Address, - type Hex, - type Hash, - formatUnits, - getContract -} from 'viem'; +import { type Address, getContract } from 'viem'; import { getPublicClient } from './clients.js'; -// Standard ERC20 ABI (minimal for reading) -const erc20Abi = [ - { - inputs: [], - name: 'name', - outputs: [{ type: 'string' }], - stateMutability: 'view', - type: 'function' - }, - { - inputs: [], - name: 'symbol', - outputs: [{ type: 'string' }], - stateMutability: 'view', - type: 'function' - }, - { - inputs: [], - name: 'decimals', - outputs: [{ type: 'uint8' }], - stateMutability: 'view', - type: 'function' - }, - { - inputs: [], - name: 'totalSupply', - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - } -] as const; - // Standard ERC721 ABI (minimal for reading) const erc721Abi = [ { @@ -64,54 +26,6 @@ const erc721Abi = [ } ] as const; -// Standard ERC1155 ABI (minimal for reading) -const erc1155Abi = [ - { - inputs: [{ type: 'uint256', name: 'id' }], - name: 'uri', - outputs: [{ type: 'string' }], - stateMutability: 'view', - type: 'function' - } -] as const; - -/** - * Get ERC20 token information - */ -export async function getERC20TokenInfo( - tokenAddress: Address, - network: string = 'ethereum' -): Promise<{ - name: string; - symbol: string; - decimals: number; - totalSupply: bigint; - formattedTotalSupply: string; -}> { - const publicClient = getPublicClient(network); - - const contract = getContract({ - address: tokenAddress, - abi: erc20Abi, - client: publicClient, - }); - - const [name, symbol, decimals, totalSupply] = await Promise.all([ - contract.read.name(), - contract.read.symbol(), - contract.read.decimals(), - contract.read.totalSupply() - ]); - - return { - name, - symbol, - decimals, - totalSupply, - formattedTotalSupply: formatUnits(totalSupply, decimals) - }; -} - /** * Get ERC721 token metadata */ @@ -144,22 +58,3 @@ export async function getERC721TokenMetadata( tokenURI }; } - -/** - * Get ERC1155 token URI - */ -export async function getERC1155TokenURI( - tokenAddress: Address, - tokenId: bigint, - network: string = 'ethereum' -): Promise { - const publicClient = getPublicClient(network); - - const contract = getContract({ - address: tokenAddress, - abi: erc1155Abi, - client: publicClient, - }); - - return contract.read.uri([tokenId]); -} \ No newline at end of file diff --git a/src/core/services/transactions.ts b/src/core/services/transactions.ts index 52954e6..d8cebbf 100644 --- a/src/core/services/transactions.ts +++ b/src/core/services/transactions.ts @@ -1,9 +1,4 @@ -import { - type Address, - type Hash, - type TransactionReceipt, - type EstimateGasParameters -} from 'viem'; +import { type Hash } from 'viem'; import { getPublicClient } from './clients.js'; /** @@ -14,31 +9,6 @@ export async function getTransaction(hash: Hash, network = 'ethereum') { return await client.getTransaction({ hash }); } -/** - * Get a transaction receipt by hash for a specific network - */ -export async function getTransactionReceipt(hash: Hash, network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.getTransactionReceipt({ hash }); -} - -/** - * Get the transaction count for an address for a specific network - */ -export async function getTransactionCount(address: Address, network = 'ethereum'): Promise { - const client = getPublicClient(network); - const count = await client.getTransactionCount({ address }); - return Number(count); -} - -/** - * Estimate gas for a transaction for a specific network - */ -export async function estimateGas(params: EstimateGasParameters, network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.estimateGas(params); -} - /** * Get the chain ID for a specific network */ @@ -46,4 +16,4 @@ export async function getChainId(network = 'ethereum'): Promise { const client = getPublicClient(network); const chainId = await client.getChainId(); return Number(chainId); -} \ No newline at end of file +} diff --git a/src/core/services/transfer.ts b/src/core/services/transfer.ts index 06f8f75..5a2f4f3 100644 --- a/src/core/services/transfer.ts +++ b/src/core/services/transfer.ts @@ -1,16 +1,12 @@ import { parseEther, parseUnits, - formatUnits, type Address, type Hash, type Hex, - type Abi, - getContract, - type Account + getContract } from 'viem'; import { getPublicClient, getWalletClient } from './clients.js'; -import { getChain } from '../chains.js'; import { resolveAddress } from './ens.js'; // Standard ERC20 ABI for transfers @@ -51,81 +47,18 @@ const erc20TransferAbi = [ } ] as const; -// Standard ERC721 ABI for transfers -const erc721TransferAbi = [ - { - inputs: [ - { type: 'address', name: 'from' }, - { type: 'address', name: 'to' }, - { type: 'uint256', name: 'tokenId' } - ], - name: 'transferFrom', - outputs: [], - stateMutability: 'nonpayable', - type: 'function' - }, - { - inputs: [], - name: 'name', - outputs: [{ type: 'string' }], - stateMutability: 'view', - type: 'function' - }, - { - inputs: [], - name: 'symbol', - outputs: [{ type: 'string' }], - stateMutability: 'view', - type: 'function' - }, - { - inputs: [{ type: 'uint256', name: 'tokenId' }], - name: 'ownerOf', - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' - } -] as const; - -// ERC1155 ABI for transfers -const erc1155TransferAbi = [ - { - inputs: [ - { type: 'address', name: 'from' }, - { type: 'address', name: 'to' }, - { type: 'uint256', name: 'id' }, - { type: 'uint256', name: 'amount' }, - { type: 'bytes', name: 'data' } - ], - name: 'safeTransferFrom', - outputs: [], - stateMutability: 'nonpayable', - type: 'function' - }, - { - inputs: [ - { type: 'address', name: 'account' }, - { type: 'uint256', name: 'id' } - ], - name: 'balanceOf', - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - } -] as const; - /** - * Transfer ETH to an address + * Transfer a chain's native token to an address * @param privateKey Sender's private key * @param toAddressOrEns Recipient address or ENS name - * @param amount Amount to send in ETH + * @param amount Amount to send in whole native-token units * @param network Network name or chain ID * @returns Transaction hash */ export async function transferETH( privateKey: string | Hex, toAddressOrEns: string, - amount: string, // in ether + amount: string, // in whole native-token units network = 'ethereum' ): Promise { // Resolve ENS name to address if needed @@ -230,7 +163,7 @@ export async function transferERC20( * @param amount Amount to approve (in token units) * @param privateKey Owner's private key * @param network Network name or chain ID - * @returns Transaction details + * @returns Transaction hash */ export async function approveERC20( tokenAddressOrEns: string, @@ -238,17 +171,7 @@ export async function approveERC20( amount: string, privateKey: string | `0x${string}`, network: string = 'ethereum' -): Promise<{ - txHash: Hash; - amount: { - raw: bigint; - formatted: string; - }; - token: { - symbol: string; - decimals: number; - }; -}> { +): Promise { // Resolve ENS names to addresses if needed const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; const spenderAddress = await resolveAddress(spenderAddressOrEns, network) as Address; @@ -266,9 +189,8 @@ export async function approveERC20( client: publicClient, }); - // Get token decimals and symbol + // Get token decimals const decimals = await contract.read.decimals(); - const symbol = await contract.read.symbol(); // Parse the amount with the correct number of decimals const rawAmount = parseUnits(amount, decimals); @@ -277,7 +199,7 @@ export async function approveERC20( const walletClient = getWalletClient(formattedKey, network); // Send the transaction - const hash = await walletClient.writeContract({ + return walletClient.writeContract({ address: tokenAddress, abi: erc20TransferAbi, functionName: 'approve', @@ -285,148 +207,4 @@ export async function approveERC20( account: walletClient.account!, chain: walletClient.chain }); - - return { - txHash: hash, - amount: { - raw: rawAmount, - formatted: amount - }, - token: { - symbol, - decimals - } - }; -} - -/** - * Transfer an NFT (ERC721) to an address - * @param tokenAddressOrEns NFT contract address or ENS name - * @param toAddressOrEns Recipient address or ENS name - * @param tokenId Token ID to transfer - * @param privateKey Owner's private key - * @param network Network name or chain ID - * @returns Transaction details - */ -export async function transferERC721( - tokenAddressOrEns: string, - toAddressOrEns: string, - tokenId: bigint, - privateKey: string | `0x${string}`, - network: string = 'ethereum' -): Promise<{ - txHash: Hash; - tokenId: string; - token: { - name: string; - symbol: string; - }; -}> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; - const toAddress = await resolveAddress(toAddressOrEns, network) as Address; - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as `0x${string}` - : privateKey as `0x${string}`; - - // Create wallet client for sending the transaction - const walletClient = getWalletClient(formattedKey, network); - const fromAddress = walletClient.account!.address; - - // Send the transaction - const hash = await walletClient.writeContract({ - address: tokenAddress, - abi: erc721TransferAbi, - functionName: 'transferFrom', - args: [fromAddress, toAddress, tokenId], - account: walletClient.account!, - chain: walletClient.chain - }); - - // Get token metadata - const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc721TransferAbi, - client: publicClient, - }); - - // Get token name and symbol - let name = 'Unknown'; - let symbol = 'NFT'; - - try { - [name, symbol] = await Promise.all([ - contract.read.name(), - contract.read.symbol() - ]); - } catch (error) { - console.error('Error fetching NFT metadata:', error); - } - - return { - txHash: hash, - tokenId: tokenId.toString(), - token: { - name, - symbol - } - }; } - -/** - * Transfer ERC1155 tokens to an address - * @param tokenAddressOrEns Token contract address or ENS name - * @param toAddressOrEns Recipient address or ENS name - * @param tokenId Token ID to transfer - * @param amount Amount to transfer - * @param privateKey Owner's private key - * @param network Network name or chain ID - * @returns Transaction details - */ -export async function transferERC1155( - tokenAddressOrEns: string, - toAddressOrEns: string, - tokenId: bigint, - amount: string, - privateKey: string | `0x${string}`, - network: string = 'ethereum' -): Promise<{ - txHash: Hash; - tokenId: string; - amount: string; -}> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; - const toAddress = await resolveAddress(toAddressOrEns, network) as Address; - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as `0x${string}` - : privateKey as `0x${string}`; - - // Create wallet client for sending the transaction - const walletClient = getWalletClient(formattedKey, network); - const fromAddress = walletClient.account!.address; - - // Parse amount to bigint - const amountBigInt = BigInt(amount); - - // Send the transaction - const hash = await walletClient.writeContract({ - address: tokenAddress, - abi: erc1155TransferAbi, - functionName: 'safeTransferFrom', - args: [fromAddress, toAddress, tokenId, amountBigInt, '0x'], - account: walletClient.account!, - chain: walletClient.chain - }); - - return { - txHash: hash, - tokenId: tokenId.toString(), - amount - }; -} \ No newline at end of file diff --git a/src/core/services/utils.ts b/src/core/services/utils.ts deleted file mode 100644 index f8ed8e5..0000000 --- a/src/core/services/utils.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - parseEther, - formatEther, - type Account, - type Hash, - type Chain, - type WalletClient, - type Transport, - type HttpTransport -} from 'viem'; - -/** - * Utility functions for formatting and parsing values - */ -export const utils = { - // Convert ether to wei - parseEther, - - // Convert wei to ether - formatEther, - - // Format a bigint to a string - formatBigInt: (value: bigint): string => value.toString(), - - // Format an object to JSON with bigint handling - formatJson: (obj: unknown): string => JSON.stringify(obj, (_, value) => - typeof value === 'bigint' ? value.toString() : value, 2), - - // Format a number with commas - formatNumber: (value: number | string): string => { - return Number(value).toLocaleString(); - }, - - // Convert a hex string to a number - hexToNumber: (hex: string): number => { - return parseInt(hex, 16); - }, - - // Convert a number to a hex string - numberToHex: (num: number): string => { - return '0x' + num.toString(16); - } -}; \ No newline at end of file diff --git a/src/core/services/wallet.ts b/src/core/services/wallet.ts index c55dbeb..4640190 100644 --- a/src/core/services/wallet.ts +++ b/src/core/services/wallet.ts @@ -6,7 +6,7 @@ import { privateKeyToAccount, mnemonicToAccount, type HDAccount, type PrivateKey * * Configuration options: * - EVM_PRIVATE_KEY: Hex private key (with or without 0x prefix) - * - EVM_MNEMONIC: BIP-39 mnemonic phrase (12 or 24 words) + * - EVM_MNEMONIC: BIP-39 mnemonic phrase * - EVM_ACCOUNT_INDEX: Optional account index for HD wallet derivation (default: 0) */ export const getConfiguredAccount = (): HDAccount | PrivateKeyAccount => { @@ -34,7 +34,7 @@ export const getConfiguredAccount = (): HDAccount | PrivateKeyAccount => { "Neither EVM_PRIVATE_KEY nor EVM_MNEMONIC environment variable is set. " + "Configure one of them to enable write operations.\n" + "- EVM_PRIVATE_KEY: Your private key in hex format\n" + - "- EVM_MNEMONIC: Your 12 or 24 word mnemonic phrase\n" + + "- EVM_MNEMONIC: Your BIP-39 mnemonic phrase\n" + "- EVM_ACCOUNT_INDEX: (Optional) Account index for HD wallet (default: 0)" ); } @@ -90,7 +90,7 @@ export const getConfiguredWallet = (): { address: Address } => { /** * Sign an arbitrary message using the configured wallet - * @param message The message to sign (can be a string or hex data) + * @param message The plain-text message to sign * @returns The signature as a hex string */ export const signMessage = async (message: string): Promise => { diff --git a/src/core/tools.ts b/src/core/tools.ts index b8f5f17..e1cbbc8 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -1,9 +1,329 @@ -import { McpServer } from "@modelcontextprotocol/server"; +import { + acceptedContent, + inputRequired, + inputResponse, + McpServer, + type CallToolResult, + type InputRequiredResult, + type ServerContext +} from "@modelcontextprotocol/server"; import { z } from "zod"; import { getSupportedNetworks, getRpcUrl } from "./chains.js"; import * as services from "./services/index.js"; -import { type Address, type Hex, type Hash } from 'viem'; +import { type Address, type Hash } from 'viem'; import { normalize } from 'viem/ens'; +import { + consumeConfirmationRequestState, + createOperationDigest, + isConfirmationRequestState, + mintConfirmationRequestState +} from "../server/request-state.js"; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +const walletAddressOutputSchema = z.object({ + address: z.string(), + message: z.string() +}); + +const chainInfoOutputSchema = z.object({ + network: z.string(), + chainId: z.number(), + blockNumber: z.string(), + rpcUrl: z.string() +}); + +const supportedNetworksOutputSchema = z.object({ + supportedNetworks: z.array(z.string()) +}); + +const gasPriceOutputSchema = z.object({ + network: z.string(), + gasPricePerGas: z.string(), + priorityFeePerGas: z.string().nullable(), + currency: z.literal("wei") +}); + +const resolveEnsOutputSchema = z.object({ + ensName: z.string(), + normalizedName: z.string(), + resolvedAddress: z.string(), + network: z.string() +}); + +const lookupEnsOutputSchema = z.object({ + address: z.string(), + ensName: z.string(), + network: z.string() +}); + +// Blocks, transactions, and receipts are viem-defined objects whose fields vary by chain. +const viemObjectOutputSchema = z.record(z.string(), z.json()); + +const nativeBalanceOutputSchema = z.object({ + network: z.string(), + address: z.string(), + balance: z.object({ + raw: z.string(), + formatted: z.string() + }) +}); + +const tokenBalanceOutputSchema = z.object({ + network: z.string(), + tokenAddress: z.string(), + address: z.string(), + balance: z.object({ + raw: z.string(), + formatted: z.string(), + symbol: z.string(), + decimals: z.number() + }) +}); + +const allowanceOutputSchema = z.object({ + network: z.string(), + tokenAddress: z.string(), + owner: z.string(), + spenderAddress: z.string(), + allowance: z.string(), + message: z.string() +}); + +const waitForTransactionOutputSchema = z.object({ + network: z.string(), + txHash: z.string(), + status: z.enum(["confirmed", "failed"]), + blockNumber: z.string(), + gasUsed: z.string(), + confirmations: z.number(), + timeoutSeconds: z.number() +}); + +const contractAbiOutputSchema = z.object({ + contractAddress: z.string(), + network: z.string(), + abiFormat: z.literal("json"), + readableFunctions: z.array(z.string()), + totalFunctions: z.number(), + abi: z.array(z.json()) +}); + +const readContractOutputSchema = z.object({ + contractAddress: z.string(), + function: z.string(), + args: z.array(z.string()).optional(), + result: z.json().optional(), + abiSource: z.enum(["provided", "auto-fetched or built-in"]) +}); + +const writeContractOutputSchema = z.object({ + network: z.string(), + contractAddress: z.string(), + function: z.string(), + args: z.array(z.string()).optional(), + value: z.string().optional(), + from: z.string(), + txHash: z.string(), + abiSource: z.enum(["provided", "auto-fetched"]), + message: z.string() +}); + +const multicallResultOutputSchema = z.discriminatedUnion("status", [ + z.object({ + contractAddress: z.string(), + functionName: z.string(), + args: z.array(z.string()).optional(), + result: z.json().optional(), + status: z.literal("success") + }), + z.object({ + contractAddress: z.string(), + functionName: z.string(), + args: z.array(z.string()).optional(), + error: z.string(), + status: z.literal("failure") + }) +]); + +const multicallOutputSchema = z.object({ + network: z.string(), + totalCalls: z.number(), + successfulCalls: z.number(), + failedCalls: z.number(), + results: z.array(multicallResultOutputSchema) +}); + +const nativeTransferOutputSchema = z.object({ + network: z.string(), + from: z.string(), + to: z.string(), + amount: z.string(), + txHash: z.string(), + message: z.string() +}); + +const erc20TransferOutputSchema = z.object({ + network: z.string(), + tokenAddress: z.string(), + from: z.string(), + to: z.string(), + amount: z.string(), + symbol: z.string(), + decimals: z.number(), + txHash: z.string(), + message: z.string() +}); + +const tokenApprovalOutputSchema = z.object({ + network: z.string(), + tokenAddress: z.string(), + owner: z.string(), + spender: z.string(), + approvalAmount: z.string(), + txHash: z.string(), + message: z.string() +}); + +const nftInfoOutputSchema = z.object({ + network: z.string(), + contract: z.string(), + tokenId: z.string(), + name: z.string(), + symbol: z.string(), + tokenURI: z.string() +}); + +const erc1155BalanceOutputSchema = z.object({ + network: z.string(), + contract: z.string(), + tokenId: z.string(), + owner: z.string(), + balance: z.string() +}); + +const signedMessageOutputSchema = z.object({ + message: z.string(), + signature: z.string(), + signer: z.string(), + messageType: z.literal("personal_sign") +}); + +const signedTypedDataOutputSchema = z.object({ + domain: z.json(), + types: z.json(), + primaryType: z.string(), + message: z.json(), + signature: z.string(), + signer: z.string(), + messageType: z.literal("EIP-712") +}); + +/** + * Create a tool result with both the legacy text rendering and structured JSON. + * viem bigint values are encoded as decimal strings so the structured result is JSON-safe. + */ +function createToolResult(value: unknown) { + const text = JSON.stringify( + value, + (_, nestedValue) => typeof nestedValue === 'bigint' ? nestedValue.toString() : nestedValue, + 2 + ); + + if (text === undefined) { + throw new TypeError("Tool result must be JSON-serializable"); + } + + return { + content: [{ type: "text" as const, text }], + structuredContent: JSON.parse(text) as JsonValue + }; +} + +const confirmationSchema = z.object({ + confirm: z.boolean().describe("Set to true to authorize this exact operation.") +}); + +/** + * Require an explicit MCP input response before a wallet-backed operation. + * A declined or cancelled request is terminal and never re-prompts. + */ +async function requireConfirmation( + ctx: ServerContext, + toolName: string, + argumentsValue: Record, + message: string +): Promise { + const operationDigest = await createOperationDigest(toolName, argumentsValue); + const requestState = ctx.mcpReq.requestState(); + + if ( + !isConfirmationRequestState(requestState) + || requestState.operationDigest !== operationDigest + ) { + return inputRequired({ + inputRequests: { + confirmation: inputRequired.elicit({ + message, + requestedSchema: confirmationSchema + }) + }, + requestState: await mintConfirmationRequestState(operationDigest, ctx) + }); + } + + if (!consumeConfirmationRequestState(requestState)) { + return { + content: [{ + type: "text", + text: "Confirmation expired or already used. Request a new confirmation before retrying." + }], + isError: true + }; + } + + const response = inputResponse(ctx.mcpReq.inputResponses, "confirmation"); + + if (response.kind === "elicit" && response.action !== "accept") { + return { + content: [{ type: "text", text: "Operation cancelled by the user." }], + isError: true + }; + } + + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + "confirmation", + confirmationSchema + ); + + if (answer?.confirm === false) { + return { + content: [{ type: "text", text: "Operation declined by the user." }], + isError: true + }; + } + + if (answer?.confirm !== true) { + return inputRequired({ + inputRequests: { + confirmation: inputRequired.elicit({ + message, + requestedSchema: confirmationSchema + }) + }, + requestState: await mintConfirmationRequestState(operationDigest, ctx) + }); + } + + return undefined; +} /** * Register all EVM-related tools with the MCP server @@ -14,16 +334,15 @@ import { normalize } from 'viem/ens'; * * Configuration options: * - EVM_PRIVATE_KEY: Hex private key (with or without 0x prefix) - * - EVM_MNEMONIC: BIP-39 mnemonic phrase (12 or 24 words) + * - EVM_MNEMONIC: BIP-39 mnemonic phrase * - EVM_ACCOUNT_INDEX: Optional account index for HD wallet derivation (default: 0) * - * All tools that accept addresses also support ENS names (e.g., 'vitalik.eth'). - * ENS names are automatically resolved to addresses using the Ethereum Name Service. + * ENS support is declared per input. Raw contract interaction parameters require + * hexadecimal addresses unless their individual description says otherwise. * * @param server The MCP server instance */ export function registerEVMTools(server: McpServer) { - // Helpers are now imported from services/wallet.ts const { getConfiguredPrivateKey, getWalletAddressFromKey, getConfiguredWallet } = services; // ============================================================================ @@ -35,6 +354,7 @@ export function registerEVMTools(server: McpServer) { { description: "Get the address of the configured wallet. Use this to verify which wallet is active.", inputSchema: z.strictObject({}), + outputSchema: walletAddressOutputSchema, annotations: { title: "Get Wallet Address", readOnlyHint: true, @@ -46,15 +366,10 @@ export function registerEVMTools(server: McpServer) { async () => { try { const address = getWalletAddressFromKey(); - return { - content: [{ - type: "text", - text: JSON.stringify({ - address, - message: "This is the wallet that will be used for all transactions" - }, null, 2) - }] - }; + return createToolResult({ + address, + message: "This is the wallet that will be used for all transactions" + }); } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }], @@ -75,6 +390,7 @@ export function registerEVMTools(server: McpServer) { inputSchema: z.object({ network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base') or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: chainInfoOutputSchema, annotations: { title: "Get Chain Info", readOnlyHint: true, @@ -89,12 +405,7 @@ export function registerEVMTools(server: McpServer) { const blockNumber = await services.getBlockNumber(network); const rpcUrl = getRpcUrl(network); - return { - content: [{ - type: "text", - text: JSON.stringify({ network, chainId, blockNumber: blockNumber.toString(), rpcUrl }, null, 2) - }] - }; + return createToolResult({ network, chainId, blockNumber: blockNumber.toString(), rpcUrl }); } catch (error) { return { content: [{ type: "text", text: `Error fetching chain info: ${error instanceof Error ? error.message : String(error)}` }], @@ -107,8 +418,9 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "get_supported_networks", { - description: "Get a list of all supported EVM networks", + description: "Get the configured network names and aliases for all supported EVM chains", inputSchema: z.strictObject({}), + outputSchema: supportedNetworksOutputSchema, annotations: { title: "Get Supported Networks", readOnlyHint: true, @@ -120,9 +432,7 @@ export function registerEVMTools(server: McpServer) { async () => { try { const networks = getSupportedNetworks(); - return { - content: [{ type: "text", text: JSON.stringify({ supportedNetworks: networks }, null, 2) }] - }; + return createToolResult({ supportedNetworks: networks }); } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }], @@ -135,10 +445,11 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "get_gas_price", { - description: "Get current gas prices (base fee, standard, and fast) for a network", + description: "Get the node's current transaction gas-price estimate and, when supported, its estimated priority fee", inputSchema: z.object({ network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: gasPriceOutputSchema, annotations: { title: "Get Gas Prices", readOnlyHint: true, @@ -150,22 +461,20 @@ export function registerEVMTools(server: McpServer) { async ({ network = "ethereum" }) => { try { const client = await services.getPublicClient(network); - const [baseFee, priorityFee] = await Promise.all([ - client.getGasPrice(), - client.estimateMaxPriorityFeePerGas() - ]); + const gasPrice = await client.getGasPrice(); + let priorityFee: bigint | null = null; + try { + priorityFee = await client.estimateMaxPriorityFeePerGas(); + } catch { + // Legacy fee markets do not expose an EIP-1559 priority fee. + } - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - baseFeePerGas: baseFee.toString(), - priorityFeePerGas: priorityFee?.toString() || "N/A", - currency: "wei" - }, null, 2) - }] - }; + return createToolResult({ + network, + gasPricePerGas: gasPrice.toString(), + priorityFeePerGas: priorityFee?.toString() ?? null, + currency: "wei" + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching gas prices: ${error instanceof Error ? error.message : String(error)}` }], @@ -187,6 +496,7 @@ export function registerEVMTools(server: McpServer) { ensName: z.string().describe("ENS name to resolve (e.g., 'vitalik.eth')"), network: z.string().optional().describe("Network name or chain ID. ENS resolution works best on Ethereum mainnet. Defaults to Ethereum mainnet.") }), + outputSchema: resolveEnsOutputSchema, annotations: { title: "Resolve ENS Name", readOnlyHint: true, @@ -206,17 +516,12 @@ export function registerEVMTools(server: McpServer) { const normalizedEns = normalize(ensName); const address = await services.resolveAddress(ensName, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - ensName, - normalizedName: normalizedEns, - resolvedAddress: address, - network - }, null, 2) - }] - }; + return createToolResult({ + ensName, + normalizedName: normalizedEns, + resolvedAddress: address, + network + }); } catch (error) { return { content: [{ type: "text", text: `Error resolving ENS name: ${error instanceof Error ? error.message : String(error)}` }], @@ -234,6 +539,7 @@ export function registerEVMTools(server: McpServer) { address: z.string().describe("Ethereum address to lookup"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: lookupEnsOutputSchema, annotations: { title: "Lookup ENS Address", readOnlyHint: true, @@ -248,16 +554,11 @@ export function registerEVMTools(server: McpServer) { const ensName = await client.getEnsName({ address: address as Address }); - return { - content: [{ - type: "text", - text: JSON.stringify({ - address, - ensName: ensName || "No ENS name found", - network - }, null, 2) - }] - }; + return createToolResult({ + address, + ensName: ensName || "No ENS name found", + network + }); } catch (error) { return { content: [{ type: "text", text: `Error looking up ENS name: ${error instanceof Error ? error.message : String(error)}` }], @@ -279,6 +580,7 @@ export function registerEVMTools(server: McpServer) { blockIdentifier: z.string().describe("Block number (as string) or block hash"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: viemObjectOutputSchema, annotations: { title: "Get Block", readOnlyHint: true, @@ -297,7 +599,7 @@ export function registerEVMTools(server: McpServer) { // It's a number block = await services.getBlockByNumber(parseInt(blockIdentifier), network); } - return { content: [{ type: "text", text: services.helpers.formatJson(block) }] }; + return createToolResult(block); } catch (error) { return { content: [{ type: "text", text: `Error fetching block: ${error instanceof Error ? error.message : String(error)}` }], @@ -314,6 +616,7 @@ export function registerEVMTools(server: McpServer) { inputSchema: z.object({ network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: viemObjectOutputSchema, annotations: { title: "Get Latest Block", readOnlyHint: true, @@ -325,7 +628,7 @@ export function registerEVMTools(server: McpServer) { async ({ network = "ethereum" }) => { try { const block = await services.getLatestBlock(network); - return { content: [{ type: "text", text: services.helpers.formatJson(block) }] }; + return createToolResult(block); } catch (error) { return { content: [{ type: "text", text: `Error fetching latest block: ${error instanceof Error ? error.message : String(error)}` }], @@ -342,11 +645,12 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "get_balance", { - description: "Get the native token balance (ETH, MATIC, etc.) for an address", + description: "Get the native token balance (ETH, POL, etc.) for an address", inputSchema: z.object({ address: z.string().describe("The wallet address or ENS name"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: nativeBalanceOutputSchema, annotations: { title: "Get Native Token Balance", readOnlyHint: true, @@ -358,16 +662,14 @@ export function registerEVMTools(server: McpServer) { async ({ address, network = "ethereum" }) => { try { const balance = await services.getETHBalance(address as Address, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - address, - balance: { wei: balance.wei.toString(), ether: balance.ether } - }, null, 2) - }] - }; + return createToolResult({ + network, + address, + balance: { + raw: balance.wei.toString(), + formatted: balance.ether + } + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching balance: ${error instanceof Error ? error.message : String(error)}` }], @@ -386,6 +688,7 @@ export function registerEVMTools(server: McpServer) { tokenAddress: z.string().describe("The ERC20 token contract address"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: tokenBalanceOutputSchema, annotations: { title: "Get ERC20 Token Balance", readOnlyHint: true, @@ -397,22 +700,17 @@ export function registerEVMTools(server: McpServer) { async ({ address, tokenAddress, network = "ethereum" }) => { try { const balance = await services.getERC20Balance(tokenAddress as Address, address as Address, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - tokenAddress, - address, - balance: { - raw: balance.raw.toString(), - formatted: balance.formatted, - symbol: balance.token.symbol, - decimals: balance.token.decimals - } - }, null, 2) - }] - }; + return createToolResult({ + network, + tokenAddress, + address, + balance: { + raw: balance.raw.toString(), + formatted: balance.formatted, + symbol: balance.token.symbol, + decimals: balance.token.decimals + } + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching token balance: ${error instanceof Error ? error.message : String(error)}` }], @@ -432,6 +730,7 @@ export function registerEVMTools(server: McpServer) { ownerAddress: z.string().optional().describe("The owner address (defaults to the configured wallet)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: allowanceOutputSchema, annotations: { title: "Get Token Allowance", readOnlyHint: true, @@ -463,19 +762,14 @@ export function registerEVMTools(server: McpServer) { args: [owner, spenderAddress as Address] }); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - tokenAddress, - owner, - spenderAddress, - allowance: allowance.toString(), - message: allowance === 0n ? "No allowance set" : "Allowance is set" - }, null, 2) - }] - }; + return createToolResult({ + network, + tokenAddress, + owner, + spenderAddress, + allowance: allowance.toString(), + message: allowance === 0n ? "No allowance set" : "Allowance is set" + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching allowance: ${error instanceof Error ? error.message : String(error)}` }], @@ -497,6 +791,7 @@ export function registerEVMTools(server: McpServer) { txHash: z.string().describe("Transaction hash (0x...)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: viemObjectOutputSchema, annotations: { title: "Get Transaction", readOnlyHint: true, @@ -508,7 +803,7 @@ export function registerEVMTools(server: McpServer) { async ({ txHash, network = "ethereum" }) => { try { const tx = await services.getTransaction(txHash as Hash, network); - return { content: [{ type: "text", text: services.helpers.formatJson(tx) }] }; + return createToolResult(tx); } catch (error) { return { content: [{ type: "text", text: `Error fetching transaction: ${error instanceof Error ? error.message : String(error)}` }], @@ -526,6 +821,7 @@ export function registerEVMTools(server: McpServer) { txHash: z.string().describe("Transaction hash (0x...)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: viemObjectOutputSchema, annotations: { title: "Get Transaction Receipt", readOnlyHint: true, @@ -540,7 +836,7 @@ export function registerEVMTools(server: McpServer) { const receipt = await client.getTransactionReceipt({ hash: txHash as Hash }); - return { content: [{ type: "text", text: services.helpers.formatJson(receipt) }] }; + return createToolResult(receipt); } catch (error) { return { content: [{ type: "text", text: `Error fetching transaction receipt: ${error instanceof Error ? error.message : String(error)}` }], @@ -553,12 +849,14 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "wait_for_transaction", { - description: "Wait for a transaction to be confirmed (mined). Polls the network until confirmation.", + description: "Wait up to a bounded timeout for a transaction to be confirmed (mined). If it is still pending, call this tool again or use get_transaction_receipt.", inputSchema: z.object({ txHash: z.string().describe("Transaction hash (0x...)"), - confirmations: z.number().optional().describe("Number of block confirmations required. Defaults to 1."), + confirmations: z.number().int().positive().optional().describe("Number of block confirmations required. Defaults to 1."), + timeoutSeconds: z.number().int().min(1).max(90).optional().describe("Maximum time to wait before returning an error. Defaults to 90 seconds and is capped below the HTTP transport timeout."), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: waitForTransactionOutputSchema, annotations: { title: "Wait For Transaction", readOnlyHint: true, @@ -567,27 +865,24 @@ export function registerEVMTools(server: McpServer) { openWorldHint: true } }, - async ({ txHash, confirmations = 1, network = "ethereum" }) => { + async ({ txHash, confirmations = 1, timeoutSeconds = 90, network = "ethereum" }) => { try { const client = await services.getPublicClient(network); const receipt = await client.waitForTransactionReceipt({ hash: txHash as Hash, - confirmations + confirmations, + timeout: timeoutSeconds * 1000 }); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - txHash, - status: receipt.status === 'success' ? 'confirmed' : 'failed', - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), - confirmations - }, null, 2) - }] - }; + return createToolResult({ + network, + txHash, + status: receipt.status === 'success' ? 'confirmed' : 'failed', + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + confirmations, + timeoutSeconds + }); } catch (error) { return { content: [{ type: "text", text: `Error waiting for transaction: ${error instanceof Error ? error.message : String(error)}` }], @@ -604,11 +899,12 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "get_contract_abi", { - description: "Fetch a contract's full ABI from Etherscan/block explorers. Use this to understand verified contracts before interacting. Requires ETHERSCAN_API_KEY. Supports 30+ EVM networks. Works best with verified contracts on block explorers.", + description: "Fetch a verified contract ABI through the Etherscan v2 API. Requires ETHERSCAN_API_KEY and explorer support for the selected chain.", inputSchema: z.object({ contractAddress: z.string().describe("The contract address (0x...)"), - network: z.string().optional().describe("Network name or chain ID. Defaults to ethereum. Supported: ethereum, polygon, arbitrum, optimism, base, avalanche, gnosis, fantom, bsc, celo, scroll, linea, zksync, manta, blast, and testnets (sepolia, mumbai, arbitrum-sepolia, optimism-sepolia, base-sepolia, avalanche-fuji)") + network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet; use polygon-amoy for the Polygon testnet.") }), + outputSchema: contractAbiOutputSchema, annotations: { title: "Get Contract ABI", readOnlyHint: true, @@ -623,19 +919,14 @@ export function registerEVMTools(server: McpServer) { const parsed = services.parseABI(abi); const readableFunctions = services.getReadableFunctions(parsed); - return { - content: [{ - type: "text", - text: JSON.stringify({ - contractAddress, - network, - abiFormat: "json", - readableFunctions, - totalFunctions: parsed.filter(i => i.type === 'function').length, - abi: parsed - }, null, 2) - }] - }; + return createToolResult({ + contractAddress, + network, + abiFormat: "json", + readableFunctions, + totalFunctions: parsed.filter(i => i.type === 'function').length, + abi: parsed + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching ABI: ${error instanceof Error ? error.message : String(error)}` }], @@ -656,6 +947,7 @@ export function registerEVMTools(server: McpServer) { abiJson: z.string().optional().describe("Full contract ABI as JSON string (optional - will auto-fetch verified contract ABI if not provided)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: readContractOutputSchema, annotations: { title: "Read Smart Contract", readOnlyHint: true, @@ -729,18 +1021,13 @@ export function registerEVMTools(server: McpServer) { args: args as any }); - return { - content: [{ - type: "text", - text: JSON.stringify({ - contractAddress, - function: functionName, - args: args.length > 0 ? args : undefined, - result: result?.toString(), - abiSource: abiJson ? 'provided' : 'auto-fetched or built-in' - }, null, 2) - }] - }; + return createToolResult({ + contractAddress, + function: functionName, + args: args.length > 0 ? args : undefined, + result, + abiSource: abiJson ? 'provided' : 'auto-fetched or built-in' + }); } catch (error) { return { content: [{ type: "text", text: `Error reading contract: ${error instanceof Error ? error.message : String(error)}` }], @@ -753,15 +1040,16 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "write_contract", { - description: "Execute state-changing functions on a smart contract. Automatically fetches ABI from block explorer if not provided (requires ETHERSCAN_API_KEY). Use this to call any write function on verified contracts. Requires wallet to be configured (via private key or mnemonic).", + description: "Execute an ABI-described state-changing smart-contract function with string-form arguments. Automatically fetches the ABI from Etherscan v2 if not provided (requires ETHERSCAN_API_KEY and a supported chain). Requires a configured wallet.", inputSchema: z.object({ contractAddress: z.string().describe("The contract address"), functionName: z.string().describe("Function name to call (e.g., 'mint', 'swap', 'stake', 'approve')"), args: z.array(z.string()).optional().describe("Function arguments as strings (e.g., ['0xAddress', '1000000'])"), - value: z.string().optional().describe("ETH value to send with transaction in ether (e.g., '0.1' for payable functions)"), + value: z.string().optional().describe("Native-token value to send in whole-token units (e.g., '0.1' for payable functions)"), abiJson: z.string().optional().describe("Full contract ABI as JSON string (optional - will auto-fetch verified contract ABI if not provided)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: writeContractOutputSchema, annotations: { title: "Write to Smart Contract", readOnlyHint: false, @@ -770,12 +1058,8 @@ export function registerEVMTools(server: McpServer) { openWorldHint: true } }, - async ({ contractAddress, functionName, args = [], value, abiJson, network = "ethereum" }) => { + async ({ contractAddress, functionName, args = [], value, abiJson, network = "ethereum" }, ctx) => { try { - const privateKey = getConfiguredPrivateKey(); - const senderAddress = getWalletAddressFromKey(); - const client = await services.getPublicClient(network); - let abi: any[] | undefined; let functionAbi: any; @@ -821,6 +1105,32 @@ export function registerEVMTools(server: McpServer) { }; } + const functionSignature = `${functionAbi.name}(${ + (functionAbi.inputs ?? []) + .map((input: { type?: unknown }) => String(input.type ?? "unknown")) + .join(",") + })`; + const confirmation = await requireConfirmation( + ctx, + "write_contract", + { + contractAddress, + functionName, + args, + value: value ?? null, + abiJson: abiJson ?? null, + functionAbi, + network + }, + `Call ${functionSignature} on contract ${contractAddress} on ${network} with arguments ${JSON.stringify(args)}${value ? ` and ${value} native tokens` : ""} using the ${abiJson ? "provided" : "auto-fetched"} ABI?` + ); + if (confirmation) { + return confirmation; + } + + const privateKey = getConfiguredPrivateKey(); + const senderAddress = getWalletAddressFromKey(); + // Prepare write parameters const writeParams: any = { address: contractAddress as Address, @@ -838,22 +1148,17 @@ export function registerEVMTools(server: McpServer) { // Execute the write operation const txHash = await services.writeContract(privateKey, writeParams, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - contractAddress, - function: functionName, - args: args.length > 0 ? args : undefined, - value: value || undefined, - from: senderAddress, - txHash, - abiSource: abiJson ? 'provided' : 'auto-fetched', - message: "Transaction sent. Use get_transaction_receipt or wait_for_transaction to check confirmation." - }, null, 2) - }] - }; + return createToolResult({ + network, + contractAddress, + function: functionName, + args: args.length > 0 ? args : undefined, + value: value || undefined, + from: senderAddress, + txHash, + abiSource: abiJson ? 'provided' : 'auto-fetched', + message: "Transaction sent. Use get_transaction_receipt or wait_for_transaction to check confirmation." + }); } catch (error) { return { content: [{ type: "text", text: `Error writing to contract: ${error instanceof Error ? error.message : String(error)}` }], @@ -866,7 +1171,7 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "multicall", { - description: "Batch multiple contract read calls into a single RPC request. Significantly reduces latency and RPC usage when querying multiple functions. Uses the Multicall3 contract deployed on all major networks. Perfect for portfolio analysis, price aggregation, and querying multiple contract states efficiently.", + description: "Batch contract reads through Viem and Multicall3. Large batches may be split across RPC requests, and the selected chain must have a configured Multicall3 deployment.", inputSchema: z.object({ calls: z.array(z.object({ contractAddress: z.string().describe("The contract address"), @@ -877,6 +1182,7 @@ export function registerEVMTools(server: McpServer) { allowFailure: z.boolean().optional().describe("If true, returns partial results even if some calls fail. Defaults to true."), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: multicallOutputSchema, annotations: { title: "Multicall (Batch Read)", readOnlyHint: true, @@ -952,7 +1258,7 @@ export function registerEVMTools(server: McpServer) { contractAddress: call.contractAddress, functionName: call.functionName, args: call.args, - result: result.result?.toString(), + result: result.result, status: 'success' }; } else { @@ -966,18 +1272,13 @@ export function registerEVMTools(server: McpServer) { } }); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - totalCalls: calls.length, - successfulCalls: formattedResults.filter((r: any) => r.status === 'success').length, - failedCalls: formattedResults.filter((r: any) => r.status === 'failure').length, - results: formattedResults - }, null, 2) - }] - }; + return createToolResult({ + network, + totalCalls: calls.length, + successfulCalls: formattedResults.filter((r: any) => r.status === 'success').length, + failedCalls: formattedResults.filter((r: any) => r.status === 'failure').length, + results: formattedResults + }); } catch (error) { return { content: [{ type: "text", text: `Error executing multicall: ${error instanceof Error ? error.message : String(error)}` }], @@ -994,12 +1295,13 @@ export function registerEVMTools(server: McpServer) { server.registerTool( "transfer_native", { - description: "Transfer native tokens (ETH, MATIC, etc.) to an address. Uses the configured wallet.", + description: "Transfer native tokens (ETH, POL, etc.) to an address. Uses the configured wallet.", inputSchema: z.object({ to: z.string().describe("Recipient address or ENS name"), - amount: z.string().describe("Amount to send in ether (e.g., '0.5' for 0.5 ETH)"), + amount: z.string().describe("Amount to send in whole native-token units (e.g., '0.5')"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: nativeTransferOutputSchema, annotations: { title: "Transfer Native Tokens", readOnlyHint: false, @@ -1008,24 +1310,30 @@ export function registerEVMTools(server: McpServer) { openWorldHint: true } }, - async ({ to, amount, network = "ethereum" }) => { + async ({ to, amount, network = "ethereum" }, ctx) => { try { + const resolvedRecipient = await services.resolveAddress(to, network); + const confirmation = await requireConfirmation( + ctx, + "transfer_native", + { to, resolvedRecipient, amount, network }, + `Transfer ${amount} native tokens to ${to} (${resolvedRecipient}) on ${network}?` + ); + if (confirmation) { + return confirmation; + } + const privateKey = getConfiguredPrivateKey(); const senderAddress = getWalletAddressFromKey(); - const txHash = await services.transferETH(privateKey, to as Address, amount, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - from: senderAddress, - to, - amount, - txHash, - message: "Transaction sent. Use get_transaction_receipt to check confirmation." - }, null, 2) - }] - }; + const txHash = await services.transferETH(privateKey, resolvedRecipient, amount, network); + return createToolResult({ + network, + from: senderAddress, + to: resolvedRecipient, + amount, + txHash, + message: "Transaction sent. Use get_transaction_receipt to check confirmation." + }); } catch (error) { return { content: [{ type: "text", text: `Error transferring native tokens: ${error instanceof Error ? error.message : String(error)}` }], @@ -1045,6 +1353,7 @@ export function registerEVMTools(server: McpServer) { amount: z.string().describe("Amount to send (in token units, accounting for decimals)"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: erc20TransferOutputSchema, annotations: { title: "Transfer ERC20 Tokens", readOnlyHint: false, @@ -1053,27 +1362,49 @@ export function registerEVMTools(server: McpServer) { openWorldHint: true } }, - async ({ tokenAddress, to, amount, network = "ethereum" }) => { + async ({ tokenAddress, to, amount, network = "ethereum" }, ctx) => { try { + const [resolvedTokenAddress, resolvedRecipient] = await Promise.all([ + services.resolveAddress(tokenAddress, network), + services.resolveAddress(to, network) + ]); + const confirmation = await requireConfirmation( + ctx, + "transfer_erc20", + { + tokenAddress, + resolvedTokenAddress, + to, + resolvedRecipient, + amount, + network + }, + `Transfer ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) to ${to} (${resolvedRecipient}) on ${network}?` + ); + if (confirmation) { + return confirmation; + } + const privateKey = getConfiguredPrivateKey(); const senderAddress = getWalletAddressFromKey(); - const result = await services.transferERC20(tokenAddress as Address, to as Address, amount, privateKey, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - tokenAddress, - from: senderAddress, - to, - amount: result.amount.formatted, - symbol: result.token.symbol, - decimals: result.token.decimals, - txHash: result.txHash, - message: "Transaction sent. Use get_transaction_receipt to check confirmation." - }, null, 2) - }] - }; + const result = await services.transferERC20( + resolvedTokenAddress, + resolvedRecipient, + amount, + privateKey, + network + ); + return createToolResult({ + network, + tokenAddress: resolvedTokenAddress, + from: senderAddress, + to: resolvedRecipient, + amount: result.amount.formatted, + symbol: result.token.symbol, + decimals: result.token.decimals, + txHash: result.txHash, + message: "Transaction sent. Use get_transaction_receipt to check confirmation." + }); } catch (error) { return { content: [{ type: "text", text: `Error transferring ERC20 tokens: ${error instanceof Error ? error.message : String(error)}` }], @@ -1093,33 +1424,56 @@ export function registerEVMTools(server: McpServer) { amount: z.string().describe("Amount to approve (in token units). Use '0' to revoke approval."), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: tokenApprovalOutputSchema, annotations: { title: "Approve Token Spending", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true } }, - async ({ tokenAddress, spenderAddress, amount, network = "ethereum" }) => { + async ({ tokenAddress, spenderAddress, amount, network = "ethereum" }, ctx) => { try { + const [resolvedTokenAddress, resolvedSpenderAddress] = await Promise.all([ + services.resolveAddress(tokenAddress, network), + services.resolveAddress(spenderAddress, network) + ]); + const confirmation = await requireConfirmation( + ctx, + "approve_token_spending", + { + tokenAddress, + resolvedTokenAddress, + spenderAddress, + resolvedSpenderAddress, + amount, + network + }, + `Approve ${spenderAddress} (${resolvedSpenderAddress}) to spend ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) on ${network}?` + ); + if (confirmation) { + return confirmation; + } + const privateKey = getConfiguredPrivateKey(); const senderAddress = getWalletAddressFromKey(); - const txHash = await services.approveERC20(tokenAddress as Address, spenderAddress as Address, amount, privateKey, network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - tokenAddress, - owner: senderAddress, - spender: spenderAddress, - approvalAmount: amount, - txHash, - message: "Approval transaction sent. Use get_transaction_receipt to check confirmation." - }, null, 2) - }] - }; + const txHash = await services.approveERC20( + resolvedTokenAddress, + resolvedSpenderAddress, + amount, + privateKey, + network + ); + return createToolResult({ + network, + tokenAddress: resolvedTokenAddress, + owner: senderAddress, + spender: resolvedSpenderAddress, + approvalAmount: amount, + txHash, + message: "Approval transaction sent. Use get_transaction_receipt to check confirmation." + }); } catch (error) { return { content: [{ type: "text", text: `Error approving token spending: ${error instanceof Error ? error.message : String(error)}` }], @@ -1142,6 +1496,7 @@ export function registerEVMTools(server: McpServer) { tokenId: z.string().describe("The NFT token ID"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: nftInfoOutputSchema, annotations: { title: "Get NFT Info", readOnlyHint: true, @@ -1153,17 +1508,12 @@ export function registerEVMTools(server: McpServer) { async ({ contractAddress, tokenId, network = "ethereum" }) => { try { const nftInfo = await services.getERC721TokenMetadata(contractAddress as Address, BigInt(tokenId), network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - contract: contractAddress, - tokenId, - ...nftInfo - }, null, 2) - }] - }; + return createToolResult({ + network, + contract: contractAddress, + tokenId, + ...nftInfo + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching NFT info: ${error instanceof Error ? error.message : String(error)}` }], @@ -1183,6 +1533,7 @@ export function registerEVMTools(server: McpServer) { address: z.string().describe("The owner address or ENS name"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") }), + outputSchema: erc1155BalanceOutputSchema, annotations: { title: "Get ERC1155 Balance", readOnlyHint: true, @@ -1194,18 +1545,13 @@ export function registerEVMTools(server: McpServer) { async ({ contractAddress, tokenId, address, network = "ethereum" }) => { try { const balance = await services.getERC1155Balance(contractAddress as Address, address as Address, BigInt(tokenId), network); - return { - content: [{ - type: "text", - text: JSON.stringify({ - network, - contract: contractAddress, - tokenId, - owner: address, - balance: balance.toString() - }, null, 2) - }] - }; + return createToolResult({ + network, + contract: contractAddress, + tokenId, + owner: address, + balance: balance.toString() + }); } catch (error) { return { content: [{ type: "text", text: `Error fetching ERC1155 balance: ${error instanceof Error ? error.message : String(error)}` }], @@ -1224,8 +1570,9 @@ export function registerEVMTools(server: McpServer) { { description: "Sign an arbitrary message using the configured wallet. Useful for authentication (SIWE), meta-transactions, and off-chain signatures. The signature can be verified on-chain or off-chain.", inputSchema: z.object({ - message: z.string().describe("The message to sign (plain text or hex-encoded data)") + message: z.string().describe("The plain-text message to sign") }), + outputSchema: signedMessageOutputSchema, annotations: { title: "Sign Message", readOnlyHint: false, @@ -1234,21 +1581,26 @@ export function registerEVMTools(server: McpServer) { openWorldHint: false } }, - async ({ message }) => { + async ({ message }, ctx) => { + const confirmation = await requireConfirmation( + ctx, + "sign_message", + { message }, + `Sign this message with the configured wallet?\n\n${message}` + ); + if (confirmation) { + return confirmation; + } + try { const senderAddress = getWalletAddressFromKey(); const signature = await services.signMessage(message); - return { - content: [{ - type: "text", - text: JSON.stringify({ - message, - signature, - signer: senderAddress, - messageType: "personal_sign" - }, null, 2) - }] - }; + return createToolResult({ + message, + signature, + signer: senderAddress, + messageType: "personal_sign" + }); } catch (error) { return { content: [{ type: "text", text: `Error signing message: ${error instanceof Error ? error.message : String(error)}` }], @@ -1268,6 +1620,7 @@ export function registerEVMTools(server: McpServer) { primaryType: z.string().describe("The primary type name (e.g., 'Mail', 'Permit', 'MetaTransaction')"), messageJson: z.string().describe("The message data to sign as JSON string") }), + outputSchema: signedTypedDataOutputSchema, annotations: { title: "Sign Typed Data (EIP-712)", readOnlyHint: false, @@ -1276,10 +1629,8 @@ export function registerEVMTools(server: McpServer) { openWorldHint: false } }, - async ({ domainJson, typesJson, primaryType, messageJson }) => { + async ({ domainJson, typesJson, primaryType, messageJson }, ctx) => { try { - const senderAddress = getWalletAddressFromKey(); - // Parse JSON inputs let domain, types, message; try { @@ -1296,22 +1647,33 @@ export function registerEVMTools(server: McpServer) { }; } + const confirmation = await requireConfirmation( + ctx, + "sign_typed_data", + { + domainJson, + typesJson, + primaryType, + messageJson + }, + `Sign EIP-712 ${primaryType} typed data with domain ${JSON.stringify(domain)}, types ${JSON.stringify(types)}, and message ${JSON.stringify(message)}?` + ); + if (confirmation) { + return confirmation; + } + + const senderAddress = getWalletAddressFromKey(); const signature = await services.signTypedData(domain, types, primaryType, message); - return { - content: [{ - type: "text", - text: JSON.stringify({ - domain, - types, - primaryType, - message, - signature, - signer: senderAddress, - messageType: "EIP-712" - }, null, 2) - }] - }; + return createToolResult({ + domain, + types, + primaryType, + message, + signature, + signer: senderAddress, + messageType: "EIP-712" + }); } catch (error) { return { content: [{ type: "text", text: `Error signing typed data: ${error instanceof Error ? error.message : String(error)}` }], diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..cb456d1 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,436 @@ +import type { OAuthTokenVerifier } from "@modelcontextprotocol/express"; +import { + OAuthError, + OAuthErrorCode, + type AuthInfo, + type OAuthMetadata +} from "@modelcontextprotocol/server"; +import { z } from "zod"; + +export const BASE_MCP_SCOPE = "mcp"; +export const WRITE_MCP_SCOPE = "evm:write"; +export const SIGN_MCP_SCOPE = "evm:sign"; +export const DEFAULT_MCP_SCOPES = [ + BASE_MCP_SCOPE +] as const; +const OAUTH_NETWORK_TIMEOUT_MS = 10_000; + +const toolScopeRequirements: Readonly> = { + "write_contract": WRITE_MCP_SCOPE, + "transfer_native": WRITE_MCP_SCOPE, + "transfer_erc20": WRITE_MCP_SCOPE, + "approve_token_spending": WRITE_MCP_SCOPE, + "sign_message": SIGN_MCP_SCOPE, + "sign_typed_data": SIGN_MCP_SCOPE +}; + +const oauthMetadataSchema = z.looseObject({ + issuer: z.url(), + authorization_endpoint: z.url(), + token_endpoint: z.url(), + registration_endpoint: z.url().optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + grant_types_supported: z.array(z.string()).optional(), + code_challenge_methods_supported: z.array(z.string()), + introspection_endpoint: z.url().optional() +}); + +const introspectionResponseSchema = z.looseObject({ + active: z.boolean(), + token_type: z.string().optional(), + client_id: z.string().optional(), + sub: z.string().optional(), + scope: z.union([z.string(), z.array(z.string())]).optional(), + exp: z.number().int().positive().optional(), + aud: z.union([z.string(), z.array(z.string())]).optional(), + resource: z.string().optional(), + iss: z.string().optional() +}); + +export type OAuthResourceServerConfiguration = { + oauthMetadata: OAuthMetadata; + resourceServerUrl: URL; + scopesSupported: string[]; + requiredScopes: string[]; + verifier: OAuthTokenVerifier; +}; + +type OAuthEnvironment = NodeJS.ProcessEnv; +type FetchImplementation = typeof fetch; + +function requiredEnvironmentValue( + environment: OAuthEnvironment, + name: string +): string { + const value = environment[name]?.trim(); + if (!value) { + throw new Error(`${name} is required when MCP OAuth is enabled`); + } + return value; +} + +function parseScopes(value: string | undefined): string[] { + const scopes = value + ?.split(/[,\s]+/) + .map(scope => scope.trim()) + .filter(Boolean); + + return scopes?.length ? [...new Set(scopes)] : []; +} + +export function getRequiredToolScope(toolName: unknown): string | undefined { + return typeof toolName === "string" + ? toolScopeRequirements[toolName] + : undefined; +} + +export function getRequiredRequestScopes(body: unknown): string[] { + const requests = Array.isArray(body) ? body : [body]; + const requiredScopes = new Set(); + + for (const request of requests) { + if (!request || typeof request !== "object") { + continue; + } + + const message = request as { + method?: unknown; + params?: unknown; + }; + if (message.method !== "tools/call" || !message.params || typeof message.params !== "object") { + continue; + } + + const requiredScope = getRequiredToolScope( + (message.params as { name?: unknown }).name + ); + if (requiredScope) { + requiredScopes.add(requiredScope); + } + } + + return [...requiredScopes]; +} + +function canonicalResourceUrl(value: URL): URL { + if (value.hash) { + throw new Error("OAuth resource identifiers must not contain a fragment"); + } + if (value.protocol !== "https:" && value.protocol !== "http:") { + throw new Error("OAuth resource identifiers must use HTTP or HTTPS"); + } + return new URL(value); +} + +function isLoopbackHostname(hostname: string): boolean { + return hostname === "localhost" + || hostname === "127.0.0.1" + || hostname === "[::1]" + || hostname === "::1"; +} + +function requireHttpsAuthorizationUrl(value: URL, label: string): void { + if (value.protocol !== "https:") { + throw new Error(`${label} must use HTTPS`); + } +} + +function requireSecureResourceUrl(value: URL, label: string): void { + if ( + value.protocol !== "https:" + && !(value.protocol === "http:" && isLoopbackHostname(value.hostname)) + ) { + throw new Error(`${label} must use HTTPS unless it is a loopback URL`); + } +} + +function getAuthorizationServerMetadataUrl(issuer: URL): URL { + const metadataUrl = new URL(issuer.origin); + metadataUrl.pathname = `/.well-known/oauth-authorization-server${ + issuer.pathname === "/" ? "" : issuer.pathname + }`; + return metadataUrl; +} + +function matchesAudience( + audience: string | string[] | undefined, + expectedAudience: string +): boolean { + if (!audience) { + return false; + } + + const values = Array.isArray(audience) ? audience : [audience]; + return values.some(value => { + try { + return canonicalResourceUrl(new URL(value)).href === expectedAudience; + } catch { + return false; + } + }); +} + +function formEncodeCredential(value: string): string { + return new URLSearchParams({ value }).toString().slice("value=".length); +} + +function validateAuthorizationServerMetadata( + metadata: z.infer +): void { + if (!metadata.response_types_supported.includes("code")) { + throw new Error("OAuth metadata must support the authorization code response type"); + } + if ( + metadata.grant_types_supported + && !metadata.grant_types_supported.includes("authorization_code") + ) { + throw new Error("OAuth metadata grant_types_supported must include authorization_code"); + } + if (!metadata.code_challenge_methods_supported.includes("S256")) { + throw new Error("OAuth metadata must advertise PKCE S256 support"); + } + + const endpointEntries = [ + ["authorization_endpoint", metadata.authorization_endpoint], + ["token_endpoint", metadata.token_endpoint], + ["registration_endpoint", metadata.registration_endpoint], + ["introspection_endpoint", metadata.introspection_endpoint] + ] as const; + for (const [name, value] of endpointEntries) { + if (!value) { + continue; + } + + const endpoint = new URL(value); + if (endpoint.hash) { + throw new Error(`OAuth metadata ${name} must not contain a fragment`); + } + requireHttpsAuthorizationUrl(endpoint, `OAuth metadata ${name}`); + } +} + +function createIntrospectionVerifier(options: { + clientId: string; + clientSecret: string; + expectedAudience: string; + expectedIssuer: string; + fetchImplementation: FetchImplementation; + introspectionEndpoint: string; + resourceServerUrl: URL; +}): OAuthTokenVerifier { + return { + async verifyAccessToken(token: string): Promise { + let response: Response; + try { + response = await options.fetchImplementation(options.introspectionEndpoint, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(OAUTH_NETWORK_TIMEOUT_MS), + headers: { + "Authorization": `Basic ${Buffer.from( + `${formEncodeCredential(options.clientId)}:${formEncodeCredential(options.clientSecret)}` + ).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json" + }, + body: new URLSearchParams({ + token, + token_type_hint: "access_token" + }) + }); + } catch { + throw new OAuthError( + OAuthErrorCode.ServerError, + "The authorization server could not be reached" + ); + } + + if (!response.ok) { + throw new OAuthError( + OAuthErrorCode.ServerError, + `The authorization server rejected token introspection with HTTP ${response.status}` + ); + } + + const parsed = introspectionResponseSchema.safeParse(await response.json()); + if (!parsed.success || !parsed.data.active) { + throw new OAuthError(OAuthErrorCode.InvalidToken, "The access token is invalid"); + } + + const tokenInfo = parsed.data; + if ( + tokenInfo.token_type + && tokenInfo.token_type.toLowerCase() !== "bearer" + ) { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + "The access token is not a bearer token" + ); + } + + const clientId = tokenInfo.client_id ?? tokenInfo.sub; + if (!clientId || !tokenInfo.exp) { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + "The access token is missing required identity or expiration claims" + ); + } + + if (tokenInfo.exp <= Math.floor(Date.now() / 1000)) { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + "The access token has expired" + ); + } + + if (tokenInfo.iss && tokenInfo.iss !== options.expectedIssuer) { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + "The access token was issued by an unexpected authorization server" + ); + } + + const audience = tokenInfo.resource ?? tokenInfo.aud; + if (!matchesAudience(audience, options.expectedAudience)) { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + "The access token is not intended for this MCP server" + ); + } + + const scopes = Array.isArray(tokenInfo.scope) + ? tokenInfo.scope + : tokenInfo.scope?.split(/\s+/).filter(Boolean) ?? []; + + return { + token, + clientId, + scopes, + expiresAt: tokenInfo.exp, + resource: options.resourceServerUrl, + extra: { + issuer: options.expectedIssuer + } + }; + } + }; +} + +/** + * Load the OAuth resource-server configuration for Streamable HTTP. + * + * Localhost remains usable without OAuth. Binding to a non-local interface + * requires an external OAuth authorization server and RFC 7662 introspection. + */ +export async function loadOAuthResourceServerConfiguration(options: { + environment?: OAuthEnvironment; + fetchImplementation?: FetchImplementation; + isLocalHost: boolean; +}): Promise { + const environment = options.environment ?? process.env; + const fetchImplementation = options.fetchImplementation ?? fetch; + const issuer = environment.MCP_OAUTH_ISSUER_URL?.trim(); + + if (!issuer) { + if (options.isLocalHost) { + return undefined; + } + + throw new Error( + "MCP_OAUTH_ISSUER_URL is required when MCP_HOST binds to a non-local interface" + ); + } + + const issuerUrl = new URL(issuer); + if (issuerUrl.search || issuerUrl.hash) { + throw new Error("MCP_OAUTH_ISSUER_URL must not include a query or fragment"); + } + requireHttpsAuthorizationUrl(issuerUrl, "MCP_OAUTH_ISSUER_URL"); + const expectedIssuer = issuer; + + const publicUrl = new URL(requiredEnvironmentValue(environment, "MCP_PUBLIC_URL")); + if (publicUrl.pathname !== "/mcp" || publicUrl.search || publicUrl.hash) { + throw new Error("MCP_PUBLIC_URL must be the exact public MCP endpoint ending in /mcp, without a query or fragment"); + } + requireSecureResourceUrl(publicUrl, "MCP_PUBLIC_URL"); + + const metadataUrl = environment.MCP_OAUTH_METADATA_URL?.trim() + ?? getAuthorizationServerMetadataUrl(issuerUrl).href; + const parsedMetadataUrl = new URL(metadataUrl); + if (parsedMetadataUrl.hash) { + throw new Error("OAuth metadata URL must not contain a fragment"); + } + requireHttpsAuthorizationUrl(parsedMetadataUrl, "OAuth metadata URL"); + let metadataResponse: Response; + try { + metadataResponse = await fetchImplementation(metadataUrl, { + redirect: "error", + signal: AbortSignal.timeout(OAUTH_NETWORK_TIMEOUT_MS), + headers: { + "Accept": "application/json" + } + }); + } catch { + throw new Error("Unable to load OAuth authorization server metadata"); + } + if (!metadataResponse.ok) { + throw new Error( + `Unable to load OAuth authorization server metadata: HTTP ${metadataResponse.status}` + ); + } + + const metadata = oauthMetadataSchema.parse(await metadataResponse.json()); + if (metadata.issuer !== expectedIssuer) { + throw new Error("OAuth metadata issuer does not match MCP_OAUTH_ISSUER_URL"); + } + validateAuthorizationServerMetadata(metadata); + + const introspectionEndpoint = environment.MCP_OAUTH_INTROSPECTION_URL?.trim() + ?? metadata.introspection_endpoint; + if (!introspectionEndpoint) { + throw new Error( + "The authorization server metadata must provide introspection_endpoint, or MCP_OAUTH_INTROSPECTION_URL must be set" + ); + } + const introspectionUrl = new URL(introspectionEndpoint); + if (introspectionUrl.hash) { + throw new Error("OAuth token introspection URL must not contain a fragment"); + } + requireHttpsAuthorizationUrl(introspectionUrl, "OAuth token introspection URL"); + + const requiredScopes = [ + ...new Set([ + BASE_MCP_SCOPE, + ...parseScopes(environment.MCP_OAUTH_REQUIRED_SCOPES) + ]) + ]; + const scopesSupported = [ + ...new Set([ + ...DEFAULT_MCP_SCOPES, + ...parseScopes(environment.MCP_OAUTH_SCOPES), + ...requiredScopes + ]) + ]; + const expectedAudienceUrl = canonicalResourceUrl(new URL( + environment.MCP_OAUTH_AUDIENCE?.trim() ?? publicUrl.href + )); + requireSecureResourceUrl(expectedAudienceUrl, "MCP_OAUTH_AUDIENCE"); + const expectedAudience = expectedAudienceUrl.href; + + return { + oauthMetadata: metadata as OAuthMetadata, + resourceServerUrl: publicUrl, + scopesSupported, + requiredScopes, + verifier: createIntrospectionVerifier({ + clientId: requiredEnvironmentValue(environment, "MCP_OAUTH_CLIENT_ID"), + clientSecret: requiredEnvironmentValue(environment, "MCP_OAUTH_CLIENT_SECRET"), + expectedAudience, + expectedIssuer, + fetchImplementation, + introspectionEndpoint: introspectionUrl.href, + resourceServerUrl: publicUrl + }) + }; +} diff --git a/src/server/http-app.ts b/src/server/http-app.ts new file mode 100644 index 0000000..ed20754 --- /dev/null +++ b/src/server/http-app.ts @@ -0,0 +1,183 @@ +import express, { + type Express, + type NextFunction, + type Request, + type Response +} from "express"; +import { + bearerAuthChallengeResponse, + createMcpHandler, + OAuthError, + OAuthErrorCode +} from "@modelcontextprotocol/server"; +import { + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from "@modelcontextprotocol/express"; +import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node"; +import { + getRequiredRequestScopes, + type OAuthResourceServerConfiguration +} from "./auth.js"; +import createServer from "./server.js"; +import { CACHE_SCOPE, CACHE_TTL_MS, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "./protocol.js"; + +export type HttpAppOptions = { + allowedHostnames: string[]; + allowedOriginHostnames: string[]; + oauthConfiguration?: OAuthResourceServerConfiguration; +}; + +export type HttpApp = { + app: Express; + mcpHandler: ReturnType; +}; + +/** + * Create the Streamable HTTP application without binding a network listener. + * + * Runtime configuration and process lifecycle remain the responsibility of the + * HTTP entry point so tests can exercise the complete middleware chain safely. + */ +export function createHttpApp(options: HttpAppOptions): HttpApp { + const { + allowedHostnames, + allowedOriginHostnames, + oauthConfiguration + } = options; + const validateHost = hostHeaderValidation(allowedHostnames); + const validateOrigin = originValidation(allowedOriginHostnames); + const app = express(); + const mcpHandler = createMcpHandler(createServer, { + legacy: "reject" + }); + const nodeHandler = toNodeHandler(mcpHandler, { + onerror: (error) => { + console.error("MCP Node adapter error:", error); + } + }); + + if (oauthConfiguration) { + app.use(mcpAuthMetadataRouter({ + oauthMetadata: oauthConfiguration.oauthMetadata, + resourceServerUrl: oauthConfiguration.resourceServerUrl, + scopesSupported: oauthConfiguration.scopesSupported, + resourceName: SERVER_INFO.name + })); + } + + const validateMcpRequest = ( + req: Request, + res: Response, + next: NextFunction + ) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) { + return; + } + + next(); + }; + + const handleMcpRequest = (req: Request, res: Response) => { + void nodeHandler(req, res, req.body); + }; + + if (oauthConfiguration) { + const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl( + oauthConfiguration.resourceServerUrl + ); + const requireOperationScope = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const missingScopes = getRequiredRequestScopes(req.body).filter( + scope => !req.auth?.scopes.includes(scope) + ); + if (missingScopes.length === 0) { + next(); + return; + } + + const challenge = bearerAuthChallengeResponse( + new OAuthError( + OAuthErrorCode.InsufficientScope, + `Additional scope required: ${missingScopes.join(" ")}` + ), + { + requiredScopes: missingScopes, + resourceMetadataUrl + } + ); + + challenge.headers.forEach((value, name) => { + res.setHeader(name, value); + }); + res.status(challenge.status).send(await challenge.text()); + }; + + app.all( + "/mcp", + validateMcpRequest, + requireBearerAuth({ + verifier: oauthConfiguration.verifier, + requiredScopes: oauthConfiguration.requiredScopes, + resourceMetadataUrl + }), + express.json({ limit: "1mb" }), + requireOperationScope, + handleMcpRequest + ); + } else { + app.all( + "/mcp", + validateMcpRequest, + express.json({ limit: "1mb" }), + handleMcpRequest + ); + } + + app.get("/health", (_req: Request, res: Response) => { + res.status(200).json({ + status: "ok", + protocol: `MCP ${MODERN_PROTOCOL_VERSION}`, + transport: "Streamable HTTP", + stateless: true, + authorization: oauthConfiguration ? "oauth" : "localhost-only" + }); + }); + + app.get("/", (_req: Request, res: Response) => { + res.status(200).json({ + name: SERVER_INFO.name, + version: SERVER_INFO.version, + protocol: `MCP ${MODERN_PROTOCOL_VERSION}`, + transport: "Streamable HTTP", + endpoints: { + mcp: "/mcp", + health: "/health" + }, + cache: { + ttlMs: CACHE_TTL_MS, + cacheScope: CACHE_SCOPE + }, + authorization: oauthConfiguration ? { + type: "oauth", + resourceMetadata: getOAuthProtectedResourceMetadataUrl( + oauthConfiguration.resourceServerUrl + ) + } : { + type: "none", + restriction: "localhost-only" + }, + status: "ready", + stateless: true + }); + }); + + return { + app, + mcpHandler + }; +} diff --git a/src/server/http-server.ts b/src/server/http-server.ts index ab34eb1..90b1697 100644 --- a/src/server/http-server.ts +++ b/src/server/http-server.ts @@ -1,9 +1,7 @@ -import express, { Request, Response } from "express"; -import { createMcpHandler } from "@modelcontextprotocol/server"; -import { hostHeaderValidation, originValidation, toNodeHandler } from "@modelcontextprotocol/node"; -import { getSupportedNetworks } from "../core/chains.js"; -import createServer from "./server.js"; -import { CACHE_SCOPE, CACHE_TTL_MS, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "./protocol.js"; +import { getSupportedChainCount, getSupportedNetworks } from "../core/chains.js"; +import { loadOAuthResourceServerConfiguration } from "./auth.js"; +import { createHttpApp } from "./http-app.js"; +import { MODERN_PROTOCOL_VERSION, SERVER_INFO } from "./protocol.js"; const PORT = parseInt(process.env.MCP_PORT || "3001", 10); const HOST = process.env.MCP_HOST || "127.0.0.1"; @@ -35,55 +33,22 @@ function isLocalHost(host: string): boolean { const defaultHostnames = isLocalHost(HOST) ? LOCAL_HOSTNAMES : [HOST]; const allowedHostnames = configuredHostnames("MCP_ALLOWED_HOSTS", defaultHostnames); const allowedOriginHostnames = configuredHostnames("MCP_ALLOWED_ORIGINS", defaultHostnames); -const validateHost = hostHeaderValidation(allowedHostnames); -const validateOrigin = originValidation(allowedOriginHostnames); console.error(`Configured to listen on ${HOST}:${PORT}`); -const app = express(); -const mcpHandler = createMcpHandler(createServer, { - legacy: "reject" -}); -const nodeHandler = toNodeHandler(mcpHandler, { - onerror: (error) => { - console.error("MCP Node adapter error:", error); - } -}); - -app.all("/mcp", (req: Request, res: Response) => { - if (!validateHost(req, res) || !validateOrigin(req, res)) { - return; - } - - void nodeHandler(req, res); -}); - -app.get("/health", (_req: Request, res: Response) => { - res.status(200).json({ - status: "ok", - protocol: `MCP ${MODERN_PROTOCOL_VERSION}`, - transport: "Streamable HTTP", - stateless: true - }); +const oauthConfiguration = await loadOAuthResourceServerConfiguration({ + isLocalHost: isLocalHost(HOST) +}).catch((error: unknown) => { + console.error( + `HTTP authorization configuration error: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); }); -app.get("/", (_req: Request, res: Response) => { - res.status(200).json({ - name: SERVER_INFO.name, - version: SERVER_INFO.version, - protocol: `MCP ${MODERN_PROTOCOL_VERSION}`, - transport: "Streamable HTTP", - endpoints: { - mcp: "/mcp", - health: "/health" - }, - cache: { - ttlMs: CACHE_TTL_MS, - cacheScope: CACHE_SCOPE - }, - status: "ready", - stateless: true - }); +const { app, mcpHandler } = createHttpApp({ + allowedHostnames, + allowedOriginHostnames, + oauthConfiguration }); const httpServer = app.listen(PORT, HOST, () => { @@ -91,7 +56,10 @@ const httpServer = app.listen(PORT, HOST, () => { console.error(`MCP endpoint: http://${HOST}:${PORT}/mcp`); console.error(`Health check: http://${HOST}:${PORT}/health`); console.error(`Protocol: MCP ${MODERN_PROTOCOL_VERSION} (stateless Streamable HTTP)`); - console.error(`Supported networks: ${getSupportedNetworks().length} networks`); + console.error(`Authorization: ${oauthConfiguration ? "OAuth bearer tokens required" : "disabled for localhost-only binding"}`); + console.error( + `Supported chains: ${getSupportedChainCount()} (${getSupportedNetworks().length} configured names and aliases)` + ); }).on("error", (error: Error) => { console.error("HTTP server error:", error); process.exit(1); diff --git a/src/server/protocol.ts b/src/server/protocol.ts index fcd9deb..94568b8 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -9,4 +9,4 @@ export const CACHE_TTL_MS = 60 * 60 * 1000; export const CACHE_SCOPE = "public"; export const SERVER_INSTRUCTIONS = - "Use the EVM tools to inspect supported chains, resolve ENS names, read balances and contract data, and prepare or submit transactions with the configured wallet. Always ask the user to confirm write operations before invoking transfer or approval tools."; + "Use the EVM tools to inspect supported chains, resolve ENS names, read balances and contract data, and prepare or submit transactions with the configured wallet. The six wallet-backed write and signing tools enforce exact-operation confirmation through MCP input_required results; invoke them directly and let the client complete that confirmation instead of asking separately. wait_for_transaction accepts a bounded timeoutSeconds value from 1 through 90 and defaults to 90."; diff --git a/src/server/request-state.ts b/src/server/request-state.ts new file mode 100644 index 0000000..0e99b76 --- /dev/null +++ b/src/server/request-state.ts @@ -0,0 +1,120 @@ +import { + createRequestStateCodec, + type ServerContext +} from "@modelcontextprotocol/server"; + +const CONFIRMATION_STATE_TTL_SECONDS = 5 * 60; +const requestStateKey = crypto.getRandomValues(new Uint8Array(32)); +const consumedConfirmationNonces = new Map(); + +export type ConfirmationRequestState = { + purpose: "wallet-operation-confirmation"; + operationDigest: string; + nonce: string; + expiresAt: number; +}; + +function randomHex(byteLength: number): string { + return Array.from(crypto.getRandomValues(new Uint8Array(byteLength))) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function pruneConsumedConfirmationNonces(now: number): void { + for (const [nonce, expiresAt] of consumedConfirmationNonces) { + if (expiresAt <= now) { + consumedConfirmationNonces.delete(nonce); + } + } +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nestedValue]) => [key, canonicalize(nestedValue)]) + ); + } + + return value; +} + +/** + * HMAC-protect MCP request state and bind it to the current request and bearer token. + * A process-local random key intentionally invalidates pending confirmations on restart. + */ +export const confirmationRequestStateCodec = + createRequestStateCodec({ + key: requestStateKey, + ttlSeconds: CONFIRMATION_STATE_TTL_SECONDS, + bind: ctx => [ + ctx.mcpReq.method, + ctx.http?.authInfo?.token ?? "local-or-stdio" + ].join("\0") + }); + +export async function createOperationDigest( + toolName: string, + argumentsValue: Record +): Promise { + const encoded = new TextEncoder().encode(JSON.stringify(canonicalize({ + toolName, + arguments: argumentsValue + }))); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoded)); + return Array.from(digest) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export async function mintConfirmationRequestState( + operationDigest: string, + ctx: ServerContext +): Promise { + const now = Math.floor(Date.now() / 1000); + pruneConsumedConfirmationNonces(now); + + return confirmationRequestStateCodec.mint({ + purpose: "wallet-operation-confirmation", + operationDigest, + nonce: randomHex(16), + expiresAt: now + CONFIRMATION_STATE_TTL_SECONDS + }, ctx); +} + +export function isConfirmationRequestState( + value: unknown +): value is ConfirmationRequestState { + if (!value || typeof value !== "object") { + return false; + } + + const state = value as Partial; + return state.purpose === "wallet-operation-confirmation" + && typeof state.operationDigest === "string" + && typeof state.nonce === "string" + && typeof state.expiresAt === "number"; +} + +/** + * Mark a verified confirmation as consumed before executing its operation. + * This prevents a signed confirmation from being replayed within this process. + */ +export function consumeConfirmationRequestState( + state: ConfirmationRequestState +): boolean { + const now = Math.floor(Date.now() / 1000); + pruneConsumedConfirmationNonces(now); + + if (state.expiresAt <= now || consumedConfirmationNonces.has(state.nonce)) { + return false; + } + + consumedConfirmationNonces.set(state.nonce, state.expiresAt); + return true; +} diff --git a/src/server/server.ts b/src/server/server.ts index c6c27cc..6b30b5c 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -3,6 +3,7 @@ import { registerEVMResources } from "../core/resources.js"; import { registerEVMTools } from "../core/tools.js"; import { registerEVMPrompts } from "../core/prompts.js"; import { CACHE_SCOPE, CACHE_TTL_MS, SERVER_INFO, SERVER_INSTRUCTIONS } from "./protocol.js"; +import { confirmationRequestStateCodec } from "./request-state.js"; // Create the MCP server used by the stdio and per-request HTTP serving entries. function createServer() { @@ -25,8 +26,10 @@ function createServer() { "tools/list": cacheHint, "prompts/list": cacheHint, "resources/list": cacheHint, - "resources/templates/list": cacheHint, - "resources/read": cacheHint + "resources/templates/list": cacheHint + }, + requestState: { + verify: confirmationRequestStateCodec.verify } } ); diff --git a/src/server/stdio-server.ts b/src/server/stdio-server.ts index a4de983..e9cc846 100644 --- a/src/server/stdio-server.ts +++ b/src/server/stdio-server.ts @@ -1,5 +1,5 @@ import { serveStdio, type StdioServerHandle } from "@modelcontextprotocol/server/stdio"; -import { getSupportedNetworks } from "../core/chains.js"; +import { getSupportedChainCount, getSupportedNetworks } from "../core/chains.js"; import createServer from "./server.js"; import { LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "./protocol.js"; @@ -9,7 +9,9 @@ import { LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION, SERVER_INFO } from ". export function runStdioServer(): StdioServerHandle { console.error(`EVM MCP Server v${SERVER_INFO.version} running on stdio`); console.error(`Protocol: MCP ${MODERN_PROTOCOL_VERSION} (modern), MCP ${LEGACY_PROTOCOL_VERSION} (legacy)`); - console.error(`Supported networks: ${getSupportedNetworks().length} networks`); + console.error( + `Supported chains: ${getSupportedChainCount()} (${getSupportedNetworks().length} configured names and aliases)` + ); return serveStdio(createServer, { legacy: "serve", diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..07363d2 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, test } from "bun:test"; +import { OAuthErrorCode } from "@modelcontextprotocol/server"; +import { + BASE_MCP_SCOPE, + SIGN_MCP_SCOPE, + WRITE_MCP_SCOPE, + getRequiredRequestScopes, + getRequiredToolScope, + loadOAuthResourceServerConfiguration +} from "../src/server/auth.js"; + +const ISSUER = "https://auth.example.test/"; +const RESOURCE_SERVER = "https://mcp.example.test/mcp"; +const INTROSPECTION_ENDPOINT = `${ISSUER}introspect`; + +function oauthEnvironment( + overrides: NodeJS.ProcessEnv = {} +): NodeJS.ProcessEnv { + return { + MCP_OAUTH_ISSUER_URL: ISSUER, + MCP_PUBLIC_URL: RESOURCE_SERVER, + MCP_OAUTH_CLIENT_ID: "resource-server", + MCP_OAUTH_CLIENT_SECRET: "test-secret", + ...overrides + }; +} + +function authorizationServerMetadata( + overrides: Record = {} +): Record { + return { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}authorize`, + token_endpoint: `${ISSUER}token`, + introspection_endpoint: INTROSPECTION_ENDPOINT, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + ...overrides + }; +} + +describe("HTTP OAuth resource-server configuration", () => { + test("maps wallet-backed tools to step-up scopes", () => { + for (const toolName of [ + "write_contract", + "transfer_native", + "transfer_erc20", + "approve_token_spending" + ]) { + expect(getRequiredToolScope(toolName)).toBe(WRITE_MCP_SCOPE); + } + + for (const toolName of ["sign_message", "sign_typed_data"]) { + expect(getRequiredToolScope(toolName)).toBe(SIGN_MCP_SCOPE); + } + + expect(getRequiredToolScope("get_supported_networks")).toBeUndefined(); + expect(getRequiredToolScope(undefined)).toBeUndefined(); + }); + + test("collects step-up scopes from single and batched MCP requests", () => { + expect(getRequiredRequestScopes({ + method: "tools/call", + params: { + name: "transfer_native" + } + })).toEqual([WRITE_MCP_SCOPE]); + + expect(getRequiredRequestScopes([ + { + method: "tools/call", + params: { + name: "sign_message" + } + }, + { + method: "tools/call", + params: { + name: "approve_token_spending" + } + }, + { + method: "tools/list" + } + ])).toEqual([SIGN_MCP_SCOPE, WRITE_MCP_SCOPE]); + }); + + test("allows unauthenticated localhost but fails closed for a remote bind", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: {}, + isLocalHost: true + })).resolves.toBeUndefined(); + + await expect(loadOAuthResourceServerConfiguration({ + environment: {}, + isLocalHost: false + })).rejects.toThrow( + "MCP_OAUTH_ISSUER_URL is required when MCP_HOST binds to a non-local interface" + ); + }); + + test("requires HTTPS for a remotely exposed MCP resource server", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_PUBLIC_URL: "http://mcp.example.test/mcp" + }), + fetchImplementation: async () => { + throw new Error("metadata fetch should not run"); + }, + isLocalHost: false + })).rejects.toThrow( + "MCP_PUBLIC_URL must use HTTPS unless it is a loopback URL" + ); + }); + + test("requires HTTPS for authorization-server URLs even on localhost", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_OAUTH_ISSUER_URL: "http://localhost:9000/" + }), + fetchImplementation: async () => { + throw new Error("metadata fetch should not run"); + }, + isLocalHost: true + })).rejects.toThrow( + "MCP_OAUTH_ISSUER_URL must use HTTPS" + ); + }); + + test("requires MCP_PUBLIC_URL to identify the exact MCP endpoint", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_PUBLIC_URL: "https://mcp.example.test/" + }), + fetchImplementation: async () => { + throw new Error("metadata fetch should not run"); + }, + isLocalHost: false + })).rejects.toThrow( + "MCP_PUBLIC_URL must be the exact public MCP endpoint ending in /mcp" + ); + }); + + test("uses RFC 8414 discovery for an issuer with a path", async () => { + const pathIssuer = "https://auth.example.test/tenant"; + const expectedMetadataUrl = + "https://auth.example.test/.well-known/oauth-authorization-server/tenant"; + let requestedUrl: string | undefined; + + await loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_OAUTH_ISSUER_URL: pathIssuer + }), + fetchImplementation: async input => { + requestedUrl = String(input); + return Response.json(authorizationServerMetadata({ + issuer: pathIssuer + })); + }, + isLocalHost: false + }); + + expect(requestedUrl).toBe(expectedMetadataUrl); + }); + + test("loads authorization metadata and validates introspected tokens", async () => { + const requests: Array<{ + url: string; + init?: RequestInit; + }> = []; + const expiresAt = Math.floor(Date.now() / 1000) + 300; + + const configuration = await loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_OAUTH_SCOPES: `${BASE_MCP_SCOPE}, ${WRITE_MCP_SCOPE} ${SIGN_MCP_SCOPE}`, + MCP_OAUTH_REQUIRED_SCOPES: BASE_MCP_SCOPE + }), + fetchImplementation: async (input, init) => { + const url = String(input); + requests.push({ url, init }); + + if (url === `${ISSUER}.well-known/oauth-authorization-server`) { + return Response.json(authorizationServerMetadata()); + } + + if (url === INTROSPECTION_ENDPOINT) { + return Response.json({ + active: true, + client_id: "mcp-client", + scope: `${BASE_MCP_SCOPE} ${WRITE_MCP_SCOPE}`, + exp: expiresAt, + aud: RESOURCE_SERVER, + iss: ISSUER + }); + } + + return new Response("not found", { status: 404 }); + }, + isLocalHost: false + }); + + expect(configuration).toBeDefined(); + if (!configuration) { + throw new Error("Expected OAuth configuration"); + } + + expect(configuration.resourceServerUrl.href).toBe(RESOURCE_SERVER); + expect(configuration.scopesSupported).toEqual([ + BASE_MCP_SCOPE, + WRITE_MCP_SCOPE, + SIGN_MCP_SCOPE + ]); + expect(configuration.requiredScopes).toEqual([BASE_MCP_SCOPE]); + + const authInfo = await configuration.verifier.verifyAccessToken("access-token"); + + expect(authInfo).toEqual(expect.objectContaining({ + token: "access-token", + clientId: "mcp-client", + scopes: [BASE_MCP_SCOPE, WRITE_MCP_SCOPE], + expiresAt + })); + expect(authInfo.resource?.href).toBe(RESOURCE_SERVER); + expect(authInfo.extra).toEqual({ issuer: ISSUER }); + + const introspectionRequest = requests.find( + request => request.url === INTROSPECTION_ENDPOINT + ); + const metadataRequest = requests.find( + request => request.url === `${ISSUER}.well-known/oauth-authorization-server` + ); + expect(metadataRequest?.init?.redirect).toBe("error"); + expect(metadataRequest?.init?.signal).toBeInstanceOf(AbortSignal); + + const headers = introspectionRequest?.init?.headers as Record; + expect(introspectionRequest?.init?.redirect).toBe("error"); + expect(introspectionRequest?.init?.signal).toBeInstanceOf(AbortSignal); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from("resource-server:test-secret").toString("base64")}` + ); + expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + expect(String(introspectionRequest?.init?.body)).toBe( + "token=access-token&token_type_hint=access_token" + ); + }); + + test("rejects inactive, expired, and wrong-audience tokens", async () => { + let introspectionResponse: Record = { + active: false + }; + const expiresAt = Math.floor(Date.now() / 1000) + 300; + + const configuration = await loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment(), + fetchImplementation: async input => { + const url = String(input); + if (url === `${ISSUER}.well-known/oauth-authorization-server`) { + return Response.json(authorizationServerMetadata()); + } + + return Response.json(introspectionResponse); + }, + isLocalHost: false + }); + + if (!configuration) { + throw new Error("Expected OAuth configuration"); + } + + await expect( + configuration.verifier.verifyAccessToken("inactive-token") + ).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken + }); + + introspectionResponse = { + active: true, + token_type: "DPoP", + client_id: "mcp-client", + scope: BASE_MCP_SCOPE, + exp: expiresAt, + aud: RESOURCE_SERVER, + iss: ISSUER + }; + + await expect( + configuration.verifier.verifyAccessToken("wrong-token-type") + ).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken + }); + + introspectionResponse = { + active: true, + client_id: "mcp-client", + scope: BASE_MCP_SCOPE, + exp: expiresAt, + aud: "https://another-resource.example.test/mcp", + iss: ISSUER + }; + + await expect( + configuration.verifier.verifyAccessToken("wrong-audience-token") + ).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken + }); + + introspectionResponse = { + active: true, + client_id: "mcp-client", + scope: BASE_MCP_SCOPE, + exp: Math.floor(Date.now() / 1000) - 1, + aud: RESOURCE_SERVER, + iss: ISSUER + }; + + await expect( + configuration.verifier.verifyAccessToken("expired-token") + ).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken + }); + + introspectionResponse = { + active: true, + client_id: "mcp-client", + scope: BASE_MCP_SCOPE, + exp: expiresAt, + aud: RESOURCE_SERVER, + iss: "https://other-auth.example.test/" + }; + + await expect( + configuration.verifier.verifyAccessToken("wrong-issuer-token") + ).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken + }); + }); + + test("requires OAuth 2.1 authorization metadata before republishing it", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment(), + fetchImplementation: async () => Response.json( + authorizationServerMetadata({ + response_types_supported: ["token"] + }) + ), + isLocalHost: false + })).rejects.toThrow( + "OAuth metadata must support the authorization code response type" + ); + + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment(), + fetchImplementation: async () => Response.json( + authorizationServerMetadata({ + code_challenge_methods_supported: ["plain"] + }) + ), + isLocalHost: false + })).rejects.toThrow( + "OAuth metadata must advertise PKCE S256 support" + ); + + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment(), + fetchImplementation: async () => Response.json( + authorizationServerMetadata({ + authorization_endpoint: "http://auth.example.test/authorize" + }) + ), + isLocalHost: false + })).rejects.toThrow( + "OAuth metadata authorization_endpoint must use HTTPS" + ); + }); + + test("rejects a fragment in the configured OAuth audience", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_OAUTH_AUDIENCE: `${RESOURCE_SERVER}#other` + }), + fetchImplementation: async () => Response.json( + authorizationServerMetadata() + ), + isLocalHost: false + })).rejects.toThrow( + "OAuth resource identifiers must not contain a fragment" + ); + }); + + test("form-encodes client_secret_basic credentials", async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 300; + let authorization: string | undefined; + const configuration = await loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment({ + MCP_OAUTH_CLIENT_ID: "resource server:1", + MCP_OAUTH_CLIENT_SECRET: "secret % value" + }), + fetchImplementation: async (input, init) => { + if (String(input) === INTROSPECTION_ENDPOINT) { + authorization = (init?.headers as Record).Authorization; + return Response.json({ + active: true, + client_id: "mcp-client", + scope: BASE_MCP_SCOPE, + exp: expiresAt, + aud: RESOURCE_SERVER, + iss: ISSUER + }); + } + + return Response.json(authorizationServerMetadata()); + }, + isLocalHost: false + }); + + if (!configuration) { + throw new Error("Expected OAuth configuration"); + } + await configuration.verifier.verifyAccessToken("encoded-credentials"); + + expect(authorization).toBe( + `Basic ${Buffer.from( + "resource+server%3A1:secret+%25+value" + ).toString("base64")}` + ); + }); + + test("rejects authorization metadata from an unexpected issuer", async () => { + await expect(loadOAuthResourceServerConfiguration({ + environment: oauthEnvironment(), + fetchImplementation: async () => Response.json( + authorizationServerMetadata({ + issuer: "https://other-auth.example.test/" + }) + ), + isLocalHost: false + })).rejects.toThrow( + "OAuth metadata issuer does not match MCP_OAUTH_ISSUER_URL" + ); + }); +}); diff --git a/test/http-auth.test.ts b/test/http-auth.test.ts new file mode 100644 index 0000000..2499365 --- /dev/null +++ b/test/http-auth.test.ts @@ -0,0 +1,373 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server"; +import { + BASE_MCP_SCOPE, + SIGN_MCP_SCOPE, + WRITE_MCP_SCOPE, + type OAuthResourceServerConfiguration +} from "../src/server/auth.js"; +import { createHttpApp } from "../src/server/http-app.js"; +import { MODERN_PROTOCOL_VERSION } from "../src/server/protocol.js"; + +const ISSUER = "https://auth.example.test/"; +const RESOURCE_SERVER = new URL("https://mcp.example.test/mcp"); +const RESOURCE_METADATA_URL = + "https://mcp.example.test/.well-known/oauth-protected-resource/mcp"; +const LOCAL_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource/mcp"; +const CLIENT_META = { + "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {} +}; + +const oauthConfiguration: OAuthResourceServerConfiguration = { + oauthMetadata: { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}authorize`, + token_endpoint: `${ISSUER}token`, + response_types_supported: ["code"] + }, + resourceServerUrl: RESOURCE_SERVER, + scopesSupported: [BASE_MCP_SCOPE], + requiredScopes: [BASE_MCP_SCOPE], + verifier: { + async verifyAccessToken(token) { + if (![ + "mcp-token", + "signing-token-a", + "signing-token-b" + ].includes(token)) { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + "The access token is invalid" + ); + } + + return { + token, + clientId: "test-client", + scopes: token.startsWith("signing-token") + ? [BASE_MCP_SCOPE, SIGN_MCP_SCOPE] + : [BASE_MCP_SCOPE], + expiresAt: Math.floor(Date.now() / 1000) + 300, + resource: RESOURCE_SERVER, + extra: { + issuer: ISSUER + } + }; + } + } +}; + +const { app, mcpHandler } = createHttpApp({ + allowedHostnames: ["127.0.0.1"], + allowedOriginHostnames: ["127.0.0.1"], + oauthConfiguration +}); + +let httpServer: Server; +let baseUrl: string; + +function mcpHeaders(options: { + authorization?: string; + method?: string; + name?: string; +} = {}): Headers { + const headers = new Headers({ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, + "Mcp-Method": options.method ?? "tools/list" + }); + + if (options.authorization) { + headers.set("Authorization", options.authorization); + } + if (options.name) { + headers.set("Mcp-Name", options.name); + } + + return headers; +} + +function mcpRequest( + id: string, + method: string, + params: Record = {}, + clientMeta: Record = CLIENT_META +) { + return { + jsonrpc: "2.0", + id, + method, + params: { + ...params, + _meta: clientMeta + } + }; +} + +async function postMcp( + body: unknown, + options: { + authorization?: string; + method?: string; + name?: string; + } = {} +): Promise { + return fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: mcpHeaders(options), + body: JSON.stringify(body) + }); +} + +beforeAll(async () => { + httpServer = app.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + httpServer.once("listening", resolve); + httpServer.once("error", reject); + }); + + const address = httpServer.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await mcpHandler.close(); + await new Promise((resolve, reject) => { + httpServer.close(error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +}); + +describe("HTTP OAuth middleware integration", () => { + test("serves public protected-resource metadata for the exact MCP resource", async () => { + const response = await fetch(`${baseUrl}${LOCAL_RESOURCE_METADATA_PATH}`); + + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(await response.json()).toEqual(expect.objectContaining({ + resource: RESOURCE_SERVER.href, + authorization_servers: [ISSUER], + scopes_supported: [BASE_MCP_SCOPE] + })); + }); + + test("challenges a missing bearer token with resource metadata", async () => { + const response = await postMcp( + mcpRequest("missing-bearer", "tools/list") + ); + const challenge = response.headers.get("WWW-Authenticate"); + const body = await response.json() as { + error?: string; + }; + + expect(response.status).toBe(401); + expect(body.error).toBe("invalid_token"); + expect(challenge).toContain("Bearer"); + expect(challenge).toContain(`scope="${BASE_MCP_SCOPE}"`); + expect(challenge).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`); + }); + + test("permits read-only discovery with the baseline MCP scope", async () => { + const response = await postMcp( + mcpRequest("tools-list", "tools/list"), + { + authorization: "Bearer mcp-token" + } + ); + const body = await response.json() as { + error?: unknown; + result?: { + tools?: unknown[]; + }; + }; + + expect(response.status).toBe(200); + expect(body.error).toBeUndefined(); + expect(body.result?.tools).toBeArray(); + }); + + test("challenges wallet writes and signatures for their step-up scope", async () => { + const calls = [ + { + name: "transfer_native", + arguments: { + to: "0x0000000000000000000000000000000000000001", + amount: "0.01" + }, + scope: WRITE_MCP_SCOPE + }, + { + name: "sign_message", + arguments: { + message: "HTTP OAuth scope test" + }, + scope: SIGN_MCP_SCOPE + } + ]; + + for (const [index, call] of calls.entries()) { + const response = await postMcp( + mcpRequest( + `step-up-${index}`, + "tools/call", + { + name: call.name, + arguments: call.arguments + } + ), + { + authorization: "Bearer mcp-token", + method: "tools/call", + name: call.name + } + ); + const challenge = response.headers.get("WWW-Authenticate"); + const body = await response.json() as { + error?: string; + }; + + expect(response.status).toBe(403); + expect(body.error).toBe("insufficient_scope"); + expect(challenge).toContain(`scope="${call.scope}"`); + expect(challenge).toContain( + `resource_metadata="${RESOURCE_METADATA_URL}"` + ); + } + }); + + test("challenges a batched write and signature for both step-up scopes", async () => { + const response = await postMcp( + [ + mcpRequest( + "batch-write", + "tools/call", + { + name: "transfer_native", + arguments: { + to: "0x0000000000000000000000000000000000000001", + amount: "0.01" + } + } + ), + mcpRequest( + "batch-sign", + "tools/call", + { + name: "sign_message", + arguments: { + message: "HTTP OAuth batch scope test" + } + } + ) + ], + { + authorization: "Bearer mcp-token", + method: "tools/call" + } + ); + const challenge = response.headers.get("WWW-Authenticate"); + const body = await response.json() as { + error?: string; + }; + + expect(response.status).toBe(403); + expect(body.error).toBe("insufficient_scope"); + expect(challenge).toContain( + `scope="${WRITE_MCP_SCOPE} ${SIGN_MCP_SCOPE}"` + ); + expect(challenge).toContain( + `resource_metadata="${RESOURCE_METADATA_URL}"` + ); + }); + + test("binds confirmation continuation state to the presenting bearer token", async () => { + const argumentsValue = { + message: "HTTP request-state binding test" + }; + const elicitationClientMeta = { + ...CLIENT_META, + "io.modelcontextprotocol/clientCapabilities": { + elicitation: { + form: {} + } + } + }; + const firstResponse = await postMcp( + mcpRequest( + "token-bound-first", + "tools/call", + { + name: "sign_message", + arguments: argumentsValue + }, + elicitationClientMeta + ), + { + authorization: "Bearer signing-token-a", + method: "tools/call", + name: "sign_message" + } + ); + const firstBody = await firstResponse.json() as { + result?: { + requestState?: string; + resultType?: string; + }; + }; + + expect(firstBody.result?.resultType).toBe("input_required"); + expect(firstBody.result?.requestState).toEqual(expect.any(String)); + + const retryResponse = await postMcp( + mcpRequest( + "token-bound-retry", + "tools/call", + { + name: "sign_message", + arguments: argumentsValue, + inputResponses: { + confirmation: { + action: "accept", + content: { + confirm: true + } + } + }, + requestState: firstBody.result?.requestState + }, + elicitationClientMeta + ), + { + authorization: "Bearer signing-token-b", + method: "tools/call", + name: "sign_message" + } + ); + const retryBody = await retryResponse.json() as { + error?: { + code?: number; + data?: { + reason?: string; + }; + }; + }; + + expect(retryResponse.status).toBe(200); + expect(retryBody.error).toEqual(expect.objectContaining({ + code: -32602, + data: { + reason: "invalid_request_state" + } + })); + }); +}); diff --git a/test/mcp-2026.test.ts b/test/mcp-2026.test.ts index b393b39..d3d6f73 100644 --- a/test/mcp-2026.test.ts +++ b/test/mcp-2026.test.ts @@ -1,5 +1,11 @@ import { afterAll, describe, expect, test } from "bun:test"; import { createMcpHandler, SERVER_INFO_META_KEY } from "@modelcontextprotocol/server"; +import { + getChain, + getRpcUrl, + getSupportedNetworks, + resolveChainId +} from "../src/core/chains.js"; import createServer from "../src/server/server.js"; import { CACHE_SCOPE, CACHE_TTL_MS, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "../src/server/protocol.js"; @@ -12,6 +18,73 @@ const CLIENT_META = { "io.modelcontextprotocol/clientCapabilities": {} }; +const ELICITATION_CLIENT_META = { + ...CLIENT_META, + "io.modelcontextprotocol/clientCapabilities": { + elicitation: { + form: {} + } + } +}; + +const DANGEROUS_TOOL_CALLS = [ + { + name: "write_contract", + arguments: { + contractAddress: "0x0000000000000000000000000000000000000001", + functionName: "setValue", + args: ["1"], + abiJson: JSON.stringify([{ + type: "function", + name: "setValue", + stateMutability: "nonpayable", + inputs: [{ name: "value", type: "uint256" }], + outputs: [] + }]) + } + }, + { + name: "transfer_native", + arguments: { + to: "0x0000000000000000000000000000000000000001", + amount: "0.01" + } + }, + { + name: "transfer_erc20", + arguments: { + tokenAddress: "0x0000000000000000000000000000000000000001", + to: "0x0000000000000000000000000000000000000002", + amount: "1" + } + }, + { + name: "approve_token_spending", + arguments: { + tokenAddress: "0x0000000000000000000000000000000000000001", + spenderAddress: "0x0000000000000000000000000000000000000002", + amount: "1" + } + }, + { + name: "sign_message", + arguments: { + message: "MCP confirmation test" + } + }, + { + name: "sign_typed_data", + arguments: { + domainJson: JSON.stringify({ name: "MCP Test", version: "1" }), + typesJson: JSON.stringify({ + Message: [{ name: "contents", type: "string" }] + }), + primaryType: "Message", + messageJson: JSON.stringify({ contents: "MCP confirmation test" }) + } + } +] as const; + type JsonRpcResponse = { jsonrpc: "2.0"; id: string | number | null; @@ -31,6 +104,7 @@ async function modernRequest( name?: string; protocolVersion?: string; meta?: Record; + requestHandler?: typeof handler; } = {} ): Promise<{ status: number; body: JsonRpcResponse }> { const protocolVersion = options.protocolVersion ?? MODERN_PROTOCOL_VERSION; @@ -45,7 +119,7 @@ async function modernRequest( headers.set("Mcp-Name", options.name); } - const response = await handler.fetch(new Request("http://test.local/mcp", { + const response = await (options.requestHandler ?? handler).fetch(new Request("http://test.local/mcp", { method: "POST", headers, body: JSON.stringify({ @@ -70,6 +144,20 @@ afterAll(async () => { }); describe("MCP 2026-07-28 SDK integration", () => { + test("accepts configured numeric chain identifiers and rejects unknown networks", () => { + expect(resolveChainId("137")).toBe(137); + expect(getChain("137").id).toBe(137); + expect(getRpcUrl("137")).toContain("polygon"); + expect(getSupportedNetworks()).toContain("op"); + + expect(() => resolveChainId("not-a-network")).toThrow( + "Unsupported network: not-a-network" + ); + expect(() => getChain("999999998")).toThrow( + "Unsupported network: 999999998" + ); + }); + test("discovers protocol metadata and stamps server identity on the result", async () => { const { status, body } = await modernRequest("discover", "server/discover"); @@ -114,6 +202,65 @@ describe("MCP 2026-07-28 SDK integration", () => { })); }); + test("advertises an output schema for every tool", async () => { + const { body } = await modernRequest("tool-output-schemas", "tools/list"); + const tools = body.result?.tools as Array>; + + expect(tools).toHaveLength(25); + for (const tool of tools) { + expect(tool.outputSchema).toEqual(expect.objectContaining({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object" + })); + } + }); + + test("marks token approvals as destructive", async () => { + const { body } = await modernRequest("approval-annotations", "tools/list"); + const tools = body.result?.tools as Array>; + const approvalTool = tools.find(tool => tool.name === "approve_token_spending"); + + expect(approvalTool?.annotations).toEqual(expect.objectContaining({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false + })); + }); + + test("keeps the experimental Tasks extension unadvertised and bounds the synchronous fallback", async () => { + const { body: discoverBody } = await modernRequest( + "tasks-discovery", + "server/discover" + ); + const capabilities = discoverBody.result?.capabilities as Record; + const extensions = capabilities.extensions as Record | undefined; + expect(extensions?.["io.modelcontextprotocol/tasks"]).toBeUndefined(); + + const { body: toolsBody } = await modernRequest("tasks-tools", "tools/list"); + const tools = toolsBody.result?.tools as Array>; + const waitTool = tools.find(tool => tool.name === "wait_for_transaction"); + const inputSchema = waitTool?.inputSchema as { + properties?: Record>; + }; + + expect(inputSchema.properties?.timeoutSeconds).toEqual(expect.objectContaining({ + type: "integer", + minimum: 1, + maximum: 90 + })); + + const { status, body } = await modernRequest( + "tasks-get", + "tasks/get", + { taskId: "not-supported" } + ); + expect(status).toBe(404); + expect(body.error).toEqual(expect.objectContaining({ + code: -32601, + message: "Method not found" + })); + }); + test("reads static resources with cache hints", async () => { const { body } = await modernRequest( "read", @@ -134,6 +281,47 @@ describe("MCP 2026-07-28 SDK integration", () => { })); }); + test("uses conservative cache defaults for resources without a cache hint", async () => { + const privateResourceHandler = createMcpHandler(() => { + const server = createServer(); + server.registerResource( + "private_test_resource", + "test://private", + { mimeType: "text/plain" }, + async uri => ({ + contents: [{ + uri: uri.href, + mimeType: "text/plain", + text: "private" + }] + }) + ); + return server; + }, { + legacy: "reject" + }); + + try { + const { body } = await modernRequest( + "private-resource", + "resources/read", + { uri: "test://private" }, + { + name: "test://private", + requestHandler: privateResourceHandler + } + ); + + expect(body.result).toEqual(expect.objectContaining({ + resultType: "complete", + ttlMs: 0, + cacheScope: "private" + })); + } finally { + await privateResourceHandler.close(); + } + }); + test("calls read-only tools without wallet or RPC credentials", async () => { const { body } = await modernRequest( "call", @@ -145,9 +333,364 @@ describe("MCP 2026-07-28 SDK integration", () => { { name: "get_supported_networks" } ); - expect(body.result).toEqual(expect.objectContaining({ + const result = body.result as Record; + const content = result.content as Array>; + + expect(result).toEqual(expect.objectContaining({ resultType: "complete", - content: [expect.objectContaining({ type: "text" })] + content: [expect.objectContaining({ type: "text" })], + structuredContent: expect.objectContaining({ + supportedNetworks: expect.any(Array) + }) + })); + expect(JSON.parse(content[0].text as string)).toEqual(result.structuredContent); + }); + + test("requires client elicitation capability before requesting confirmation", async () => { + const tool = DANGEROUS_TOOL_CALLS[1]; + const { status, body } = await modernRequest( + "confirmation-capability", + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { name: tool.name } + ); + + expect(status).toBe(400); + expect(body.error).toEqual(expect.objectContaining({ + code: -32021, + data: { + requiredCapabilities: { + elicitation: { + form: {} + } + } + } + })); + }); + + test("requests confirmation before every wallet-backed operation and handles decline", async () => { + for (const [index, tool] of DANGEROUS_TOOL_CALLS.entries()) { + const { status, body } = await modernRequest( + `confirmation-${index}`, + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(status).toBe(200); + expect(body.result?.resultType).toBe("input_required"); + expect(body.result?.requestState).toEqual(expect.any(String)); + + const inputRequests = body.result?.inputRequests as Record>; + const entries = Object.entries(inputRequests); + expect(entries).toHaveLength(1); + + const [confirmationKey, inputRequest] = entries[0]; + expect(inputRequest).toEqual(expect.objectContaining({ + method: "elicitation/create", + params: expect.objectContaining({ + mode: "form", + message: expect.any(String), + requestedSchema: expect.objectContaining({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + confirm: expect.objectContaining({ + type: "boolean" + }) + }, + required: ["confirm"] + }) + }) + })); + + const { status: declinedStatus, body: declinedBody } = await modernRequest( + `confirmation-${index}-declined`, + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: { + [confirmationKey]: { + action: "decline" + } + }, + requestState: body.result?.requestState + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(declinedStatus).toBe(200); + expect(declinedBody.result?.resultType).toBe("complete"); + expect(declinedBody.result?.isError).toBe(true); + const declinedContent = declinedBody.result?.content as Array>; + expect(declinedContent[0]?.text).toMatch(/cancel|declin|not confirmed/i); + } + }); + + test("does not accept injected confirmation input without server-issued state", async () => { + const tool = DANGEROUS_TOOL_CALLS[1]; + const { body } = await modernRequest( + "confirmation-injected", + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: { + confirmation: { + action: "accept", + content: { + confirm: true + } + } + } + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(body.result?.resultType).toBe("input_required"); + expect(body.result?.requestState).toEqual(expect.any(String)); + }); + + test("binds confirmation state to the complete operation arguments", async () => { + const tool = DANGEROUS_TOOL_CALLS[1]; + const { body: firstBody } = await modernRequest( + "confirmation-bound-first", + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + const { body: changedBody } = await modernRequest( + "confirmation-bound-changed", + "tools/call", + { + name: tool.name, + arguments: { + ...tool.arguments, + amount: "0.02" + }, + inputResponses: { + confirmation: { + action: "accept", + content: { + confirm: true + } + } + }, + requestState: firstBody.result?.requestState + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(changedBody.result?.resultType).toBe("input_required"); + expect(changedBody.result?.requestState).toEqual(expect.any(String)); + expect(changedBody.result?.requestState).not.toBe(firstBody.result?.requestState); + }); + + test("executes one accepted signing confirmation and rejects its replay", async () => { + const previousPrivateKey = process.env.EVM_PRIVATE_KEY; + process.env.EVM_PRIVATE_KEY = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + + try { + const tool = DANGEROUS_TOOL_CALLS[4]; + const { body: firstBody } = await modernRequest( + "confirmation-accepted-first", + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + const confirmationResponse = { + confirmation: { + action: "accept", + content: { + confirm: true + } + } + }; + + const { body: acceptedBody } = await modernRequest( + "confirmation-accepted-second", + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: confirmationResponse, + requestState: firstBody.result?.requestState + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(acceptedBody.result).toEqual(expect.objectContaining({ + resultType: "complete", + structuredContent: expect.objectContaining({ + message: tool.arguments.message, + signature: expect.stringMatching(/^0x[0-9a-f]+$/), + signer: "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + messageType: "personal_sign" + }) + })); + expect(acceptedBody.result?.isError).toBeUndefined(); + + const { body: replayBody } = await modernRequest( + "confirmation-accepted-replay", + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: confirmationResponse, + requestState: firstBody.result?.requestState + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(replayBody.result).toEqual(expect.objectContaining({ + resultType: "complete", + isError: true + })); + const replayContent = replayBody.result?.content as Array>; + expect(replayContent[0]?.text).toMatch(/already used/i); + } finally { + if (previousPrivateKey === undefined) { + delete process.env.EVM_PRIVATE_KEY; + } else { + process.env.EVM_PRIVATE_KEY = previousPrivateKey; + } + } + }); + + test("treats an accepted false confirmation as a terminal decline", async () => { + const tool = DANGEROUS_TOOL_CALLS[4]; + const { body: firstBody } = await modernRequest( + "confirmation-false-first", + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + const { body: declinedBody } = await modernRequest( + "confirmation-false-second", + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: { + confirmation: { + action: "accept", + content: { + confirm: false + } + } + }, + requestState: firstBody.result?.requestState + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(declinedBody.result).toEqual(expect.objectContaining({ + resultType: "complete", + isError: true + })); + const declinedContent = declinedBody.result?.content as Array>; + expect(declinedContent[0]?.text).toMatch(/declined/i); + }); + + test("rejects tampered confirmation request state before running a tool", async () => { + const tool = DANGEROUS_TOOL_CALLS[4]; + const { body: firstBody } = await modernRequest( + "confirmation-tamper-first", + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + const requestState = firstBody.result?.requestState as string; + const stateParts = requestState.split("."); + stateParts[2] = `${ + stateParts[2].startsWith("A") ? "B" : "A" + }${stateParts[2].slice(1)}`; + const tamperedState = stateParts.join("."); + + const { status, body } = await modernRequest( + "confirmation-tamper-second", + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: { + confirmation: { + action: "accept", + content: { + confirm: true + } + } + }, + requestState: tamperedState + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(status).toBe(200); + expect(body.error).toEqual(expect.objectContaining({ + code: -32602, + message: "Invalid or expired requestState", + data: { + reason: "invalid_request_state" + } })); }); From 749010d3604d5c12a039712b059cc97a1037dcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:19:16 -0400 Subject: [PATCH 3/7] chore: refresh stable MCP migration dependencies Update viem, Zod, runtime types and compatible transitive packages. Use TypeScript 5.9 as a development dependency and add the matching v2 client for transport interoperability tests. Builds, type checking and all 38 existing tests pass. --- bun.lock | 161 +++++++++++++++++++++++++++++++-------------------- package.json | 15 +++-- 2 files changed, 106 insertions(+), 70 deletions(-) diff --git a/bun.lock b/bun.lock index afffd75..863c604 100644 --- a/bun.lock +++ b/bun.lock @@ -9,33 +9,34 @@ "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "express": "^5.2.1", - "viem": "^2.55.10", - "zod": "^4.2.0", + "viem": "^2.56.5", + "zod": "^4.6.4", }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/bun": "latest", - "@types/express": "^5.0.0", - "@types/node": "^22.0.0", + "@types/express": "^5.0.6", + "@types/node": "^22.20.2", "conventional-changelog-cli": "^5.0.0", - }, - "peerDependencies": { - "typescript": "^5.8.2", + "typescript": "^5.9.3", }, }, }, "packages": { - "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.0", "", {}, "sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg=="], + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], - "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@conventional-changelog/git-client": ["@conventional-changelog/git-client@1.0.1", "", { "dependencies": { "@types/semver": "^7.5.5", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-PJEqBwAleffCMETaVm/fUgHldzBE35JFk3/9LL6NUA5EXa3qednu+UT6M7E5iBu3zIQZCULYIiZ90fBYHt6xUw=="], + "@conventional-changelog/git-client": ["@conventional-changelog/git-client@2.7.0", "", { "dependencies": { "@simple-libs/child-process-utils": "^1.0.0", "@simple-libs/stream-utils": "^1.2.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.4.0" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw=="], "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], "@hutson/parse-repository-url": ["@hutson/parse-repository-url@5.0.0", "", {}, "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], "@modelcontextprotocol/express": ["@modelcontextprotocol/express@2.0.0", "", { "dependencies": { "cors": "^2.8.5" }, "peerDependencies": { "@modelcontextprotocol/server": "^2.0.0", "express": "^4.18.0 || ^5.0.0" } }, "sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ=="], @@ -56,35 +57,33 @@ "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], + + "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.2.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], - "@types/express": ["@types/express@5.0.5", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^1" } }, "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ=="], + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], - "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.3", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw=="], "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], - - "@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="], + "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="], "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], - "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - - "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], - - "@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], @@ -96,7 +95,7 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -112,42 +111,44 @@ "conventional-changelog": ["conventional-changelog@6.0.0", "", { "dependencies": { "conventional-changelog-angular": "^8.0.0", "conventional-changelog-atom": "^5.0.0", "conventional-changelog-codemirror": "^5.0.0", "conventional-changelog-conventionalcommits": "^8.0.0", "conventional-changelog-core": "^8.0.0", "conventional-changelog-ember": "^5.0.0", "conventional-changelog-eslint": "^6.0.0", "conventional-changelog-express": "^5.0.0", "conventional-changelog-jquery": "^6.0.0", "conventional-changelog-jshint": "^5.0.0", "conventional-changelog-preset-loader": "^5.0.0" } }, "sha512-tuUH8H/19VjtD9Ig7l6TQRh+Z0Yt0NZ6w/cCkkyzUbGQTnUEmKfGtkC9gGfVgCfOL1Rzno5NgNF4KY8vR+Jo3w=="], - "conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="], + "conventional-changelog-angular": ["conventional-changelog-angular@8.3.1", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg=="], - "conventional-changelog-atom": ["conventional-changelog-atom@5.0.0", "", {}, "sha512-WfzCaAvSCFPkznnLgLnfacRAzjgqjLUjvf3MftfsJzQdDICqkOOpcMtdJF3wTerxSpv2IAAjX8doM3Vozqle3g=="], + "conventional-changelog-atom": ["conventional-changelog-atom@5.1.0", "", {}, "sha512-fw7GpI9jHNCWGBnTsPRI452ypQbNupGwsjrXfozvRNE0c92pJRpoj9rXfzDKUYJcsmk0H4XKaQjhjelwI9z27w=="], "conventional-changelog-cli": ["conventional-changelog-cli@5.0.0", "", { "dependencies": { "add-stream": "^1.0.0", "conventional-changelog": "^6.0.0", "meow": "^13.0.0", "tempfile": "^5.0.0" }, "bin": { "conventional-changelog": "cli.js" } }, "sha512-9Y8fucJe18/6ef6ZlyIlT2YQUbczvoQZZuYmDLaGvcSBP+M6h+LAvf7ON7waRxKJemcCII8Yqu5/8HEfskTxJQ=="], - "conventional-changelog-codemirror": ["conventional-changelog-codemirror@5.0.0", "", {}, "sha512-8gsBDI5Y3vrKUCxN6Ue8xr6occZ5nsDEc4C7jO/EovFGozx8uttCAyfhRrvoUAWi2WMm3OmYs+0mPJU7kQdYWQ=="], + "conventional-changelog-codemirror": ["conventional-changelog-codemirror@5.1.0", "", {}, "sha512-iXhy63YczB+yWA9DrsYbquSYLvWKsK9M3WC+xQPEm8cOn4oXzKpmTp2uH3qi7+i10oTcGJTvq9lsBpZmMADaNg=="], "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@8.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-eOvlTO6OcySPyyyk8pKz2dP4jjElYunj9hn9/s0OB+gapTO8zwS9UQWrZ1pmF2hFs3vw1xhonOLGcGjy/zgsuA=="], "conventional-changelog-core": ["conventional-changelog-core@8.0.0", "", { "dependencies": { "@hutson/parse-repository-url": "^5.0.0", "add-stream": "^1.0.0", "conventional-changelog-writer": "^8.0.0", "conventional-commits-parser": "^6.0.0", "git-raw-commits": "^5.0.0", "git-semver-tags": "^8.0.0", "hosted-git-info": "^7.0.0", "normalize-package-data": "^6.0.0", "read-package-up": "^11.0.0", "read-pkg": "^9.0.0" } }, "sha512-EATUx5y9xewpEe10UEGNpbSHRC6cVZgO+hXQjofMqpy+gFIrcGvH3Fl6yk2VFKh7m+ffenup2N7SZJYpyD9evw=="], - "conventional-changelog-ember": ["conventional-changelog-ember@5.0.0", "", {}, "sha512-RPflVfm5s4cSO33GH/Ey26oxhiC67akcxSKL8CLRT3kQX2W3dbE19sSOM56iFqUJYEwv9mD9r6k79weWe1urfg=="], + "conventional-changelog-ember": ["conventional-changelog-ember@5.1.0", "", {}, "sha512-XNcgGcdJt7wh341BBML0CI8DKpqE5lKD1WahzFHGZFvKTzJr1rZW976cw7beqKLOBbzdrH9ZIkE/s2TfbOuM3g=="], - "conventional-changelog-eslint": ["conventional-changelog-eslint@6.0.0", "", {}, "sha512-eiUyULWjzq+ybPjXwU6NNRflApDWlPEQEHvI8UAItYW/h22RKkMnOAtfCZxMmrcMO1OKUWtcf2MxKYMWe9zJuw=="], + "conventional-changelog-eslint": ["conventional-changelog-eslint@6.1.0", "", {}, "sha512-beWr3qzuEMN9gznMWa8PhTVfGkGXoq+XnUzViNXg5KygrgV728ZRqZngz3uPhz5+ayUhPrpNFYqIE0qHWz9NAw=="], - "conventional-changelog-express": ["conventional-changelog-express@5.0.0", "", {}, "sha512-D8Q6WctPkQpvr2HNCCmwU5GkX22BVHM0r4EW8vN0230TSyS/d6VQJDAxGb84lbg0dFjpO22MwmsikKL++Oo/oQ=="], + "conventional-changelog-express": ["conventional-changelog-express@5.1.0", "", {}, "sha512-g/s9eLohrefYTSNQaB6+k0ONbiVx41YOKBbIOIM3ST/NtedAgppCJnrpKXVN9sOmpPkN4vjFwURlfvpEDUjoeg=="], - "conventional-changelog-jquery": ["conventional-changelog-jquery@6.0.0", "", {}, "sha512-2kxmVakyehgyrho2ZHBi90v4AHswkGzHuTaoH40bmeNqUt20yEkDOSpw8HlPBfvEQBwGtbE+5HpRwzj6ac2UfA=="], + "conventional-changelog-jquery": ["conventional-changelog-jquery@6.1.0", "", {}, "sha512-/sFhULybhFrMg+qc8MHHHSj7kTVMfx5C7rSM6Z9EjduVoAQJdGRq/wpv/SWPMQ+KPNSYHqDLwm/x2Z5hOcYvqQ=="], - "conventional-changelog-jshint": ["conventional-changelog-jshint@5.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-gGNphSb/opc76n2eWaO6ma4/Wqu3tpa2w7i9WYqI6Cs2fncDSI2/ihOfMvXveeTTeld0oFvwMVNV+IYQIk3F3g=="], + "conventional-changelog-jshint": ["conventional-changelog-jshint@5.2.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-OaatyvHXP1fjI7Mx0b1IkmhbhTsVHsytnsQSkOj4rhGbFMoTcfvbwm/vAtCzRMXOxojK1EDMBBmBj1pM9KNy/Q=="], "conventional-changelog-preset-loader": ["conventional-changelog-preset-loader@5.0.0", "", {}, "sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA=="], - "conventional-changelog-writer": ["conventional-changelog-writer@8.2.0", "", { "dependencies": { "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw=="], + "conventional-changelog-writer": ["conventional-changelog-writer@8.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g=="], "conventional-commits-filter": ["conventional-commits-filter@5.0.0", "", {}, "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q=="], - "conventional-commits-parser": ["conventional-commits-parser@6.2.1", "", { "dependencies": { "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA=="], + "conventional-commits-parser": ["conventional-commits-parser@6.4.0", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" }, "bin": { "conventional-commits-parser": "dist/cli/index.js" } }, "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw=="], - "cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -164,7 +165,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -172,6 +173,10 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -188,23 +193,23 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "git-raw-commits": ["git-raw-commits@5.0.0", "", { "dependencies": { "@conventional-changelog/git-client": "^1.0.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg=="], + "git-raw-commits": ["git-raw-commits@5.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="], - "git-semver-tags": ["git-semver-tags@8.0.0", "", { "dependencies": { "@conventional-changelog/git-client": "^1.0.0", "meow": "^13.0.0" }, "bin": { "git-semver-tags": "src/cli.js" } }, "sha512-N7YRIklvPH3wYWAR2vysaqGLPRcpwQ0GKdlqTiVN5w1UmCdaeY3K8s6DMKRCh54DDdzyt/OAB6C8jgVtb7Y2Fg=="], + "git-semver-tags": ["git-semver-tags@8.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "^2.6.0", "meow": "^13.0.0" }, "bin": { "git-semver-tags": "src/cli.js" } }, "sha512-zMbamckSNdlT4U48IMFa2Cn6FTzM+2yF6/gEmStPJI8PiLxd/bT6dw10+mc6u5Qe4fhrc/y9nU290FWjQhAV7g=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="], + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], @@ -218,8 +223,12 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -240,7 +249,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -254,21 +263,25 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "ox": ["ox@0.14.33", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ=="], + "ox": ["ox@0.14.44", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-O54qXXHEk4ySamMSyBpI0AeBegS5frxU6+LA6Jb9Sf6kMOxcTNaxYmwZfpOu22DIQsdKfgQxHq8EsAGaoBQScA=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -280,7 +293,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -288,6 +301,10 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -304,9 +321,9 @@ "spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], - "spdx-license-ids": ["spdx-license-ids@3.0.22", "", {}, "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ=="], + "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], - "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "temp-dir": ["temp-dir@3.0.0", "", {}, "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw=="], @@ -318,11 +335,11 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -332,7 +349,9 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "viem": ["viem@2.55.10", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.33", "ws": "8.21.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ=="], + "viem": ["viem@2.56.5", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.44", "ws": "8.21.0" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-GuEf/oee0PHgy4D6JIA8u0rY1g+sM5nF+p7HyMHO3vjlHC5G/ljfrgnUOnrES9m5Ny/P3RwWCHjDuN2T6HUWzQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], @@ -340,22 +359,40 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.4", "", {}, "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA=="], + + "@scure/bip32/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@types/body-parser/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "@types/connect/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "@types/express-serve-static-core/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "@types/send/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "@types/serve-static/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "bun-types/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "ox/abitype": ["abitype@1.3.0", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg=="], - "body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "send/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "@types/connect/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "send/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@types/send/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "body-parser/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "@types/serve-static/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], } } diff --git a/package.json b/package.json index 5166014..29f0a76 100644 --- a/package.json +++ b/package.json @@ -31,21 +31,20 @@ "test:mcp": "bun test test/mcp-2026.test.ts test/auth.test.ts test/http-auth.test.ts" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/bun": "latest", - "@types/express": "^5.0.0", - "@types/node": "^22.0.0", - "conventional-changelog-cli": "^5.0.0" - }, - "peerDependencies": { - "typescript": "^5.8.2" + "@types/express": "^5.0.6", + "@types/node": "^22.20.2", + "conventional-changelog-cli": "^5.0.0", + "typescript": "^5.9.3" }, "dependencies": { "@modelcontextprotocol/express": "^2.0.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "express": "^5.2.1", - "viem": "^2.55.10", - "zod": "^4.2.0" + "viem": "^2.56.5", + "zod": "^4.6.4" }, "keywords": [ "mcp", From 8a393d08a50765c29e0a70052d03a4c4144d12d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:23:37 -0400 Subject: [PATCH 4/7] fix: complete stable MCP transport interoperability Enforce HTTP Accept and protocol-version headers and return JSON-RPC parser errors. Verify final metadata, encoded names, custom parameter headers and both stdio eras through SDK clients. Propagate CLI startup failures, derive server identity from package metadata and type-check tests. All 53 tests and both Node bundles pass. --- bin/cli.js | 6 +- package.json | 6 +- src/server/auth.ts | 2 +- src/server/http-app.ts | 52 +++++++++- src/server/protocol.ts | 4 +- test/http-protocol.test.ts | 190 +++++++++++++++++++++++++++++++++++++ test/mcp-2026.test.ts | 47 +++++++++ test/stdio.test.ts | 65 +++++++++++++ tsconfig.json | 2 +- 9 files changed, 365 insertions(+), 9 deletions(-) create mode 100644 test/http-protocol.test.ts create mode 100644 test/stdio.test.ts diff --git a/bin/cli.js b/bin/cli.js index 97f5b91..fd98414 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -23,7 +23,7 @@ try { require.resolve(scriptPath); // Execute the server - const server = spawn('node', [scriptPath], { + const server = spawn(process.execPath, [scriptPath], { stdio: 'inherit', shell: false }); @@ -33,6 +33,10 @@ try { process.exit(1); }); + server.on('exit', (code, signal) => { + process.exitCode = code ?? (signal ? 1 : 0); + }); + // Handle clean shutdown const cleanup = () => { if (!server.killed) { diff --git a/package.json b/package.json index 29f0a76..6c988ad 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dev": "bun --watch src/index.ts", "start:http": "bun run src/server/http-server.ts", "dev:http": "bun --watch src/server/http-server.ts", - "prepublishOnly": "bun run build && bun run build:http", + "prepublishOnly": "bun run check", "version:patch": "npm version patch", "version:minor": "npm version minor", "version:major": "npm version major", @@ -28,7 +28,9 @@ "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md", "inspect": "npx @modelcontextprotocol/inspector node build/index.js", - "test:mcp": "bun test test/mcp-2026.test.ts test/auth.test.ts test/http-auth.test.ts" + "typecheck": "tsc --noEmit", + "test:mcp": "bun test test", + "check": "bun run typecheck && bun run build && bun run build:http && bun run test:mcp" }, "devDependencies": { "@modelcontextprotocol/client": "^2.0.0", diff --git a/src/server/auth.ts b/src/server/auth.ts index cb456d1..7c81cc4 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -57,7 +57,7 @@ export type OAuthResourceServerConfiguration = { }; type OAuthEnvironment = NodeJS.ProcessEnv; -type FetchImplementation = typeof fetch; +type FetchImplementation = (input: string | URL | Request, init?: RequestInit) => Promise; function requiredEnvironmentValue( environment: OAuthEnvironment, diff --git a/src/server/http-app.ts b/src/server/http-app.ts index ed20754..ddad468 100644 --- a/src/server/http-app.ts +++ b/src/server/http-app.ts @@ -8,7 +8,8 @@ import { bearerAuthChallengeResponse, createMcpHandler, OAuthError, - OAuthErrorCode + OAuthErrorCode, + ProtocolErrorCode } from "@modelcontextprotocol/server"; import { getOAuthProtectedResourceMetadataUrl, @@ -80,6 +81,32 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { }; const handleMcpRequest = (req: Request, res: Response) => { + // The v2 entry validates header values but permits an absent version header. + if (req.method === "POST" && !req.get("MCP-Protocol-Version")) { + res.status(400).json({ + jsonrpc: "2.0", + ...(typeof req.body?.id === "string" || typeof req.body?.id === "number" + ? { id: req.body.id } : {}), + error: { code: -32020, message: "Missing MCP-Protocol-Version header" } + }); + return; + } + + if (req.method === "POST" && ( + !req.get("Accept") + || !req.accepts("application/json") + || !req.accepts("text/event-stream") + )) { + res.status(406).json({ + jsonrpc: "2.0", + error: { + code: ProtocolErrorCode.InvalidRequest, + message: "Accept must allow application/json and text/event-stream" + } + }); + return; + } + void nodeHandler(req, res, req.body); }; @@ -125,7 +152,7 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { requiredScopes: oauthConfiguration.requiredScopes, resourceMetadataUrl }), - express.json({ limit: "1mb" }), + express.json({ limit: "1mb", strict: false }), requireOperationScope, handleMcpRequest ); @@ -133,11 +160,30 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { app.all( "/mcp", validateMcpRequest, - express.json({ limit: "1mb" }), + express.json({ limit: "1mb", strict: false }), handleMcpRequest ); } + app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => { + const parserError = error as { type?: string } | undefined; + if (parserError?.type === "entity.parse.failed") { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: ProtocolErrorCode.ParseError, message: "Parse error" } + }); + return; + } + if (parserError?.type === "entity.too.large") { + res.status(413).json({ + jsonrpc: "2.0", + error: { code: ProtocolErrorCode.InvalidRequest, message: "Request body exceeds 1 MB" } + }); + return; + } + next(error); + }); + app.get("/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok", diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 94568b8..39d170e 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -1,6 +1,8 @@ +import packageInfo from "../../package.json" with { type: "json" }; + export const SERVER_INFO = { name: "evm-mcp-server", - version: "2.0.4" + version: packageInfo.version } as const; export const MODERN_PROTOCOL_VERSION = "2026-07-28"; diff --git a/test/http-protocol.test.ts b/test/http-protocol.test.ts new file mode 100644 index 0000000..c716d95 --- /dev/null +++ b/test/http-protocol.test.ts @@ -0,0 +1,190 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { createHttpApp } from "../src/server/http-app.js"; +import { MODERN_PROTOCOL_VERSION, SERVER_INFO } from "../src/server/protocol.js"; + +const { app, mcpHandler } = createHttpApp({ + allowedHostnames: ["127.0.0.1"], + allowedOriginHostnames: ["127.0.0.1"] +}); +let httpServer: Server; +let endpoint: string; + +beforeAll(async () => { + httpServer = app.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + httpServer.once("listening", resolve); + httpServer.once("error", reject); + }); + endpoint = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/mcp`; +}); + +afterAll(async () => { + await mcpHandler.close(); + if (httpServer?.listening) { + await new Promise((resolve, reject) => { + httpServer.close(error => error ? reject(error) : resolve()); + }); + } +}); + +function post(options: { + method?: string; + params?: Record; + headers?: Record; + omitHeader?: string; + rawBody?: string; +} = {}): Promise { + const method = options.method ?? "server/discover"; + const headers = new Headers({ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, + "Mcp-Method": method, + ...options.headers + }); + if (options.omitHeader) headers.delete(options.omitHeader); + return fetch(endpoint, { + method: "POST", + headers, + body: options.rawBody ?? JSON.stringify({ + jsonrpc: "2.0", + id: "http-test", + method, + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {} + }, + ...options.params + } + }) + }); +} + +describe("Streamable HTTP protocol boundary", () => { + test("serves an SDK client without protocol sessions", async () => { + const client = new Client({ name: "http-test", version: "1.0.0" }, { + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } + }); + const transport = new StreamableHTTPClientTransport(new URL(endpoint)); + try { + await client.connect(transport); + expect(client.getServerVersion()).toEqual(SERVER_INFO); + expect(transport.sessionId).toBeUndefined(); + expect((await client.listTools()).tools).toHaveLength(25); + expect((await client.listResources()).resources).toHaveLength(1); + expect((await client.listResourceTemplates()).resourceTemplates).toEqual([]); + expect((await client.listPrompts()).prompts).toHaveLength(10); + } finally { + await client.close(); + } + }); + + test("rejects malformed JSON with a protocol parse error", async () => { + const response = await post({ rawBody: "{" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + error: expect.objectContaining({ code: -32700 }) + })); + }); + + test("rejects non-JSON media types and accepts JSON parameters", async () => { + for (const type of ["text/plain", "text/plain; a=application/json"]) { + const response = await post({ headers: { "Content-Type": type } }); + expect(response.status).toBe(415); + await response.text(); + } + const response = await post({ headers: { "Content-Type": "application/json; charset=utf-8" } }); + expect(response.status).toBe(200); + await response.text(); + }); + + test("requires both accepted response media types", async () => { + for (const accept of ["application/json", "application/json, text/event-stream;q=0"]) { + const response = await post({ headers: { Accept: accept } }); + expect(response.status).toBe(406); + await response.text(); + } + }); + + test("rejects oversized JSON without exposing a parser stack trace", async () => { + const response = await post({ rawBody: JSON.stringify({ padding: "x".repeat(1024 * 1024) }) }); + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32600, message: "Request body exceeds 1 MB" } + }); + }); + + test("distinguishes valid JSON with an invalid RPC shape from malformed JSON", async () => { + const response = await post({ rawBody: "42" }); + expect(response.status).toBe(400); + expect((await response.json() as { error: { code: number } }).error.code).toBe(-32600); + }); + + test("validates required headers and names with the final error code", async () => { + for (const omitHeader of ["MCP-Protocol-Version", "Mcp-Method", "Mcp-Name"]) { + const response = await post({ + method: "resources/read", + params: { uri: "evm://networks" }, + headers: { "Mcp-Name": "evm://networks" }, + omitHeader + }); + expect(response.status).toBe(400); + expect((await response.json() as { error: { code: number } }).error.code).toBe(-32020); + } + }); + + test("decodes Base64 sentinel names before comparing them with the body", async () => { + const response = await post({ + method: "resources/read", + params: { uri: "evm://networks" }, + headers: { "Mcp-Name": `=?base64?${Buffer.from("evm://networks").toString("base64")}?=` } + }); + expect(response.status).toBe(200); + expect((await response.json() as { result: { contents: unknown[] } }).result.contents).toHaveLength(1); + const invalid = await post({ + method: "resources/read", + params: { uri: "evm://networks" }, + headers: { "Mcp-Name": "=?base64?%%%?=" } + }); + expect(invalid.status).toBe(400); + expect((await invalid.json() as { error: { code: number } }).error.code).toBe(-32020); + }); + + test("rejects malformed client identity while accepting its omission", async () => { + const response = await post({ params: { _meta: { + "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { name: 123 } + } } }); + expect(response.status).toBe(400); + expect((await response.json() as { error: { code: number } }).error.code).toBe(-32602); + }); + + test("rejects invalid hosts and origins before dispatch", async () => { + const invalidHeaders: Record[] = [ + { Host: "attacker.invalid" }, { Origin: "https://attacker.invalid" } + ]; + for (const headers of invalidHeaders) { + const response = await post({ headers }); + expect(response.status).toBe(403); + await response.text(); + } + }); + + test("rejects removed HTTP methods and unknown RPCs", async () => { + for (const method of ["GET", "DELETE"]) { + const response = await fetch(endpoint, { method }); + expect(response.status).toBe(405); + await response.text(); + } + const response = await post({ method: "unknown/method" }); + expect(response.status).toBe(404); + expect((await response.json() as { error: { code: number } }).error.code).toBe(-32601); + }); +}); diff --git a/test/mcp-2026.test.ts b/test/mcp-2026.test.ts index d3d6f73..1c38ff0 100644 --- a/test/mcp-2026.test.ts +++ b/test/mcp-2026.test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from "bun:test"; import { createMcpHandler, SERVER_INFO_META_KEY } from "@modelcontextprotocol/server"; +import { z } from "zod"; import { getChain, getRpcUrl, @@ -104,6 +105,7 @@ async function modernRequest( name?: string; protocolVersion?: string; meta?: Record; + headers?: Record; requestHandler?: typeof handler; } = {} ): Promise<{ status: number; body: JsonRpcResponse }> { @@ -118,6 +120,9 @@ async function modernRequest( if (options.name) { headers.set("Mcp-Name", options.name); } + for (const [name, value] of Object.entries(options.headers ?? {})) { + headers.set(name, value); + } const response = await (options.requestHandler ?? handler).fetch(new Request("http://test.local/mcp", { method: "POST", @@ -144,6 +149,48 @@ afterAll(async () => { }); describe("MCP 2026-07-28 SDK integration", () => { + test("validates annotated parameter headers before invoking a tool", async () => { + let calls = 0; + const requestHandler = createMcpHandler(() => { + const server = createServer(); + server.registerTool("header_test", { + inputSchema: z.object({ + region: z.string().meta({ "x-mcp-header": "Region" }) + }) + }, async ({ region }) => { + calls += 1; + return { content: [{ type: "text", text: region }] }; + }); + return server; + }, { legacy: "reject" }); + + try { + const invalidHeaders: Record[] = [ + {}, { "Mcp-Param-Region": "wrong" }, { "Mcp-Param-Region": "=?base64?%%%?=" } + ]; + for (const headers of invalidHeaders) { + const { status, body } = await modernRequest("header-invalid", "tools/call", { + name: "header_test", arguments: { region: " padded " } + }, { name: "header_test", requestHandler, headers }); + expect(status).toBe(400); + expect(body.error?.code).toBe(-32020); + expect(calls).toBe(0); + } + const { status, body } = await modernRequest("header-valid", "tools/call", { + name: "header_test", arguments: { region: " padded " } + }, { + name: "header_test", + requestHandler, + headers: { "Mcp-Param-Region": `=?base64?${Buffer.from(" padded ").toString("base64")}?=` } + }); + expect(status).toBe(200); + expect(body.result?.content).toEqual([{ type: "text", text: " padded " }]); + expect(calls).toBe(1); + } finally { + await requestHandler.close(); + } + }); + test("accepts configured numeric chain identifiers and rejects unknown networks", () => { expect(resolveChainId("137")).toBe(137); expect(getChain("137").id).toBe(137); diff --git a/test/stdio.test.ts b/test/stdio.test.ts new file mode 100644 index 0000000..c9084e8 --- /dev/null +++ b/test/stdio.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { resolve } from "node:path"; +import { SERVER_INFO } from "../src/server/protocol.js"; + +describe("Packaged CLI stdio interoperability", () => { + test("reports HTTP startup failures to its caller", async () => { + const child = Bun.spawn(["node", resolve(import.meta.dir, "../bin/cli.js"), "--http"], { + env: { ...process.env, MCP_HOST: "0.0.0.0", MCP_OAUTH_ISSUER_URL: "" }, + stdout: "pipe", + stderr: "pipe" + }); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text() + ]); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("MCP_OAUTH_ISSUER_URL is required"); + } finally { + child.kill(); + await child.exited; + } + }); + + for (const era of ["modern", "legacy"] as const) { + test(`negotiates ${era} and serves tools, resources and prompts`, async () => { + const client = new Client({ name: "stdio-integration", version: "1.0.0" }, { + versionNegotiation: { + mode: era === "modern" ? { pin: "2026-07-28" } : "legacy" + } + }); + const transport = new StdioClientTransport({ + command: "node", + args: [resolve(import.meta.dir, "../bin/cli.js")], + stderr: "pipe" + }); + const errors: Error[] = []; + client.onerror = error => errors.push(error); + + try { + await client.connect(transport); + expect(client.getProtocolEra()).toBe(era); + expect(client.getServerVersion()).toEqual(SERVER_INFO); + expect((await client.listTools()).tools).toHaveLength(25); + const result = await client.callTool({ name: "get_supported_networks", arguments: {} }); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toEqual(expect.objectContaining({ + supportedNetworks: expect.any(Array) + })); + expect((await client.readResource({ uri: "evm://networks" })).contents[0].mimeType) + .toBe("application/json"); + expect((await client.listPrompts()).prompts).toHaveLength(10); + expect((await client.getPrompt({ name: "check_network_status", arguments: {} })).messages.length) + .toBeGreaterThan(0); + expect(errors).toEqual([]); + } finally { + await client.close(); + } + }, 15000); + } +}); diff --git a/tsconfig.json b/tsconfig.json index d2dcaca..a30518b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,6 @@ "forceConsistentCasingInFileNames": true, "noEmit": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules", "dist", "build"] } From 89cd75c452981b36851d1fcd834a5cc717b7c8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:26:21 -0400 Subject: [PATCH 5/7] ci: gate MCP releases on transport and runtime checks Run frozen installs, type checking, both builds and protocol tests across Node 20, 22, 24 and 26. Update release actions and use Node 24; validate before atomically pushing a new release commit and tag without rewriting history. Document the dependency baseline and reproducible verification command. Actionlint and package dry-run pass. --- .github/workflows/check.yml | 30 ++++++++++++++ .github/workflows/release-publish.yml | 59 ++++++++++++--------------- README.md | 13 +++++- docs/mcp-2026-07-28-upgrade.md | 20 +++++++-- 4 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..0bfd13a --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,30 @@ +name: Check + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 22, 24, 26] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.2' + - name: Install locked dependencies + run: bun install --frozen-lockfile + - name: Check types, build and test both transports + run: bun run check + - name: Verify package contents + run: npm pack --dry-run --ignore-scripts diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index d1a2f8e..1080a17 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -26,6 +26,10 @@ on: default: 'latest' type: string +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release-and-publish: runs-on: ubuntu-latest @@ -35,24 +39,24 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.PAT_GITHUB }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: '18.x' + node-version: '24.x' registry-url: 'https://registry.npmjs.org' - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: '1.4.2' - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Configure Git run: | @@ -62,42 +66,32 @@ jobs: - name: Bump version id: bump_version + env: + CUSTOM_VERSION: ${{ inputs.custom_version }} + VERSION_TYPE: ${{ inputs.version_type }} run: | - if [ -n "${{ github.event.inputs.custom_version }}" ]; then - echo "Using custom version ${{ github.event.inputs.custom_version }}" - npm version ${{ github.event.inputs.custom_version }} --no-git-tag-version - echo "VERSION=${{ github.event.inputs.custom_version }}" >> $GITHUB_ENV - else - echo "Bumping ${{ github.event.inputs.version_type }} version" - NEW_VERSION=$(npm version ${{ github.event.inputs.version_type }} --no-git-tag-version) - echo "VERSION=${NEW_VERSION:1}" >> $GITHUB_ENV - fi - echo "New version: ${{ env.VERSION }}" + npm version "${CUSTOM_VERSION:-$VERSION_TYPE}" --no-git-tag-version + VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version") + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + bun install --lockfile-only - name: Generate Changelog run: | npm run changelog npm run changelog:latest - - name: Build project - run: bun run build && bun run build:http - - - name: Commit and push changes - run: | - git pull origin main --no-edit - git add package.json CHANGELOG.md - git commit -m "Bump version to v${{ env.VERSION }}" - git push --force-with-lease + - name: Check types, build and test before release + run: bun run check - - name: Create and push tag + - name: Commit and tag the verified release run: | - git tag -d "v${{ env.VERSION }}" 2>/dev/null || true - git push origin --delete "v${{ env.VERSION }}" 2>/dev/null || true - git tag -a "v${{ env.VERSION }}" -m "Release v${{ env.VERSION }}" - git push origin "v${{ env.VERSION }}" + git add package.json bun.lock CHANGELOG.md + git commit -m "chore: release v$VERSION" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push --atomic origin "HEAD:refs/heads/$GITHUB_REF_NAME" "refs/tags/v$VERSION" - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: tag_name: v${{ env.VERSION }} name: Release v${{ env.VERSION }} @@ -107,7 +101,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.PAT_GITHUB }} - name: Publish to npm - run: npm publish --access public --provenance --tag ${{ github.event.inputs.dist_tag || 'latest' }} + run: npm publish --ignore-scripts --access public --provenance --tag "$DIST_TAG" env: + DIST_TAG: ${{ inputs.dist_tag || 'latest' }} # Restored the token here to ensure it works NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 4af440d..bf78afa 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ All services are exposed through a consistent interface of MCP tools, resources, ## 🛠️ Prerequisites -- [Bun](https://bun.sh/) 1.0.0 or higher (recommended) +- [Bun](https://bun.sh/) 1.4.2 (the version used by CI) - Node.js 20.0.0 or higher (if not using Bun) - Optional: [Etherscan API key](https://etherscan.io/apis) for ABI fetching @@ -187,7 +187,7 @@ git clone https://github.com/mcpdotdirect/evm-mcp-server.git cd evm-mcp-server # Install dependencies with Bun -bun install +bun install --frozen-lockfile # Or with npm npm install @@ -747,6 +747,15 @@ evm-mcp-server/ ## 🛠️ Development +Run the complete migration checks before committing or publishing: + +```bash +bun install --frozen-lockfile +bun run check +``` + +This type-checks source and tests, builds both Node entry points, and runs the HTTP, OAuth, and modern/legacy stdio integration tests. Tests use local fixtures and do not submit blockchain transactions. CI runs the checks across Node 20, 22, 24, and 26. See [the migration notes](docs/mcp-2026-07-28-upgrade.md) for protocol decisions and dependency versions. + To modify or extend the server: 1. Add new services in the appropriate file under `src/core/services/` diff --git a/docs/mcp-2026-07-28-upgrade.md b/docs/mcp-2026-07-28-upgrade.md index a755f94..84dcde6 100644 --- a/docs/mcp-2026-07-28-upgrade.md +++ b/docs/mcp-2026-07-28-upgrade.md @@ -9,6 +9,10 @@ This repository targets the final MCP `2026-07-28` specification through the rel - SDK v1-to-v2 migration: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md - SDK `2026-07-28` support: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/support-2026-07-28.md +## Dependency Baseline + +Verified against the registry on September 13, 2026: the server, Node, Express, and test-client MCP packages are on stable `2.0.0`. Runtime dependencies include Express `5.2.1`, viem `2.56.5`, and Zod `4.6.4`; `bun.lock` pins the complete dependency graph. TypeScript `5.9.3` is a development dependency, not a peer requirement for consumers of the compiled CLI. Node types stay on the existing 22.x line rather than introducing Node 26-only APIs. The migration does not require the TypeScript 7 major upgrade. + ## Final Alignment - Replaced `@modelcontextprotocol/sdk` v1 with: @@ -29,6 +33,9 @@ This repository targets the final MCP `2026-07-28` specification through the rel - `Mcp-Method` - `Mcp-Name` - Added Host and Origin validation before the HTTP MCP handler. +- Added application-level enforcement of the protocol-version header and both accepted response types, including `q=0` exclusions. These supplement SDK v2.0.0's header value validation. +- Return JSON-RPC parse errors for malformed JSON and a JSON error for the 1 MB request limit; syntactically valid non-RPC JSON remains the SDK's responsibility. +- Verified Base64 sentinel decoding for `Mcp-Name` and `x-mcp-header` parameter validation. Current EVM tools do not declare routing headers; an annotated test tool verifies missing, malformed, mismatched, and correctly encoded headers before handler execution. - Added MCP OAuth resource-server support for HTTP: - localhost can run without authorization - non-local binds fail closed unless OAuth is configured @@ -52,6 +59,7 @@ This repository targets the final MCP `2026-07-28` specification through the rel - rejects tampering, argument changes, cross-token use, and replay - Bounded `wait_for_transaction` with `timeoutSeconds` from 1 through 90, defaulting to 90 seconds so it returns before the 120-second HTTP transport timeout. - Kept process diagnostics on `stderr`, including the npm CLI startup line, so stdio `stdout` contains protocol messages only. +- The CLI uses its parent Node executable and propagates child startup failures. Server identity reads the package version at build time so version bumps update both entry points. ## Final-Spec Differences from the RC @@ -108,17 +116,21 @@ The automated MCP integration tests cover: - cache hints on discovery, list, and resource results - resource reads and a read-only tool call - final `HeaderMismatch` and `UnsupportedProtocolVersion` error codes +- real Express HTTP requests covering media types, required headers, Host/Origin rejection, parser errors, removed methods, and the SDK client +- packaged Node CLI clients exercising tools, resource reads, and prompts over modern and legacy stdio, plus startup exit-code propagation - local authorization opt-out, remote fail-closed behavior, OAuth metadata validation, RFC 7662 introspection, audience checks, and scopes Release checks: ```bash -bunx tsc --noEmit -bun run test:mcp -bun run build -bun run build:http +bun install --frozen-lockfile +bun run check +bun audit +npm pack --dry-run --ignore-scripts ``` +`bun run check` type-checks source and tests, builds both entry points, then runs the complete test suite (including the built CLI). `bun run test:mcp` runs the suite against existing build output. Tests use localhost listeners and fixture credentials; they do not submit blockchain transactions. CI runs the checks with Node 20, 22, 24, and 26 and Bun 1.4.2. The manual release workflow uses Node 24, verifies the bumped package before committing, and pushes the commit and new tag atomically without rewriting existing tags or branches. No npm release is triggered by a normal branch push. + ## Follow-up Work These are enhancements, not compliance blockers: From 48df803c8dcf34f8e924ecad0bd1f538bb4b7a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:30:16 -0400 Subject: [PATCH 6/7] fix: bound HTTP bodies before MCP adapter conversion Reject unsupported POST media types before consuming uploads. Route accepted bodies through the bounded Express reader and always supply a parsed value to prevent the SDK raw-stream fallback. Keep parser and encoding failures in JSON-RPC form. Regression tests cover unfinished uploads, chunked and gzip size limits, empty bodies and unsupported encodings; all 56 tests pass. --- docs/mcp-2026-07-28-upgrade.md | 2 + src/server/http-app.ts | 33 +++++++++++-- test/http-protocol.test.ts | 88 +++++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/docs/mcp-2026-07-28-upgrade.md b/docs/mcp-2026-07-28-upgrade.md index 84dcde6..d9043e2 100644 --- a/docs/mcp-2026-07-28-upgrade.md +++ b/docs/mcp-2026-07-28-upgrade.md @@ -35,6 +35,7 @@ Verified against the registry on September 13, 2026: the server, Node, Express, - Added Host and Origin validation before the HTTP MCP handler. - Added application-level enforcement of the protocol-version header and both accepted response types, including `q=0` exclusions. These supplement SDK v2.0.0's header value validation. - Return JSON-RPC parse errors for malformed JSON and a JSON error for the 1 MB request limit; syntactically valid non-RPC JSON remains the SDK's responsibility. +- Reject non-JSON or missing POST media types before reading the body. All accepted bodies pass through Express's 1 MB reader (including chunked and decompressed uploads); the Node adapter always receives a parsed value so its unbounded raw-stream fallback is never used. Unsupported encodings also return JSON-RPC errors. - Verified Base64 sentinel decoding for `Mcp-Name` and `x-mcp-header` parameter validation. Current EVM tools do not declare routing headers; an annotated test tool verifies missing, malformed, mismatched, and correctly encoded headers before handler execution. - Added MCP OAuth resource-server support for HTTP: - localhost can run without authorization @@ -117,6 +118,7 @@ The automated MCP integration tests cover: - resource reads and a read-only tool call - final `HeaderMismatch` and `UnsupportedProtocolVersion` error codes - real Express HTTP requests covering media types, required headers, Host/Origin rejection, parser errors, removed methods, and the SDK client +- unfinished uploads proving unsupported media types are rejected before body completion, plus chunked and gzip uploads exceeding the decoded size limit - packaged Node CLI clients exercising tools, resource reads, and prompts over modern and legacy stdio, plus startup exit-code propagation - local authorization opt-out, remote fail-closed behavior, OAuth metadata validation, RFC 7662 introspection, audience checks, and scopes diff --git a/src/server/http-app.ts b/src/server/http-app.ts index ddad468..1690784 100644 --- a/src/server/http-app.ts +++ b/src/server/http-app.ts @@ -7,6 +7,7 @@ import express, { import { bearerAuthChallengeResponse, createMcpHandler, + isJsonContentType, OAuthError, OAuthErrorCode, ProtocolErrorCode @@ -77,9 +78,24 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { return; } + if (req.method === "POST" && !isJsonContentType(req.get("Content-Type") ?? null)) { + res.status(415).json({ + jsonrpc: "2.0", + error: { + code: ProtocolErrorCode.InvalidRequest, + message: "Content-Type must be application/json" + } + }); + return; + } + next(); }; + // Media types are checked above. Force every remaining body through this + // bounded reader, including chunked uploads and decompressed JSON. + const parseMcpBody = express.json({ limit: "1mb", strict: false, type: () => true }); + const handleMcpRequest = (req: Request, res: Response) => { // The v2 entry validates header values but permits an absent version header. if (req.method === "POST" && !req.get("MCP-Protocol-Version")) { @@ -107,7 +123,9 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { return; } - void nodeHandler(req, res, req.body); + // An undefined parsedBody makes the SDK buffer the raw stream without a limit. + // Treat an absent body as invalid JSON-RPC instead of entering that fallback. + void nodeHandler(req, res, req.body ?? null); }; if (oauthConfiguration) { @@ -152,7 +170,7 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { requiredScopes: oauthConfiguration.requiredScopes, resourceMetadataUrl }), - express.json({ limit: "1mb", strict: false }), + parseMcpBody, requireOperationScope, handleMcpRequest ); @@ -160,13 +178,13 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { app.all( "/mcp", validateMcpRequest, - express.json({ limit: "1mb", strict: false }), + parseMcpBody, handleMcpRequest ); } app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => { - const parserError = error as { type?: string } | undefined; + const parserError = error as { type?: string; status?: number } | undefined; if (parserError?.type === "entity.parse.failed") { res.status(400).json({ jsonrpc: "2.0", @@ -181,6 +199,13 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { }); return; } + if (parserError?.status === 400 || parserError?.status === 415) { + res.status(parserError.status).json({ + jsonrpc: "2.0", + error: { code: ProtocolErrorCode.InvalidRequest, message: "Invalid request body or encoding" } + }); + return; + } next(error); }); diff --git a/test/http-protocol.test.ts b/test/http-protocol.test.ts index c716d95..dc19667 100644 --- a/test/http-protocol.test.ts +++ b/test/http-protocol.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import type { Server } from "node:http"; +import { request, type Server } from "node:http"; +import { gzipSync } from "node:zlib"; import type { AddressInfo } from "node:net"; import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { createHttpApp } from "../src/server/http-app.js"; @@ -65,6 +66,75 @@ function post(options: { } describe("Streamable HTTP protocol boundary", () => { + test("rejects unsupported media types before waiting for the request body", async () => { + for (const contentType of [undefined, "text/plain", "text/plain; a=application/json"]) { + for (const framing of ["length", "chunked"]) { + const response = await new Promise<{ status: number; type?: string; body: string }>((resolve, reject) => { + const req = request(endpoint, { + method: "POST", + headers: { + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": MODERN_PROTOCOL_VERSION, + "Mcp-Method": "server/discover", + ...(contentType ? { "Content-Type": contentType } : {}), + ...(framing === "length" ? { "Content-Length": 2 * 1024 * 1024 } : { "Transfer-Encoding": "chunked" }) + } + }); + const deadline = setTimeout(() => { + req.destroy(); + reject(new Error("Server waited for an unsupported request body")); + }, 2000); + req.on("error", error => { + clearTimeout(deadline); + reject(error); + }); + req.on("response", res => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", chunk => { body += chunk; }); + res.on("end", () => { + clearTimeout(deadline); + resolve({ status: res.statusCode!, type: res.headers["content-type"], body }); + req.destroy(); + }); + }); + // Deliberately leave the upload unfinished: status-only tests miss buffering. + req.write("{"); + }); + expect(response.status).toBe(415); + expect(response.type).toContain("application/json"); + expect(JSON.parse(response.body).error.code).toBe(-32600); + } + } + }, 15000); + + test("limits chunked and decompressed JSON bodies", async () => { + const body = JSON.stringify({ padding: "x".repeat(1100 * 1024) }); + const chunked = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", "Transfer-Encoding": "chunked" } + }, res => { + let responseBody = ""; + res.setEncoding("utf8"); + res.on("data", chunk => { responseBody += chunk; }); + res.on("end", () => resolve({ status: res.statusCode!, body: responseBody })); + }); + req.on("error", reject); + req.end(body); + }); + expect(chunked.status).toBe(413); + expect(JSON.parse(chunked.body).error.code).toBe(-32600); + + const compressed = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Encoding": "gzip" }, + body: gzipSync(body) + }); + expect(compressed.status).toBe(413); + expect((await compressed.json() as { error: { code: number } }).error.code).toBe(-32600); + }); + test("serves an SDK client without protocol sessions", async () => { const client = new Client({ name: "http-test", version: "1.0.0" }, { versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } @@ -92,6 +162,22 @@ describe("Streamable HTTP protocol boundary", () => { })); }); + test("returns JSON-RPC errors for empty bodies and unsupported encodings", async () => { + const empty = await post({ rawBody: "" }); + expect(empty.status).toBe(400); + expect((await empty.json() as { error: { code: number } }).error.code).toBe(-32600); + + for (const headers of [ + { "Content-Type": "application/json; charset=iso-8859-1" }, + { "Content-Encoding": "unsupported" } + ] as Record[]) { + const response = await post({ headers }); + expect(response.status).toBe(415); + expect(response.headers.get("Content-Type")).toContain("application/json"); + expect((await response.json() as { error: { code: number } }).error.code).toBe(-32600); + } + }); + test("rejects non-JSON media types and accepts JSON parameters", async () => { for (const type of ["text/plain", "text/plain; a=application/json"]) { const response = await post({ headers: { "Content-Type": type } }); From 67e11cfd04248e9652c71ad062d2d0b350baacfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?charles-=C3=A9douard?= <59705750+ccbbccbb@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:52:02 -0400 Subject: [PATCH 7/7] fix: enforce exact amounts and browser MCP interoperability --- README.md | 6 ++ mcp-inspector.json | 15 +++ package.json | 3 +- src/core/services/transfer.ts | 75 +++++++++----- src/core/tools.ts | 30 ++++-- src/server/http-app.ts | 45 ++++++++- test/amounts.test.ts | 52 ++++++++++ test/http-auth.test.ts | 33 +++++++ test/http-protocol.test.ts | 42 ++++++++ test/mcp-2026.test.ts | 180 ++++++++++++++++++++++------------ 10 files changed, 380 insertions(+), 101 deletions(-) create mode 100644 mcp-inspector.json create mode 100644 test/amounts.test.ts diff --git a/README.md b/README.md index bf78afa..0b05b6f 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,8 @@ The HTTP server uses the following default configuration: When binding to a non-local interface, explicitly configure the public hostnames accepted by `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`. Values may be hostnames or origin URLs; validation is port-agnostic. +Allowed browser origins receive CORS headers on responses, including OAuth challenges and discovery metadata. Preflight requests are validated before authentication; actual MCP requests still require the configured bearer scopes. CORS permits GET/POST, standard MCP headers, authorization, and annotated `Mcp-Param-*` headers. Cookies are not enabled. + #### HTTP OAuth The HTTP process is an OAuth resource server; it does not issue access tokens. OAuth is optional only when `MCP_HOST` is local (`127.0.0.1`, `localhost`, or `::1`). If `MCP_OAUTH_ISSUER_URL` is set, OAuth is enabled even for a local bind. A non-local bind fails during startup unless OAuth is fully configured. @@ -351,6 +353,8 @@ bun dev:http Connect to this MCP server using any MCP-compatible client. For testing and debugging, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). +Run `bun run inspect` for the Inspector UI, or `bun run inspect:check` for strict tool-schema checks. Both build the stdio server and use `mcp-inspector.json` with explicit modern protocol negotiation. Inspector requires Node >=22.19.0 and is pinned to the verified 2.5.0 release. To inspect HTTP, start `bun run start:http` separately and select `evm-http`; adjust the config URL if using a different port. + ### Connecting from Cursor To connect to the MCP server from Cursor: @@ -576,6 +580,8 @@ The following wallet-backed tools enforce confirmation through MCP multi-round-t The first invocation describes the exact operation and requests a boolean `confirm` input. No wallet action occurs until the client returns an accepted response with `confirm: true`; declining or cancelling terminates the operation. Clients should display this protocol-level request instead of adding a separate conversational confirmation. +Amounts must be non-negative decimal strings exactly representable in the asset's base units; excess nonzero decimal places are rejected before confirmation. Token precision is read before each confirmation round, and the confirmation binds both decimals and the exact base-unit amount. A precision change requires fresh confirmation. Execution uses the confirmed base units without another decimals lookup. + Confirmation continuation state is HMAC integrity-protected and binds the complete tool arguments. It expires after five minutes, is process-local and single-use, and is also bound to the authenticated bearer token for HTTP requests. An expired, replayed, cross-process, or differently authenticated continuation requires a new confirmation. For example, the initial transfer call uses ordinary `tools/call` parameters: diff --git a/mcp-inspector.json b/mcp-inspector.json new file mode 100644 index 0000000..4e6f33f --- /dev/null +++ b/mcp-inspector.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "evm-stdio": { + "type": "stdio", + "command": "node", + "args": ["bin/cli.js"], + "protocolEra": "modern" + }, + "evm-http": { + "type": "streamable-http", + "url": "http://127.0.0.1:3001/mcp", + "protocolEra": "modern" + } + } +} diff --git a/package.json b/package.json index 6c988ad..4536ce7 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "release": "npm publish", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", "changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md", - "inspect": "npx @modelcontextprotocol/inspector node build/index.js", + "inspect": "bun run build && npx -y @modelcontextprotocol/inspector@2.5.0 --config mcp-inspector.json", + "inspect:check": "bun run build && npx -y @modelcontextprotocol/inspector@2.5.0 --cli --config mcp-inspector.json --server evm-stdio --method tools/list --strict", "typecheck": "tsc --noEmit", "test:mcp": "bun test test", "check": "bun run typecheck && bun run build && bun run build:http && bun run test:mcp" diff --git a/src/core/services/transfer.ts b/src/core/services/transfer.ts index 5a2f4f3..bd5d716 100644 --- a/src/core/services/transfer.ts +++ b/src/core/services/transfer.ts @@ -1,5 +1,4 @@ import { - parseEther, parseUnits, type Address, type Hash, @@ -47,6 +46,45 @@ const erc20TransferAbi = [ } ] as const; +/** Parse a non-negative amount without rounding away fractional base units. */ +export function parseExactAmount(amount: string, decimals: number): bigint { + if (!/^\d+(\.\d+)?$/.test(amount)) { + throw new Error('Amount must be a non-negative decimal string'); + } + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) { + throw new Error('Invalid token decimals'); + } + const fraction = (amount.split('.')[1] ?? '').replace(/0+$/, ''); + if (fraction.length > decimals) { + throw new Error(`Amount exceeds ${decimals} decimal places; rounding is not allowed`); + } + const raw = parseUnits(amount, decimals); + if (raw >= 2n ** 256n) { + throw new Error('Amount exceeds uint256'); + } + return raw; +} + +export type TokenAmount = { + raw: bigint; + formatted: string; + decimals: number; +}; + +/** Resolve token precision before confirmation and retain the exact execution amount. */ +export async function prepareERC20Amount( + tokenAddress: Address, + amount: string, + network = 'ethereum' +): Promise { + const decimals = await getPublicClient(network).readContract({ + address: tokenAddress, + abi: erc20TransferAbi, + functionName: 'decimals' + }); + return { raw: parseExactAmount(amount, decimals), formatted: amount, decimals }; +} + /** * Transfer a chain's native token to an address * @param privateKey Sender's private key @@ -70,7 +108,7 @@ export async function transferETH( : privateKey as Hex; const client = getWalletClient(formattedKey, network); - const amountWei = parseEther(amount); + const amountWei = parseExactAmount(amount, 18); return client.sendTransaction({ to: toAddress, @@ -84,7 +122,7 @@ export async function transferETH( * Transfer ERC20 tokens to an address * @param tokenAddressOrEns Token contract address or ENS name * @param toAddressOrEns Recipient address or ENS name - * @param amount Amount to send (in token units) + * @param amount Exact token amount prepared before confirmation * @param privateKey Sender's private key * @param network Network name or chain ID * @returns Transaction details @@ -92,7 +130,7 @@ export async function transferETH( export async function transferERC20( tokenAddressOrEns: string, toAddressOrEns: string, - amount: string, + amount: TokenAmount, privateKey: string | `0x${string}`, network: string = 'ethereum' ): Promise<{ @@ -123,12 +161,12 @@ export async function transferERC20( client: publicClient, }); - // Get token decimals and symbol - const decimals = await contract.read.decimals(); + // Preserve the precision and base units that were confirmed. + const decimals = amount.decimals; const symbol = await contract.read.symbol(); - // Parse the amount with the correct number of decimals - const rawAmount = parseUnits(amount, decimals); + // Use the exact base units prepared before confirmation. + const rawAmount = amount.raw; // Create wallet client for sending the transaction const walletClient = getWalletClient(formattedKey, network); @@ -147,7 +185,7 @@ export async function transferERC20( txHash: hash, amount: { raw: rawAmount, - formatted: amount + formatted: amount.formatted }, token: { symbol, @@ -160,7 +198,7 @@ export async function transferERC20( * Approve ERC20 token spending * @param tokenAddressOrEns Token contract address or ENS name * @param spenderAddressOrEns Spender address or ENS name - * @param amount Amount to approve (in token units) + * @param amount Exact token amount prepared before confirmation * @param privateKey Owner's private key * @param network Network name or chain ID * @returns Transaction hash @@ -168,7 +206,7 @@ export async function transferERC20( export async function approveERC20( tokenAddressOrEns: string, spenderAddressOrEns: string, - amount: string, + amount: TokenAmount, privateKey: string | `0x${string}`, network: string = 'ethereum' ): Promise { @@ -181,19 +219,8 @@ export async function approveERC20( ? `0x${privateKey}` as `0x${string}` : privateKey as `0x${string}`; - // Get token details - const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc20TransferAbi, - client: publicClient, - }); - - // Get token decimals - const decimals = await contract.read.decimals(); - - // Parse the amount with the correct number of decimals - const rawAmount = parseUnits(amount, decimals); + // Use the base units bound to the accepted confirmation, without re-reading decimals. + const rawAmount = amount.raw; // Create wallet client for sending the transaction const walletClient = getWalletClient(formattedKey, network); diff --git a/src/core/tools.ts b/src/core/tools.ts index e1cbbc8..5f8e6c8 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -46,7 +46,11 @@ const supportedNetworksOutputSchema = z.object({ const gasPriceOutputSchema = z.object({ network: z.string(), gasPricePerGas: z.string(), - priorityFeePerGas: z.string().nullable(), + // Branch descriptions preserve anyOf instead of Zod's compact type array. + priorityFeePerGas: z.union([ + z.string().describe("Estimated priority fee in wei."), + z.null().describe("The network does not provide a priority fee estimate.") + ]), currency: z.literal("wei") }); @@ -1105,6 +1109,7 @@ export function registerEVMTools(server: McpServer) { }; } + const valueWei = value === undefined ? undefined : services.parseExactAmount(value, 18); const functionSignature = `${functionAbi.name}(${ (functionAbi.inputs ?? []) .map((input: { type?: unknown }) => String(input.type ?? "unknown")) @@ -1118,6 +1123,7 @@ export function registerEVMTools(server: McpServer) { functionName, args, value: value ?? null, + valueWei: valueWei?.toString() ?? null, abiJson: abiJson ?? null, functionAbi, network @@ -1140,9 +1146,8 @@ export function registerEVMTools(server: McpServer) { }; // Add value if provided (for payable functions) - if (value) { - const { parseEther } = await import('viem'); - writeParams.value = parseEther(value); + if (valueWei !== undefined) { + writeParams.value = valueWei; } // Execute the write operation @@ -1312,11 +1317,12 @@ export function registerEVMTools(server: McpServer) { }, async ({ to, amount, network = "ethereum" }, ctx) => { try { + const amountWei = services.parseExactAmount(amount, 18); const resolvedRecipient = await services.resolveAddress(to, network); const confirmation = await requireConfirmation( ctx, "transfer_native", - { to, resolvedRecipient, amount, network }, + { to, resolvedRecipient, amount, amountWei: amountWei.toString(), network }, `Transfer ${amount} native tokens to ${to} (${resolvedRecipient}) on ${network}?` ); if (confirmation) { @@ -1368,6 +1374,7 @@ export function registerEVMTools(server: McpServer) { services.resolveAddress(tokenAddress, network), services.resolveAddress(to, network) ]); + const tokenAmount = await services.prepareERC20Amount(resolvedTokenAddress, amount, network); const confirmation = await requireConfirmation( ctx, "transfer_erc20", @@ -1377,9 +1384,11 @@ export function registerEVMTools(server: McpServer) { to, resolvedRecipient, amount, + rawAmount: tokenAmount.raw.toString(), + decimals: tokenAmount.decimals, network }, - `Transfer ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) to ${to} (${resolvedRecipient}) on ${network}?` + `Transfer ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) to ${to} (${resolvedRecipient}) on ${network} (${tokenAmount.raw} base units at ${tokenAmount.decimals} decimals)?` ); if (confirmation) { return confirmation; @@ -1390,7 +1399,7 @@ export function registerEVMTools(server: McpServer) { const result = await services.transferERC20( resolvedTokenAddress, resolvedRecipient, - amount, + tokenAmount, privateKey, network ); @@ -1439,6 +1448,7 @@ export function registerEVMTools(server: McpServer) { services.resolveAddress(tokenAddress, network), services.resolveAddress(spenderAddress, network) ]); + const tokenAmount = await services.prepareERC20Amount(resolvedTokenAddress, amount, network); const confirmation = await requireConfirmation( ctx, "approve_token_spending", @@ -1448,9 +1458,11 @@ export function registerEVMTools(server: McpServer) { spenderAddress, resolvedSpenderAddress, amount, + rawAmount: tokenAmount.raw.toString(), + decimals: tokenAmount.decimals, network }, - `Approve ${spenderAddress} (${resolvedSpenderAddress}) to spend ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) on ${network}?` + `Approve ${spenderAddress} (${resolvedSpenderAddress}) to spend ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) on ${network} (${tokenAmount.raw} base units at ${tokenAmount.decimals} decimals)?` ); if (confirmation) { return confirmation; @@ -1461,7 +1473,7 @@ export function registerEVMTools(server: McpServer) { const txHash = await services.approveERC20( resolvedTokenAddress, resolvedSpenderAddress, - amount, + tokenAmount, privateKey, network ); diff --git a/src/server/http-app.ts b/src/server/http-app.ts index 1690784..8d5cb79 100644 --- a/src/server/http-app.ts +++ b/src/server/http-app.ts @@ -60,6 +60,47 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { } }); + // Browser preflights do not carry bearer tokens. Validate their host/origin + // before answering, and retain CORS headers on OAuth challenges and metadata. + app.use((req: Request, res: Response, next: NextFunction) => { + if (!validateHost(req, res) || !validateOrigin(req, res)) { + return; + } + res.vary("Origin"); + const origin = req.get("Origin"); + if (!origin) { + next(); + return; + } + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, MCP-Protocol-Version"); + if (req.method !== "OPTIONS") { + next(); + return; + } + + res.vary("Access-Control-Request-Method"); + res.vary("Access-Control-Request-Headers"); + const method = req.get("Access-Control-Request-Method"); + const headers = (req.get("Access-Control-Request-Headers") ?? "") + .split(",").map(header => header.trim().toLowerCase()).filter(Boolean); + const allowedHeaders = new Set([ + "accept", "content-type", "authorization", "mcp-protocol-version", "mcp-method", "mcp-name" + ]); + if ( + !method || !["GET", "POST"].includes(method) + || headers.some(header => !allowedHeaders.has(header) && !/^mcp-param-[a-z0-9-]+$/.test(header)) + ) { + res.status(403).end(); + return; + } + res.setHeader("Access-Control-Allow-Methods", "GET, POST"); + if (headers.length) { + res.setHeader("Access-Control-Allow-Headers", headers.join(", ")); + } + res.status(204).end(); + }); + if (oauthConfiguration) { app.use(mcpAuthMetadataRouter({ oauthMetadata: oauthConfiguration.oauthMetadata, @@ -74,10 +115,6 @@ export function createHttpApp(options: HttpAppOptions): HttpApp { res: Response, next: NextFunction ) => { - if (!validateHost(req, res) || !validateOrigin(req, res)) { - return; - } - if (req.method === "POST" && !isJsonContentType(req.get("Content-Type") ?? null)) { res.status(415).json({ jsonrpc: "2.0", diff --git a/test/amounts.test.ts b/test/amounts.test.ts new file mode 100644 index 0000000..7062ab7 --- /dev/null +++ b/test/amounts.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import type { WalletClient } from "viem"; +import * as clients from "../src/core/services/clients.js"; +import { approveERC20, parseExactAmount, prepareERC20Amount, transferERC20 } from "../src/core/services/transfer.js"; + +describe("Exact operation amounts", () => { + test("rejects rounding, malformed amounts and uint256 overflow", () => { + for (const value of ["0.0000006", "0.0000004", "1.0000006"]) { + expect(() => parseExactAmount(value, 6)).toThrow("rounding is not allowed"); + } + for (const value of ["-1", "1e6", "NaN", "", " 1", "1."]) { + expect(() => parseExactAmount(value, 18)).toThrow("non-negative decimal"); + } + expect(() => parseExactAmount((2n ** 256n).toString(), 0)).toThrow("uint256"); + expect(parseExactAmount("0", 6)).toBe(0n); + expect(parseExactAmount("1.2300000", 6)).toBe(1230000n); + expect(parseExactAmount("0.000000000000000001", 18)).toBe(1n); + expect(parseExactAmount("2.000", 0)).toBe(2n); + }); + + test("transfers and approvals execute prepared base units without re-reading decimals", async () => { + const token = "0x0000000000000000000000000000000000000001"; + const recipient = "0x0000000000000000000000000000000000000002"; + const key = `0x${"0".repeat(63)}1` as const; + const hash = `0x${"1".repeat(64)}` as const; + const reads = spyOn(clients.getPublicClient(), "readContract").mockResolvedValue(6); + const sent: unknown[][] = []; + const wallet = spyOn(clients, "getWalletClient").mockReturnValue({ + account: { address: recipient }, + chain: { id: 1 }, + writeContract: async ({ args }: { args: unknown[] }) => { + sent.push(args); + return hash; + } + } as unknown as WalletClient); + try { + const amount = await prepareERC20Amount(token, "1.000001"); + expect(amount.raw).toBe(1000001n); + reads.mockClear(); + reads.mockResolvedValue("TEST"); + const transfer = await transferERC20(token, recipient, amount, key); + await approveERC20(token, recipient, amount, key); + expect(sent).toEqual([[recipient, 1000001n], [recipient, 1000001n]]); + expect(transfer.amount).toEqual({ raw: 1000001n, formatted: "1.000001" }); + expect(reads).toHaveBeenCalledTimes(1); + expect(reads.mock.calls[0][0].functionName).toBe("symbol"); + } finally { + reads.mockRestore(); + wallet.mockRestore(); + } + }); +}); diff --git a/test/http-auth.test.ts b/test/http-auth.test.ts index 2499365..af85376 100644 --- a/test/http-auth.test.ts +++ b/test/http-auth.test.ts @@ -149,6 +149,39 @@ afterAll(async () => { }); describe("HTTP OAuth middleware integration", () => { + test("serves browser preflights before auth and exposes OAuth challenges and metadata", async () => { + const origin = "http://127.0.0.1:9876"; + const preflight = await fetch(`${baseUrl}/mcp`, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type,mcp-protocol-version,mcp-method" + } + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("Access-Control-Allow-Origin")).toBe(origin); + + const headers = mcpHeaders(); + headers.set("Origin", origin); + const unauthorized = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers, + body: JSON.stringify(mcpRequest("browser-auth", "tools/list")) + }); + expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(unauthorized.headers.get("Access-Control-Expose-Headers")).toContain("WWW-Authenticate"); + expect(unauthorized.headers.get("WWW-Authenticate")).toContain("resource_metadata"); + await unauthorized.text(); + + const metadata = await fetch(`${baseUrl}${LOCAL_RESOURCE_METADATA_PATH}`, { headers: { Origin: origin } }); + expect(metadata.status).toBe(200); + // The SDK intentionally publishes public OAuth metadata with wildcard CORS. + expect(metadata.headers.get("Access-Control-Allow-Origin")).toBe("*"); + await metadata.text(); + }); + test("serves public protected-resource metadata for the exact MCP resource", async () => { const response = await fetch(`${baseUrl}${LOCAL_RESOURCE_METADATA_PATH}`); diff --git a/test/http-protocol.test.ts b/test/http-protocol.test.ts index dc19667..5beeb84 100644 --- a/test/http-protocol.test.ts +++ b/test/http-protocol.test.ts @@ -66,6 +66,48 @@ function post(options: { } describe("Streamable HTTP protocol boundary", () => { + test("allows constrained browser preflights and exposes response headers", async () => { + const origin = "http://127.0.0.1:9876"; + const preflight = await fetch(endpoint, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Content-Type,Authorization,MCP-Protocol-Version,Mcp-Method,Mcp-Name,Mcp-Param-network" + } + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(preflight.headers.get("Access-Control-Allow-Headers")).toContain("authorization"); + expect(preflight.headers.get("Access-Control-Allow-Headers")).toContain("mcp-param-network"); + expect(preflight.headers.get("Vary")).toContain("Origin"); + expect(preflight.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + const response = await post({ headers: { Origin: origin } }); + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(origin); + await response.text(); + }); + + test("rejects unapproved preflight origins, methods and headers", async () => { + for (const overrides of [ + { Origin: "https://attacker.invalid" }, + { "Access-Control-Request-Method": "DELETE" }, + { "Access-Control-Request-Headers": "x-unapproved" } + ]) { + const response = await fetch(endpoint, { + method: "OPTIONS", + headers: { + Origin: "http://127.0.0.1:9876", + "Access-Control-Request-Method": "POST", + ...overrides + } as Record + }); + expect(response.status).toBe(403); + if (overrides.Origin) expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + await response.text(); + } + }); + test("rejects unsupported media types before waiting for the request body", async () => { for (const contentType of [undefined, "text/plain", "text/plain; a=application/json"]) { for (const framing of ["length", "chunked"]) { diff --git a/test/mcp-2026.test.ts b/test/mcp-2026.test.ts index 1c38ff0..343e1b1 100644 --- a/test/mcp-2026.test.ts +++ b/test/mcp-2026.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, spyOn, test } from "bun:test"; import { createMcpHandler, SERVER_INFO_META_KEY } from "@modelcontextprotocol/server"; import { z } from "zod"; import { @@ -8,6 +8,7 @@ import { resolveChainId } from "../src/core/chains.js"; import createServer from "../src/server/server.js"; +import { getPublicClient } from "../src/core/services/clients.js"; import { CACHE_SCOPE, CACHE_TTL_MS, MODERN_PROTOCOL_VERSION, SERVER_INFO } from "../src/server/protocol.js"; const handler = createMcpHandler(createServer, { @@ -419,71 +420,124 @@ describe("MCP 2026-07-28 SDK integration", () => { }); test("requests confirmation before every wallet-backed operation and handles decline", async () => { - for (const [index, tool] of DANGEROUS_TOOL_CALLS.entries()) { - const { status, body } = await modernRequest( - `confirmation-${index}`, - "tools/call", - { - name: tool.name, - arguments: tool.arguments - }, - { - name: tool.name, - meta: ELICITATION_CLIENT_META - } - ); - - expect(status).toBe(200); - expect(body.result?.resultType).toBe("input_required"); - expect(body.result?.requestState).toEqual(expect.any(String)); - - const inputRequests = body.result?.inputRequests as Record>; - const entries = Object.entries(inputRequests); - expect(entries).toHaveLength(1); - - const [confirmationKey, inputRequest] = entries[0]; - expect(inputRequest).toEqual(expect.objectContaining({ - method: "elicitation/create", - params: expect.objectContaining({ - mode: "form", - message: expect.any(String), - requestedSchema: expect.objectContaining({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - confirm: expect.objectContaining({ - type: "boolean" - }) - }, - required: ["confirm"] + const decimals = spyOn(getPublicClient(), "readContract").mockResolvedValue(6); + try { + for (const [index, tool] of DANGEROUS_TOOL_CALLS.entries()) { + const { status, body } = await modernRequest( + `confirmation-${index}`, + "tools/call", + { + name: tool.name, + arguments: tool.arguments + }, + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); + + expect(status).toBe(200); + expect(body.result?.resultType).toBe("input_required"); + expect(body.result?.requestState).toEqual(expect.any(String)); + + const inputRequests = body.result?.inputRequests as Record>; + const entries = Object.entries(inputRequests); + expect(entries).toHaveLength(1); + + const [confirmationKey, inputRequest] = entries[0]; + expect(inputRequest).toEqual(expect.objectContaining({ + method: "elicitation/create", + params: expect.objectContaining({ + mode: "form", + message: expect.any(String), + requestedSchema: expect.objectContaining({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + confirm: expect.objectContaining({ + type: "boolean" + }) + }, + required: ["confirm"] + }) }) - }) - })); - - const { status: declinedStatus, body: declinedBody } = await modernRequest( - `confirmation-${index}-declined`, - "tools/call", - { - name: tool.name, - arguments: tool.arguments, - inputResponses: { - [confirmationKey]: { - action: "decline" - } + })); + + const { status: declinedStatus, body: declinedBody } = await modernRequest( + `confirmation-${index}-declined`, + "tools/call", + { + name: tool.name, + arguments: tool.arguments, + inputResponses: { + [confirmationKey]: { + action: "decline" + } + }, + requestState: body.result?.requestState }, - requestState: body.result?.requestState - }, - { - name: tool.name, - meta: ELICITATION_CLIENT_META - } - ); + { + name: tool.name, + meta: ELICITATION_CLIENT_META + } + ); - expect(declinedStatus).toBe(200); - expect(declinedBody.result?.resultType).toBe("complete"); - expect(declinedBody.result?.isError).toBe(true); - const declinedContent = declinedBody.result?.content as Array>; - expect(declinedContent[0]?.text).toMatch(/cancel|declin|not confirmed/i); + expect(declinedStatus).toBe(200); + expect(declinedBody.result?.resultType).toBe("complete"); + expect(declinedBody.result?.isError).toBe(true); + const declinedContent = declinedBody.result?.content as Array>; + expect(declinedContent[0]?.text).toMatch(/cancel|declin|not confirmed/i); + } + } finally { + decimals.mockRestore(); + } + }); + + test("rejects non-representable amounts before requesting confirmation", async () => { + const decimals = spyOn(getPublicClient(), "readContract").mockResolvedValue(6); + try { + const calls = [ + { ...DANGEROUS_TOOL_CALLS[0], arguments: { ...DANGEROUS_TOOL_CALLS[0].arguments, value: "0.0000000000000000006" } }, + { ...DANGEROUS_TOOL_CALLS[1], arguments: { ...DANGEROUS_TOOL_CALLS[1].arguments, amount: "0.0000000000000000006" } }, + ...DANGEROUS_TOOL_CALLS.slice(2, 4).map(tool => ({ + ...tool, arguments: { ...tool.arguments, amount: "0.0000006" } + })) + ]; + for (const tool of calls) { + const { body } = await modernRequest("invalid-amount", "tools/call", tool, { + name: tool.name, meta: ELICITATION_CLIENT_META + }); + expect(body.result?.isError).toBe(true); + expect(body.result?.requestState).toBeUndefined(); + expect(body.result?.content).toEqual(expect.arrayContaining([ + expect.objectContaining({ text: expect.stringContaining("rounding is not allowed") }) + ])); + } + } finally { + decimals.mockRestore(); + } + }); + + test("requires fresh confirmation when token decimals change", async () => { + const decimals = spyOn(getPublicClient(), "readContract"); + try { + for (const tool of DANGEROUS_TOOL_CALLS.slice(2, 4)) { + decimals.mockResolvedValue(6); + const { body: first } = await modernRequest("precision-first", "tools/call", tool, { + name: tool.name, meta: ELICITATION_CLIENT_META + }); + expect(first.result?.resultType).toBe("input_required"); + decimals.mockResolvedValue(7); + const { body: changed } = await modernRequest("precision-changed", "tools/call", { + ...tool, + requestState: first.result?.requestState, + inputResponses: { confirmation: { action: "accept", content: { confirm: true } } } + }, { name: tool.name, meta: ELICITATION_CLIENT_META }); + expect(changed.result?.resultType).toBe("input_required"); + expect(changed.result?.requestState).not.toBe(first.result?.requestState); + } + } finally { + decimals.mockRestore(); } });