diff --git a/.env.example b/.env.example index 937cbac..a1865bc 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,4 @@ LOG_LEVEL=DEBUG PRIVATE_KEY=your_private_key # Server configuration (optional, will use default values if not set) -PORT=3001 # Port for HTTP server (only used in SSE mode) +PORT=3001 # Port for HTTP/SSE server (default: 3001) diff --git a/README.md b/README.md index af0be57..23023b5 100644 --- a/README.md +++ b/README.md @@ -34,32 +34,32 @@ To connect to the MCP server from Cursor: 3. Click "Add new global MCP server" 4. Enter the following details: -Default mode +**Streamable HTTP mode (Recommended)** ```json { "mcpServers": { "bnbchain-mcp": { "command": "npx", - "args": ["-y", "@bnb-chain/mcp@latest"], + "args": ["-y", "@bnb-chain/mcp@latest", "--http"], "env": { - "PRIVATE_KEY": "your_private_key_here. (optional)" + "PRIVATE_KEY": "your_private_key_here (optional)" } } } } ``` -SSE mode +**stdio mode (Alternative)** ```json { "mcpServers": { "bnbchain-mcp": { "command": "npx", - "args": ["-y", "@bnb-chain/mcp@latest", "--sse"], + "args": ["-y", "@bnb-chain/mcp@latest"], "env": { - "PRIVATE_KEY": "your_private_key_here. (optional)" + "PRIVATE_KEY": "your_private_key_here (optional)" } } } @@ -75,6 +75,24 @@ To connect to the MCP server from Claude Desktop: 3. Click the "Edit Config" Button 4. Add the following configuration to the `claude_desktop_config.json` file: +**Streamable HTTP mode (Recommended)** + +```json +{ + "mcpServers": { + "bnbchain-mcp": { + "command": "npx", + "args": ["-y", "@bnb-chain/mcp@latest", "--http"], + "env": { + "PRIVATE_KEY": "your_private_key_here (optional)" + } + } + } +} +``` + +**stdio mode (Alternative)** + ```json { "mcpServers": { @@ -82,7 +100,7 @@ To connect to the MCP server from Claude Desktop: "command": "npx", "args": ["-y", "@bnb-chain/mcp@latest"], "env": { - "PRIVATE_KEY": "your_private_key_here" + "PRIVATE_KEY": "your_private_key_here (optional)" } } } @@ -97,6 +115,30 @@ Once connected, you can use all the MCP prompts and tools directly in your Claud - "Explain the EVM concept of gas" - "Check the latest block on BSC" +## Integration with Claude Code + +To connect to the MCP server from Claude Code: + +**Streamable HTTP mode (Recommended)** + +```bash +# Add the server (it will auto-start when needed) +claude mcp add bnb-chain npx -y @bnb-chain/mcp@latest --http + +# Verify the connection +claude mcp list +``` + +The server will automatically start on `http://localhost:3001/mcp` when Claude Code needs it. + +**stdio mode (Alternative)** + +```bash +claude mcp add bnb-chain npx -y @bnb-chain/mcp@latest +``` + +**Note:** To set a private key for write operations (transfers, contract writes, etc.), set the `PRIVATE_KEY` environment variable before starting Claude Code. + ## Integration with Other Clients If you want to integrate BNBChain MCP into your own client, please check out the [examples](./examples) directory for more detailed information and reference implementations. @@ -143,21 +185,57 @@ Edit `.env` file with your configuration: # Install project dependencies bun install -# Start the development server +# Start with Streamable HTTP transport (recommended, protocol 2025-03-26) +bun dev:http + +# OR start with SSE transport (deprecated, protocol 2024-11-05) bun dev:sse + +# OR start with stdio mode (for local MCP clients) +bun dev ``` ### Testing with MCP Clients -Configure the local server in your MCP clients using this template: +**For Streamable HTTP (`bun dev:http`):** + +```json +{ + "mcpServers": { + "bnbchain-mcp": { + "url": "http://localhost:3001/mcp" + } + } +} +``` + +Or using Claude Code CLI: +```bash +claude mcp add --transport http bnbchain-mcp http://localhost:3001/mcp +``` + +**For SSE mode (`bun dev:sse`):** + +```json +{ + "mcpServers": { + "bnbchain-mcp": { + "url": "http://localhost:3001/sse" + } + } +} +``` + +**For stdio mode (`bun dev`):** ```json { "mcpServers": { "bnbchain-mcp": { - "url": "http://localhost:3001/sse", + "command": "bun", + "args": ["run", "dev"], "env": { - "PRIVATE_KEY": "your_private_key_here" + "PRIVATE_KEY": "your_private_key_here (optional)" } } } @@ -174,9 +252,12 @@ bun run test ### Available Scripts -- `bun dev:sse`: Start development server with hot reload +- `bun dev:http`: Start server with Streamable HTTP transport (recommended, protocol 2025-03-26) +- `bun dev:sse`: Start server with SSE transport (deprecated, protocol 2024-11-05) +- `bun dev`: Start server with stdio transport - `bun build`: Build the project -- `bun test`: Run test suite +- `bun test`: Run test suite with MCP inspector +- `bun e2e`: Run end-to-end tests ## Available Prompts and Tools diff --git a/bun.lockb b/bun.lockb index 77049c3..ba585d3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/e2e/http-transport.test.ts b/e2e/http-transport.test.ts new file mode 100644 index 0000000..c5b8ecd --- /dev/null +++ b/e2e/http-transport.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" +import { spawn, type ChildProcess } from "child_process" + +describe("HTTP Transport Test", async () => { + let serverProcess: ChildProcess + let client: Client + let transport: StreamableHTTPClientTransport + + beforeAll(async () => { + // Start the server with HTTP transport + serverProcess = spawn(process.execPath, ["dist/index.js", "--http"], { + env: { + ...process.env, + PORT: "3002", + LOG_LEVEL: "ERROR" + } + }) + + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Create client and connect + client = new Client({ + name: "bnbchain-mcp-http-test-client", + version: "1.0.0" + }) + + transport = new StreamableHTTPClientTransport( + new URL("http://localhost:3002/mcp") + ) + + await client.connect(transport) + }) + + afterAll(async () => { + // Clean up + await transport.close() + serverProcess.kill() + }) + + it("should connect to HTTP server", async () => { + expect(client).toBeDefined() + expect(transport.sessionId).toBeDefined() + }) + + it("should list all MCP tools via HTTP", async () => { + const toolResult = await client.listTools() + const names = toolResult.tools.map((tool) => tool.name) + + expect(names).toBeArray() + expect(names.length).toBeGreaterThan(0) + + // Verify some expected tools exist + expect(names).toContain("get_latest_block") + expect(names).toContain("get_chain_info") + }) + + it("should call a tool via HTTP", async () => { + const result = await client.callTool({ + name: "get_supported_networks", + arguments: {} + }) + + expect(result).toBeDefined() + expect(result.content).toBeArray() + expect(result.content).toEqual(expect.any(Array)); + expect(result.content).not.toHaveLength(0) + }) + + it("should list prompts via HTTP", async () => { + const promptResult = await client.listPrompts() + expect(promptResult.prompts).toBeArray() + }) +}) diff --git a/package.json b/package.json index 7cc1227..f2bdf63 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "A MCP server for BNB Chain that supports BSC, opBNB, Greenfield, and other popular EVM-compatible networks.", "scripts": { "start": "node dist/index.js", + "start:http": "npm run start --http", "start:sse": "npm run start --sse", + "dev:http": "bun run --watch src/index.ts --http", "dev:sse": "bun run --watch src/index.ts --sse", "dev": "bun run --watch src/index.ts", "build": "bun build src/*.ts --outdir dist --target node --format cjs", @@ -24,28 +26,28 @@ "access": "public" }, "dependencies": { - "@bnb-chain/greenfield-js-sdk": "^2.2.1", + "@bnb-chain/greenfield-js-sdk": "^2.2.2", "@bnb-chain/reed-solomon": "^1.1.4", - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.21.1", "cors": "^2.8.5", - "dotenv": "^16.5.0", - "express": "^4.18.2", - "mime": "^4.0.7", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "mime": "^4.1.0", "reflect-metadata": "^0.2.2", - "viem": "^2.27.2", - "zod": "^3.22.4" + "viem": "^2.38.6", + "zod": "^3.25.76" }, "devDependencies": { - "@commitlint/cli": "^19.8.0", - "@commitlint/config-conventional": "^19.8.0", + "@commitlint/cli": "^20.1.0", + "@commitlint/config-conventional": "^20.0.0", "@ianvs/prettier-plugin-sort-imports": "4.1.1", - "@types/bun": "^1.2.12", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/node": "^20.0.0", + "@types/bun": "^1.3.2", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.25", + "@types/node": "^20.19.24", "husky": "^9.1.7", "prettier": "3.2.4", - "typescript": "^5.0.0" + "typescript": "^5.9.3" }, "license": "MIT", "commitlint": { diff --git a/src/index.ts b/src/index.ts index 30a5512..f7181d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,21 @@ #!/usr/bin/env node import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp" +import { startHTTPServer } from "./server/http" import { startSSEServer } from "./server/sse" import { startStdioServer } from "./server/stdio" import logger from "./utils/logger" const args = process.argv.slice(2) +const httpMode = args.includes("--http") || args.includes("-h") const sseMode = args.includes("--sse") || args.includes("-s") async function main() { let server: McpServer | undefined - if (sseMode) { - server = await startSSEServer() + if (httpMode) { + server = await startHTTPServer() // Streamable HTTP only + } else if (sseMode) { + server = await startSSEServer() // SSE (deprecated) } else { server = await startStdioServer() } @@ -22,7 +26,7 @@ async function main() { } const handleShutdown = async () => { - await server.close() + await server?.close() process.exit(0) } // Handle process termination diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..319f252 --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,140 @@ +import "dotenv/config" + +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" +import cors from "cors" +import express from "express" +import type { Request, Response } from "express" +import { randomUUID } from "crypto" + +import Logger from "@/utils/logger" +import { startServer } from "./base" + +export const startHTTPServer = async () => { + try { + const app = express() + const server = startServer() + app.use(express.json()) + app.use(cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'Mcp-Session-Id', 'Accept'] + })) + + Logger.info(`Starting Streamable HTTP server with log level: ${Logger.getLevel()}`) + + const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} + const lastSeen: { [sessionId: string]: number } = {} + + // Touch session to track activity + const touch = (sid?: string) => { + if (sid) lastSeen[sid] = Date.now() + } + + // Clean up idle sessions every minute (15 min TTL) + setInterval(() => { + const now = Date.now() + for (const sid of Object.keys(transports)) { + if (now - (lastSeen[sid] || 0) > 15 * 60_000) { + Logger.info(`Cleaning up idle session ${sid}`) + transports[sid]?.close?.() + delete transports[sid] + delete lastSeen[sid] + } + } + }, 60_000) + + //============================================================================= + // STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) + //============================================================================= + app.all("/mcp", async (req: Request, res: Response) => { + + if (!["GET", "POST", "OPTIONS"].includes(req.method)) { + res.setHeader("Allow", "GET, POST, OPTIONS") + return res.status(405).end() + } + + const accept = String(req.headers["accept"] || "") + if (!accept.includes("application/json") && !accept.includes("text/event-stream") && !accept.includes("*/*")) { + return res.status(406).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Not Acceptable: Include application/json or text/event-stream in Accept header' + }, + id: null + }) + } + + Logger.debug(`Received ${req.method} request to /mcp`) + + try { + const sessionId = req.headers['mcp-session-id'] as string | undefined + let transport: StreamableHTTPServerTransport | undefined + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId] + touch(sessionId) + } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId: string) => { + Logger.info(`StreamableHTTP session initialized with ID: ${newSessionId}`) + transports[newSessionId] = transport! + touch(newSessionId) + } + }) + + transport.onclose = () => { + const sid = transport!.sessionId + if (sid && transports[sid]) { + Logger.info(`Transport closed for session ${sid}`) + delete transports[sid] + delete lastSeen[sid] + } + } + + await server.connect(transport) + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided or not an initialize request' + }, + id: null + }) + return + } + + await transport!.handleRequest(req, res, req.body) + touch(sessionId) + } catch (error) { + Logger.error('Error handling MCP request:', error) + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }) + } + } + }) + + const PORT = process.env.PORT || 3001 + app.listen(PORT, () => { + Logger.info( + `BNBChain MCP Server (Streamable HTTP) is running on http://localhost:${PORT}` + ) + Logger.info( + `Endpoint: http://localhost:${PORT}/mcp` + ) + }) + return server + } catch (error) { + Logger.error("Error starting BNBChain MCP Server:", error) + } +} diff --git a/src/server/sse.ts b/src/server/sse.ts index 39da472..bda9f58 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -15,12 +15,15 @@ export const startSSEServer = async () => { app.use(cors()) // Log the current log level on startup - Logger.info(`Starting sse server with log level: ${Logger.getLevel()}`) + Logger.info(`Starting SSE server with log level: ${Logger.getLevel()}`) // to support multiple simultaneous connections we have a lookup object from // sessionId to transport const transports: { [sessionId: string]: SSEServerTransport } = {} + //============================================================================= + // DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) + //============================================================================= app.get("/sse", async (_: Request, res: Response) => { const transport = new SSEServerTransport("/messages", res) transports[transport.sessionId] = transport @@ -66,6 +69,9 @@ export const startSSEServer = async () => { Logger.info( `BNBChain MCP SSE Server is running on http://localhost:${PORT}` ) + Logger.info( + `SSE endpoint (deprecated): http://localhost:${PORT}/sse` + ) }) return server } catch (error) {