Skip to content

Commit ce4f116

Browse files
committed
feat: enhance MCP server functionality with new ServerRuntime interface and improved graceful shutdown process
1 parent 0aea92b commit ce4f116

2 files changed

Lines changed: 102 additions & 43 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"url":"http://127.0.0.1:5181","routeLogs":"/__client-logs","timestamp":1755771840799,"pid":51019}
1+
{"url":"http://127.0.0.1:5179","routeLogs":"/__client-logs","timestamp":1755775962617,"pid":14791}

packages/mcp/src/server.ts

Lines changed: 101 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createServer as createNodeServer } from 'node:http';
1+
import { createServer as createNodeServer, type Server as NodeHttpServer } from 'node:http';
22
import { randomUUID } from 'node:crypto';
33

44
import { createApp, createRouter, defineEventHandler, getQuery, readRawBody, setResponseStatus, toNodeListener } from 'h3';
@@ -25,13 +25,21 @@ interface HttpOptions {
2525

2626
export 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
*/
3139
export 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
*/
6676
async 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

184222
async 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

Comments
 (0)