返回 AiToEarn
mcp-streamable-http.service.ts
根目录 / project / aitoearn-backend / libs / nest-mcp / src / services / mcp-streamable-http.service.ts
1 import { randomUUID } from 'node:crypto'
2 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
4 import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common'
5 import { ContextIdFactory, ModuleRef } from '@nestjs/core'
6 import { HttpAdapterFactory } from '../adapters/http-adapter.factory'
7 import { McpOptions } from '../interfaces'
8 import {
9 HttpRequest,
10 HttpResponse,
11 } from '../interfaces/http-adapter.interface'
12 import { buildMcpCapabilities } from '../utils/capabilities-builder'
13 import { McpExecutorService } from './mcp-executor.service'
14 import { McpRegistryService } from './mcp-registry.service'
15
16 @Injectable()
17 export class McpStreamableHttpService implements OnModuleDestroy {
18 private readonly logger = new Logger(McpStreamableHttpService.name)
19 private readonly transports: {
20 [sessionId: string]: StreamableHTTPServerTransport
21 } = {}
22
23 private readonly mcpServers: { [sessionId: string]: McpServer } = {}
24 private readonly executors: { [sessionId: string]: McpExecutorService } = {}
25 private readonly isStatelessMode: boolean
26
27 constructor(
28 @Inject('MCP_OPTIONS') private readonly options: McpOptions,
29 @Inject('MCP_MODULE_ID') private readonly mcpModuleId: string,
30 private readonly moduleRef: ModuleRef,
31 private readonly toolRegistry: McpRegistryService,
32 ) {
33 // Determine if we're in stateless mode
34 this.isStatelessMode = !!options.streamableHttp?.statelessMode
35 }
36
37 /**
38 * Create a new MCP server instance for stateless requests
39 */
40 async createStatelessServer(rawReq: any): Promise<{
41 server: McpServer
42 transport: StreamableHTTPServerTransport
43 }> {
44 // Create a new transport for this request (stateless = no session management)
45 const transport = new StreamableHTTPServerTransport({
46 sessionIdGenerator: undefined,
47 enableJsonResponse:
48 this.options.streamableHttp?.enableJsonResponse || false,
49 })
50
51 // Create a new MCP server instance with dynamic capabilities
52 const capabilities = buildMcpCapabilities(
53 this.mcpModuleId,
54 this.toolRegistry,
55 this.options,
56 )
57 this.logger.debug(
58 `[Stateless] Built MCP capabilities: ${JSON.stringify(capabilities)}`,
59 )
60
61 const server = new McpServer(
62 { name: this.options.name, version: this.options.version },
63 {
64 capabilities,
65 instructions: this.options.instructions || '',
66 },
67 )
68
69 // Connect the transport to the MCP server first
70 await server.connect(transport)
71
72 // Now resolve the request-scoped tool executor service
73 const contextId = ContextIdFactory.getByRequest(rawReq)
74 const executor = await this.moduleRef.resolve(
75 McpExecutorService,
76 contextId,
77 { strict: true },
78 )
79
80 // Register request handlers after connection
81 this.logger.debug(
82 '[Stateless] Registering request handlers for stateless MCP server',
83 )
84 executor.registerRequestHandlers(server, rawReq)
85
86 return { server, transport }
87 }
88
89 /**
90 * Handle POST requests
91 */
92 async handlePostRequest(req: any, res: any, body: unknown): Promise<void> {
93 // Get the appropriate HTTP adapter for the request/response
94 const adapter = HttpAdapterFactory.getAdapter(req, res)
95 const adaptedReq = adapter.adaptRequest(req)
96 const adaptedRes = adapter.adaptResponse(res)
97 const sessionId = adaptedReq.headers['mcp-session-id'] as
98 | string
99 | undefined
100
101 this.logger.debug(
102 `[${sessionId || 'No-Session'}] Received MCP request: ${JSON.stringify(body)}`,
103 )
104
105 try {
106 if (this.isStatelessMode) {
107 return this.handleStatelessRequest(adaptedReq, adaptedRes, body)
108 }
109 else {
110 return this.handleStatefulRequest(adaptedReq, adaptedRes, body)
111 }
112 }
113 catch (error) {
114 this.logger.error(
115 `[${sessionId || 'No-Session'}] Error handling MCP request: ${error}`,
116 )
117 if (!adaptedRes.headersSent) {
118 adaptedRes.status(500).json({
119 jsonrpc: '2.0',
120 error: {
121 code: -32603,
122 message: 'Internal server error',
123 },
124 id: null,
125 })
126 }
127 }
128 }
129
130 /**
131 * Handle requests in stateless mode
132 */
133 async handleStatelessRequest(
134 req: any,
135 res: HttpResponse,
136 body: unknown,
137 ): Promise<void> {
138 this.logger.debug(
139 `[Stateless] Handling stateless MCP request at ${req.url}`,
140 )
141
142 let server: McpServer | null = null
143 let transport: StreamableHTTPServerTransport | null = null
144
145 try {
146 // Create a new server and transport for each request
147 const stateless = await this.createStatelessServer(req)
148 server = stateless.server
149 transport = stateless.transport
150
151 // Handle the request
152 await transport.handleRequest(req.raw, res.raw, body)
153
154 // Clean up after response is sent
155 res.raw.on('finish', async () => {
156 this.logger.debug('[Stateless] Response sent, cleaning up')
157 try {
158 if (transport)
159 await transport.close()
160 if (server)
161 await server.close()
162 }
163 catch (error) {
164 this.logger.error('[Stateless] Error cleaning up:', error)
165 }
166 })
167 }
168 catch (error) {
169 this.logger.error(
170 `[Stateless] Error in stateless request handling: ${error}`,
171 )
172 // Clean up on error
173 try {
174 if (transport)
175 await transport.close()
176 if (server)
177 await server.close()
178 }
179 catch (error) {
180 this.logger.error('[Stateless] Error cleaning up on error:', error)
181 }
182 throw error
183 }
184 }
185
186 /**
187 * Handle requests in stateful mode
188 */
189 async handleStatefulRequest(
190 req: HttpRequest,
191 res: HttpResponse,
192 body: unknown,
193 ): Promise<void> {
194 const sessionId = req.headers['mcp-session-id'] as string | undefined
195
196 this.logger.debug(`[${sessionId || 'New'}] Handling stateful MCP request`)
197
198 // Case 1: New initialization request
199 if (!sessionId && this.isInitializeRequest(body)) {
200 // Validate it's not a batch with multiple requests
201 if (Array.isArray(body) && body.length > 1) {
202 res.status(400).json({
203 jsonrpc: '2.0',
204 error: {
205 code: -32600,
206 message:
207 'Invalid Request: Only one initialization request is allowed',
208 },
209 id: null,
210 })
211 return
212 }
213
214 // Build capabilities
215 const capabilities = buildMcpCapabilities(
216 this.mcpModuleId,
217 this.toolRegistry,
218 this.options,
219 )
220
221 // Create MCP server
222 const mcpServer = new McpServer(
223 { name: this.options.name, version: this.options.version },
224 {
225 capabilities,
226 instructions: this.options.instructions || '',
227 },
228 )
229
230 // Create transport with session management
231 const transport = new StreamableHTTPServerTransport({
232 sessionIdGenerator:
233 this.options.streamableHttp?.sessionIdGenerator
234 || (() => randomUUID()),
235 enableJsonResponse:
236 this.options.streamableHttp?.enableJsonResponse || false,
237 onsessioninitialized: async (sid: string) => {
238 this.logger.debug(`[${sid}] Session initialized, storing references`)
239 // Store all session data
240 this.transports[sid] = transport
241 this.mcpServers[sid] = mcpServer
242
243 // Resolve and store the executor for this session
244 const contextId = ContextIdFactory.getByRequest(req)
245 const executor = await this.moduleRef.resolve(
246 McpExecutorService,
247 contextId,
248 { strict: true },
249 )
250 this.executors[sid] = executor
251
252 // Register request handlers ONCE during initialization
253 executor.registerRequestHandlers(mcpServer, req)
254 },
255 onsessionclosed: async (sid: string) => {
256 this.logger.debug(`[${sid}] Session closed via DELETE`)
257 await this.cleanupSession(sid)
258 },
259 })
260
261 // Connect transport to server
262 await mcpServer.connect(transport)
263
264 // Handle the initialization request
265 await transport.handleRequest(req.raw, res.raw, body)
266
267 this.logger.log(`[${transport.sessionId}] New session initialized`)
268 return
269 }
270
271 // Case 2: Request with session ID
272 if (sessionId) {
273 // Check if session exists
274 if (!this.transports[sessionId]) {
275 this.logger.debug(`[${sessionId}] Session not found`)
276 res.status(404).json({
277 jsonrpc: '2.0',
278 error: {
279 code: -32001,
280 message: 'Session not found',
281 },
282 id: null,
283 })
284 return
285 }
286
287 // Reject re-initialization attempts
288 if (this.isInitializeRequest(body)) {
289 res.status(400).json({
290 jsonrpc: '2.0',
291 error: {
292 code: -32600,
293 message: 'Invalid Request: Server already initialized',
294 },
295 id: null,
296 })
297 return
298 }
299
300 // Use existing transport
301 const transport = this.transports[sessionId]
302
303 this.logger.debug(
304 `[${sessionId}] Handling request with existing session`,
305 )
306
307 // Handle the request with existing transport and handlers
308 await transport.handleRequest(req.raw, res.raw, body)
309 return
310 }
311
312 // Case 3: No session ID and not initialization
313 res.status(400).json({
314 jsonrpc: '2.0',
315 error: {
316 code: -32000,
317 message: 'Bad Request: Mcp-Session-Id header is required',
318 },
319 id: null,
320 })
321 }
322
323 /**
324 * Handle GET requests for SSE streams
325 */
326 async handleGetRequest(req: any, res: any): Promise<void> {
327 const adapter = HttpAdapterFactory.getAdapter(req, res)
328 const adaptedReq = adapter.adaptRequest(req)
329 const adaptedRes = adapter.adaptResponse(res)
330
331 if (this.isStatelessMode) {
332 adaptedRes.status(405).json({
333 jsonrpc: '2.0',
334 error: {
335 code: -32000,
336 message: 'Method not allowed in stateless mode',
337 },
338 id: null,
339 })
340 return
341 }
342
343 const sessionId = adaptedReq.headers['mcp-session-id'] as
344 | string
345 | undefined
346
347 if (!sessionId) {
348 adaptedRes.status(400).json({
349 jsonrpc: '2.0',
350 error: {
351 code: -32000,
352 message: 'Bad Request: Mcp-Session-Id header is required',
353 },
354 id: null,
355 })
356 return
357 }
358
359 if (!this.transports[sessionId]) {
360 this.logger.debug(`[${sessionId}] GET request - session not found`)
361 adaptedRes.status(404).json({
362 jsonrpc: '2.0',
363 error: {
364 code: -32001,
365 message: 'Session not found',
366 },
367 id: null,
368 })
369 return
370 }
371
372 this.logger.debug(`[${sessionId}] Establishing SSE stream`)
373 const transport = this.transports[sessionId]
374 await transport.handleRequest(adaptedReq.raw, adaptedRes.raw)
375 }
376
377 /**
378 * Handle DELETE requests for terminating sessions
379 */
380 async handleDeleteRequest(req: any, res: any): Promise<void> {
381 const adapter = HttpAdapterFactory.getAdapter(req, res)
382 const adaptedReq = adapter.adaptRequest(req)
383 const adaptedRes = adapter.adaptResponse(res)
384
385 if (this.isStatelessMode) {
386 adaptedRes.status(405).json({
387 jsonrpc: '2.0',
388 error: {
389 code: -32000,
390 message: 'Method not allowed in stateless mode',
391 },
392 id: null,
393 })
394 return
395 }
396
397 const sessionId = adaptedReq.headers['mcp-session-id'] as
398 | string
399 | undefined
400
401 if (!sessionId) {
402 adaptedRes.status(400).json({
403 jsonrpc: '2.0',
404 error: {
405 code: -32000,
406 message: 'Bad Request: Mcp-Session-Id header is required',
407 },
408 id: null,
409 })
410 return
411 }
412
413 if (!this.transports[sessionId]) {
414 this.logger.debug(`[${sessionId}] DELETE request - session not found`)
415 adaptedRes.status(404).json({
416 jsonrpc: '2.0',
417 error: {
418 code: -32001,
419 message: 'Session not found',
420 },
421 id: null,
422 })
423 return
424 }
425
426 this.logger.debug(`[${sessionId}] Processing DELETE request`)
427 const transport = this.transports[sessionId]
428
429 // Let transport handle the DELETE request
430 // The onsessionclosed callback will handle cleanup
431 await transport.handleRequest(adaptedReq.raw, adaptedRes.raw)
432 }
433
434 /**
435 * Helper function to detect initialize requests
436 */
437 private isInitializeRequest(body: unknown): boolean {
438 if (Array.isArray(body)) {
439 return body.some(
440 msg =>
441 typeof msg === 'object'
442 && msg !== null
443 && 'method' in msg
444 && msg.method === 'initialize',
445 )
446 }
447 return (
448 typeof body === 'object'
449 && body !== null
450 && 'method' in body
451 && (body as Record<string, unknown>)['method'] === 'initialize'
452 )
453 }
454
455 /**
456 * Clean up session resources
457 */
458 private async cleanupSession(sessionId: string): Promise<void> {
459 if (!sessionId || !this.transports[sessionId]) {
460 return
461 }
462
463 this.logger.debug(`[${sessionId}] Cleaning up session`)
464
465 try {
466 // Close transport if still open
467 const transport = this.transports[sessionId]
468 if (transport) {
469 await transport.close()
470 }
471
472 // Close MCP server
473 const server = this.mcpServers[sessionId]
474 if (server) {
475 await server.close()
476 }
477
478 // Clean up all references
479 delete this.transports[sessionId]
480 delete this.mcpServers[sessionId]
481 delete this.executors[sessionId]
482 }
483 catch (error) {
484 this.logger.error(`[${sessionId}] Error during cleanup:`, error)
485 }
486 }
487
488 /**
489 * Clean up all sessions on module destroy
490 */
491 async onModuleDestroy(): Promise<void> {
492 this.logger.log('Cleaning up all MCP sessions...')
493 const sessionIds = Object.keys(this.transports)
494
495 await Promise.all(
496 sessionIds.map(sessionId => this.cleanupSession(sessionId)),
497 )
498
499 this.logger.log(`Cleaned up ${sessionIds.length} MCP sessions`)
500 }
501 }
502
502 lines TYPESCRIPT