diff --git a/index.ts b/index.ts index edf7d0b..921013b 100755 --- a/index.ts +++ b/index.ts @@ -9,8 +9,6 @@ import { join } from 'path' import { readFileSync } from 'fs' import { tmpdir } from 'os' import { createServer } from 'http' -import { randomUUID } from 'crypto' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' const __dirname = import.meta.dirname @@ -67,8 +65,7 @@ async function getApiKeyInteractively (): Promise { // Initialize API key let SOCKET_API_KEY = process.env['SOCKET_API_KEY'] || '' -// Transport management -const transports: Record = {} +// No session management: each HTTP request is handled statelessly // Create server instance const server = new McpServer({ @@ -246,6 +243,9 @@ if (useHttp) { // HTTP mode with Server-Sent Events logger.info(`Starting HTTP server on port ${port}`) + // Singleton transport to preserve initialization state without explicit sessions + let httpTransport: StreamableHTTPServerTransport | null = null + const httpServer = createServer(async (req, res) => { // Validate Origin header as required by MCP spec const origin = req.headers.origin @@ -275,9 +275,8 @@ if (useHttp) { } else { res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000') } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Accept, Last-Event-ID') - res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id') + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept') if (req.method === 'OPTIONS') { res.writeHead(200) @@ -313,93 +312,38 @@ if (useHttp) { if (url.pathname === '/') { if (req.method === 'POST') { - // Validate Accept header as required by MCP spec - const acceptHeader = req.headers.accept - if (!acceptHeader || (!acceptHeader.includes('application/json') && !acceptHeader.includes('text/event-stream'))) { - logger.warn(`Invalid Accept header: ${acceptHeader}`) - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: Accept header must include application/json or text/event-stream' }, - id: null - })) - return - } - - // Handle JSON-RPC messages + // Handle JSON-RPC messages statelessly let body = '' req.on('data', chunk => (body += chunk)) req.on('end', async () => { try { const jsonData = JSON.parse(body) - const sessionId = req.headers['mcp-session-id'] as string - - // Validate session ID format if provided (must contain only visible ASCII characters) - if (sessionId && !/^[\x21-\x7E]+$/.test(sessionId)) { - logger.warn(`Invalid session ID format: ${sessionId}`) - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: Session ID must contain only visible ASCII characters' }, - id: jsonData.id || null - })) - return - } - let transport: StreamableHTTPServerTransport - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId] - } else if (!sessionId) { - // Create new session (either for initialize request or fallback) - const newSessionId = randomUUID() - const isInit = isInitializeRequest(jsonData) - - if (isInit) { - logger.info(`Creating new session for initialize request: ${newSessionId}`) - } else { - logger.warn(`Creating fallback session for non-initialize request: ${newSessionId}`) + // If this is an initialize, reset the singleton transport so clients can (re)initialize cleanly + if (jsonData && jsonData.method === 'initialize') { + if (httpTransport) { + try { httpTransport.close() } catch {} } - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => newSessionId, - onsessioninitialized: (id) => { - transports[id] = transport - logger.info(`Session initialized: ${id}`) - // Set session ID in response headers as required by MCP spec - res.setHeader('mcp-session-id', id) - } + httpTransport = new StreamableHTTPServerTransport({ + // Stateless mode: no session management required + sessionIdGenerator: undefined, + // Return JSON responses to avoid SSE streaming + enableJsonResponse: true }) - - transport.onclose = () => { - const sid = transport.sessionId - if (sid && transports[sid]) { - delete transports[sid] - logger.info(`Session closed: ${sid}`) - } - } - - await server.connect(transport) - await transport.handleRequest(req, res, jsonData) - return - } else { - // Invalid request - session ID provided but not found - logger.error(`Invalid session ID: ${sessionId}. Active sessions count: ${Object.keys(transports).length}`) - res.writeHead(400) - res.end(JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Invalid session ID. Please initialize a new session first.' - }, - id: jsonData.id || null - })) + await server.connect(httpTransport) + await httpTransport.handleRequest(req, res, jsonData) return } - // Handle request with existing transport - await transport.handleRequest(req, res, jsonData) + // For non-initialize requests, ensure transport exists (client should have initialized already) + if (!httpTransport) { + httpTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }) + await server.connect(httpTransport) + } + await httpTransport.handleRequest(req, res, jsonData) } catch (error) { logger.error(`Error processing POST request: ${error}`) if (!res.headersSent) { @@ -412,117 +356,6 @@ if (useHttp) { } } }) - } else if (req.method === 'GET') { - // Validate Accept header for SSE as required by MCP spec - const acceptHeader = req.headers.accept - if (!acceptHeader || !acceptHeader.includes('text/event-stream')) { - logger.warn(`GET request without text/event-stream Accept header: ${acceptHeader}`) - res.writeHead(405, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Method Not Allowed: GET requires Accept: text/event-stream' }, - id: null - })) - return - } - - // Handle SSE streams - const sessionId = req.headers['mcp-session-id'] as string - - // Validate session ID format - if (sessionId && !/^[\x21-\x7E]+$/.test(sessionId)) { - logger.warn(`Invalid session ID format in GET request: ${sessionId}`) - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: Session ID must contain only visible ASCII characters' }, - id: null - })) - return - } - - if (!sessionId || !transports[sessionId]) { - logger.warn(`SSE request with invalid session ID: ${sessionId}`) - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: Invalid or missing session ID for SSE stream' }, - id: null - })) - return - } - - // Check for Last-Event-ID header for resumability (optional MCP feature) - const lastEventId = req.headers['last-event-id'] as string - if (lastEventId) { - logger.info(`SSE resumability requested with Last-Event-ID: ${lastEventId}`) - // Note: Actual resumability implementation would require message storage - // For now, we log the request but don't implement full resumability - } - - logger.info(`Opening SSE stream for session: ${sessionId}`) - - // Prevent connection timeout and keep it alive - req.socket?.setTimeout(0) - req.socket?.setKeepAlive(true, 30000) - - let streamClosed = false - - // Handle client disconnection gracefully - req.on('close', () => { - streamClosed = true - logger.info(`Client disconnected SSE stream for session: ${sessionId}`) - }) - - req.on('aborted', () => { - streamClosed = true - logger.info(`Client aborted SSE stream for session: ${sessionId}`) - }) - - // Let the MCP transport handle the SSE stream completely - const transport = transports[sessionId] - - try { - await transport.handleRequest(req, res) - - // If the transport completes without the client disconnecting, - // it might have closed the stream prematurely. Keep it open with heartbeat. - if (!streamClosed && !res.destroyed) { - logger.info(`Transport completed, maintaining SSE stream for session: ${sessionId}`) - - // Send periodic heartbeat to keep connection alive - const heartbeat = setInterval(() => { - if (streamClosed || res.destroyed) { - clearInterval(heartbeat) - return - } - - try { - res.write(': heartbeat\n\n') - } catch (error) { - logger.error(error, `Heartbeat error for session ${sessionId}:`) - clearInterval(heartbeat) - } - }, 30000) - - // Clean up heartbeat when connection closes - req.on('close', () => clearInterval(heartbeat)) - res.on('close', () => clearInterval(heartbeat)) - } - } catch (error) { - logger.error(error, `SSE transport error for session ${sessionId}:`) - } - } else if (req.method === 'DELETE') { - // Handle session termination - const sessionId = req.headers['mcp-session-id'] as string - if (!sessionId || !transports[sessionId]) { - res.writeHead(400) - res.end('Invalid or missing session ID') - return - } - - const transport = transports[sessionId] - await transport.handleRequest(req, res) } else { res.writeHead(405) res.end('Method not allowed') diff --git a/mock-client/http-client.ts b/mock-client/http-client.ts index 8531441..fb6196d 100644 --- a/mock-client/http-client.ts +++ b/mock-client/http-client.ts @@ -21,14 +21,12 @@ async function parseResponse (response: any) { // Simple HTTP client for testing MCP server in HTTP mode async function testHTTPMode () { const baseUrl = (process.env['MCP_URL'] || 'http://localhost:3000').replace(/\/$/, '') // Remove trailing slash - const sessionId = `test-session-${Date.now()}` console.log('Testing Socket MCP in HTTP mode...') console.log(`Server URL: ${baseUrl}`) - console.log(`Session ID: ${sessionId}`) try { - // 1. Initialize connection + // 1. Initialize connection (stateless) console.log('\n1. Initializing connection...') const initRequest = { jsonrpc: '2.0', @@ -48,6 +46,7 @@ async function testHTTPMode () { method: 'POST', headers: { 'Content-Type': 'application/json', + // SDK requires Accept to include both types even if server returns JSON Accept: 'application/json, text/event-stream', 'User-Agent': 'socket-mcp-debug-client/1.0.0' }, @@ -57,10 +56,7 @@ async function testHTTPMode () { const initResult = await parseResponse(initResponse) console.log('Initialize response:', JSON.stringify(initResult, null, 2)) - // Extract session ID from response headers - const serverSessionId = initResponse.headers.get('mcp-session-id') - const actualSessionId = serverSessionId || sessionId - console.log('Session ID:', actualSessionId) + console.log('Initialized (stateless)') // 2. List tools console.log('\n2. Listing available tools...') @@ -75,14 +71,22 @@ async function testHTTPMode () { method: 'POST', headers: { 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': actualSessionId + Accept: 'application/json, text/event-stream' }, body: JSON.stringify(toolsRequest) }) const toolsResult = await parseResponse(toolsResponse) console.log('Available tools:', JSON.stringify(toolsResult, null, 2)) + // Assert that the 'depscore' tool exists in the toolsResult + if ( + !toolsResult || + !toolsResult.result || + !Array.isArray(toolsResult.result.tools) || + !toolsResult.result.tools.some((tool: any) => tool.name === 'depscore') + ) { + throw new Error('depscore tool not found in available tools') + } // 3. Call depscore console.log('\n3. Calling depscore tool...') @@ -106,8 +110,7 @@ async function testHTTPMode () { method: 'POST', headers: { 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': actualSessionId + Accept: 'application/json, text/event-stream' }, body: JSON.stringify(depscoreRequest) }) @@ -115,31 +118,7 @@ async function testHTTPMode () { const depscoreResult = await parseResponse(depscoreResponse) console.log('Depscore result:', JSON.stringify(depscoreResult, null, 2)) - // 4. Test SSE stream (optional) - console.log('\n4. Testing SSE stream connection...') - const sseResponse = await fetch(`${baseUrl}/`, { - method: 'GET', - headers: { - 'mcp-session-id': actualSessionId, - Accept: 'text/event-stream' - } - }) - - if (sseResponse.ok) { - console.log('SSE stream connected successfully') - // Note: In a real implementation, you'd parse the SSE stream - } - - // 5. Clean up session - console.log('\n5. Cleaning up session...') - const cleanupResponse = await fetch(`${baseUrl}/`, { - method: 'DELETE', - headers: { - 'mcp-session-id': actualSessionId - } - }) - - console.log('Session cleanup:', cleanupResponse.status === 200 ? 'Success' : 'Failed') + console.log('\n4. HTTP mode test complete (no sessions)') } catch (error) { console.error('Error:', error) } diff --git a/package-lock.json b/package-lock.json index 42d7a68..1884154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@socketsecurity/mcp", "version": "0.0.11", "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.3", + "@modelcontextprotocol/sdk": "^1.18.0", "pino": "^9.7.0", "pino-pretty": "^13.0.0", "semver": "^7.7.2", diff --git a/package.json b/package.json index ee2afa2..8ed257f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/mcp", - "version": "0.0.11", + "version": "0.0.12", "type": "module", "main": "./index.js", "bin": { @@ -28,8 +28,8 @@ "debug-stdio": "node --experimental-strip-types ./mock-client/debug-client.ts", "debug-sdk": "node --experimental-strip-types ./mock-client/stdio-client.ts", "debug-http": "node --experimental-strip-types ./mock-client/http-client.ts", - "server-stdio": "SOCKET_API_KEY=${SOCKET_API_KEY} --experimental-strip-types ./index.ts", - "server-http": "MCP_HTTP_MODE=true SOCKET_API_KEY=${SOCKET_API_KEY} ./build/index.js" + "server-stdio": "SOCKET_API_KEY=${SOCKET_API_KEY} node --experimental-strip-types ./index.ts", + "server-http": "MCP_HTTP_MODE=true SOCKET_API_KEY=${SOCKET_API_KEY} node --experimental-strip-types ./index.ts" }, "keywords": [], "files": [ @@ -46,19 +46,19 @@ "url": "https://github.com/SocketDev/socket-mcp" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.11.3", + "@modelcontextprotocol/sdk": "^1.18.0", "pino": "^9.7.0", "pino-pretty": "^13.0.0", "semver": "^7.7.2", "zod": "^3.24.4" }, "devDependencies": { - "neostandard": "^0.12.0", "@anthropic-ai/dxt": "^0.2.0", "@types/node": "^24.0.7", "@types/semver": "^7.7.0", "@types/triple-beam": "^1.3.5", "c8": "^10.0.0", + "neostandard": "^0.12.0", "npm-run-all2": "^8.0.1", "typescript": "~5.9.2" } diff --git a/test.ts b/test.ts index 238fcac..a0848ad 100644 --- a/test.ts +++ b/test.ts @@ -7,7 +7,7 @@ import { join } from 'path' test('Socket MCP Server', async (t) => { const apiKey = process.env['SOCKET_API_KEY'] - assert.ok(apiKey, 'We have an API key. Tests will not pass without it') + assert.ok(apiKey, 'We need an API key. Tests will not pass without it') const serverPath = join(import.meta.dirname, 'index.ts') const transport = new StdioClientTransport({