1- import { createServer as createNodeServer } from 'node:http' ;
1+ import { createServer as createNodeServer , type Server as NodeHttpServer } from 'node:http' ;
22import { randomUUID } from 'node:crypto' ;
33
44import { createApp , createRouter , defineEventHandler , getQuery , readRawBody , setResponseStatus , toNodeListener } from 'h3' ;
@@ -25,13 +25,21 @@ interface HttpOptions {
2525
2626export type StartOptions = HttpOptions ;
2727
28+ /** New: a runtime you can close without exiting the process */
29+ export interface ServerRuntime {
30+ mcp : McpServer ;
31+ transport : StreamableHTTPServerTransport ;
32+ http : NodeHttpServer ;
33+ close ( ) : Promise < void > ;
34+ }
35+
2836/**
2937 * Starts the given MCP server with HTTP transport.
3038 */
3139export async function startServer (
3240 server : McpServer ,
3341 options : StartOptions ,
34- ) : Promise < void > {
42+ ) : Promise < ServerRuntime > {
3543 const { port, host, endpoint, logsRoute } = options ;
3644
3745 // Create store
@@ -43,29 +51,31 @@ export async function startServer(
4351 registerTools ( context ) ;
4452 registerResources ( context ) ;
4553
46- // Start HTTP server
47- await startHttpServer ( server , store , { host, port, endpoint, logsRoute } ) ;
54+ // Start HTTP server & return a runtime handle
55+ return await startHttpServer ( server , store , { host, port, endpoint, logsRoute } ) ;
4856}
4957
50- export async function stopServer ( server : McpServer ) {
51- try {
52- await server . close ( ) ;
53- }
54- catch ( error ) {
55- console . error ( 'Error occurred during server stop:' , error ) ;
58+ export async function stopServer ( runtime : ServerRuntime | McpServer ) {
59+ // Back-compat: if someone passes the raw MCP server,
60+ // close it but don't exit; prefer passing the runtime.
61+ if ( ( runtime as ServerRuntime ) . close ) {
62+ await ( runtime as ServerRuntime ) . close ( ) ;
63+ return ;
5664 }
57- finally {
58- process . exit ( 0 ) ;
65+ try {
66+ await ( runtime as McpServer ) . close ( ) ;
67+ } catch ( error ) {
68+ console . error ( 'Error occurred during MCP server stop:' , error ) ;
5969 }
6070}
6171
62- /** Start a standalone H3 HTTP server exposing:
72+ /** Start an H3 HTTP server exposing:
6373 * - MCP endpoint (POST for requests, GET for SSE) at {endpoint}
6474 * - Log ingest/diagnostics at {logsRoute} (POST to append, GET to view)
6575 */
6676async function startHttpServer ( mcp : McpServer , store : LogStore , opts : {
6777 host : string ; port : number ; endpoint : `/${string } `; logsRoute : `/${string } `;
68- } ) : Promise < void > {
78+ } ) : Promise < ServerRuntime > {
6979 const app = createApp ( ) ;
7080 const router = createRouter ( ) ;
7181
@@ -100,45 +110,52 @@ async function startHttpServer(mcp: McpServer, store: LogStore, opts: {
100110 return '' ;
101111 }
102112
103- // For GET/DELETE: require session id to prevent ambiguous streams without init
104- if ( method === 'GET' || method === 'DELETE' ) {
105- const sidHeader = ( event . node . req . headers [ 'mcp-session-id' ] as string | undefined ) || '' ;
106- if ( ! sidHeader ) {
107- setResponseStatus ( event , 405 ) ;
108- try {
109- event . node . res . setHeader ( 'Allow' , 'POST' ) ;
110- event . node . res . setHeader ( 'content-type' , 'application/json' ) ;
111- } catch { }
112- return JSON . stringify ( {
113- jsonrpc : '2.0' ,
114- error : {
115- code : - 32000 ,
116- message : 'Method not allowed. First POST InitializeRequest, read Mcp-Session-Id, and include it (plus MCP-Protocol-Version) on subsequent GET/DELETE/POST.'
117- } ,
118- id : null
119- } ) ;
120- }
121- }
122-
123- // Normalize body for POST
113+ // Read/parse body for POST-like early so we can detect "initialize"
124114 let bodyBuf : Buffer | undefined ;
115+ let parsed : any | undefined ;
125116 if ( method === 'POST' || method === 'PUT' || method === 'PATCH' ) {
126117 const raw = await readRawBody ( event ) ;
127118 if ( raw && typeof raw === 'string' ) bodyBuf = Buffer . from ( raw ) ;
128119 else if ( raw && ( raw as any ) instanceof Uint8Array ) bodyBuf = Buffer . from ( raw as any ) ;
120+ if ( bodyBuf ) {
121+ try { parsed = JSON . parse ( bodyBuf . toString ( 'utf-8' ) ) ; } catch { /* ignore */ }
122+ }
123+ }
129124
130- // Allow tool calls without explicit session id by assigning a default
125+ const isInitialize = method === 'POST' && parsed && parsed . method === 'initialize' ;
126+ const headers = event . node . req . headers as Record < string , string | string [ ] | undefined > ;
127+ const sidHeader = ( headers [ 'mcp-session-id' ] as string | undefined ) || '' ;
128+
129+ // For GET/DELETE always require session id.
130+ // For POST require session id unless it's an initialize request (first handshake).
131+ if ( ( method === 'GET' || method === 'DELETE' || ( method === 'POST' && ! isInitialize ) ) && ! sidHeader ) {
132+ setResponseStatus ( event , 405 ) ;
131133 try {
132- const reqHeaders : Record < string , string | string [ ] | undefined > = event . node . req . headers as any ;
133- const existingSid = reqHeaders [ 'mcp-session-id' ] || reqHeaders [ 'Mcp-Session-Id' ] ;
134- if ( ! existingSid ) {
135- ( event . node . req . headers as any ) [ 'mcp-session-id' ] = 'default-session' ;
136- }
134+ event . node . res . setHeader ( 'Allow' , 'POST' ) ;
135+ event . node . res . setHeader ( 'content-type' , 'application/json' ) ;
137136 } catch { }
137+ // If we know the JSON-RPC id from the body, echo it; otherwise null.
138+ const id = parsed ?. id ?? null ;
139+ return JSON . stringify ( {
140+ jsonrpc : '2.0' ,
141+ error : {
142+ code : - 32000 ,
143+ message : 'Method not allowed. First POST initialize (no Mcp-Session-Id), read Mcp-Session-Id from the response, and include it (plus MCP-Protocol-Version) on subsequent GET/DELETE/POST.'
144+ } ,
145+ id
146+ } ) ;
138147 }
139148
149+ // ❌ Removed: never force a "default-session" header.
150+ // Let the transport generate a session id on initialize and return it.
151+
140152 // Delegate to the SDK transport (writes to the Node response directly)
141- await transport . handleRequest ( event . node . req as any , event . node . res as any , bodyBuf ? JSON . parse ( bodyBuf . toString ( 'utf-8' ) ) : undefined ) ;
153+ await transport . handleRequest (
154+ event . node . req as any ,
155+ event . node . res as any ,
156+ // Pass already-parsed JSON to avoid double parse
157+ parsed ?? undefined
158+ ) ;
142159 // h3 will consider the response handled.
143160 return undefined as any ;
144161 } catch ( err ) {
@@ -179,6 +196,27 @@ async function startHttpServer(mcp: McpServer, store: LogStore, opts: {
179196 console . log ( `MCP (Streamable HTTP) listening → http://${ opts . host } :${ opts . port } ${ opts . endpoint } ` ) ;
180197 // eslint-disable-next-line no-console
181198 console . log ( `Log ingest endpoint → http://${ opts . host } :${ opts . port } ${ opts . logsRoute } ` ) ;
199+
200+ const close = async ( ) => {
201+ // Close in order: transport → MCP sessions → HTTP server
202+ try {
203+ if ( typeof ( transport as any ) . close === 'function' ) {
204+ await ( transport as any ) . close ( ) ;
205+ }
206+ } catch ( e ) {
207+ console . warn ( 'Transport close failed:' , e ) ;
208+ }
209+ try {
210+ await mcp . close ( ) ;
211+ } catch ( e ) {
212+ console . warn ( 'MCP close failed:' , e ) ;
213+ }
214+ await new Promise < void > ( ( resolve ) => {
215+ try { nodeServer . close ( ( ) => resolve ( ) ) ; } catch { resolve ( ) ; }
216+ } ) ;
217+ } ;
218+
219+ return { mcp, transport, http : nodeServer , close } ;
182220}
183221
184222async function advertiseDiscovery ( host : string , port : number , logsRoute : `/${string } `) {
@@ -255,3 +293,24 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) {
255293
256294 return router ;
257295}
296+
297+ // Example usage with the new ServerRuntime:
298+ /*
299+ ```ts
300+ import { createServer, startServer, stopServer } from './server';
301+
302+ async function example() {
303+ const mcp = createServer({ name: 'Browser Echo (Frontend Logs)', version: '1.0.0' });
304+ const runtime = await startServer(mcp, {
305+ type: 'http',
306+ host: 'localhost',
307+ port: 3001,
308+ endpoint: '/mcp',
309+ logsRoute: '/logs'
310+ });
311+
312+ // Later, to stop gracefully:
313+ await stopServer(runtime); // No process.exit, just graceful shutdown
314+ }
315+ ```
316+ */
0 commit comments