|
| 1 | +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 2 | +import express, { Request, Response } from "express"; |
| 3 | +import { randomUUID } from "node:crypto"; |
| 4 | +import { z } from "zod"; |
| 5 | +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 6 | +import { |
| 7 | + CallToolResult, |
| 8 | + isInitializeRequest, |
| 9 | + ReadResourceResult, |
| 10 | + Resource, |
| 11 | +} from "@modelcontextprotocol/sdk/types.js"; |
| 12 | +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; |
| 13 | +import cors from "cors"; |
| 14 | +import path from "node:path"; |
| 15 | +import fs from "node:fs/promises"; |
| 16 | +import { fileURLToPath } from "node:url"; |
| 17 | + |
| 18 | +// Define the resource URI meta key inline (from MCP Apps Extension protocol) |
| 19 | +const RESOURCE_URI_META_KEY = "ui/resourceUri"; |
| 20 | + |
| 21 | +const __filename = fileURLToPath(import.meta.url); |
| 22 | +const __dirname = path.dirname(__filename); |
| 23 | + |
| 24 | +// Load UI HTML file from dist/ |
| 25 | +const distDir = path.join(__dirname, "dist"); |
| 26 | +const loadHtml = async (name: string) => { |
| 27 | + const htmlPath = path.join(distDir, `${name}.html`); |
| 28 | + return fs.readFile(htmlPath, "utf-8"); |
| 29 | +}; |
| 30 | + |
| 31 | +// Create an MCP server with UI tools |
| 32 | +const getServer = async () => { |
| 33 | + const server = new McpServer( |
| 34 | + { |
| 35 | + name: "mcp-apps-demo-server", |
| 36 | + version: "1.0.0", |
| 37 | + }, |
| 38 | + { capabilities: { logging: {} } }, |
| 39 | + ); |
| 40 | + |
| 41 | + // Load HTML for the raw UI |
| 42 | + const rawHtml = await loadHtml("ui-raw"); |
| 43 | + |
| 44 | + const registerResource = (resource: Resource, htmlContent: string) => { |
| 45 | + server.registerResource( |
| 46 | + resource.name, |
| 47 | + resource.uri, |
| 48 | + resource, |
| 49 | + async (): Promise<ReadResourceResult> => ({ |
| 50 | + contents: [ |
| 51 | + { |
| 52 | + uri: resource.uri, |
| 53 | + mimeType: resource.mimeType, |
| 54 | + text: htmlContent, |
| 55 | + }, |
| 56 | + ], |
| 57 | + }), |
| 58 | + ); |
| 59 | + return resource; |
| 60 | + }; |
| 61 | + |
| 62 | + // Register the raw UI resource and tool |
| 63 | + { |
| 64 | + const rawResource = registerResource( |
| 65 | + { |
| 66 | + name: "ui-raw-template", |
| 67 | + uri: "ui://raw", |
| 68 | + title: "Raw UI Template", |
| 69 | + description: "A simple raw HTML UI", |
| 70 | + mimeType: "text/html+mcp", |
| 71 | + }, |
| 72 | + rawHtml, |
| 73 | + ); |
| 74 | + |
| 75 | + server.registerTool( |
| 76 | + "create-ui-raw", |
| 77 | + { |
| 78 | + title: "Raw UI", |
| 79 | + description: "A tool that returns a raw HTML UI (no Apps SDK runtime)", |
| 80 | + inputSchema: { |
| 81 | + message: z.string().describe("Message to display"), |
| 82 | + }, |
| 83 | + _meta: { |
| 84 | + [RESOURCE_URI_META_KEY]: rawResource.uri, |
| 85 | + }, |
| 86 | + }, |
| 87 | + async ({ message }): Promise<CallToolResult> => ({ |
| 88 | + content: [{ type: "text", text: JSON.stringify({ message }) }], |
| 89 | + structuredContent: { message }, |
| 90 | + }), |
| 91 | + ); |
| 92 | + } |
| 93 | + |
| 94 | + // Register the get-weather tool (no UI resource, for testing from within UI) |
| 95 | + server.registerTool( |
| 96 | + "get-weather", |
| 97 | + { |
| 98 | + title: "Get Weather", |
| 99 | + description: "Returns current weather for a location", |
| 100 | + inputSchema: { |
| 101 | + location: z.string().describe("Location to get weather for"), |
| 102 | + }, |
| 103 | + }, |
| 104 | + async ({ location }): Promise<CallToolResult> => { |
| 105 | + const temperature = 25; |
| 106 | + const condition = "sunny"; |
| 107 | + return { |
| 108 | + content: [ |
| 109 | + { |
| 110 | + type: "text", |
| 111 | + text: `The weather in ${location} is ${condition}, ${temperature}°C.`, |
| 112 | + }, |
| 113 | + ], |
| 114 | + structuredContent: { temperature, condition }, |
| 115 | + }; |
| 116 | + }, |
| 117 | + ); |
| 118 | + |
| 119 | + return server; |
| 120 | +}; |
| 121 | + |
| 122 | +const MCP_PORT = process.env.MCP_PORT |
| 123 | + ? parseInt(process.env.MCP_PORT, 10) |
| 124 | + : 3001; |
| 125 | + |
| 126 | +const app = express(); |
| 127 | +app.use(express.json()); |
| 128 | +app.use( |
| 129 | + cors({ |
| 130 | + origin: "*", |
| 131 | + exposedHeaders: ["Mcp-Session-Id"], |
| 132 | + }), |
| 133 | +); |
| 134 | + |
| 135 | +// Map to store transports by session ID |
| 136 | +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; |
| 137 | + |
| 138 | +const mcpPostHandler = async (req: Request, res: Response) => { |
| 139 | + const sessionId = req.headers["mcp-session-id"] as string | undefined; |
| 140 | + |
| 141 | + try { |
| 142 | + let transport: StreamableHTTPServerTransport; |
| 143 | + if (sessionId && transports[sessionId]) { |
| 144 | + transport = transports[sessionId]; |
| 145 | + } else if (!sessionId && isInitializeRequest(req.body)) { |
| 146 | + const eventStore = new InMemoryEventStore(); |
| 147 | + transport = new StreamableHTTPServerTransport({ |
| 148 | + sessionIdGenerator: () => randomUUID(), |
| 149 | + eventStore, |
| 150 | + onsessioninitialized: (sessionId) => { |
| 151 | + console.log(`Session initialized: ${sessionId}`); |
| 152 | + transports[sessionId] = transport; |
| 153 | + }, |
| 154 | + }); |
| 155 | + |
| 156 | + transport.onclose = () => { |
| 157 | + const sid = transport.sessionId; |
| 158 | + if (sid && transports[sid]) { |
| 159 | + console.log(`Session closed: ${sid}`); |
| 160 | + delete transports[sid]; |
| 161 | + } |
| 162 | + }; |
| 163 | + |
| 164 | + const server = await getServer(); |
| 165 | + await server.connect(transport); |
| 166 | + await transport.handleRequest(req, res, req.body); |
| 167 | + return; |
| 168 | + } else { |
| 169 | + res.status(400).json({ |
| 170 | + jsonrpc: "2.0", |
| 171 | + error: { code: -32000, message: "Bad Request: No valid session ID" }, |
| 172 | + id: null, |
| 173 | + }); |
| 174 | + return; |
| 175 | + } |
| 176 | + |
| 177 | + await transport.handleRequest(req, res, req.body); |
| 178 | + } catch (error) { |
| 179 | + console.error("Error handling MCP request:", error); |
| 180 | + if (!res.headersSent) { |
| 181 | + res.status(500).json({ |
| 182 | + jsonrpc: "2.0", |
| 183 | + error: { code: -32603, message: "Internal server error" }, |
| 184 | + id: null, |
| 185 | + }); |
| 186 | + } |
| 187 | + } |
| 188 | +}; |
| 189 | + |
| 190 | +app.post("/mcp", mcpPostHandler); |
| 191 | + |
| 192 | +app.get("/mcp", async (req: Request, res: Response) => { |
| 193 | + const sessionId = req.headers["mcp-session-id"] as string | undefined; |
| 194 | + if (!sessionId || !transports[sessionId]) { |
| 195 | + res.status(400).send("Invalid or missing session ID"); |
| 196 | + return; |
| 197 | + } |
| 198 | + const transport = transports[sessionId]; |
| 199 | + await transport.handleRequest(req, res); |
| 200 | +}); |
| 201 | + |
| 202 | +app.delete("/mcp", async (req: Request, res: Response) => { |
| 203 | + const sessionId = req.headers["mcp-session-id"] as string | undefined; |
| 204 | + if (!sessionId || !transports[sessionId]) { |
| 205 | + res.status(400).send("Invalid or missing session ID"); |
| 206 | + return; |
| 207 | + } |
| 208 | + try { |
| 209 | + const transport = transports[sessionId]; |
| 210 | + await transport.handleRequest(req, res); |
| 211 | + } catch (error) { |
| 212 | + console.error("Error handling session termination:", error); |
| 213 | + if (!res.headersSent) { |
| 214 | + res.status(500).send("Error processing session termination"); |
| 215 | + } |
| 216 | + } |
| 217 | +}); |
| 218 | + |
| 219 | +app.listen(MCP_PORT, () => { |
| 220 | + console.log(`MCP Server listening on http://localhost:${MCP_PORT}/mcp`); |
| 221 | +}); |
| 222 | + |
| 223 | +process.on("SIGINT", async () => { |
| 224 | + console.log("Shutting down..."); |
| 225 | + for (const sessionId in transports) { |
| 226 | + try { |
| 227 | + await transports[sessionId].close(); |
| 228 | + delete transports[sessionId]; |
| 229 | + } catch (error) { |
| 230 | + console.error(`Error closing session ${sessionId}:`, error); |
| 231 | + } |
| 232 | + } |
| 233 | + process.exit(0); |
| 234 | +}); |
0 commit comments