diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a96661 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +build +logs +.git +.github +coverage +npm-debug.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005b121..a83d0c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,17 @@ name: CI on: push: - branches: [main] pull_request: - branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - test: + lint-test: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository @@ -18,6 +22,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20.x" + cache: npm - name: Install dependencies run: npm ci @@ -28,5 +33,45 @@ jobs: - name: Run tests run: npm test - - name: Build project - run: npm run build + build-and-push: + needs: lint-test + if: github.event_name == 'push' && github.ref_type == 'tag' && (startsWith(github.ref_name, 'test-v') || startsWith(github.ref_name, 'v')) + runs-on: ubuntu-latest + permissions: + contents: read + env: + IMAGE_TAG: ${{ startsWith(github.ref_name, 'test-v') && 'test' || github.ref_name }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: bankofai/mcp-server-tron + tags: | + type=raw,value=${{ env.IMAGE_TAG }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b4022..6b6c4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [1.1.6] - 2026-03-18 + +### Changed + +- Switched HTTP MCP to stateless Streamable HTTP to avoid `mcp-session-id` issues across multiple instances. +- Added Docker support with `docker-start.sh`, `Dockerfile`, and a GitHub Actions Docker build workflow. +- Container logs now write to local `logs/` files with date-based names prefixed by `mcp-server-tron`. + ## [1.1.5] - 2026-03-13 ### Changed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fce2214 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-bookworm-slim AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20-bookworm-slim AS runtime + +WORKDIR /app + +RUN groupmod -n ec2-user node && \ + usermod -l ec2-user node + +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=builder /app/build ./build +COPY docker-start.sh ./docker-start.sh + +RUN mkdir -p /app/logs \ + && chown -R ec2-user:ec2-user /app \ + && chmod +x /app/docker-start.sh + +USER ec2-user + +ENV NODE_ENV=production +ENV MCP_HOST=0.0.0.0 +ENV MCP_PORT=3001 +ENV MCP_LOG_DIR=/app/logs + +EXPOSE 3001 + +ENTRYPOINT ["./docker-start.sh"] diff --git a/README.md b/README.md index 4880ffb..15e08d5 100644 --- a/README.md +++ b/README.md @@ -184,10 +184,36 @@ npm start # Start in readonly mode (disables write tools) npm start -- --readonly -# Start in HTTP mode (Streamable HTTP) +# Start in stateless HTTP mode (Streamable HTTP) npm run start:http ``` +### Docker + +Build the image: + +```bash +docker build -t mcp-server-tron:test . +``` + +Run the container with local logs mounted: + +```bash +docker run -d \ + --name mcp-tron \ + -p 3001:3001 \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=3001 \ + -e MCP_LOG_DIR=/app/logs \ + -v "$(pwd)/logs:/app/logs" \ + mcp-server-tron:test +``` + +Docker logs are written to the mounted `logs/` directory and are named by date, for example: + +- `logs/mcp-server-tron-2026-03-18-combined.log` +- `logs/mcp-server-tron-2026-03-18-error.log` + ### Testing The project includes a comprehensive test suite with unit tests and integration tests (using the Nile testnet). @@ -248,7 +274,7 @@ claude mcp add -e AGENT_WALLET_PASSWORD=xxx -e TRONGRID_API_KEY=xxx mcp-server-t #### Option B: Official Hosted Server (Remote) -Connect to the official hosted server at `https://tron-mcp-server.bankofai.io`. No installation required, readonly mode. +Connect to the official hosted server at `https://tron-mcp-server.bankofai.io`. No installation required, readonly mode, stateless HTTP. **Claude Code:** diff --git a/docker-start.sh b/docker-start.sh new file mode 100644 index 0000000..65c79d8 --- /dev/null +++ b/docker-start.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +set -eu + +APP_DIR="/app" +LOG_DIR="${MCP_LOG_DIR:-${APP_DIR}/logs}" +DATE_TAG="$(date +%F)" +PROJECT_NAME="mcp-server-tron" +COMBINED_LOG="${LOG_DIR}/${PROJECT_NAME}-${DATE_TAG}-combined.log" +ERROR_LOG="${LOG_DIR}/${PROJECT_NAME}-${DATE_TAG}-error.log" + +mkdir -p "${LOG_DIR}" +touch "${COMBINED_LOG}" "${ERROR_LOG}" + +exec node build/server/http-server.js --readonly >>"${COMBINED_LOG}" 2>>"${ERROR_LOG}" diff --git a/package.json b/package.json index 861ab92..79a7ccd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "mcpName": "io.github.bankofai/mcp-server-tron", "module": "src/index.ts", "type": "module", - "version": "1.1.5", + "version": "1.1.6", "description": "MCP server for TRON blockchain. Supports TRX/TRC20 transfers, smart contracts, and AI prompts.", "bin": { "mcp-server-tron": "./bin/cli.js" @@ -89,4 +89,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/src/server/http-app.ts b/src/server/http-app.ts index d4c1f8c..735bf8e 100644 --- a/src/server/http-app.ts +++ b/src/server/http-app.ts @@ -1,14 +1,7 @@ -import { randomUUID } from "node:crypto"; import express, { Request, Response } from "express"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import startServer, { MCP_PROTOCOL_VERSION, version } from "./server.js"; -type SessionContext = { - server: McpServer; - transport: StreamableHTTPServerTransport; -}; - export type HttpAppContext = { app: express.Express; shutdown: () => Promise; @@ -19,134 +12,47 @@ export function createHttpApp(options: { readOnly?: boolean } = {}): HttpAppCont const app = express(); app.use(express.json({ limit: "10mb" })); // Prevent DoS attacks with huge payloads - const sessions = new Map(); - const sessionTimestamps = new Map(); - const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - - const cleanupExpiredSessions = () => { - 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 session = sessions.get(sessionId); - if (session) { - session.transport - .close() - .catch((err) => console.error(`Error closing stale session ${sessionId}:`, err)); - } - sessions.delete(sessionId); - sessionTimestamps.delete(sessionId); - } - } - }; - - const cleanupInterval = setInterval(cleanupExpiredSessions, 5 * 60 * 1000); - cleanupInterval.unref?.(); - - async function createSessionContext(): Promise { - const server = await startServer({ readOnly: options.readOnly }); + app.post("/mcp", async (req: Request, res: Response) => { + console.log(`Received POST /mcp request from ${req.ip}`); + let server: Awaited> | undefined; const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - console.error(`Session initialized: ${newSessionId}`); - sessions.set(newSessionId, { server, transport }); - sessionTimestamps.set(newSessionId, Date.now()); - }, - onsessionclosed: (closedSessionId) => { - console.error(`Session closed: ${closedSessionId}`); - sessions.delete(closedSessionId); - sessionTimestamps.delete(closedSessionId); - }, + sessionIdGenerator: undefined, }); - await server.connect(transport); - console.error("New transport connected to server"); - - return { server, transport }; - } - - app.post("/mcp", async (req: Request, res: Response) => { - console.error(`Received POST /mcp request from ${req.ip}`); - - const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && sessions.has(sessionId)) { - transport = sessions.get(sessionId)!.transport; - sessionTimestamps.set(sessionId, Date.now()); - console.error(`Reusing transport for session: ${sessionId}`); - } else if (!sessionId) { - ({ transport } = await createSessionContext()); - } else { - console.error(`Invalid session ID: ${sessionId}`); - res.status(404).json({ error: "Session not found" }); - return; - } - try { + server = await startServer({ readOnly: options.readOnly }); + await server.connect(transport); 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" }); } - } - }); - - app.get("/mcp", async (req: Request, res: Response) => { - console.error(`Received GET /mcp request from ${req.ip}`); - - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (!sessionId) { - res.setHeader("Allow", "POST, DELETE"); - res.status(405).json({ error: "Session ID required for GET" }); - return; - } - - if (!sessions.has(sessionId)) { - res.status(404).json({ error: "Session not found" }); - return; - } - - const transport = sessions.get(sessionId)!.transport; - sessionTimestamps.set(sessionId, Date.now()); - - 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" }); + } finally { + if (server) { + await server.close().catch((closeError) => { + console.error(`Error closing stateless server: ${closeError}`); + }); } } }); - app.delete("/mcp", async (req: Request, res: Response) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - - if (!sessionId || !sessions.has(sessionId)) { - res.status(404).json({ error: "Session not found" }); - return; - } - - const transport = sessions.get(sessionId)!.transport; + app.get("/mcp", async (_req: Request, res: Response) => { + res.setHeader("Allow", "POST"); + res.status(405).json({ error: "GET not supported for stateless Streamable HTTP" }); + }); - 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" }); - } - } + app.delete("/mcp", async (_req: Request, res: Response) => { + res.setHeader("Allow", "POST"); + res.status(405).json({ error: "DELETE not supported for stateless Streamable HTTP" }); }); app.get("/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok", server: "ready", - activeSessions: sessions.size, + mode: "stateless", + activeSessions: 0, }); }); @@ -156,28 +62,19 @@ export function createHttpApp(options: { readOnly?: boolean } = {}): HttpAppCont version, protocol: `MCP ${MCP_PROTOCOL_VERSION}`, transport: "Streamable HTTP", + mode: "stateless", endpoints: { mcp: "/mcp", health: "/health", }, status: "ready", - activeSessions: sessions.size, + activeSessions: 0, }); }); - const shutdown = async () => { - clearInterval(cleanupInterval); - for (const [sessionId, session] of sessions) { - console.error(`Closing transport for session: ${sessionId}`); - await session.transport.close(); - } - sessions.clear(); - sessionTimestamps.clear(); - }; - return { app, - shutdown, - getActiveSessionCount: () => sessions.size, + shutdown: async () => {}, + getActiveSessionCount: () => 0, }; } diff --git a/src/server/http-server.ts b/src/server/http-server.ts index 3b43631..4718574 100644 --- a/src/server/http-server.ts +++ b/src/server/http-server.ts @@ -5,20 +5,20 @@ import { MCP_PROTOCOL_VERSION } from "./server.js"; const PORT = parseInt(process.env.MCP_PORT || "3001", 10); const HOST = process.env.MCP_HOST || "0.0.0.0"; -console.error(`Configured to listen on ${HOST}:${PORT}`); +console.log(`Configured to listen on ${HOST}:${PORT}`); const isReadOnly = process.argv.includes("--readonly") || process.argv.includes("-r"); const { app, shutdown } = createHttpApp({ readOnly: isReadOnly }); // Handle process termination gracefully process.on("SIGINT", async () => { - console.error("Shutting down server..."); + console.log("Shutting down server..."); await shutdown(); process.exit(0); }); process.on("SIGTERM", async () => { - console.error("Received SIGTERM, shutting down..."); + console.log("Received SIGTERM, shutting down..."); await shutdown(); process.exit(0); }); @@ -26,10 +26,10 @@ process.on("SIGTERM", async () => { // Start the HTTP server const httpServer = app .listen(PORT, HOST, () => { - console.error(`mcp-server-tron 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 ${MCP_PROTOCOL_VERSION} (Streamable HTTP)`); + console.log(`mcp-server-tron running at http://${HOST}:${PORT}`); + console.log(`MCP endpoint: http://${HOST}:${PORT}/mcp`); + console.log(`Health check: http://${HOST}:${PORT}/health`); + console.log(`Protocol: MCP ${MCP_PROTOCOL_VERSION} (Streamable HTTP)`); }) .on("error", (err: Error) => { console.error(`Server error: ${err}`); diff --git a/src/server/server.ts b/src/server/server.ts index 5ff132b..6e613d8 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -54,7 +54,7 @@ async function startServer(options: { readOnly?: boolean } = {}) { return server; } catch (error) { console.error("Failed to initialize server:", error); - process.exit(1); + throw error; } } diff --git a/tests/integration_stdio.ts b/tests/integration_stdio.ts index 81bd2ce..0706fed 100644 --- a/tests/integration_stdio.ts +++ b/tests/integration_stdio.ts @@ -36,6 +36,7 @@ async function runIntegrationTest() { }; let buffer = ""; + const badStdoutLines: string[] = []; // Helper to wait for a specific response const waitForResponse = (id: string | number): Promise => { @@ -54,7 +55,7 @@ async function runIntegrationTest() { resolve(json); } } catch (_e) { - // Ignore non-JSON lines (logs) + badStdoutLines.push(line); } } }; @@ -129,6 +130,10 @@ async function runIntegrationTest() { const callRes = await callPromise; console.log("✅ Tool Result:", JSON.parse(callRes.result.content[0].text)); + if (badStdoutLines.length > 0) { + throw new Error(`Non-JSON stdout detected: ${badStdoutLines.join(" | ")}`); + } + console.log("🎉 Integration Test Passed!"); } catch (error) { console.error("❌ Test Failed:", error); diff --git a/tests/server/http-app.test.ts b/tests/server/http-app.test.ts new file mode 100644 index 0000000..4bddf04 --- /dev/null +++ b/tests/server/http-app.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/server/server.js", () => ({ + default: vi.fn().mockRejectedValue(new Error("init boom")), + MCP_PROTOCOL_VERSION: "2025-11-25", + version: "1.1.6", +})); + +describe("createHttpApp", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns 500 when stateless initialization fails", async () => { + const { createHttpApp } = await import("../../src/server/http-app.js"); + const { app } = createHttpApp({ readOnly: true }); + const routeLayer = (app as any)._router.stack.find( + (layer: any) => layer.route?.path === "/mcp" && layer.route?.methods?.post, + ); + + expect(routeLayer).toBeDefined(); + const handler = routeLayer.route.stack[0].handle as (req: any, res: any) => Promise; + + const res: any = { + headersSent: false, + statusCode: 200, + body: undefined, + headers: new Map(), + setHeader(name: string, value: string) { + this.headers.set(name, value); + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + this.headersSent = true; + return this; + }, + }; + + await handler({ ip: "127.0.0.1" }, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "Internal server error" }); + }); +}); diff --git a/tests/server/start-server.test.ts b/tests/server/start-server.test.ts new file mode 100644 index 0000000..34a9127 --- /dev/null +++ b/tests/server/start-server.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/resources.js", () => ({ + registerTRONResources: vi.fn(() => { + throw new Error("resource boom"); + }), +})); + +vi.mock("../../src/core/tools/index.js", () => ({ + registerTRONTools: vi.fn(), +})); + +vi.mock("../../src/core/prompts.js", () => ({ + registerTRONPrompts: vi.fn(), +})); + +describe("startServer", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("throws on initialization failures without exiting the process", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as any); + const { default: startServer } = await import("../../src/server/server.js"); + + await expect(startServer({ readOnly: true })).rejects.toThrow("resource boom"); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/server/stdio-logging.test.ts b/tests/server/stdio-logging.test.ts new file mode 100644 index 0000000..4a06f52 --- /dev/null +++ b/tests/server/stdio-logging.test.ts @@ -0,0 +1,165 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { afterEach, describe, expect, it } from "vitest"; +import { MCP_PROTOCOL_VERSION } from "../../src/server/server.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverPath = join(__dirname, "../../src/index.ts"); + +type PendingResponse = { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + timer: NodeJS.Timeout; +}; + +function startStdioServer() { + const serverProcess = spawn(process.execPath, ["--import", "tsx", serverPath, "--readonly"], { + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const pendingResponses = new Map(); + const badStdoutLines: string[] = []; + let buffer = ""; + let processExitError: Error | null = null; + + const settleResponse = (id: number, value: unknown) => { + const pending = pendingResponses.get(id); + if (!pending) { + return; + } + + clearTimeout(pending.timer); + pendingResponses.delete(id); + pending.resolve(value); + }; + + const onStdoutData = (data: Buffer) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + try { + const parsed = JSON.parse(trimmed) as { id?: number }; + if (typeof parsed.id === "number") { + settleResponse(parsed.id, parsed); + } + } catch (_error) { + badStdoutLines.push(trimmed); + } + } + }; + + serverProcess.stdout.on("data", onStdoutData); + serverProcess.stderr.on("data", () => { + // Drain stderr so the child never blocks on backpressure. + }); + serverProcess.once("error", (error) => { + processExitError = error instanceof Error ? error : new Error(String(error)); + for (const pending of pendingResponses.values()) { + clearTimeout(pending.timer); + pending.reject(processExitError); + } + pendingResponses.clear(); + }); + serverProcess.once("exit", (code, signal) => { + if (code === 0) { + return; + } + + processExitError = new Error( + `stdio server exited unexpectedly: code=${code}, signal=${signal ?? "null"}`, + ); + for (const pending of pendingResponses.values()) { + clearTimeout(pending.timer); + pending.reject(processExitError); + } + pendingResponses.clear(); + }); + + const send = (message: Record) => { + serverProcess.stdin.write(`${JSON.stringify(message)}\n`); + }; + + const waitForResponse = (id: number, timeoutMs = 5000) => + new Promise((resolve, reject) => { + if (processExitError) { + reject(processExitError); + return; + } + + const timer = setTimeout(() => { + pendingResponses.delete(id); + reject(new Error(`Timeout waiting for response to ${id}`)); + }, timeoutMs); + + pendingResponses.set(id, { resolve, reject, timer }); + }); + + return { + badStdoutLines, + send, + serverProcess, + waitForResponse, + }; +} + +describe("stdio logging", () => { + const startedProcesses: ChildProcessWithoutNullStreams[] = []; + + afterEach(() => { + while (startedProcesses.length > 0) { + const child = startedProcesses.pop(); + child?.kill(); + } + }); + + it("keeps human-readable startup logs off stdout", async () => { + const { badStdoutLines, send, serverProcess, waitForResponse } = startStdioServer(); + startedProcesses.push(serverProcess); + + const initPromise = waitForResponse(1); + send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: "vitest-stdio-client", + version: "1.0.0", + }, + }, + }); + + const initResponse = (await initPromise) as { + result: { protocolVersion: string }; + }; + expect(initResponse.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION); + + const toolsPromise = waitForResponse(2); + send({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + + const toolsResponse = (await toolsPromise) as { + result: { tools: Array<{ name: string }> }; + }; + expect(toolsResponse.result.tools.length).toBeGreaterThan(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(badStdoutLines).toEqual([]); + }); +}); diff --git a/tests/server/streamable-http-session.test.ts b/tests/server/streamable-http-session.test.ts index bfa2270..6b37881 100644 --- a/tests/server/streamable-http-session.test.ts +++ b/tests/server/streamable-http-session.test.ts @@ -37,93 +37,29 @@ async function readFirstSseMessage(response: Response): Promise { throw new Error("No SSE message received"); } -async function initializeTransport(transport: WebStandardStreamableHTTPServerTransport) { - const response = await transport.handleRequest( - new Request("https://example.test/mcp", { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: "vitest-client", - version: "1.0.0", - }, - }, - }), - }), - ); - - expect(response.status).toBe(200); - - const sessionId = response.headers.get("mcp-session-id"); - expect(sessionId).toBeTruthy(); - - const payload = (await readFirstSseMessage(response)) as { result: { protocolVersion: string } }; - expect(payload.result.protocolVersion).toBe(PROTOCOL_VERSION); - - return sessionId!; -} - -async function sendInitializedNotification( - transport: WebStandardStreamableHTTPServerTransport, - sessionId: string, -) { - const response = await transport.handleRequest( - new Request("https://example.test/mcp", { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": sessionId, - "mcp-protocol-version": PROTOCOL_VERSION, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "notifications/initialized", - params: {}, - }), - }), - ); +async function sendStatelessRequest(body: Record) { + const server = await startServer({ readOnly: true }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); - expect(response.status).toBe(202); -} + await server.connect(transport); -async function sendPing( - transport: WebStandardStreamableHTTPServerTransport, - sessionId: string, - id: number, -) { const response = await transport.handleRequest( new Request("https://example.test/mcp", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", - "mcp-session-id": sessionId, - "mcp-protocol-version": PROTOCOL_VERSION, }, - body: JSON.stringify({ - jsonrpc: "2.0", - id, - method: "ping", - params: {}, - }), + body: JSON.stringify(body), }), ); - expect(response.status).toBe(200); - return (await readFirstSseMessage(response)) as { id: number; result: Record }; + return { server, response }; } -describe("Streamable HTTP session lifecycle", () => { +describe("Streamable HTTP stateless lifecycle", () => { const serversToClose: Awaited>[] = []; afterEach(async () => { @@ -133,68 +69,99 @@ describe("Streamable HTTP session lifecycle", () => { } }); - it("rejects connecting a second transport to the same MCP server", async () => { - const server = await startServer({ readOnly: true }); + it("allows initialize without returning an MCP session id", async () => { + const { server, response } = await sendStatelessRequest({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: "vitest-client", + version: "1.0.0", + }, + }, + }); serversToClose.push(server); - const firstTransport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => "session-a", - }); - const secondTransport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => "session-b", + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toBeNull(); + + const payload = (await readFirstSseMessage(response)) as { + result: { protocolVersion: string }; + }; + expect(payload.result.protocolVersion).toBe(PROTOCOL_VERSION); + }); + + it("handles tools/list as an independent stateless request", async () => { + const { server, response } = await sendStatelessRequest({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, }); + serversToClose.push(server); - await server.connect(firstTransport); + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toBeNull(); - await expect(server.connect(secondTransport)).rejects.toThrow( - "Already connected to a transport", - ); + const payload = (await readFirstSseMessage(response)) as { + id: number; + result: { tools: Array<{ name: string }> }; + }; + expect(payload.id).toBe(2); + expect(payload.result.tools.length).toBeGreaterThan(0); }); - it("keeps a session usable after initialize", async () => { + it("does not allow reusing a stateless transport across requests", async () => { const server = await startServer({ readOnly: true }); serversToClose.push(server); const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => "session-a", + sessionIdGenerator: undefined, }); await server.connect(transport); - const sessionId = await initializeTransport(transport); - await sendInitializedNotification(transport, sessionId); - - const pingResponse = await sendPing(transport, sessionId, 2); - - expect(pingResponse.id).toBe(2); - expect(pingResponse.result).toEqual({}); - }); - - it("supports multiple independent sessions when each has its own server", async () => { - const serverA = await startServer({ readOnly: true }); - const serverB = await startServer({ readOnly: true }); - serversToClose.push(serverA, serverB); - - const transportA = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => "session-a", - }); - const transportB = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => "session-b", - }); - - await serverA.connect(transportA); - await serverB.connect(transportB); - - const sessionA = await initializeTransport(transportA); - const sessionB = await initializeTransport(transportB); - - await sendInitializedNotification(transportA, sessionA); - await sendInitializedNotification(transportB, sessionB); + const firstResponse = await transport.handleRequest( + new Request("https://example.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "vitest-client", version: "1.0.0" }, + }, + }), + }), + ); - const pingA = await sendPing(transportA, sessionA, 11); - const pingB = await sendPing(transportB, sessionB, 22); + expect(firstResponse.status).toBe(200); - expect(pingA.id).toBe(11); - expect(pingB.id).toBe(22); + await expect( + transport.handleRequest( + new Request("https://example.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "ping", + params: {}, + }), + }), + ), + ).rejects.toThrow("Stateless transport cannot be reused across requests"); }); });