11/**
22 * MCP server factory and Express route handlers (§12.42, §6.14, §12.1).
33 *
4- * SSE transport only (Phase 1 — no WebSocket per §12.42, §16).
5- * One McpServer + SSEServerTransport per SSE connection (SDK 1:1 design).
6- * All tool calls route through the full 13-stage pipeline.
4+ * Dual transport:
5+ * - Streamable HTTP (primary, protocol 2025-11-25) — /mcp
6+ * - SSE (deprecated, backward compat, protocol 2024-11-05) — /sse + /messages
7+ *
8+ * One McpServer + Transport per session. All tool calls route through the
9+ * full 13-stage pipeline.
710 */
811
912import express from 'express' ;
1013import { randomUUID } from 'node:crypto' ;
1114import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' ;
1215import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' ;
16+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' ;
17+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' ;
1318import { logger } from '../config/logger' ;
1419import { registerTools } from './tool-adapter' ;
1520
16- /** Active SSE sessions: sessionId → transport */
17- const sessions = new Map < string , SSEServerTransport > ( ) ;
21+ /** Active sessions: sessionId → transport (both transport types) */
22+ const sessions = new Map < string , SSEServerTransport | StreamableHTTPServerTransport > ( ) ;
23+
24+ /**
25+ * Extract Bearer API key from Authorization header.
26+ */
27+ function extractApiKey ( req : express . Request ) : string | null {
28+ const authHeader = req . headers . authorization ;
29+ if ( ! authHeader || ! authHeader . startsWith ( 'Bearer ' ) ) {
30+ return null ;
31+ }
32+ return authHeader . slice ( 7 ) ;
33+ }
1834
1935/**
20- * Create Express router for MCP endpoint.
36+ * Create Express router for MCP endpoints.
37+ *
38+ * Streamable HTTP (primary):
39+ * POST /mcp — JSON-RPC messages (initialize creates session)
40+ * GET /mcp — SSE subscription for server-initiated notifications
41+ * DELETE /mcp — close session
2142 *
22- * GET /mcp — SSE stream (sends endpoint event with sessionId)
23- * POST /mcp?sessionId=x — JSON-RPC messages routed to the correct transport
43+ * SSE (deprecated, backward compat):
44+ * GET /sse — SSE stream
45+ * POST /messages — JSON-RPC messages
2446 */
2547export function createMcpRouter ( ) : express . Router {
2648 const router = express . Router ( ) ;
2749
28- // -------------------------------------------------------------------------
29- // GET /mcp — SSE stream
30- // -------------------------------------------------------------------------
31- router . get ( '/mcp' , ( req : express . Request , res : express . Response ) => {
32- const authHeader = req . headers . authorization ;
33- if ( ! authHeader || ! authHeader . startsWith ( 'Bearer ' ) ) {
50+ // =========================================================================
51+ // Streamable HTTP transport — /mcp (primary, protocol 2025-11-25)
52+ // =========================================================================
53+
54+ // --- POST /mcp: JSON-RPC messages ---
55+ router . post ( '/mcp' , async ( req : express . Request , res : express . Response ) => {
56+ try {
57+ const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined ;
58+
59+ // Existing session: route to stored transport
60+ if ( sessionId ) {
61+ const transport = sessions . get ( sessionId ) ;
62+ if ( ! transport || ! ( transport instanceof StreamableHTTPServerTransport ) ) {
63+ res . status ( 404 ) . json ( {
64+ jsonrpc : '2.0' ,
65+ error : { code : - 32000 , message : 'Session not found' } ,
66+ id : null ,
67+ } ) ;
68+ return ;
69+ }
70+ await transport . handleRequest ( req , res , req . body ) ;
71+ return ;
72+ }
73+
74+ // New session: must be an initialize request
75+ if ( ! isInitializeRequest ( req . body ) ) {
76+ res . status ( 400 ) . json ( {
77+ jsonrpc : '2.0' ,
78+ error : {
79+ code : - 32600 ,
80+ message : 'Bad Request: first request must be an initialize request' ,
81+ } ,
82+ id : null ,
83+ } ) ;
84+ return ;
85+ }
86+
87+ const apiKey = extractApiKey ( req ) ;
88+ if ( ! apiKey ) {
89+ res . status ( 401 ) . json ( {
90+ error : 'unauthorized' ,
91+ message : 'Missing or invalid Authorization header. Expected: Bearer <api_key>' ,
92+ } ) ;
93+ return ;
94+ }
95+
96+ const requestId = ( req . headers [ 'x-request-id' ] as string ) || randomUUID ( ) ;
97+
98+ const transport = new StreamableHTTPServerTransport ( {
99+ sessionIdGenerator : ( ) => randomUUID ( ) ,
100+ onsessioninitialized : ( sid : string ) => {
101+ sessions . set ( sid , transport ) ;
102+ logger . info (
103+ { request_id : requestId , session_id : sid } ,
104+ 'MCP Streamable HTTP session created' ,
105+ ) ;
106+ } ,
107+ onsessionclosed : ( sid : string ) => {
108+ sessions . delete ( sid ) ;
109+ logger . info (
110+ { request_id : requestId , session_id : sid } ,
111+ 'MCP Streamable HTTP session closed' ,
112+ ) ;
113+ } ,
114+ } ) ;
115+
116+ transport . onerror = ( error : Error ) => {
117+ logger . error (
118+ { request_id : requestId , err : error } ,
119+ 'MCP Streamable HTTP transport error' ,
120+ ) ;
121+ } ;
122+
123+ const mcpServer = new McpServer (
124+ { name : 'APIbase' , version : '1.0.0' } ,
125+ { capabilities : { tools : { } } } ,
126+ ) ;
127+
128+ registerTools ( mcpServer , apiKey , requestId ) ;
129+ await mcpServer . connect ( transport ) ;
130+
131+ await transport . handleRequest ( req , res , req . body ) ;
132+ } catch ( error ) {
133+ logger . error ( { err : error } , 'MCP Streamable HTTP POST error' ) ;
134+ if ( ! res . headersSent ) {
135+ res . status ( 500 ) . json ( { error : 'internal_error' , message : 'MCP request handling failed' } ) ;
136+ }
137+ }
138+ } ) ;
139+
140+ // --- GET /mcp: SSE subscription for server-initiated notifications ---
141+ router . get ( '/mcp' , async ( req : express . Request , res : express . Response ) => {
142+ try {
143+ const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined ;
144+ if ( ! sessionId ) {
145+ res . status ( 400 ) . json ( {
146+ error : 'bad_request' ,
147+ message : 'Missing mcp-session-id header' ,
148+ } ) ;
149+ return ;
150+ }
151+
152+ const transport = sessions . get ( sessionId ) ;
153+ if ( ! transport || ! ( transport instanceof StreamableHTTPServerTransport ) ) {
154+ res . status ( 404 ) . json ( {
155+ jsonrpc : '2.0' ,
156+ error : { code : - 32000 , message : 'Session not found' } ,
157+ id : null ,
158+ } ) ;
159+ return ;
160+ }
161+
162+ await transport . handleRequest ( req , res ) ;
163+ } catch ( error ) {
164+ logger . error ( { err : error } , 'MCP Streamable HTTP GET error' ) ;
165+ if ( ! res . headersSent ) {
166+ res . status ( 500 ) . json ( { error : 'internal_error' , message : 'MCP SSE subscription failed' } ) ;
167+ }
168+ }
169+ } ) ;
170+
171+ // --- DELETE /mcp: close session ---
172+ router . delete ( '/mcp' , async ( req : express . Request , res : express . Response ) => {
173+ try {
174+ const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined ;
175+ if ( ! sessionId ) {
176+ res . status ( 400 ) . json ( {
177+ error : 'bad_request' ,
178+ message : 'Missing mcp-session-id header' ,
179+ } ) ;
180+ return ;
181+ }
182+
183+ const transport = sessions . get ( sessionId ) ;
184+ if ( ! transport || ! ( transport instanceof StreamableHTTPServerTransport ) ) {
185+ res . status ( 404 ) . json ( {
186+ jsonrpc : '2.0' ,
187+ error : { code : - 32000 , message : 'Session not found' } ,
188+ id : null ,
189+ } ) ;
190+ return ;
191+ }
192+
193+ await transport . handleRequest ( req , res ) ;
194+ sessions . delete ( sessionId ) ;
195+ } catch ( error ) {
196+ logger . error ( { err : error } , 'MCP Streamable HTTP DELETE error' ) ;
197+ if ( ! res . headersSent ) {
198+ res . status ( 500 ) . json ( { error : 'internal_error' , message : 'MCP session close failed' } ) ;
199+ }
200+ }
201+ } ) ;
202+
203+ // =========================================================================
204+ // SSE transport — /sse + /messages (deprecated, backward compat)
205+ // =========================================================================
206+
207+ // --- GET /sse: establish SSE stream ---
208+ router . get ( '/sse' , ( req : express . Request , res : express . Response ) => {
209+ const apiKey = extractApiKey ( req ) ;
210+ if ( ! apiKey ) {
34211 res . status ( 401 ) . json ( {
35212 error : 'unauthorized' ,
36213 message : 'Missing or invalid Authorization header. Expected: Bearer <api_key>' ,
37214 } ) ;
38215 return ;
39216 }
40217
41- const apiKey = authHeader . slice ( 7 ) ;
42218 const requestId = ( req . headers [ 'x-request-id' ] as string ) || randomUUID ( ) ;
43219
44- const transport = new SSEServerTransport ( '/mcp ' , res ) ;
220+ const transport = new SSEServerTransport ( '/messages ' , res ) ;
45221 const sessionId = transport . sessionId ;
46222
47223 const mcpServer = new McpServer (
@@ -54,7 +230,6 @@ export function createMcpRouter(): express.Router {
54230 sessions . set ( sessionId , transport ) ;
55231 logger . info ( { request_id : requestId , session_id : sessionId } , 'MCP SSE session created' ) ;
56232
57- // Cleanup on connection close
58233 const cleanup = ( ) : void => {
59234 sessions . delete ( sessionId ) ;
60235 logger . info ( { request_id : requestId , session_id : sessionId } , 'MCP SSE session closed' ) ;
@@ -82,10 +257,8 @@ export function createMcpRouter(): express.Router {
82257 } ) ;
83258 } ) ;
84259
85- // -------------------------------------------------------------------------
86- // POST /mcp — JSON-RPC messages
87- // -------------------------------------------------------------------------
88- router . post ( '/mcp' , ( req : express . Request , res : express . Response ) => {
260+ // --- POST /messages: JSON-RPC messages for SSE transport ---
261+ router . post ( '/messages' , ( req : express . Request , res : express . Response ) => {
89262 const sessionId = req . query . sessionId as string | undefined ;
90263 if ( ! sessionId ) {
91264 res . status ( 400 ) . json ( {
@@ -96,7 +269,7 @@ export function createMcpRouter(): express.Router {
96269 }
97270
98271 const transport = sessions . get ( sessionId ) ;
99- if ( ! transport ) {
272+ if ( ! transport || ! ( transport instanceof SSEServerTransport ) ) {
100273 res . status ( 400 ) . json ( {
101274 error : 'bad_request' ,
102275 message : 'Unknown or expired sessionId' ,
0 commit comments