UNPKG

mcp-framework

Version:

Framework for building Model Context Protocol (MCP) servers in Typescript

824 lines (823 loc) 41.9 kB
import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; import contentType from "content-type"; import getRawBody from "raw-body"; import { AbstractTransport } from "../base.js"; import { DEFAULT_HTTP_STREAM_CONFIG } from "./types.js"; import { DEFAULT_AUTH_ERROR } from "../../auth/types.js"; import { logger } from "../../core/Logger.js"; import { getRequestHeader, setResponseHeaders } from "../../utils/headers.js"; import { DEFAULT_CORS_CONFIG } from "../sse/types.js"; import { PING_SSE_MESSAGE } from "../utils/ping-message.js"; function isRequest(msg) { return msg && msg.jsonrpc === "2.0" && 'id' in msg && msg.id !== null && !('result' in msg || 'error' in msg) && 'method' in msg && typeof msg.method === 'string'; } function isNotification(msg) { return msg && msg.jsonrpc === "2.0" && !('id' in msg) && 'method' in msg && typeof msg.method === 'string'; } function isSuccessResponse(msg) { return msg && msg.jsonrpc === "2.0" && 'id' in msg && 'result' in msg && !('error' in msg); } function isErrorResponse(msg) { return msg && msg.jsonrpc === "2.0" && 'id' in msg && 'error' in msg && !('result' in msg); } function isResponse(msg) { return isSuccessResponse(msg) || isErrorResponse(msg); } const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }; const JSON_CONTENT_TYPE = "application/json"; const SSE_CONTENT_TYPE = "text/event-stream"; export class HttpStreamTransport extends AbstractTransport { type = "http-stream"; _server; _config; _activeSessions = new Map(); _activeSseConnections = new Set(); _requestStreamMap = new Map(); _pendingBatches = new Map(); _eventCounter = 0; _globalMessageStore = new Map(); _pruneInterval; constructor(config = {}) { super(); this._config = { ...DEFAULT_HTTP_STREAM_CONFIG, ...config, session: { ...DEFAULT_HTTP_STREAM_CONFIG.session, ...config.session }, resumability: { ...DEFAULT_HTTP_STREAM_CONFIG.resumability, ...config.resumability }, cors: { ...DEFAULT_CORS_CONFIG, ...(config.cors || {}) }, auth: config.auth ?? DEFAULT_HTTP_STREAM_CONFIG.auth, headers: config.headers ?? DEFAULT_HTTP_STREAM_CONFIG.headers, enableGetSse: config.enableGetSse ?? DEFAULT_HTTP_STREAM_CONFIG.enableGetSse, }; if (this._config.auth?.endpoints) { logger.warn("Auth 'endpoints' config ignored for HttpStreamTransport."); } logger.debug(`HttpStreamTransport configured: ${JSON.stringify({ port: this._config.port, endpoint: this._config.endpoint, responseMode: this._config.responseMode, sessionEnabled: this._config.session.enabled, resumabilityEnabled: this._config.resumability.enabled, resumabilityStore: this._config.resumability.messageStoreType, enableGetSse: this._config.enableGetSse, authEnabled: !!this._config.auth, corsOrigin: this._config.cors.allowOrigin }, null, 2)}`); if (this._config.resumability.enabled && this._config.resumability.messageStoreType === 'global') { this._pruneInterval = setInterval(() => this.pruneMessageStore(), this._config.resumability.historyDuration / 3); } } getCorsHeaders(req, includeMaxAge = false) { const corsConfig = this._config.cors; const headers = { "Access-Control-Allow-Origin": corsConfig.allowOrigin || req.headers.origin || '*', "Access-Control-Allow-Methods": this._config.enableGetSse ? corsConfig.allowMethods : corsConfig.allowMethods.replace(/GET,?\s*/, ''), "Access-Control-Allow-Headers": corsConfig.allowHeaders, "Access-Control-Expose-Headers": [corsConfig.exposeHeaders, this._config.session.enabled ? this._config.session.headerName : null].filter(Boolean).join(', '), "Access-Control-Allow-Credentials": "true", }; if (req.headers.origin && corsConfig.allowOrigin !== '*') { headers['Vary'] = 'Origin'; } if (includeMaxAge) { headers["Access-Control-Max-Age"] = corsConfig.maxAge; } return headers; } async start() { if (this._server) { throw new Error("HttpStreamTransport already started"); } return new Promise((resolve, reject) => { this._server = createServer(async (req, res) => { if (this._config.headers) setResponseHeaders(res, this._config.headers); try { await this.handleRequest(req, res); } catch (error) { logger.error(`Unhandled error in handleRequest: ${error?.message || error}\n${error?.stack}`); if (!res.headersSent) res.writeHead(error.statusCode || 500, { "Content-Type": JSON_CONTENT_TYPE }); if (!res.writableEnded) res.end(JSON.stringify({ jsonrpc: "2.0", id: error.requestId || null, error: { code: error.code || -32000, message: error.message || "Internal Server Error", data: error.data } })); } }); this._server.on("error", (error) => { logger.error(`HttpStream server error: ${error}`); this._onerror?.(error); if (!this.isRunning()) reject(error); }); this._server.on("close", () => { logger.info("HttpStream server closed"); this.cleanupAllConnections(); this._onclose?.(); }); this._server.listen(this._config.port, () => { const address = this._server?.address(); if (address) { logger.info(`HttpStream transport listening on port ${this._config.port}, endpoint ${this._config.endpoint}`); resolve(); } else { const listenError = new Error(`Server failed to listen on port ${this._config.port}`); logger.error(listenError.message); reject(listenError); } }); }); } async handleRequest(req, res) { const url = new URL(req.url, `http://${req.headers.host}`); logger.debug(`Incoming request: ${req.method} ${url.pathname}`); if (req.method === "OPTIONS") { setResponseHeaders(res, this.getCorsHeaders(req, true)); res.writeHead(204).end(); logger.debug(`Responded to OPTIONS request for ${url.pathname}`); return; } setResponseHeaders(res, this.getCorsHeaders(req)); if (url.pathname !== this._config.endpoint) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end("Not Found"); logger.warn(`Request to unknown path: ${url.pathname}`); return; } try { switch (req.method) { case "POST": await this.handlePost(req, res); break; case "GET": await this.handleGet(req, res); break; case "DELETE": await this.handleDelete(req, res); break; default: { // Add block scope for the default case const allowHeader = this._config.enableGetSse ? 'GET, POST, DELETE, OPTIONS' : 'POST, DELETE, OPTIONS'; res.writeHead(405, { 'Content-Type': 'text/plain', 'Allow': allowHeader }); res.end("Method Not Allowed"); logger.warn(`Unsupported method: ${req.method}`); break; } // Close block scope } } catch (error) { logger.error(`Error processing ${req.method} ${url.pathname}: ${error.message}`); if (!res.headersSent) res.writeHead(error.statusCode || 500, { 'Content-Type': JSON_CONTENT_TYPE }); if (!res.writableEnded) res.end(JSON.stringify({ jsonrpc: "2.0", id: error.requestId || null, error: { code: error.code || -32000, message: error.message || 'Internal Server Error', data: error.data } })); } } async handlePost(req, res) { logger.debug(`Handling POST request to ${this._config.endpoint}`); let messages; let parsedMessages; let firstRequestId = null; const reqContentTypeHeader = req.headers["content-type"]; if (!reqContentTypeHeader) throw this.httpError(415, 'Unsupported Media Type: Content-Type header missing'); const reqContentType = contentType.parse(reqContentTypeHeader); if (reqContentType.type !== JSON_CONTENT_TYPE) throw this.httpError(415, `Unsupported Media Type: Content-Type must be ${JSON_CONTENT_TYPE}`); const acceptHeader = req.headers.accept || ''; const acceptsJson = acceptHeader.includes(JSON_CONTENT_TYPE) || acceptHeader.includes('*/*'); const acceptsSse = acceptHeader.includes(SSE_CONTENT_TYPE) || acceptHeader.includes('*/*'); if (!acceptsJson || !acceptsSse) { logger.error(`POST request rejected: Accept header ('${acceptHeader}') missing required types (${JSON_CONTENT_TYPE} and ${SSE_CONTENT_TYPE})`); throw this.httpError(406, `Not Acceptable: Accept header must include ${JSON_CONTENT_TYPE} and ${SSE_CONTENT_TYPE}`); } try { const rawBody = await getRawBody(req, { limit: this._config.maxMessageSize, encoding: reqContentType.parameters.charset ?? "utf-8" }); const parsed = JSON.parse(rawBody.toString()); messages = Array.isArray(parsed) ? parsed : [parsed]; if (messages.length === 0) throw new Error('Empty JSON array received'); parsedMessages = messages.map((msg, index) => { if (!msg || typeof msg !== 'object' || msg.jsonrpc !== "2.0") { throw new Error(`Invalid JSON-RPC structure at index ${index}`); } if (firstRequestId === null && 'id' in msg && msg.id !== null) { firstRequestId = msg.id; } return msg; }); logger.debug(`Parsed ${parsedMessages.length} message(s) from POST body`); } catch (e) { logger.error(`Failed to parse POST body: ${e.message}`); throw this.httpError(400, `Bad Request: ${e.message}`, -32700, undefined, firstRequestId); } const initializeRequests = parsedMessages.filter(msg => isRequest(msg) && msg.method === 'initialize'); if (initializeRequests.length > 0) { if (initializeRequests.length > 1) { logger.error("Multiple initialize requests in the same batch"); throw this.httpError(400, "Bad Request: Multiple initialize requests in the same batch", -32600, undefined, firstRequestId); } if (parsedMessages.length > 1) { logger.error("Initialize request cannot be batched with other requests"); throw this.httpError(400, "Bad Request: Initialize request must not be part of a JSON-RPC batch", -32600, undefined, firstRequestId); } } const isInitialize = initializeRequests.length > 0; const sessionIdHeader = getRequestHeader(req.headers, this._config.session.headerName); let session; if (this._config.session.enabled) { if (isInitialize) { if (sessionIdHeader) { throw this.httpError(400, 'Bad Request: Cannot send session ID with initialize request', -32600, undefined, firstRequestId); } } else { session = this.validateSession(sessionIdHeader, req, true, firstRequestId); session.lastActivity = Date.now(); } } const authContext = isInitialize ? "initialize" : `session ${session?.id || 'N/A'}`; await this.handleAuthentication(req, res, `POST ${authContext}`, session, firstRequestId); const clientRequests = parsedMessages.filter(isRequest); const clientNotifications = parsedMessages.filter(isNotification); const clientResponses = parsedMessages.filter(isResponse); [...clientNotifications, ...clientResponses].forEach(msg => { this.handleIncomingMessage(msg, session?.id); }); if (clientRequests.length === 0) { res.writeHead(202).end(); logger.debug("POST contained only notifications/responses, sent 202 Accepted."); } else { const responseMode = this._config.responseMode; logger.debug(`Processing ${clientRequests.length} requests with responseMode: ${responseMode}`); let newSessionId; if (isInitialize && this._config.session.enabled) { newSessionId = randomUUID(); session = { id: newSessionId, createdAt: Date.now(), lastActivity: Date.now() }; this._activeSessions.set(newSessionId, session); logger.debug(`Created new session: ${newSessionId} for initialization request`); } if (responseMode === 'batch') { const requestIds = new Set(); clientRequests.forEach(req => requestIds.add(req.id)); if (requestIds.size === 0) { logger.error("Batch mode POST contained requests but none had valid IDs. Cannot track responses."); throw this.httpError(400, "Bad Request: Requests in batch mode must have valid non-null IDs", -32600, undefined, firstRequestId); } const batchState = { res, requestIds, responses: new Map(), isCompleted: false, timeoutId: setTimeout(() => this.handleBatchTimeout(res), this._config.batchTimeout), }; this._pendingBatches.set(res, batchState); logger.debug(`Batch mode: Tracking ${requestIds.size} request IDs. Timeout: ${this._config.batchTimeout}ms.`); if (newSessionId) { const originalComplete = this.completeBatchResponse.bind(this); this.completeBatchResponse = (state) => { if (state === batchState && !state.res.headersSent) { state.res.setHeader(this._config.session.headerName, newSessionId); logger.info(`Adding session ID header to batch response: ${newSessionId}`); } originalComplete(state); this.completeBatchResponse = originalComplete; }; logger.info(`Initialized new session: ${newSessionId} for batch response`); } clientRequests.forEach(reqMsg => this.handleIncomingMessage(reqMsg, session?.id)); } else { const additionalHeaders = {}; if (newSessionId) { additionalHeaders[this._config.session.headerName] = newSessionId; logger.info(`Initialized new session: ${newSessionId} via stream`); } const requestIds = new Set(); clientRequests.forEach(req => requestIds.add(req.id)); const sseConnection = this.setupSSEConnection(req, res, newSessionId || session?.id, undefined, additionalHeaders, true); if (requestIds.size > 0) { sseConnection.pendingResponseIds = requestIds; logger.debug(`Stream mode: Tracking ${requestIds.size} pending responses for stream ${sseConnection.streamId}`); } else { logger.debug(`Stream mode: No request IDs to track for stream ${sseConnection.streamId}. Connection will remain open.`); } if (newSessionId) { sseConnection.sessionId = newSessionId; } clientRequests.forEach(reqMsg => { this._requestStreamMap.set(reqMsg.id, sseConnection); this.handleIncomingMessage(reqMsg, sseConnection.sessionId); }); } } } handleBatchTimeout(res) { const batchState = this._pendingBatches.get(res); if (batchState && !batchState.isCompleted) { logger.error(`Batch response timed out after ${this._config.batchTimeout}ms. Sending error.`); batchState.isCompleted = true; const missingIds = Array.from(batchState.requestIds).filter(id => !batchState.responses.has(id)); logger.warn(`Missing responses for request IDs: ${missingIds.join(', ')}`); const errorJson = { code: -32006, message: `Request batch timed out. Missing responses for ${missingIds.length} requests.`, data: { missingIds } }; const finalResponse = { jsonrpc: "2.0", id: null, error: errorJson }; if (!res.headersSent) res.writeHead(504, { 'Content-Type': JSON_CONTENT_TYPE }); if (!res.writableEnded) res.end(JSON.stringify(finalResponse)); this._pendingBatches.delete(res); } } completeBatchResponse(batchState) { if (batchState.isCompleted) return; logger.debug(`Completing batch response. Collected ${batchState.responses.size}/${batchState.requestIds.size} responses.`); clearTimeout(batchState.timeoutId); batchState.isCompleted = true; const finalResponses = Array.from(batchState.responses.values()); const body = JSON.stringify(finalResponses); if (!batchState.res.headersSent) { batchState.res.setHeader('Content-Type', JSON_CONTENT_TYPE); batchState.res.writeHead(200); } if (!batchState.res.writableEnded) batchState.res.end(body); this._pendingBatches.delete(batchState.res); logger.debug("Batch response sent successfully."); } async handleGet(req, res) { logger.debug(`Handling GET request to ${this._config.endpoint}`); if (!this._config.enableGetSse) { logger.debug(`GET SSE is disabled. Returning 405 Method Not Allowed.`); res.writeHead(405, { 'Content-Type': 'text/plain', 'Allow': 'POST, DELETE, OPTIONS' }); res.end("Method Not Allowed: GET-based SSE is disabled on this server."); return; } const acceptHeader = req.headers.accept || ''; if (!acceptHeader.includes(SSE_CONTENT_TYPE) && !acceptHeader.includes('*/*')) { throw this.httpError(406, `Not Acceptable: GET requires Accept header including ${SSE_CONTENT_TYPE}`); } const lastEventId = getRequestHeader(req.headers, "Last-Event-ID"); const sessionIdHeader = getRequestHeader(req.headers, this._config.session.headerName); let session; if (this._config.session.enabled && sessionIdHeader) { session = this.validateSession(sessionIdHeader, req, false); session.lastActivity = Date.now(); logger.debug(`Found valid session: ${session.id}`); await this.handleAuthentication(req, res, `GET session ${session.id}`, session); } else if (this._config.session.enabled) { logger.debug(`GET request without session ID - allowing as potential initialization connection`); await this.handleAuthentication(req, res, `GET initialization`, undefined); } else { await this.handleAuthentication(req, res, `GET (sessions disabled)`, undefined); } if (lastEventId && !this._config.resumability.enabled) { logger.warn(`Client sent Last-Event-ID (${lastEventId}) but resumability is disabled.`); } this.setupSSEConnection(req, res, session?.id, lastEventId, {}, false); logger.debug(`Established SSE stream for GET request (Session: ${session?.id || 'initialization phase'})`); } async handleDelete(req, res) { logger.debug(`Handling DELETE request to ${this._config.endpoint}`); if (!this._config.session.enabled) throw this.httpError(405, 'Method Not Allowed: Sessions are disabled'); if (!this._config.session.allowClientTermination) throw this.httpError(405, 'Method Not Allowed: Client session termination is disabled'); const sessionIdHeader = getRequestHeader(req.headers, this._config.session.headerName); const session = this.validateSession(sessionIdHeader, req, true); await this.handleAuthentication(req, res, `DELETE session ${session.id}`, session); this._activeSessions.delete(session.id); logger.info(`Terminated session ${session.id} via DELETE request.`); const streamsToClose = Array.from(this._activeSseConnections).filter(conn => conn.sessionId === session.id); streamsToClose.forEach(conn => this.cleanupConnection(conn, "Session terminated via DELETE")); res.writeHead(200, { 'Content-Type': 'text/plain' }).end("Session terminated"); } setupSSEConnection(req, res, sessionId, lastEventId, additionalHeaders = {}, isPostConnection = false) { const streamId = randomUUID(); const connection = { res, sessionId, streamId, lastEventIdSent: null, messageHistory: this._config.resumability.enabled && this._config.resumability.messageStoreType === 'connection' ? [] : undefined, pingInterval: undefined, isPostConnection }; const headers = { ...SSE_HEADERS, ...additionalHeaders }; res.writeHead(200, headers); const originInfo = isPostConnection ? `POST (will close after responses)` : `GET (persistent until client disconnects)`; logger.debug(`SSE stream ${streamId} setup (Session: ${sessionId || 'N/A'}, Origin: ${originInfo})`); if (res.socket) { res.socket.setNoDelay(true); res.socket.setKeepAlive(true); res.socket.setTimeout(0); logger.debug(`Optimized socket for SSE stream ${streamId}`); } else { logger.warn(`Could not access socket for SSE stream ${streamId} to optimize.`); } this._activeSseConnections.add(connection); connection.pingInterval = setInterval(() => this.sendPing(connection), 15000); if (lastEventId && this._config.resumability.enabled) { this.handleResumption(connection, lastEventId, sessionId).catch(err => { logger.error(`Error during stream resumption for ${streamId}: ${err.message}`); this.cleanupConnection(connection, `Resumption error: ${err.message}`); }); } const cleanupHandler = (reason) => { if (connection.pingInterval) { clearInterval(connection.pingInterval); connection.pingInterval = undefined; } this.cleanupConnection(connection, reason); }; res.on("close", () => cleanupHandler("Client closed connection")); res.on("error", (err) => { logger.error(`SSE stream ${streamId} error: ${err.message}`); cleanupHandler(`Connection error: ${err.message}`); this._onerror?.(err); }); res.on("finish", () => cleanupHandler("Stream finished")); logger.info(`SSE stream ${streamId} active (Session: ${sessionId || 'N/A'}, Origin: ${originInfo}, Total: ${this._activeSseConnections.size})`); return connection; } cleanupConnection(connection, reason) { if (!this._activeSseConnections.has(connection)) return; const { streamId, sessionId, pingInterval } = connection; logger.info(`Cleaning up SSE stream ${streamId} (Session: ${sessionId || 'N/A'}). Reason: ${reason}.`); if (pingInterval) clearInterval(pingInterval); this._activeSseConnections.delete(connection); const requestIdsToRemove = []; this._requestStreamMap.forEach((conn, reqId) => { if (conn === connection) requestIdsToRemove.push(reqId); }); requestIdsToRemove.forEach(reqId => this._requestStreamMap.delete(reqId)); if (requestIdsToRemove.length > 0) logger.debug(`Removed ${requestIdsToRemove.length} request associations for closed stream ${streamId}`); if (connection.res && !connection.res.writableEnded) { try { connection.res.end(); } catch (e) { logger.warn(`Error ending response stream ${streamId}: ${e.message}`); } } logger.debug(`Total active SSE connections after cleanup: ${this._activeSseConnections.size}`); } /** * Checks if a POST-initiated SSE connection has completed all responses. * If all responses have been sent, closes the connection as per spec recommendation. */ checkAndCloseCompletedPostConnection(connection) { if (!connection.isPostConnection || !connection.pendingResponseIds) { return; } if (connection.pendingResponseIds.size > 0) { return; } logger.info(`POST-initiated SSE stream ${connection.streamId} has sent all responses. Closing as per spec recommendation.`); this.cleanupConnection(connection, "All responses sent"); } cleanupAllConnections() { logger.info(`Cleaning up all ${this._activeSseConnections.size} active SSE connections and ${this._pendingBatches.size} pending batches.`); Array.from(this._activeSseConnections).forEach(conn => this.cleanupConnection(conn, "Server shutting down")); this._requestStreamMap.clear(); this._pendingBatches.forEach(batchState => { clearTimeout(batchState.timeoutId); if (batchState.res && !batchState.res.writableEnded) { try { batchState.res.end(); } catch (error) { logger.warn(`Error ending response during cleanup: ${error.message}`); } } }); this._pendingBatches.clear(); this._activeSessions.clear(); } async send(message) { logger.debug(`Attempting to send message: ${JSON.stringify(message)}`); if (isResponse(message) && message.id !== null) { let foundInBatch = false; this._pendingBatches.forEach((batchState) => { if (!batchState.isCompleted && typeof message.id === 'string' || typeof message.id === 'number' && batchState.requestIds.has(message.id)) { logger.debug(`Batch mode: Collected response for ID ${message.id}`); if (typeof message.id === 'string' || typeof message.id === 'number') { batchState.responses.set(message.id, message); } if (batchState.responses.size === batchState.requestIds.size) { this.completeBatchResponse(batchState); } else { logger.debug(`Batch mode: Still waiting for ${batchState.requestIds.size - batchState.responses.size} responses.`); } foundInBatch = true; } }); if (foundInBatch) { return; } logger.debug(`Response ID ${message.id} did not match pending batch, checking streams.`); } let targetConnection; if (isResponse(message) && message.id !== null) { targetConnection = this._requestStreamMap.get(message.id); if (targetConnection) { this._requestStreamMap.delete(message.id); logger.debug(`Stream mode: Found target stream ${targetConnection.streamId} for response ID ${message.id}`); if (targetConnection.pendingResponseIds && targetConnection.pendingResponseIds.has(message.id)) { targetConnection.pendingResponseIds.delete(message.id); logger.debug(`Stream ${targetConnection.streamId}: Removed ID ${message.id} from pending responses. Remaining: ${targetConnection.pendingResponseIds.size}`); } } else { logger.warn(`Stream mode: No active stream found mapping to response ID ${message.id}. Message dropped.`); return; } } else { targetConnection = Array.from(this._activeSseConnections) .filter(c => { return isResponse(message) ? c.isPostConnection : true; }) .find(c => c.res && !c.res.writableEnded); if (targetConnection) { if (isResponse(message)) { logger.debug(`Stream mode: Using POST-originated stream ${targetConnection.streamId} for response`); } else { logger.debug(`Stream mode: Selected available stream ${targetConnection.streamId} for request/notification`); } } } if (!targetConnection || !targetConnection.res || targetConnection.res.writableEnded) { if (isResponse(message)) { logger.error(`Cannot send response message via SSE: No suitable POST-originated stream found. Message dropped: ${JSON.stringify(message)}`); } else { logger.error(`Cannot send request/notification message via SSE: No suitable stream found. Message dropped: ${JSON.stringify(message)}`); } return; } try { let eventId = undefined; if (this._config.resumability.enabled) { eventId = `${Date.now()}-${this._eventCounter++}`; targetConnection.lastEventIdSent = eventId; this.storeMessage(message, targetConnection.sessionId, eventId); if (this._config.resumability.messageStoreType === 'connection' && targetConnection.messageHistory) { const timestamp = Date.now(); targetConnection.messageHistory.push({ eventId, message, timestamp }); const cutoff = timestamp - this._config.resumability.historyDuration; targetConnection.messageHistory = targetConnection.messageHistory.filter(entry => entry.timestamp >= cutoff); } logger.debug(`Sending SSE event ID: ${eventId} on stream ${targetConnection.streamId}`); targetConnection.res.write(`id: ${eventId}\n`); } logger.debug(`Sending SSE data on stream ${targetConnection.streamId}: ${JSON.stringify(message)}`); targetConnection.res.write(`data: ${JSON.stringify(message)}\n\n`); if (isResponse(message)) { this.checkAndCloseCompletedPostConnection(targetConnection); } } catch (error) { logger.error(`Error writing to SSE stream ${targetConnection.streamId}: ${error.message}. Cleaning up connection.`); this.cleanupConnection(targetConnection, `Write error: ${error.message}`); } } sendPing(connection) { if (!connection || !connection.res || connection.res.writableEnded) return; try { connection.res.write(PING_SSE_MESSAGE); logger.debug(`Sent keep-alive ping to stream ${connection.streamId}`); } catch (error) { logger.error(`Error sending ping to stream ${connection.streamId}: ${error.message}`); if (this._activeSseConnections.has(connection)) { this.cleanupConnection(connection, `Ping error: ${error.message}`); } } } async handleAuthentication(req, res, context, session, requestId) { const provider = this._config.auth?.provider; if (!provider) { logger.debug(`Auth skipped for ${context}: No provider.`); return true; } logger.debug(`Attempting auth for ${context} via ${provider.constructor.name}`); let authResult; try { authResult = await provider.authenticate(req); } catch (error) { logger.error(`Auth provider error for ${context}: ${error.message}`); throw this.httpError(500, "Authentication provider error", -32001, error, requestId); } if (!authResult) { const errDet = provider.getAuthError?.() || DEFAULT_AUTH_ERROR; logger.warn(`Auth failed for ${context}: ${errDet.message}`); throw this.httpError(errDet.status, errDet.message, -32002, undefined, requestId); } logger.info(`Auth successful for ${context}.`); return typeof authResult === 'object' ? authResult : true; } validateSession(sessionIdHeader, req, isMandatory, requestId) { if (!this._config.session.enabled) throw this.httpError(500, "Internal Server Error: Session validation called when sessions disabled", -32003, undefined, requestId); const headerName = this._config.session.headerName; if (!sessionIdHeader) { if (isMandatory) { logger.warn(`Mandatory session ID missing: ${headerName}`); throw this.httpError(400, `Bad Request: Missing required session header ${headerName}`, -32601, undefined, requestId); } else { logger.debug(`No session ID provided and not mandatory - acceptable for initialization`); return undefined; } } const session = this._activeSessions.get(sessionIdHeader); if (!session) { logger.warn(`Invalid/expired session ID: ${sessionIdHeader}`); throw this.httpError(404, 'Not Found: Invalid or expired session ID', -32004, undefined, requestId); } logger.debug(`Session ${session.id} validated.`); return session; } async handleResumption(connection, lastEventId, sessionId) { logger.info(`Attempting resume stream ${connection.streamId} from event ${lastEventId}`); let messagesToReplay = []; if (this._config.resumability.messageStoreType === 'global') { if (!this._config.resumability.enabled) { logger.warn(`Resume requested for ${connection.streamId}, but resumability is disabled. Starting fresh.`); return; } messagesToReplay = this.getMessagesAfterEvent(sessionId, lastEventId); if (messagesToReplay.length === 0) { logger.warn(`Event ${lastEventId} not found in global message store for session ${sessionId || 'N/A'}. Starting fresh.`); return; } } else if (this._config.resumability.messageStoreType === 'connection') { if (!connection.messageHistory || !this._config.resumability.enabled) { logger.warn(`Resume requested for ${connection.streamId}, but history unavailable/disabled. Starting fresh.`); return; } const history = connection.messageHistory; const lastReceivedIndex = history.findIndex(entry => entry.eventId === lastEventId); if (lastReceivedIndex === -1) { logger.warn(`Event ${lastEventId} not found in history for ${connection.streamId}. Starting fresh.`); return; } messagesToReplay = history.slice(lastReceivedIndex + 1); } if (messagesToReplay.length === 0) { logger.info(`Event ${lastEventId} was last known event for ${connection.streamId}. No replay needed.`); return; } logger.info(`Replaying ${messagesToReplay.length} messages for stream ${connection.streamId}`); for (const entry of messagesToReplay) { if (!connection.res || connection.res.writableEnded) { logger.warn(`Stream ${connection.streamId} closed during replay. Aborting.`); return; } try { logger.debug(`Replaying event ${entry.eventId}`); connection.res.write(`id: ${entry.eventId}\n`); connection.res.write(`data: ${JSON.stringify(entry.message)}\n\n`); connection.lastEventIdSent = entry.eventId; } catch (error) { logger.error(`Error replaying message ${entry.eventId} to ${connection.streamId}: ${error.message}. Aborting.`); this.cleanupConnection(connection, `Replay write error: ${error.message}`); return; } } logger.info(`Finished replaying messages for stream ${connection.streamId}`); } handleIncomingMessage(message, sessionId) { let method = 'response/notification'; let id = ''; if (isRequest(message) || isNotification(message)) { method = message.method; } if (isRequest(message) || isResponse(message)) { id = message.id; } logger.debug(`Forwarding msg to handler (Session: ${sessionId || 'N/A'}): ${method} ${id}`); logger.debug(`Incoming message detail: ${JSON.stringify(message)}`); if (!this._onmessage) { logger.error("No message handler. Dropping message."); return; } try { this._onmessage(message); } catch (error) { logger.error(`Sync error in _onmessage handler: ${error.message}.`); } } httpError(statusCode, message, code = -32000, data, requestId) { const error = new Error(message); error.statusCode = statusCode; error.code = code; error.data = data; error.requestId = requestId; return error; } async close() { logger.info("Closing HttpStreamTransport..."); if (this._pruneInterval) { clearInterval(this._pruneInterval); this._pruneInterval = undefined; } this.cleanupAllConnections(); return new Promise((resolve, reject) => { if (this._server) { const server = this._server; this._server = undefined; const timeout = setTimeout(() => { logger.warn("HTTP server close timed out."); reject(new Error("Server close timed out")); }, 5000); server.close((err) => { clearTimeout(timeout); if (err) { logger.error(`Error closing HTTP server: ${err.message}`); reject(err); } else { logger.info("HTTP server closed successfully."); resolve(); } }); } else { logger.debug("HTTP server already closed."); resolve(); } }); } isRunning() { return Boolean(this._server?.listening); } storeMessage(message, sessionId, eventId) { if (!this._config.resumability.enabled) return; const timestamp = Date.now(); const messageEntry = { eventId, message, timestamp }; if (this._config.resumability.messageStoreType === 'global' && sessionId) { if (!this._globalMessageStore.has(sessionId)) { this._globalMessageStore.set(sessionId, new Map()); } this._globalMessageStore.get(sessionId).set(eventId, messageEntry); } } pruneMessageStore() { if (!this._config.resumability.enabled || this._config.resumability.messageStoreType !== 'global') return; const cutoff = Date.now() - this._config.resumability.historyDuration; for (const [sessionId, messages] of this._globalMessageStore.entries()) { let expired = 0; for (const [eventId, entry] of messages.entries()) { if (entry.timestamp < cutoff) { messages.delete(eventId); expired++; } } if (messages.size === 0) { this._globalMessageStore.delete(sessionId); } else if (expired > 0) { logger.debug(`Pruned ${expired} expired messages for session ${sessionId}`); } } } getMessagesAfterEvent(sessionId, lastEventId) { if (!sessionId || !this._config.resumability.enabled || this._config.resumability.messageStoreType !== 'global' || !this._globalMessageStore.has(sessionId)) { return []; } const messages = this._globalMessageStore.get(sessionId); const allEntries = Array.from(messages.values()) .sort((a, b) => a.timestamp - b.timestamp); const lastReceivedIndex = allEntries.findIndex(entry => entry.eventId === lastEventId); if (lastReceivedIndex === -1) { return []; } return allEntries.slice(lastReceivedIndex + 1); } }