diff --git a/.prettierignore b/.prettierignore index e2a4cdb1..9c9b32ba 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,4 @@ examples/basic-host/**/*.ts examples/basic-host/**/*.tsx -examples/basic-server-react/**/*.ts -examples/basic-server-react/**/*.tsx -examples/basic-server-vanillajs/**/*.ts -examples/basic-server-vanillajs/**/*.tsx +examples/basic-server-*/**/*.ts +examples/basic-server-*/**/*.tsx diff --git a/README.md b/README.md index d7cb04fe..c7ef87a6 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,13 @@ Or edit your `package.json` manually: Start with these foundational examples to learn the SDK: -- [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) — Example MCP server with tools that return UI Apps (vanilla JS) -- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps (React) -- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps +- [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) — MCP server + MCP App using vanilla JS +- [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — MCP server + MCP App using [React](https://github.com/facebook/react) +- [`examples/basic-server-vue`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vue) — MCP server + MCP App using [Vue](https://github.com/vuejs/vue) +- [`examples/basic-server-svelte`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-svelte) — MCP server + MCP App using [Svelte](https://github.com/sveltejs/svelte) +- [`examples/basic-server-preact`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-preact) — MCP server + MCP App using [Preact](https://github.com/preactjs/preact) +- [`examples/basic-server-solid`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-solid) — MCP server + MCP App using [Solid](https://github.com/solidjs/solid) +- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — MCP host application supporting MCP Apps The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) directory contains additional demo apps showcasing real-world use cases. diff --git a/examples/basic-server-preact/README.md b/examples/basic-server-preact/README.md new file mode 100644 index 00000000..cc98ecb6 --- /dev/null +++ b/examples/basic-server-preact/README.md @@ -0,0 +1,30 @@ +# Example: Basic Server (Preact) + +An MCP App example with a Preact UI. + +> [!TIP] +> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)! + +## Overview + +- Tool registration with a linked UI resource +- Preact UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class +- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) + +## Key Files + +- [`server.ts`](server.ts) - MCP server with tool and resource registration +- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.tsx`](src/mcp-app.tsx) - Preact UI using `App` class + +## Getting Started + +```bash +npm install +npm run dev +``` + +## How It Works + +1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). +2. When the tool is invoked, the Host renders the UI from the resource. +3. The UI uses the MCP App SDK API to communicate with the host and call server tools. diff --git a/examples/basic-server-preact/mcp-app.html b/examples/basic-server-preact/mcp-app.html new file mode 100644 index 00000000..205ff4e7 --- /dev/null +++ b/examples/basic-server-preact/mcp-app.html @@ -0,0 +1,14 @@ + + + + + + + Get Time App + + + +
+ + + diff --git a/examples/basic-server-preact/package.json b/examples/basic-server-preact/package.json new file mode 100644 index 00000000..a7f5dd0e --- /dev/null +++ b/examples/basic-server-preact/package.json @@ -0,0 +1,32 @@ +{ + "name": "basic-server-preact", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.24.0", + "preact": "^10.0.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@preact/preset-vite": "^2.0.0", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^7.0.3", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/basic-server-preact/server.ts b/examples/basic-server-preact/server.ts new file mode 100644 index 00000000..63852b70 --- /dev/null +++ b/examples/basic-server-preact/server.ts @@ -0,0 +1,58 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps/server"; +import { startServer } from "./src/server-utils.js"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); + +/** + * Creates a new MCP server instance with tools and resources registered. + */ +function createServer(): McpServer { + const server = new McpServer({ + name: "Basic MCP App Server (Preact)", + version: "1.0.0", + }); + + // Two-part registration: tool + resource, tied together by the resource URI. + const resourceUri = "ui://get-time/mcp-app.html"; + + // Register a tool with UI metadata. When the host calls this tool, it reads + // `_meta[RESOURCE_URI_META_KEY]` to know which resource to fetch and render + // as an interactive UI. + registerAppTool(server, + "get-time", + { + title: "Get Time", + description: "Returns the current server time as an ISO 8601 string.", + inputSchema: {}, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (): Promise => { + const time = new Date().toISOString(); + return { content: [{ type: "text", text: time }] }; + }, + ); + + // Register the resource, which returns the bundled HTML/JavaScript for the UI. + registerAppResource(server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); + + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} + +startServer(createServer); diff --git a/examples/basic-server-preact/src/global.css b/examples/basic-server-preact/src/global.css new file mode 100644 index 00000000..97cda440 --- /dev/null +++ b/examples/basic-server-preact/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/basic-server-preact/src/mcp-app.module.css b/examples/basic-server-preact/src/mcp-app.module.css new file mode 100644 index 00000000..0c6429aa --- /dev/null +++ b/examples/basic-server-preact/src/mcp-app.module.css @@ -0,0 +1,65 @@ +.main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + + width: 100%; + max-width: 425px; + box-sizing: border-box; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: 1.5rem; + } +} + +.action { + > * { + margin-top: 0; + margin-bottom: 0; + width: 100%; + } + + > * + * { + margin-top: 0.5rem; + } + + /* Consistent font for form inputs (inherits from global.css) */ + textarea, + input { + font-family: inherit; + font-size: inherit; + } + + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--color-primary-hover); + } + } +} + +.notice { + padding: 0.5rem 0.75rem; + color: var(--color-primary); + text-align: center; + font-style: italic; + background-color: var(--color-notice-bg); + + &::before { + content: "ℹ️ "; + font-style: normal; + } +} diff --git a/examples/basic-server-preact/src/mcp-app.tsx b/examples/basic-server-preact/src/mcp-app.tsx new file mode 100644 index 00000000..7257a6e4 --- /dev/null +++ b/examples/basic-server-preact/src/mcp-app.tsx @@ -0,0 +1,142 @@ +/** + * @file App that demonstrates a few features using MCP Apps SDK + Preact. + */ +import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { render } from "preact"; +import styles from "./mcp-app.module.css"; + + +const IMPLEMENTATION = { name: "Get Time App", version: "1.0.0" }; + + +const log = { + info: console.log.bind(console, "[APP]"), + warn: console.warn.bind(console, "[APP]"), + error: console.error.bind(console, "[APP]"), +}; + + +function extractTime(callToolResult: CallToolResult): string { + const { text } = callToolResult.content?.find((c) => c.type === "text")!; + return text; +} + + +function GetTimeApp() { + const [app, setApp] = useState(null); + const [error, setError] = useState(null); + const [toolResult, setToolResult] = useState(null); + + useEffect(() => { + const instance = new App(IMPLEMENTATION); + + instance.ontoolinput = async (input) => { + log.info("Received tool call input:", input); + }; + + instance.ontoolresult = async (result) => { + log.info("Received tool call result:", result); + setToolResult(result); + }; + + instance.onerror = log.error; + + instance + .connect(new PostMessageTransport(window.parent)) + .then(() => setApp(instance)) + .catch(setError); + }, []); + + if (error) return
ERROR: {error.message}
; + if (!app) return
Connecting...
; + + return ; +} + + +interface GetTimeAppInnerProps { + app: App; + toolResult: CallToolResult | null; +} +function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) { + const [serverTime, setServerTime] = useState("Loading..."); + const [messageText, setMessageText] = useState("This is message text."); + const [logText, setLogText] = useState("This is log text."); + const [linkUrl, setLinkUrl] = useState("https://modelcontextprotocol.io/"); + + useEffect(() => { + if (toolResult) { + setServerTime(extractTime(toolResult)); + } + }, [toolResult]); + + const handleGetTime = useCallback(async () => { + try { + log.info("Calling get-time tool..."); + const result = await app.callServerTool({ name: "get-time", arguments: {} }); + log.info("get-time result:", result); + setServerTime(extractTime(result)); + } catch (e) { + log.error(e); + setServerTime("[ERROR]"); + } + }, [app]); + + const handleSendMessage = useCallback(async () => { + const signal = AbortSignal.timeout(5000); + try { + log.info("Sending message text to Host:", messageText); + const { isError } = await app.sendMessage( + { role: "user", content: [{ type: "text", text: messageText }] }, + { signal }, + ); + log.info("Message", isError ? "rejected" : "accepted"); + } catch (e) { + log.error("Message send error:", signal.aborted ? "timed out" : e); + } + }, [app, messageText]); + + const handleSendLog = useCallback(async () => { + log.info("Sending log text to Host:", logText); + await app.sendLog({ level: "info", data: logText }); + }, [app, logText]); + + const handleOpenLink = useCallback(async () => { + log.info("Sending open link request to Host:", linkUrl); + const { isError } = await app.openLink({ url: linkUrl }); + log.info("Open link request", isError ? "rejected" : "accepted"); + }, [app, linkUrl]); + + return ( +
+

Watch activity in the DevTools console!

+ +
+

+ Server Time: {serverTime} +

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + diff --git a/examples/basic-server-svelte/src/global.css b/examples/basic-server-svelte/src/global.css new file mode 100644 index 00000000..97cda440 --- /dev/null +++ b/examples/basic-server-svelte/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/basic-server-svelte/src/mcp-app.ts b/examples/basic-server-svelte/src/mcp-app.ts new file mode 100644 index 00000000..e57b774c --- /dev/null +++ b/examples/basic-server-svelte/src/mcp-app.ts @@ -0,0 +1,5 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./global.css"; + +mount(App, { target: document.getElementById("app")! }); diff --git a/examples/basic-server-svelte/src/server-utils.ts b/examples/basic-server-svelte/src/server-utils.ts new file mode 100644 index 00000000..40524237 --- /dev/null +++ b/examples/basic-server-svelte/src/server-utils.ts @@ -0,0 +1,110 @@ +/** + * Shared utilities for running MCP servers with various transports. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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"; + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * + * @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 startStreamableHttpServer(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 Streamable HTTP transport in stateless mode. + * + * Each request creates a fresh server and transport instance, which are + * closed when the response ends (no session tracking). + * + * The server listens on the port specified by the PORT environment variable, + * defaulting to 3001 if not set. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHttpServer( + 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()); + + 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, + }); + } + } + }); + + 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`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} diff --git a/examples/basic-server-svelte/svelte.config.js b/examples/basic-server-svelte/svelte.config.js new file mode 100644 index 00000000..d0e64483 --- /dev/null +++ b/examples/basic-server-svelte/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/examples/basic-server-svelte/tsconfig.json b/examples/basic-server-svelte/tsconfig.json new file mode 100644 index 00000000..5edb66a8 --- /dev/null +++ b/examples/basic-server-svelte/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": ["src", "server.ts", "*.d.ts"] +} diff --git a/examples/basic-server-svelte/vite.config.ts b/examples/basic-server-svelte/vite.config.ts new file mode 100644 index 00000000..84fcdbd4 --- /dev/null +++ b/examples/basic-server-svelte/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [svelte(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index d3baad23..a756b29a 100644 --- a/examples/basic-server-vanillajs/server.ts +++ b/examples/basic-server-vanillajs/server.ts @@ -1,17 +1,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps/server"; -import { startServer } from "../shared/server-utils.js"; +import { startServer } from "./src/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); -const RESOURCE_URI = "ui://get-time/mcp-app.html"; /** * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. */ function createServer(): McpServer { const server = new McpServer({ @@ -19,37 +16,37 @@ function createServer(): McpServer { version: "1.0.0", }); - // MCP Apps require two-part registration: a tool (what the LLM calls) and a - // resource (the UI it renders). The `_meta` field on the tool links to the - // resource URI, telling hosts which UI to display when the tool executes. + // Two-part registration: tool + resource, tied together by the resource URI. + const resourceUri = "ui://get-time/mcp-app.html"; + + // Register a tool with UI metadata. When the host calls this tool, it reads + // `_meta[RESOURCE_URI_META_KEY]` to know which resource to fetch and render + // as an interactive UI. registerAppTool(server, "get-time", { title: "Get Time", description: "Returns the current server time as an ISO 8601 string.", inputSchema: {}, - _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI }, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, }, async (): Promise => { const time = new Date().toISOString(); - return { - content: [{ type: "text", text: JSON.stringify({ time }) }], - }; + return { content: [{ type: "text", text: time }] }; }, ); + // Register the resource, which returns the bundled HTML/JavaScript for the UI. registerAppResource(server, - RESOURCE_URI, - RESOURCE_URI, + resourceUri, + resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise => { const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); return { contents: [ - // Per the MCP App specification, "text/html;profile=mcp-app" signals - // to the Host that this resource is indeed for an MCP App UI. - { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, ], }; }, @@ -58,16 +55,4 @@ function createServer(): McpServer { 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); -}); +startServer(createServer); diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts index 88fcfa15..7acae864 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.ts +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -15,12 +15,8 @@ const log = { function extractTime(result: CallToolResult): string { - const text = result.content! - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - const { time } = JSON.parse(text) as { time: string }; - return time; + const { text } = result.content?.find((c) => c.type === "text")!; + return text; } @@ -40,8 +36,6 @@ const app = new App({ name: "Get Time App", version: "1.0.0" }); app.onteardown = async () => { log.info("App is being torn down"); - await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work - log.info("App teardown complete"); return {}; }; diff --git a/examples/basic-server-vanillajs/src/server-utils.ts b/examples/basic-server-vanillajs/src/server-utils.ts new file mode 100644 index 00000000..40524237 --- /dev/null +++ b/examples/basic-server-vanillajs/src/server-utils.ts @@ -0,0 +1,110 @@ +/** + * Shared utilities for running MCP servers with various transports. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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"; + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * + * @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 startStreamableHttpServer(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 Streamable HTTP transport in stateless mode. + * + * Each request creates a fresh server and transport instance, which are + * closed when the response ends (no session tracking). + * + * The server listens on the port specified by the PORT environment variable, + * defaulting to 3001 if not set. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHttpServer( + 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()); + + 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, + }); + } + } + }); + + 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`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} diff --git a/examples/basic-server-vue/README.md b/examples/basic-server-vue/README.md new file mode 100644 index 00000000..1f96fba3 --- /dev/null +++ b/examples/basic-server-vue/README.md @@ -0,0 +1,30 @@ +# Example: Basic Server (Vue) + +An MCP App example with a Vue 3 UI using the Composition API. + +> [!TIP] +> Looking for a vanilla JavaScript example? See [`basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs)! + +## Overview + +- Tool registration with a linked UI resource +- Vue 3 UI using the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class +- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) + +## Key Files + +- [`server.ts`](server.ts) - MCP server with tool and resource registration +- [`mcp-app.html`](mcp-app.html) / [`src/App.vue`](src/App.vue) - Vue 3 UI using `App` class + +## Getting Started + +```bash +npm install +npm run dev +``` + +## How It Works + +1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). +2. When the tool is invoked, the Host renders the UI from the resource. +3. The UI uses the MCP App SDK API to communicate with the host and call server tools. diff --git a/examples/basic-server-vue/env.d.ts b/examples/basic-server-vue/env.d.ts new file mode 100644 index 00000000..cd390877 --- /dev/null +++ b/examples/basic-server-vue/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent; + export default component; +} diff --git a/examples/basic-server-vue/mcp-app.html b/examples/basic-server-vue/mcp-app.html new file mode 100644 index 00000000..6bac3c97 --- /dev/null +++ b/examples/basic-server-vue/mcp-app.html @@ -0,0 +1,14 @@ + + + + + + + Get Time App + + + +
+ + + diff --git a/examples/basic-server-vue/package.json b/examples/basic-server-vue/package.json new file mode 100644 index 00000000..d1719fcc --- /dev/null +++ b/examples/basic-server-vue/package.json @@ -0,0 +1,32 @@ +{ + "name": "basic-server-vue", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun server.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.24.0", + "vue": "^3.5.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@vitejs/plugin-vue": "^5.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^7.0.3", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/basic-server-vue/server.ts b/examples/basic-server-vue/server.ts new file mode 100644 index 00000000..e6229701 --- /dev/null +++ b/examples/basic-server-vue/server.ts @@ -0,0 +1,58 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps/server"; +import { startServer } from "./src/server-utils.js"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); + +/** + * Creates a new MCP server instance with tools and resources registered. + */ +function createServer(): McpServer { + const server = new McpServer({ + name: "Basic MCP App Server (Vue)", + version: "1.0.0", + }); + + // Two-part registration: tool + resource, tied together by the resource URI. + const resourceUri = "ui://get-time/mcp-app.html"; + + // Register a tool with UI metadata. When the host calls this tool, it reads + // `_meta[RESOURCE_URI_META_KEY]` to know which resource to fetch and render + // as an interactive UI. + registerAppTool(server, + "get-time", + { + title: "Get Time", + description: "Returns the current server time as an ISO 8601 string.", + inputSchema: {}, + _meta: { [RESOURCE_URI_META_KEY]: resourceUri }, + }, + async (): Promise => { + const time = new Date().toISOString(); + return { content: [{ type: "text", text: time }] }; + }, + ); + + // Register the resource, which returns the bundled HTML/JavaScript for the UI. + registerAppResource(server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); + + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} + +startServer(createServer); diff --git a/examples/basic-server-vue/src/App.vue b/examples/basic-server-vue/src/App.vue new file mode 100644 index 00000000..2b8f9f9c --- /dev/null +++ b/examples/basic-server-vue/src/App.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/examples/basic-server-vue/src/global.css b/examples/basic-server-vue/src/global.css new file mode 100644 index 00000000..97cda440 --- /dev/null +++ b/examples/basic-server-vue/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/basic-server-vue/src/mcp-app.ts b/examples/basic-server-vue/src/mcp-app.ts new file mode 100644 index 00000000..92a6bd53 --- /dev/null +++ b/examples/basic-server-vue/src/mcp-app.ts @@ -0,0 +1,5 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./global.css"; + +createApp(App).mount("#app"); diff --git a/examples/basic-server-vue/src/server-utils.ts b/examples/basic-server-vue/src/server-utils.ts new file mode 100644 index 00000000..40524237 --- /dev/null +++ b/examples/basic-server-vue/src/server-utils.ts @@ -0,0 +1,110 @@ +/** + * Shared utilities for running MCP servers with various transports. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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"; + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * + * @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 startStreamableHttpServer(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 Streamable HTTP transport in stateless mode. + * + * Each request creates a fresh server and transport instance, which are + * closed when the response ends (no session tracking). + * + * The server listens on the port specified by the PORT environment variable, + * defaulting to 3001 if not set. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHttpServer( + 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()); + + 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, + }); + } + } + }); + + 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`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} diff --git a/examples/basic-server-vue/tsconfig.json b/examples/basic-server-vue/tsconfig.json new file mode 100644 index 00000000..5edb66a8 --- /dev/null +++ b/examples/basic-server-vue/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": ["src", "server.ts", "*.d.ts"] +} diff --git a/examples/basic-server-vue/vite.config.ts b/examples/basic-server-vue/vite.config.ts new file mode 100644 index 00000000..8baddce6 --- /dev/null +++ b/examples/basic-server-vue/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [vue(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index a6bd782c..2796fa78 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -5,7 +5,6 @@ * and industry benchmarks by company stage. */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { CallToolResult, ReadResourceResult, @@ -19,7 +18,7 @@ import { registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server"; -import { startServer } from "../shared/server-utils.js"; +import { startServer } from "./src/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -232,7 +231,6 @@ const resourceUri = "ui://budget-allocator/mcp-app.html"; /** * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. */ function createServer(): McpServer { const server = new McpServer({ @@ -307,20 +305,4 @@ function createServer(): McpServer { return server; } -// --------------------------------------------------------------------------- -// Server Startup -// --------------------------------------------------------------------------- - -async function main() { - if (process.argv.includes("--stdio")) { - await createServer().connect(new StdioServerTransport()); - } else { - const port = parseInt(process.env.PORT ?? "3103", 10); - await startServer(createServer, { port, name: "Budget Allocator Server" }); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); +startServer(createServer); diff --git a/examples/budget-allocator-server/src/server-utils.ts b/examples/budget-allocator-server/src/server-utils.ts new file mode 100644 index 00000000..40524237 --- /dev/null +++ b/examples/budget-allocator-server/src/server-utils.ts @@ -0,0 +1,110 @@ +/** + * Shared utilities for running MCP servers with various transports. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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"; + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * + * @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 startStreamableHttpServer(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 Streamable HTTP transport in stateless mode. + * + * Each request creates a fresh server and transport instance, which are + * closed when the response ends (no session tracking). + * + * The server listens on the port specified by the PORT environment variable, + * defaulting to 3001 if not set. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHttpServer( + 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()); + + 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, + }); + } + } + }); + + 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`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts index 1df2d128..4c26243c 100644 --- a/examples/cohort-heatmap-server/server.ts +++ b/examples/cohort-heatmap-server/server.ts @@ -1,5 +1,4 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; @@ -10,7 +9,7 @@ import { registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server"; -import { startServer } from "../shared/server-utils.js"; +import { startServer } from "./src/server-utils.js"; const DIST_DIR = path.join(import.meta.dirname, "dist"); @@ -211,16 +210,4 @@ function createServer(): McpServer { return server; } -async function main() { - if (process.argv.includes("--stdio")) { - await createServer().connect(new StdioServerTransport()); - } else { - const port = parseInt(process.env.PORT ?? "3104", 10); - await startServer(createServer, { port, name: "Cohort Heatmap Server" }); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); +startServer(createServer); diff --git a/examples/cohort-heatmap-server/src/server-utils.ts b/examples/cohort-heatmap-server/src/server-utils.ts new file mode 100644 index 00000000..40524237 --- /dev/null +++ b/examples/cohort-heatmap-server/src/server-utils.ts @@ -0,0 +1,110 @@ +/** + * Shared utilities for running MCP servers with various transports. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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"; + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * + * @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 startStreamableHttpServer(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 Streamable HTTP transport in stateless mode. + * + * Each request creates a fresh server and transport instance, which are + * closed when the response ends (no session tracking). + * + * The server listens on the port specified by the PORT environment variable, + * defaulting to 3001 if not set. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHttpServer( + 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()); + + 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, + }); + } + } + }); + + 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`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} diff --git a/examples/customer-segmentation-server/README.md b/examples/customer-segmentation-server/README.md index 73d3b4b4..76a453e2 100644 --- a/examples/customer-segmentation-server/README.md +++ b/examples/customer-segmentation-server/README.md @@ -63,4 +63,4 @@ The tool is linked to a UI resource via `_meta.ui.resourceUri`. - Generates realistic customer data with Gaussian clustering around segment centers - Each segment has characteristic ranges for revenue, employees, engagement, etc. - Company names generated from word-list combinations (e.g., "Apex Data Corp") -- Data cached in memory for session consistency +- Data cached in memory for consistency across requests diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts index 82967517..b91ccc6d 100644 --- a/examples/customer-segmentation-server/server.ts +++ b/examples/customer-segmentation-server/server.ts @@ -1,5 +1,4 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { CallToolResult, ReadResourceResult, @@ -13,7 +12,7 @@ import { registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server"; -import { startServer } from "../shared/server-utils.js"; +import { startServer } from "./src/server-utils.js"; import { generateCustomers, generateSegmentSummaries, @@ -30,7 +29,7 @@ const GetCustomerDataInputSchema = z.object({ .describe("Filter by segment (default: All)"), }); -// Cache generated data for session consistency +// Cache generated data for consistency across requests let cachedCustomers: Customer[] | null = null; let cachedSegments: SegmentSummary[] | null = null; @@ -58,7 +57,6 @@ function getCustomerData(segmentFilter?: string): { /** * Creates a new MCP server instance with tools and resources registered. - * Each HTTP session needs its own server instance because McpServer only supports one transport. */ function createServer(): McpServer { const server = new McpServer({ @@ -119,19 +117,4 @@ function createServer(): McpServer { return server; } -async function main() { - if (process.argv.includes("--stdio")) { - await createServer().connect(new StdioServerTransport()); - } else { - const port = parseInt(process.env.PORT ?? "3105", 10); - await startServer(createServer, { - port, - name: "Customer Segmentation Server", - }); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); +startServer(createServer); diff --git a/examples/customer-segmentation-server/src/server-utils.ts b/examples/customer-segmentation-server/src/server-utils.ts new file mode 100644 index 00000000..40524237 --- /dev/null +++ b/examples/customer-segmentation-server/src/server-utils.ts @@ -0,0 +1,110 @@ +/** + * Shared utilities for running MCP servers with various transports. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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"; + +/** + * Starts an MCP server using the appropriate transport based on command-line arguments. + * + * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * + * @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 startStreamableHttpServer(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 Streamable HTTP transport in stateless mode. + * + * Each request creates a fresh server and transport instance, which are + * closed when the response ends (no session tracking). + * + * The server listens on the port specified by the PORT environment variable, + * defaulting to 3001 if not set. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHttpServer( + 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()); + + 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, + }); + } + } + }); + + 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`); + resolve(); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return promise; +} diff --git a/examples/integration-server/README.md b/examples/integration-server/README.md new file mode 100644 index 00000000..ffa1d4e9 --- /dev/null +++ b/examples/integration-server/README.md @@ -0,0 +1,29 @@ +# Example: Integration Test Server + +An MCP App example used for E2E integration testing. + +## Overview + +This example demonstrates all App SDK communication APIs and is used by the E2E test suite to verify host-app interactions: + +- Tool registration with a linked UI resource +- React UI using the [`useApp()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/_modelcontextprotocol_ext-apps_react.useApp.html) hook +- App communication APIs: [`callServerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool), [`sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage), [`sendLog`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendlog), [`openLink`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#openlink) + +## Key Files + +- [`server.ts`](server.ts) - MCP server with tool and resource registration +- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.tsx`](src/mcp-app.tsx) - React UI using `useApp()` hook + +## Getting Started + +```bash +npm install +npm run dev +``` + +## How It Works + +1. The server registers a `get-time` tool with metadata linking it to a UI HTML resource (`ui://get-time/mcp-app.html`). +2. When the tool is invoked, the Host renders the UI from the resource. +3. The UI uses the MCP App SDK API to communicate with the host and call server tools. diff --git a/examples/integration-server/mcp-app.html b/examples/integration-server/mcp-app.html new file mode 100644 index 00000000..205ff4e7 --- /dev/null +++ b/examples/integration-server/mcp-app.html @@ -0,0 +1,14 @@ + + + + + + + Get Time App + + + +
+ + + diff --git a/examples/integration-server/package.json b/examples/integration-server/package.json new file mode 100644 index 00000000..fad8bf7c --- /dev/null +++ b/examples/integration-server/package.json @@ -0,0 +1,37 @@ +{ + "name": "integration-server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve:http": "bun server.ts", + "serve:stdio": "bun server.ts --stdio", + "start": "npm run start:http", + "start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http", + "start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts new file mode 100644 index 00000000..b9ebd3b5 --- /dev/null +++ b/examples/integration-server/server.ts @@ -0,0 +1,67 @@ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { startServer } from "./src/server-utils.js"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); +const RESOURCE_URI = "ui://get-time/mcp-app.html"; + +/** + * Creates a new MCP server instance with tools and resources registered. + */ +function createServer(): McpServer { + const server = new McpServer({ + name: "Integration Test Server", + version: "1.0.0", + }); + + registerAppTool( + server, + "get-time", + { + title: "Get Time", + description: "Returns the current server time as an ISO 8601 string.", + inputSchema: {}, + _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI }, + }, + async (): Promise => { + const time = new Date().toISOString(); + return { + content: [{ type: "text", text: JSON.stringify({ time }) }], + }; + }, + ); + + registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + + return { + contents: [ + { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} + +startServer(createServer); diff --git a/examples/integration-server/src/global.css b/examples/integration-server/src/global.css new file mode 100644 index 00000000..97cda440 --- /dev/null +++ b/examples/integration-server/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; +} + +code { + font-size: 1em; +} diff --git a/examples/integration-server/src/mcp-app.module.css b/examples/integration-server/src/mcp-app.module.css new file mode 100644 index 00000000..0c6429aa --- /dev/null +++ b/examples/integration-server/src/mcp-app.module.css @@ -0,0 +1,65 @@ +.main { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-notice-bg: #eff6ff; + + width: 100%; + max-width: 425px; + box-sizing: border-box; + + > * { + margin-top: 0; + margin-bottom: 0; + } + + > * + * { + margin-top: 1.5rem; + } +} + +.action { + > * { + margin-top: 0; + margin-bottom: 0; + width: 100%; + } + + > * + * { + margin-top: 0.5rem; + } + + /* Consistent font for form inputs (inherits from global.css) */ + textarea, + input { + font-family: inherit; + font-size: inherit; + } + + button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + color: white; + font-weight: bold; + background-color: var(--color-primary); + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--color-primary-hover); + } + } +} + +.notice { + padding: 0.5rem 0.75rem; + color: var(--color-primary); + text-align: center; + font-style: italic; + background-color: var(--color-notice-bg); + + &::before { + content: "ℹ️ "; + font-style: normal; + } +} diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx new file mode 100644 index 00000000..926696c6 --- /dev/null +++ b/examples/integration-server/src/mcp-app.tsx @@ -0,0 +1,167 @@ +/** + * @file App that demonstrates a few features using MCP Apps SDK + React. + */ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { StrictMode, useCallback, useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import styles from "./mcp-app.module.css"; + +const IMPLEMENTATION = { name: "Get Time App", version: "1.0.0" }; + +const log = { + info: console.log.bind(console, "[APP]"), + warn: console.warn.bind(console, "[APP]"), + error: console.error.bind(console, "[APP]"), +}; + +function extractTime(callToolResult: CallToolResult): string { + const text = callToolResult + .content!.filter( + (c): c is { type: "text"; text: string } => c.type === "text", + ) + .map((c) => c.text) + .join(""); + const { time } = JSON.parse(text) as { time: string }; + return time; +} + +function GetTimeApp() { + const [toolResult, setToolResult] = useState(null); + const { app, error } = useApp({ + appInfo: IMPLEMENTATION, + capabilities: {}, + onAppCreated: (app) => { + app.onteardown = async () => { + log.info("App is being torn down"); + await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate cleanup work + log.info("App teardown complete"); + return {}; + }; + app.ontoolinput = async (input) => { + log.info("Received tool call input:", input); + }; + + app.ontoolresult = async (result) => { + log.info("Received tool call result:", result); + setToolResult(result); + }; + + app.onerror = log.error; + }, + }); + + if (error) + return ( +
+ ERROR: {error.message} +
+ ); + if (!app) return
Connecting...
; + + return ; +} + +interface GetTimeAppInnerProps { + app: App; + toolResult: CallToolResult | null; +} +function GetTimeAppInner({ app, toolResult }: GetTimeAppInnerProps) { + const [serverTime, setServerTime] = useState("Loading..."); + const [messageText, setMessageText] = useState("This is message text."); + const [logText, setLogText] = useState("This is log text."); + const [linkUrl, setLinkUrl] = useState("https://modelcontextprotocol.io/"); + + useEffect(() => { + if (toolResult) { + setServerTime(extractTime(toolResult)); + } + }, [toolResult]); + + const handleGetTime = useCallback(async () => { + try { + log.info("Calling get-time tool..."); + const result = await app.callServerTool({ + name: "get-time", + arguments: {}, + }); + log.info("get-time result:", result); + setServerTime(extractTime(result)); + } catch (e) { + log.error(e); + setServerTime("[ERROR]"); + } + }, [app]); + + const handleSendMessage = useCallback(async () => { + const signal = AbortSignal.timeout(5000); + try { + log.info("Sending message text to Host:", messageText); + const { isError } = await app.sendMessage( + { role: "user", content: [{ type: "text", text: messageText }] }, + { signal }, + ); + log.info("Message", isError ? "rejected" : "accepted"); + } catch (e) { + log.error("Message send error:", signal.aborted ? "timed out" : e); + } + }, [app, messageText]); + + const handleSendLog = useCallback(async () => { + log.info("Sending log text to Host:", logText); + await app.sendLog({ level: "info", data: logText }); + }, [app, logText]); + + const handleOpenLink = useCallback(async () => { + log.info("Sending open link request to Host:", linkUrl); + const { isError } = await app.openLink({ url: linkUrl }); + log.info("Open link request", isError ? "rejected" : "accepted"); + }, [app, linkUrl]); + + return ( +
+

Watch activity in the DevTools console!

+ +
+

+ Server Time:{" "} + {serverTime} +

+ +
+ +
+