diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5c4f7709..1b8a45cf 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -108,6 +108,7 @@ jobs: - cohort-heatmap-server - customer-segmentation-server - map-server + - inlined-app-server - scenario-modeler-server - shadertoy-server - sheet-music-server diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4174e6fe..8ec12f23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,6 +29,7 @@ jobs: ./examples/budget-allocator-server \ ./examples/cohort-heatmap-server \ ./examples/customer-segmentation-server \ + ./examples/inlined-app-server \ ./examples/scenario-modeler-server \ ./examples/system-monitor-server \ ./examples/threejs-server \ diff --git a/examples/inlined-app-server/README.md b/examples/inlined-app-server/README.md new file mode 100644 index 00000000..7b04342c --- /dev/null +++ b/examples/inlined-app-server/README.md @@ -0,0 +1,41 @@ +# Example: Inlined Server + +A minimal MCP App Server example with the UI HTML inlined directly in the server code. + +## Overview + +This example demonstrates the simplest possible MCP App setup: + +- Single-file server with inlined HTML UI +- No build step required (directly import the `@modelcontextprotocol/ext-apps` package from unpkg.com) +- Tool registration with linked UI resource + +## Key Files + +- [`server.ts`](server.ts) - MCP server with inlined HTML UI +- [`server-utils.ts`](server-utils.ts) - HTTP/stdio transport utilities + +## Getting Started + +```bash +npm install +npm start +``` + +The server will start on `http://localhost:3001/mcp`. + +## How It Works + +1. The server defines the UI HTML as a template string directly in the code +2. A resource handler returns this HTML when the UI resource is requested +3. A tool is registered that links to this UI resource via `_meta.ui.resourceUri` +4. When the tool is invoked, the Host fetches and renders the inlined UI + +## Use Cases + +This pattern is ideal for: + +- Quick prototyping +- Simple tools with minimal UI +- Embedding servers in other applications +- Testing and development diff --git a/examples/inlined-app-server/package.json b/examples/inlined-app-server/package.json new file mode 100644 index 00000000..9f896d85 --- /dev/null +++ b/examples/inlined-app-server/package.json @@ -0,0 +1,35 @@ +{ + "name": "@modelcontextprotocol/server-inlined-app", + "version": "0.1.0", + "type": "module", + "description": "Minimal MCP App Server example with inlined HTML UI", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/inlined-app-server" + }, + "license": "MIT", + "main": "server.ts", + "files": [ + "server.ts", + "server-utils.ts" + ], + "scripts": { + "build": "tsc --noEmit", + "serve": "bun server.ts", + "start": "npm run serve" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "typescript": "^5.9.3" + } +} diff --git a/examples/inlined-app-server/server-utils.ts b/examples/inlined-app-server/server-utils.ts new file mode 100644 index 00000000..f480c60e --- /dev/null +++ b/examples/inlined-app-server/server-utils.ts @@ -0,0 +1,180 @@ +/** + * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; + +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. + * + * @param createServer - Factory function that creates a new McpServer instance. + */ +export async function startServer( + createServer: () => McpServer, +): Promise { + try { + if (process.argv.includes("--stdio")) { + await startStdioServer(createServer); + } else { + await startHttpServer(createServer); + } + } catch (e) { + console.error(e); + process.exit(1); + } +} + +/** + * Starts an MCP server with stdio transport. + * + * @param createServer - Factory function that creates a new McpServer instance. + */ +export async function startStdioServer( + createServer: () => McpServer, +): Promise { + await createServer().connect(new StdioServerTransport()); +} + +/** + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). + * + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients + * + * @param createServer - Factory function that creates a new McpServer instance. + */ +export async function startHttpServer( + createServer: () => McpServer, +): Promise { + const port = parseInt(process.env.PORT ?? "3001", 10); + + // Express app - bind to all interfaces for development/testing + const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); + expressApp.use(cors()); + + // Streamable HTTP transport (stateless mode) + expressApp.all("/mcp", async (req: Request, res: Response) => { + // Create fresh server and transport for each request (stateless mode) + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + // Clean up when response ends + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + + const httpServer = expressApp.listen(port, (err?: Error) => { + if (err) return reject(err); + console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/inlined-app-server/server.ts b/examples/inlined-app-server/server.ts new file mode 100644 index 00000000..eaf7f969 --- /dev/null +++ b/examples/inlined-app-server/server.ts @@ -0,0 +1,101 @@ +import { + RESOURCE_MIME_TYPE, + registerAppResource, + registerAppTool, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { startServer } from "./server-utils.js"; + +export function createServer(): McpServer { + const server = new McpServer({ name: "Example Server", version: "1.0.0" }); + const uiHtml = ` + + + + + +
+ + + + `; + const resourceUri = "ui://page"; + + registerAppResource( + server, + "page", + resourceUri, + { + mimeType: RESOURCE_MIME_TYPE, + _meta: { + ui: {}, + }, + }, + () => ({ + contents: [ + { + mimeType: RESOURCE_MIME_TYPE, + text: uiHtml, + uri: resourceUri, + _meta: { + ui: { + csp: { + connectDomains: ["https://unpkg.com"], + resourceDomains: ["https://unpkg.com"], + }, + }, + }, + }, + ], + }), + ); + + registerAppTool( + server, + "show-inlined-example", + { + title: "Show Inlined Example", + inputSchema: { message: z.string() }, + outputSchema: { message: z.string() }, + _meta: { + ui: { resourceUri }, + }, + }, + ({ message }: { message: string }) => ({ + content: [{type: 'text', text: 'Displaying an App'}], + structuredContent: { message: `Server received message: ${message}` }, + _meta: { info: "example metadata" }, + }), + ); + + return server; +} + + +async function main() { + if (process.argv.includes("--stdio")) { + await createServer().connect(new StdioServerTransport()); + } else { + const port = parseInt(process.env.PORT ?? "3102", 10); + await startServer(createServer, { port, name: "Basic MCP App Server (Vanilla JS)" }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/inlined-app-server/tsconfig.json b/examples/inlined-app-server/tsconfig.json new file mode 100644 index 00000000..08ed94c9 --- /dev/null +++ b/examples/inlined-app-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["server.ts", "server-utils.ts"] +}