Skip to content

Commit e70a902

Browse files
authored
Merge pull request #74 from SocketDev/no-sessions
simplify the code by making it stateless
2 parents 7edb88f + 4839a52 commit e70a902

File tree

5 files changed

+49
-237
lines changed

5 files changed

+49
-237
lines changed

index.ts

Lines changed: 27 additions & 194 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import { join } from 'path'
99
import { readFileSync } from 'fs'
1010
import { tmpdir } from 'os'
1111
import { createServer } from 'http'
12-
import { randomUUID } from 'crypto'
13-
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
1412

1513
const __dirname = import.meta.dirname
1614

@@ -67,8 +65,7 @@ async function getApiKeyInteractively (): Promise<string> {
6765
// Initialize API key
6866
let SOCKET_API_KEY = process.env['SOCKET_API_KEY'] || ''
6967

70-
// Transport management
71-
const transports: Record<string, StreamableHTTPServerTransport> = {}
68+
// No session management: each HTTP request is handled statelessly
7269

7370
// Create server instance
7471
const server = new McpServer({
@@ -246,6 +243,9 @@ if (useHttp) {
246243
// HTTP mode with Server-Sent Events
247244
logger.info(`Starting HTTP server on port ${port}`)
248245

246+
// Singleton transport to preserve initialization state without explicit sessions
247+
let httpTransport: StreamableHTTPServerTransport | null = null
248+
249249
const httpServer = createServer(async (req, res) => {
250250
// Validate Origin header as required by MCP spec
251251
const origin = req.headers.origin
@@ -275,9 +275,8 @@ if (useHttp) {
275275
} else {
276276
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3000')
277277
}
278-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
279-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Accept, Last-Event-ID')
280-
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id')
278+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
279+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept')
281280

282281
if (req.method === 'OPTIONS') {
283282
res.writeHead(200)
@@ -313,93 +312,38 @@ if (useHttp) {
313312

314313
if (url.pathname === '/') {
315314
if (req.method === 'POST') {
316-
// Validate Accept header as required by MCP spec
317-
const acceptHeader = req.headers.accept
318-
if (!acceptHeader || (!acceptHeader.includes('application/json') && !acceptHeader.includes('text/event-stream'))) {
319-
logger.warn(`Invalid Accept header: ${acceptHeader}`)
320-
res.writeHead(400, { 'Content-Type': 'application/json' })
321-
res.end(JSON.stringify({
322-
jsonrpc: '2.0',
323-
error: { code: -32000, message: 'Bad Request: Accept header must include application/json or text/event-stream' },
324-
id: null
325-
}))
326-
return
327-
}
328-
329-
// Handle JSON-RPC messages
315+
// Handle JSON-RPC messages statelessly
330316
let body = ''
331317
req.on('data', chunk => (body += chunk))
332318
req.on('end', async () => {
333319
try {
334320
const jsonData = JSON.parse(body)
335-
const sessionId = req.headers['mcp-session-id'] as string
336-
337-
// Validate session ID format if provided (must contain only visible ASCII characters)
338-
if (sessionId && !/^[\x21-\x7E]+$/.test(sessionId)) {
339-
logger.warn(`Invalid session ID format: ${sessionId}`)
340-
res.writeHead(400, { 'Content-Type': 'application/json' })
341-
res.end(JSON.stringify({
342-
jsonrpc: '2.0',
343-
error: { code: -32000, message: 'Bad Request: Session ID must contain only visible ASCII characters' },
344-
id: jsonData.id || null
345-
}))
346-
return
347-
}
348321

349-
let transport: StreamableHTTPServerTransport
350-
351-
if (sessionId && transports[sessionId]) {
352-
// Reuse existing transport
353-
transport = transports[sessionId]
354-
} else if (!sessionId) {
355-
// Create new session (either for initialize request or fallback)
356-
const newSessionId = randomUUID()
357-
const isInit = isInitializeRequest(jsonData)
358-
359-
if (isInit) {
360-
logger.info(`Creating new session for initialize request: ${newSessionId}`)
361-
} else {
362-
logger.warn(`Creating fallback session for non-initialize request: ${newSessionId}`)
322+
// If this is an initialize, reset the singleton transport so clients can (re)initialize cleanly
323+
if (jsonData && jsonData.method === 'initialize') {
324+
if (httpTransport) {
325+
try { httpTransport.close() } catch {}
363326
}
364-
365-
transport = new StreamableHTTPServerTransport({
366-
sessionIdGenerator: () => newSessionId,
367-
onsessioninitialized: (id) => {
368-
transports[id] = transport
369-
logger.info(`Session initialized: ${id}`)
370-
// Set session ID in response headers as required by MCP spec
371-
res.setHeader('mcp-session-id', id)
372-
}
327+
httpTransport = new StreamableHTTPServerTransport({
328+
// Stateless mode: no session management required
329+
sessionIdGenerator: undefined,
330+
// Return JSON responses to avoid SSE streaming
331+
enableJsonResponse: true
373332
})
374-
375-
transport.onclose = () => {
376-
const sid = transport.sessionId
377-
if (sid && transports[sid]) {
378-
delete transports[sid]
379-
logger.info(`Session closed: ${sid}`)
380-
}
381-
}
382-
383-
await server.connect(transport)
384-
await transport.handleRequest(req, res, jsonData)
385-
return
386-
} else {
387-
// Invalid request - session ID provided but not found
388-
logger.error(`Invalid session ID: ${sessionId}. Active sessions count: ${Object.keys(transports).length}`)
389-
res.writeHead(400)
390-
res.end(JSON.stringify({
391-
jsonrpc: '2.0',
392-
error: {
393-
code: -32000,
394-
message: 'Bad Request: Invalid session ID. Please initialize a new session first.'
395-
},
396-
id: jsonData.id || null
397-
}))
333+
await server.connect(httpTransport)
334+
await httpTransport.handleRequest(req, res, jsonData)
398335
return
399336
}
400337

401-
// Handle request with existing transport
402-
await transport.handleRequest(req, res, jsonData)
338+
// For non-initialize requests, ensure transport exists (client should have initialized already)
339+
if (!httpTransport) {
340+
httpTransport = new StreamableHTTPServerTransport({
341+
sessionIdGenerator: undefined,
342+
enableJsonResponse: true
343+
})
344+
await server.connect(httpTransport)
345+
}
346+
await httpTransport.handleRequest(req, res, jsonData)
403347
} catch (error) {
404348
logger.error(`Error processing POST request: ${error}`)
405349
if (!res.headersSent) {
@@ -412,117 +356,6 @@ if (useHttp) {
412356
}
413357
}
414358
})
415-
} else if (req.method === 'GET') {
416-
// Validate Accept header for SSE as required by MCP spec
417-
const acceptHeader = req.headers.accept
418-
if (!acceptHeader || !acceptHeader.includes('text/event-stream')) {
419-
logger.warn(`GET request without text/event-stream Accept header: ${acceptHeader}`)
420-
res.writeHead(405, { 'Content-Type': 'application/json' })
421-
res.end(JSON.stringify({
422-
jsonrpc: '2.0',
423-
error: { code: -32000, message: 'Method Not Allowed: GET requires Accept: text/event-stream' },
424-
id: null
425-
}))
426-
return
427-
}
428-
429-
// Handle SSE streams
430-
const sessionId = req.headers['mcp-session-id'] as string
431-
432-
// Validate session ID format
433-
if (sessionId && !/^[\x21-\x7E]+$/.test(sessionId)) {
434-
logger.warn(`Invalid session ID format in GET request: ${sessionId}`)
435-
res.writeHead(400, { 'Content-Type': 'application/json' })
436-
res.end(JSON.stringify({
437-
jsonrpc: '2.0',
438-
error: { code: -32000, message: 'Bad Request: Session ID must contain only visible ASCII characters' },
439-
id: null
440-
}))
441-
return
442-
}
443-
444-
if (!sessionId || !transports[sessionId]) {
445-
logger.warn(`SSE request with invalid session ID: ${sessionId}`)
446-
res.writeHead(400, { 'Content-Type': 'application/json' })
447-
res.end(JSON.stringify({
448-
jsonrpc: '2.0',
449-
error: { code: -32000, message: 'Bad Request: Invalid or missing session ID for SSE stream' },
450-
id: null
451-
}))
452-
return
453-
}
454-
455-
// Check for Last-Event-ID header for resumability (optional MCP feature)
456-
const lastEventId = req.headers['last-event-id'] as string
457-
if (lastEventId) {
458-
logger.info(`SSE resumability requested with Last-Event-ID: ${lastEventId}`)
459-
// Note: Actual resumability implementation would require message storage
460-
// For now, we log the request but don't implement full resumability
461-
}
462-
463-
logger.info(`Opening SSE stream for session: ${sessionId}`)
464-
465-
// Prevent connection timeout and keep it alive
466-
req.socket?.setTimeout(0)
467-
req.socket?.setKeepAlive(true, 30000)
468-
469-
let streamClosed = false
470-
471-
// Handle client disconnection gracefully
472-
req.on('close', () => {
473-
streamClosed = true
474-
logger.info(`Client disconnected SSE stream for session: ${sessionId}`)
475-
})
476-
477-
req.on('aborted', () => {
478-
streamClosed = true
479-
logger.info(`Client aborted SSE stream for session: ${sessionId}`)
480-
})
481-
482-
// Let the MCP transport handle the SSE stream completely
483-
const transport = transports[sessionId]
484-
485-
try {
486-
await transport.handleRequest(req, res)
487-
488-
// If the transport completes without the client disconnecting,
489-
// it might have closed the stream prematurely. Keep it open with heartbeat.
490-
if (!streamClosed && !res.destroyed) {
491-
logger.info(`Transport completed, maintaining SSE stream for session: ${sessionId}`)
492-
493-
// Send periodic heartbeat to keep connection alive
494-
const heartbeat = setInterval(() => {
495-
if (streamClosed || res.destroyed) {
496-
clearInterval(heartbeat)
497-
return
498-
}
499-
500-
try {
501-
res.write(': heartbeat\n\n')
502-
} catch (error) {
503-
logger.error(error, `Heartbeat error for session ${sessionId}:`)
504-
clearInterval(heartbeat)
505-
}
506-
}, 30000)
507-
508-
// Clean up heartbeat when connection closes
509-
req.on('close', () => clearInterval(heartbeat))
510-
res.on('close', () => clearInterval(heartbeat))
511-
}
512-
} catch (error) {
513-
logger.error(error, `SSE transport error for session ${sessionId}:`)
514-
}
515-
} else if (req.method === 'DELETE') {
516-
// Handle session termination
517-
const sessionId = req.headers['mcp-session-id'] as string
518-
if (!sessionId || !transports[sessionId]) {
519-
res.writeHead(400)
520-
res.end('Invalid or missing session ID')
521-
return
522-
}
523-
524-
const transport = transports[sessionId]
525-
await transport.handleRequest(req, res)
526359
} else {
527360
res.writeHead(405)
528361
res.end('Method not allowed')

mock-client/http-client.ts

Lines changed: 15 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,12 @@ async function parseResponse (response: any) {
2121
// Simple HTTP client for testing MCP server in HTTP mode
2222
async function testHTTPMode () {
2323
const baseUrl = (process.env['MCP_URL'] || 'http://localhost:3000').replace(/\/$/, '') // Remove trailing slash
24-
const sessionId = `test-session-${Date.now()}`
2524

2625
console.log('Testing Socket MCP in HTTP mode...')
2726
console.log(`Server URL: ${baseUrl}`)
28-
console.log(`Session ID: ${sessionId}`)
2927

3028
try {
31-
// 1. Initialize connection
29+
// 1. Initialize connection (stateless)
3230
console.log('\n1. Initializing connection...')
3331
const initRequest = {
3432
jsonrpc: '2.0',
@@ -48,6 +46,7 @@ async function testHTTPMode () {
4846
method: 'POST',
4947
headers: {
5048
'Content-Type': 'application/json',
49+
// SDK requires Accept to include both types even if server returns JSON
5150
Accept: 'application/json, text/event-stream',
5251
'User-Agent': 'socket-mcp-debug-client/1.0.0'
5352
},
@@ -57,10 +56,7 @@ async function testHTTPMode () {
5756
const initResult = await parseResponse(initResponse)
5857
console.log('Initialize response:', JSON.stringify(initResult, null, 2))
5958

60-
// Extract session ID from response headers
61-
const serverSessionId = initResponse.headers.get('mcp-session-id')
62-
const actualSessionId = serverSessionId || sessionId
63-
console.log('Session ID:', actualSessionId)
59+
console.log('Initialized (stateless)')
6460

6561
// 2. List tools
6662
console.log('\n2. Listing available tools...')
@@ -75,14 +71,22 @@ async function testHTTPMode () {
7571
method: 'POST',
7672
headers: {
7773
'Content-Type': 'application/json',
78-
Accept: 'application/json, text/event-stream',
79-
'mcp-session-id': actualSessionId
74+
Accept: 'application/json, text/event-stream'
8075
},
8176
body: JSON.stringify(toolsRequest)
8277
})
8378

8479
const toolsResult = await parseResponse(toolsResponse)
8580
console.log('Available tools:', JSON.stringify(toolsResult, null, 2))
81+
// Assert that the 'depscore' tool exists in the toolsResult
82+
if (
83+
!toolsResult ||
84+
!toolsResult.result ||
85+
!Array.isArray(toolsResult.result.tools) ||
86+
!toolsResult.result.tools.some((tool: any) => tool.name === 'depscore')
87+
) {
88+
throw new Error('depscore tool not found in available tools')
89+
}
8690

8791
// 3. Call depscore
8892
console.log('\n3. Calling depscore tool...')
@@ -106,40 +110,15 @@ async function testHTTPMode () {
106110
method: 'POST',
107111
headers: {
108112
'Content-Type': 'application/json',
109-
Accept: 'application/json, text/event-stream',
110-
'mcp-session-id': actualSessionId
113+
Accept: 'application/json, text/event-stream'
111114
},
112115
body: JSON.stringify(depscoreRequest)
113116
})
114117

115118
const depscoreResult = await parseResponse(depscoreResponse)
116119
console.log('Depscore result:', JSON.stringify(depscoreResult, null, 2))
117120

118-
// 4. Test SSE stream (optional)
119-
console.log('\n4. Testing SSE stream connection...')
120-
const sseResponse = await fetch(`${baseUrl}/`, {
121-
method: 'GET',
122-
headers: {
123-
'mcp-session-id': actualSessionId,
124-
Accept: 'text/event-stream'
125-
}
126-
})
127-
128-
if (sseResponse.ok) {
129-
console.log('SSE stream connected successfully')
130-
// Note: In a real implementation, you'd parse the SSE stream
131-
}
132-
133-
// 5. Clean up session
134-
console.log('\n5. Cleaning up session...')
135-
const cleanupResponse = await fetch(`${baseUrl}/`, {
136-
method: 'DELETE',
137-
headers: {
138-
'mcp-session-id': actualSessionId
139-
}
140-
})
141-
142-
console.log('Session cleanup:', cleanupResponse.status === 200 ? 'Success' : 'Failed')
121+
console.log('\n4. HTTP mode test complete (no sessions)')
143122
} catch (error) {
144123
console.error('Error:', error)
145124
}

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)