Skip to content

Commit bff46cc

Browse files
committed
Merge origin/main into jerome/publish-examples
Resolve conflicts by: - Keep server-utils.ts next to server.ts (not in src/) - Simplify server-utils.ts to stateless HTTP only (no SSE) - Keep main() with explicit --stdio handling in server.ts - Add integration-server from main for E2E testing - Add npm start alias from main
2 parents c06ffb0 + 9e9f5f0 commit bff46cc

File tree

30 files changed

+1363
-2845
lines changed

30 files changed

+1363
-2845
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ To run all examples together:
5757

5858
```bash
5959
npm install
60-
npm run examples:start
60+
npm start
6161
```
6262

6363
Then open http://localhost:8080/.
Lines changed: 25 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,47 @@
11
/**
2-
* Shared utilities for running MCP servers with HTTP transports.
3-
*
4-
* Supports:
5-
* - Streamable HTTP transport (/mcp) - stateful sessions
6-
* - Legacy SSE transport (/sse, /messages) - backwards compatibility
2+
* Shared utilities for running MCP servers with Streamable HTTP transport.
73
*/
84

9-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
105
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
11-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
6+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
127
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
148
import cors from "cors";
15-
import { randomUUID } from "node:crypto";
169
import type { Request, Response } from "express";
1710

1811
export interface ServerOptions {
19-
/** Port to listen on (required). */
2012
port: number;
21-
/** Server name for logging. */
2213
name?: string;
2314
}
2415

25-
type Transport = StreamableHTTPServerTransport | SSEServerTransport;
26-
27-
/** Session state: transport + its dedicated server instance */
28-
interface Session {
29-
transport: Transport;
30-
server: McpServer;
31-
}
32-
3316
/**
34-
* Starts an MCP server with HTTP transports.
17+
* Starts an MCP server with Streamable HTTP transport in stateless mode.
3518
*
36-
* Provides:
37-
* - /mcp (GET/POST/DELETE): Streamable HTTP with stateful sessions
38-
* - /sse (GET) + /messages (POST): Legacy SSE for older clients
39-
*
40-
* @param createServer - Factory function that creates a new McpServer instance per session.
41-
* Each session needs its own server because McpServer only supports
42-
* one transport at a time.
19+
* @param createServer - Factory function that creates a new McpServer instance per request.
20+
* @param options - Server configuration options.
4321
*/
4422
export async function startServer(
4523
createServer: () => McpServer,
4624
options: ServerOptions,
4725
): Promise<void> {
4826
const { port, name = "MCP Server" } = options;
4927

50-
// Session store: each session has its own transport AND server instance
51-
const sessions = new Map<string, Session>();
52-
53-
// Express app - bind to all interfaces for development/testing
5428
const app = createMcpExpressApp({ host: "0.0.0.0" });
55-
app.use(
56-
cors({
57-
exposedHeaders: ["mcp-session-id"],
58-
}),
59-
);
29+
app.use(cors());
6030

61-
// Streamable HTTP (stateful)
6231
app.all("/mcp", async (req: Request, res: Response) => {
63-
try {
64-
const sessionId = req.headers["mcp-session-id"] as string | undefined;
65-
let session = sessionId ? sessions.get(sessionId) : undefined;
66-
67-
// Session exists but wrong transport type
68-
if (
69-
session &&
70-
!(session.transport instanceof StreamableHTTPServerTransport)
71-
) {
72-
return res.status(400).json({
73-
jsonrpc: "2.0",
74-
error: { code: -32000, message: "Session uses different transport" },
75-
id: null,
76-
});
77-
}
78-
79-
// New session requires initialize request
80-
if (!session) {
81-
if (req.method !== "POST" || !isInitializeRequest(req.body)) {
82-
return res.status(400).json({
83-
jsonrpc: "2.0",
84-
error: { code: -32000, message: "Bad request: not initialized" },
85-
id: null,
86-
});
87-
}
88-
89-
// Create new server instance for this session (McpServer supports only one transport)
90-
const serverInstance = createServer();
32+
const server = createServer();
33+
const transport = new StreamableHTTPServerTransport({
34+
sessionIdGenerator: undefined,
35+
});
9136

92-
const transport = new StreamableHTTPServerTransport({
93-
sessionIdGenerator: () => randomUUID(),
94-
onsessioninitialized: (id) => {
95-
sessions.set(id, { transport, server: serverInstance });
96-
},
97-
});
98-
transport.onclose = () => {
99-
if (transport.sessionId) sessions.delete(transport.sessionId);
100-
};
101-
await serverInstance.connect(transport);
102-
session = { transport, server: serverInstance };
103-
}
37+
res.on("close", () => {
38+
transport.close().catch(() => {});
39+
server.close().catch(() => {});
40+
});
10441

105-
await (session.transport as StreamableHTTPServerTransport).handleRequest(
106-
req,
107-
res,
108-
req.body,
109-
);
42+
try {
43+
await server.connect(transport);
44+
await transport.handleRequest(req, res, req.body);
11045
} catch (error) {
11146
console.error("MCP error:", error);
11247
if (!res.headersSent) {
@@ -119,63 +54,15 @@ export async function startServer(
11954
}
12055
});
12156

122-
// Legacy SSE
123-
app.get("/sse", async (_req: Request, res: Response) => {
124-
try {
125-
// Create new server instance for this session (McpServer supports only one transport)
126-
const serverInstance = createServer();
127-
const transport = new SSEServerTransport("/messages", res);
128-
sessions.set(transport.sessionId, { transport, server: serverInstance });
129-
res.on("close", () => sessions.delete(transport.sessionId));
130-
await serverInstance.connect(transport);
131-
} catch (error) {
132-
console.error("SSE error:", error);
133-
if (!res.headersSent) res.status(500).end();
134-
}
135-
});
136-
137-
app.post("/messages", async (req: Request, res: Response) => {
138-
try {
139-
const session = sessions.get(req.query.sessionId as string);
140-
if (!session || !(session.transport instanceof SSEServerTransport)) {
141-
return res.status(404).json({
142-
jsonrpc: "2.0",
143-
error: { code: -32001, message: "Session not found" },
144-
id: null,
145-
});
146-
}
147-
await session.transport.handlePostMessage(req, res, req.body);
148-
} catch (error) {
149-
console.error("Message error:", error);
150-
if (!res.headersSent) {
151-
res.status(500).json({
152-
jsonrpc: "2.0",
153-
error: { code: -32603, message: "Internal server error" },
154-
id: null,
155-
});
156-
}
157-
}
57+
const httpServer = app.listen(port, () => {
58+
console.log(`${name} listening on http://localhost:${port}/mcp`);
15859
});
15960

160-
return new Promise<void>((resolve, reject) => {
161-
const httpServer = app.listen(port);
61+
const shutdown = () => {
62+
console.log("\nShutting down...");
63+
httpServer.close(() => process.exit(0));
64+
};
16265

163-
httpServer.on("listening", () => {
164-
console.log(`${name} listening on http://localhost:${port}/mcp`);
165-
resolve();
166-
});
167-
168-
httpServer.on("error", (err: Error) => {
169-
reject(err);
170-
});
171-
172-
const shutdown = () => {
173-
console.log("\nShutting down...");
174-
sessions.forEach((session) => session.transport.close().catch(() => {}));
175-
httpServer.close(() => process.exit(0));
176-
};
177-
178-
process.on("SIGINT", shutdown);
179-
process.on("SIGTERM", shutdown);
180-
});
66+
process.on("SIGINT", shutdown);
67+
process.on("SIGTERM", shutdown);
18168
}

examples/basic-server-react/server.ts

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,50 +7,39 @@ import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, RESOURCE_URI_
77
import { startServer } from "./server-utils.js";
88

99
const DIST_DIR = path.join(import.meta.dirname, "dist");
10-
const RESOURCE_URI = "ui://get-time/mcp-app.html";
1110

1211
/**
1312
* Creates a new MCP server instance with tools and resources registered.
14-
* Each HTTP session needs its own server instance because McpServer only supports one transport.
1513
*/
1614
export function createServer(): McpServer {
1715
const server = new McpServer({
18-
name: "Basic MCP App Server (React-based)",
16+
name: "Basic MCP App Server (React)",
1917
version: "1.0.0",
2018
});
2119

22-
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
23-
// resource (the UI it renders). The `_meta` field on the tool links to the
24-
// resource URI, telling hosts which UI to display when the tool executes.
20+
const resourceUri = "ui://get-time/mcp-app.html";
21+
2522
registerAppTool(server,
2623
"get-time",
2724
{
2825
title: "Get Time",
29-
description: "Returns the current server time as an ISO 8601 string.",
26+
description: "Returns the current server time.",
3027
inputSchema: {},
31-
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
28+
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
3229
},
3330
async (): Promise<CallToolResult> => {
34-
const time = new Date().toISOString();
35-
return {
36-
content: [{ type: "text", text: JSON.stringify({ time }) }],
37-
};
31+
return { content: [{ type: "text", text: new Date().toISOString() }] };
3832
},
3933
);
4034

4135
registerAppResource(server,
42-
RESOURCE_URI,
43-
RESOURCE_URI,
36+
resourceUri,
37+
resourceUri,
4438
{ mimeType: RESOURCE_MIME_TYPE },
4539
async (): Promise<ReadResourceResult> => {
4640
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
47-
4841
return {
49-
contents: [
50-
// Per the MCP App specification, "text/html;profile=mcp-app" signals
51-
// to the Host that this resource is indeed for an MCP App UI.
52-
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
53-
],
42+
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
5443
};
5544
},
5645
);
@@ -62,8 +51,8 @@ async function main() {
6251
if (process.argv.includes("--stdio")) {
6352
await createServer().connect(new StdioServerTransport());
6453
} else {
65-
const port = parseInt(process.env.PORT ?? "3101", 10);
66-
await startServer(createServer, { port, name: "Basic MCP App Server (React-based)" });
54+
const port = parseInt(process.env.PORT ?? "3001", 10);
55+
await startServer(createServer, { port, name: "Basic MCP App Server (React)" });
6756
}
6857
}
6958

examples/basic-server-react/src/mcp-app.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,8 @@ const log = {
2020

2121

2222
function extractTime(callToolResult: CallToolResult): string {
23-
const text = callToolResult.content!
24-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
25-
.map((c) => c.text)
26-
.join("");
27-
const { time } = JSON.parse(text) as { time: string };
28-
return time;
23+
const { text } = callToolResult.content?.find((c) => c.type === "text")!;
24+
return text;
2925
}
3026

3127

@@ -37,8 +33,6 @@ function GetTimeApp() {
3733
onAppCreated: (app) => {
3834
app.onteardown = async () => {
3935
log.info("App is being torn down");
40-
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work
41-
log.info("App teardown complete");
4236
return {};
4337
};
4438
app.ontoolinput = async (input) => {
@@ -120,7 +114,7 @@ function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) {
120114

121115
<div className={styles.action}>
122116
<p>
123-
<strong>Server Time:</strong> <code>{serverTime}</code>
117+
<strong>Server Time:</strong> <code id="server-time">{serverTime}</code>
124118
</p>
125119
<button onClick={handleGetTime}>Get Server Time</button>
126120
</div>

0 commit comments

Comments
 (0)