From 00ff1c8abe58f866cd0186e825832aa7c720ae01 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 11:49:12 -0700 Subject: [PATCH 01/14] cute-dogs --- examples/cute-dogs-server/package.json | 31 ++ examples/cute-dogs-server/server.ts | 297 ++++++++++++++++++++ examples/cute-dogs-server/src/ui-raw.ts | 235 ++++++++++++++++ examples/cute-dogs-server/src/ui-react.tsx | 187 ++++++++++++ examples/cute-dogs-server/src/ui-vanilla.ts | 131 +++++++++ examples/cute-dogs-server/tsconfig.json | 20 ++ examples/cute-dogs-server/ui-raw.html | 12 + examples/cute-dogs-server/ui-react.html | 12 + examples/cute-dogs-server/ui-vanilla.html | 12 + examples/cute-dogs-server/vite.config.ts | 25 ++ 10 files changed, 962 insertions(+) create mode 100644 examples/cute-dogs-server/package.json create mode 100644 examples/cute-dogs-server/server.ts create mode 100644 examples/cute-dogs-server/src/ui-raw.ts create mode 100644 examples/cute-dogs-server/src/ui-react.tsx create mode 100644 examples/cute-dogs-server/src/ui-vanilla.ts create mode 100644 examples/cute-dogs-server/tsconfig.json create mode 100644 examples/cute-dogs-server/ui-raw.html create mode 100644 examples/cute-dogs-server/ui-react.html create mode 100644 examples/cute-dogs-server/ui-vanilla.html create mode 100644 examples/cute-dogs-server/vite.config.ts diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json new file mode 100644 index 00000000..c21ac38d --- /dev/null +++ b/examples/cute-dogs-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "cute-dogs-server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "NODE_ENV=development npm run build && npm run server", + "build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'", + "server": "bun server.ts" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@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.7.2", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts new file mode 100644 index 00000000..b329d6b1 --- /dev/null +++ b/examples/cute-dogs-server/server.ts @@ -0,0 +1,297 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import express, { Request, Response } from "express"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { + CallToolResult, + isInitializeRequest, + ReadResourceResult, + Resource, +} from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; +import cors from "cors"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load both UI HTML files from dist/ +const distDir = path.join(__dirname, "dist"); +const loadHtml = async (name: string) => { + const htmlPath = path.join(distDir, `${name}.html`); + return fs.readFile(htmlPath, "utf-8"); +}; + +// Create an MCP server with both UI tools +const getServer = async () => { + const server = new McpServer( + { + name: "simple-mcp-server", + version: "1.0.0", + }, + { capabilities: { logging: {} } }, + ); + + // Load HTML for both UIs + const [rawHtml, vanillaHtml, reactHtml] = await Promise.all([ + loadHtml("ui-raw"), + loadHtml("ui-vanilla"), + loadHtml("ui-react"), + ]); + + const registerResource = (resource: Resource, htmlContent: string) => { + server.registerResource( + resource.name, + resource.uri, + resource, + async (): Promise => ({ + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType, + text: htmlContent, + }, + ], + }), + ); + return resource; + }; + + { + const rawResource = registerResource( + { + name: "ui-raw-template", + uri: "ui://raw", + title: "Raw UI Template", + description: "A simple raw HTML UI", + mimeType: "text/html+mcp", + }, + rawHtml, + ); + + server.registerTool( + "create-ui-raw", + { + title: "Raw UI", + description: "A tool that returns a raw HTML UI (no Apps SDK runtime)", + inputSchema: { + message: z.string().describe("Message to display"), + }, + _meta: { + [RESOURCE_URI_META_KEY]: rawResource.uri, + }, + }, + async ({ message }): Promise => ({ + content: [{ type: "text", text: JSON.stringify({ message }) }], + structuredContent: { message }, + }), + ); + } + + { + const vanillaResource = registerResource( + { + name: "ui-vanilla-template", + uri: "ui://vanilla", + title: "Vanilla UI Template", + description: "A simple vanilla JS UI", + mimeType: "text/html+mcp", + }, + vanillaHtml, + ); + + server.registerTool( + "create-ui-vanilla", + { + title: "Vanilla UI", + description: "A tool that returns a vanilla TS + Apps SDK UI", + inputSchema: { + message: z.string().describe("Message to display"), + }, + _meta: { + [RESOURCE_URI_META_KEY]: vanillaResource.uri, + }, + }, + async ({ message }): Promise => ({ + content: [{ type: "text", text: JSON.stringify({ message }) }], + structuredContent: { message }, + }), + ); + } + + { + const reactResource = registerResource( + { + name: "ui-react-template", + uri: "ui://react", + title: "React UI Template", + description: "A React-based UI", + mimeType: "text/html+mcp", + }, + reactHtml, + ); + + server.registerTool( + "create-ui-react", + { + title: "React UI", + description: "A tool that returns a React-based UI", + inputSchema: { + message: z.string().describe("Message to display"), + }, + _meta: { + [RESOURCE_URI_META_KEY]: reactResource.uri, + }, + }, + async ({ message }): Promise => ({ + content: [{ type: "text", text: JSON.stringify({ message }) }], + structuredContent: { message }, + }), + ); + } + + // --- Common tool: get-weather --- + server.registerTool( + "get-weather", + { + title: "Get Weather", + description: "Returns current weather for a location", + inputSchema: { + location: z.string().describe("Location to get weather for"), + }, + }, + async ({ location }): Promise => { + const temperature = 25; + const condition = "sunny"; + return { + content: [ + { + type: "text", + text: `The weather in ${location} is ${condition}, ${temperature}°C.`, + }, + ], + structuredContent: { temperature, condition }, + }; + }, + ); + + return server; +}; + +const MCP_PORT = process.env.MCP_PORT + ? parseInt(process.env.MCP_PORT, 10) + : 3001; + +const app = express(); +app.use(express.json()); +app.use( + cors({ + origin: "*", + exposedHeaders: ["Mcp-Session-Id"], + }), +); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + onsessioninitialized: (sessionId) => { + console.log(`Session initialized: ${sessionId}`); + transports[sessionId] = transport; + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Session closed: ${sid}`); + delete transports[sid]; + } + }; + + const server = await getServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: No valid session ID" }, + id: null, + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +}; + +app.post("/mcp", mcpPostHandler); + +app.get("/mcp", async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send("Invalid or missing session ID"); + return; + } + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +app.delete("/mcp", async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send("Invalid or missing session ID"); + return; + } + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error("Error handling session termination:", error); + if (!res.headersSent) { + res.status(500).send("Error processing session termination"); + } + } +}); + +app.listen(MCP_PORT, () => { + console.log(`MCP Server listening on http://localhost:${MCP_PORT}/mcp`); +}); + +process.on("SIGINT", async () => { + console.log("Shutting down..."); + for (const sessionId in transports) { + try { + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing session ${sessionId}:`, error); + } + } + process.exit(0); +}); diff --git a/examples/cute-dogs-server/src/ui-raw.ts b/examples/cute-dogs-server/src/ui-raw.ts new file mode 100644 index 00000000..1cf2b2ea --- /dev/null +++ b/examples/cute-dogs-server/src/ui-raw.ts @@ -0,0 +1,235 @@ +/** + * @file App that does NOT depend on Apps SDK runtime. + * + * The Raw UI example has no runtime dependency to the Apps SDK + * but still imports its types for static type safety. + * Types can be just stripped, e.g. w/ the command line: + * + * + * npx esbuild src/ui-raw.ts --bundle --outfile=dist/ui-raw.js --minify --sourcemap --platform=browser + * + * + * We implement a barebones JSON-RPC message sender/receiver (see `app` object below), + * but without timeouts or runtime type validation of any kind + * (for that, use the Apps SDK / see ui-vanilla.ts or ui-react.ts). + */ + +import type { + McpUiInitializeRequest, + McpUiInitializeResult, + McpUiInitializedNotification, + McpUiToolResultNotification, + McpUiHostContextChangedNotification, + McpUiToolInputNotification, + McpUiSizeChangeNotification, + McpUiMessageRequest, + McpUiMessageResult, + McpUiOpenLinkRequest, + McpUiOpenLinkResult, +} from "@modelcontextprotocol/ext-apps"; + +import type { + CallToolRequest, + CallToolResult, + JSONRPCMessage, + LoggingMessageNotification, +} from "@modelcontextprotocol/sdk/types.js"; + +const app = (() => { + type Sendable = { method: string; params: any }; + + let nextId = 1; + + return { + sendRequest({ method, params }: T) { + const id = nextId++; + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); + return new Promise((resolve, reject) => { + window.addEventListener("message", function listener(event) { + const data: JSONRPCMessage = event.data; + if (event.data?.id === id) { + window.removeEventListener("message", listener); + if (event.data?.result) { + resolve(event.data.result as Result); + } else if (event.data?.error) { + reject(new Error(event.data.error)); + } + } else { + reject(new Error(`Unsupported message: ${JSON.stringify(data)}`)); + } + }); + }); + }, + sendNotification({ method, params }: T) { + window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); + }, + onNotification( + method: T["method"], + handler: (params: T["params"]) => void, + ) { + window.addEventListener("message", function listener(event) { + if (event.data?.method === method) { + handler(event.data.params); + } + }); + }, + }; +})(); + +window.addEventListener("load", async () => { + const root = document.getElementById("root")!; + const appendText = (textContent: string, opts = {}) => { + root.appendChild( + Object.assign(document.createElement("div"), { + textContent, + ...opts, + }), + ); + }; + const appendError = (error: unknown) => + appendText( + `Error: ${error instanceof Error ? error.message : String(error)}`, + { style: "color: red;" }, + ); + + app.onNotification( + "ui/notifications/tool-input", + async (params) => { + appendText(`Tool call input: ${JSON.stringify(params)}`); + }, + ); + app.onNotification( + "ui/notifications/tool-result", + async (params) => { + appendText(`Tool call result: ${JSON.stringify(params)}`); + }, + ); + app.onNotification( + "ui/notifications/host-context-changed", + async (params) => { + appendText(`Host context changed: ${JSON.stringify(params)}`); + }, + ); + + const initializeResult = await app.sendRequest< + McpUiInitializeRequest, + McpUiInitializeResult + >({ + method: "ui/initialize", + params: { + appCapabilities: {}, + appInfo: { name: "My UI", version: "1.0.0" }, + protocolVersion: "2025-06-18", + }, + }); + + appendText(`Initialize result: ${JSON.stringify(initializeResult)}`); + + app.sendNotification({ + method: "ui/notifications/initialized", + params: {}, + }); + + new ResizeObserver(() => { + const rect = ( + document.body.parentElement ?? document.body + ).getBoundingClientRect(); + const width = Math.ceil(rect.width); + const height = Math.ceil(rect.height); + app.sendNotification({ + method: "ui/notifications/size-change", + params: { width, height }, + }); + }).observe(document.body); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Get Weather (Tool)", + onclick: async () => { + try { + const result = await app.sendRequest( + { + method: "tools/call", + params: { + name: "get-weather", + arguments: { location: "Tokyo" }, + }, + }, + ); + + appendText(`Weather tool result: ${JSON.stringify(result)}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Notify Cart Updated", + onclick: async () => { + app.sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: "cart-updated", + }, + }); + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Prompt Weather in Tokyo", + onclick: async () => { + try { + const { isError } = await app.sendRequest< + McpUiMessageRequest, + McpUiMessageResult + >({ + method: "ui/message", + params: { + role: "user", + content: [ + { + type: "text", + text: "What is the weather in Tokyo?", + }, + ], + }, + }); + + appendText(`Message result: ${isError ? "error" : "success"}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Open Link to Google", + onclick: async () => { + try { + const { isError } = await app.sendRequest< + McpUiOpenLinkRequest, + McpUiOpenLinkResult + >({ + method: "ui/open-link", + params: { + url: "https://www.google.com", + }, + }); + appendText(`Link result: ${isError ? "error" : "success"}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + console.log("Initialized with host info:", initializeResult.hostInfo); +}); diff --git a/examples/cute-dogs-server/src/ui-react.tsx b/examples/cute-dogs-server/src/ui-react.tsx new file mode 100644 index 00000000..a8239e93 --- /dev/null +++ b/examples/cute-dogs-server/src/ui-react.tsx @@ -0,0 +1,187 @@ +/** + * @file App that demonstrates a few features using React + the Apps SDK. + */ +import { useState, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { + useApp, + McpUiSizeChangeNotificationSchema, + McpUiToolResultNotificationSchema, +} from "@modelcontextprotocol/ext-apps/react"; +import type { + CallToolResult, + Implementation, +} from "@modelcontextprotocol/sdk/types.js"; + +const APP_INFO: Implementation = { + name: "MCP UI React Example Client", + version: "1.0.0", +}; + +export function McpClientApp() { + const [toolResults, setToolResults] = useState([]); + const [messages, setMessages] = useState([]); + + const { app, isConnected, error } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (params) => { + setToolResults((prev) => [...prev, params]); + }; + }, + }); + + const handleGetWeather = useCallback(async () => { + if (!app) return; + try { + const result = await app.callServerTool({ + name: "get-weather", + arguments: { city: "Tokyo" }, + }); + setMessages((prev) => [ + ...prev, + `Weather tool result: ${JSON.stringify(result)}`, + ]); + } catch (e) { + setMessages((prev) => [...prev, `Tool call error: ${e}`]); + } + }, [app]); + + const handleNotifyCart = useCallback(async () => { + if (!app) return; + await app.sendLog({ level: "info", data: "cart-updated" }); + setMessages((prev) => [...prev, "Notification sent: cart-updated"]); + }, [app]); + + const handlePromptWeather = useCallback(async () => { + if (!app) return; + const signal = AbortSignal.timeout(5000); + try { + const { isError } = await app.sendMessage( + { + role: "user", + content: [ + { + type: "text", + text: "What is the weather in Tokyo?", + }, + ], + }, + { signal }, + ); + setMessages((prev) => [ + ...prev, + `Prompt result: ${isError ? "error" : "success"}`, + ]); + } catch (e) { + if (signal.aborted) { + setMessages((prev) => [...prev, "Prompt request timed out"]); + return; + } + setMessages((prev) => [...prev, `Prompt error: ${e}`]); + } + }, [app]); + + const handleOpenLink = useCallback(async () => { + if (!app) return; + const { isError } = await app.sendOpenLink({ + url: "https://www.google.com", + }); + setMessages((prev) => [ + ...prev, + `Open link result: ${isError ? "error" : "success"}`, + ]); + }, [app]); + + if (error) { + return ( +
Error connecting: {error.message}
+ ); + } + + if (!isConnected) { + return
Connecting...
; + } + + return ( +
+

MCP UI Client (React)

+ +
+ + + + + + + +
+ + {toolResults.length > 0 && ( +
+

Tool Results:

+ {toolResults.map((result, i) => ( +
+ isError: {String(result.isError ?? false)} +
+ content: {JSON.stringify(result.content)} +
+ {result.structuredContent && ( + <> + structuredContent:{" "} + {JSON.stringify(result.structuredContent)} + + )} +
+ ))} +
+ )} + + {messages.length > 0 && ( +
+

Messages:

+ {messages.map((msg, i) => ( +
+ {msg} +
+ ))} +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/ui-vanilla.ts b/examples/cute-dogs-server/src/ui-vanilla.ts new file mode 100644 index 00000000..f536a45b --- /dev/null +++ b/examples/cute-dogs-server/src/ui-vanilla.ts @@ -0,0 +1,131 @@ +/** + * @file Demonstrate a few Apps SDK features. + * + * The vanilla (no React) UI uses the Apps SDK. + * + * The Apps SDK offers advantages over the Raw UI example, + * such as ability to set timeouts, strong runtime type validation + * and simpler methods for each request/response interaction. + */ +import { + App, + PostMessageTransport, + McpUiToolInputNotificationSchema, + McpUiToolResultNotificationSchema, + McpUiHostContextChangedNotificationSchema, +} from "@modelcontextprotocol/ext-apps"; + +window.addEventListener("load", async () => { + const root = document.getElementById("root")!; + const appendText = (textContent: string, opts = {}) => { + root.appendChild( + Object.assign(document.createElement("div"), { + textContent, + ...opts, + }), + ); + }; + const appendError = (error: unknown) => + appendText( + `Error: ${error instanceof Error ? error.message : String(error)}`, + { style: "color: red;" }, + ); + + const app = new App({ + name: "MCP UI Client (Vanilla)", + version: "1.0.0", + }); + + app.ontoolinput = (params) => { + appendText(`Tool call input received: ${JSON.stringify(params.arguments)}`); + }; + app.ontoolresult = ({ content, structuredContent, isError }) => { + appendText( + `Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`, + ); + }; + app.onhostcontextchanged = (params) => { + appendText(`Host context changed: ${JSON.stringify(params)}`); + }; + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Get Weather (Tool)", + onclick: async () => { + try { + const result = await app.callServerTool({ + name: "get-weather", + arguments: { location: "Tokyo" }, + }); + appendText(`Weather tool result: ${JSON.stringify(result)}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Notify Cart Updated", + onclick: async () => { + try { + await app.sendLog({ + level: "info", + data: "cart-updated", + }); + } catch (e) { + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Prompt Weather in Tokyo", + onclick: async () => { + const signal = AbortSignal.timeout(5000); + try { + const { isError } = await app.sendMessage( + { + role: "user", + content: [ + { + type: "text", + text: "What is the weather in Tokyo?", + }, + ], + }, + { signal }, + ); + appendText(`Prompt result: ${isError ? "error" : "success"}`); + } catch (e) { + if (signal.aborted) { + appendError("Prompt request timed out"); + return; + } + appendError(e); + } + }, + }), + ); + + root.appendChild( + Object.assign(document.createElement("button"), { + textContent: "Open Link to Google", + onclick: async () => { + try { + const { isError } = await app.sendOpenLink({ + url: "https://www.google.com", + }); + appendText(`Open link result: ${isError ? "error" : "success"}`); + } catch (e) { + appendError(e); + } + }, + }), + ); + + await app.connect(new PostMessageTransport(window.parent)); +}); diff --git a/examples/cute-dogs-server/tsconfig.json b/examples/cute-dogs-server/tsconfig.json new file mode 100644 index 00000000..a4c834a6 --- /dev/null +++ b/examples/cute-dogs-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/cute-dogs-server/ui-raw.html b/examples/cute-dogs-server/ui-raw.html new file mode 100644 index 00000000..cf320849 --- /dev/null +++ b/examples/cute-dogs-server/ui-raw.html @@ -0,0 +1,12 @@ + + + + + + MCP UI Client (Raw) + + +
+ + + diff --git a/examples/cute-dogs-server/ui-react.html b/examples/cute-dogs-server/ui-react.html new file mode 100644 index 00000000..c0e7fd7b --- /dev/null +++ b/examples/cute-dogs-server/ui-react.html @@ -0,0 +1,12 @@ + + + + + + MCP UI Client (React) + + +
+ + + diff --git a/examples/cute-dogs-server/ui-vanilla.html b/examples/cute-dogs-server/ui-vanilla.html new file mode 100644 index 00000000..ecec6c87 --- /dev/null +++ b/examples/cute-dogs-server/ui-vanilla.html @@ -0,0 +1,12 @@ + + + + + + MCP UI Client (Vanilla) + + +
+ + + diff --git a/examples/cute-dogs-server/vite.config.ts b/examples/cute-dogs-server/vite.config.ts new file mode 100644 index 00000000..28d7e5bf --- /dev/null +++ b/examples/cute-dogs-server/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +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: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: `dist`, + emptyOutDir: false, + }, +}); From 59f22203503e8a2d72e040da85639cecf5defb14 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 12:16:02 -0700 Subject: [PATCH 02/14] Dogs --- examples/cute-dogs-server/package.json | 2 +- examples/cute-dogs-server/random-dog.html | 12 + examples/cute-dogs-server/server.ts | 125 ++-------- examples/cute-dogs-server/src/random-dog.tsx | 92 ++++++++ examples/cute-dogs-server/src/ui-raw.ts | 235 ------------------- examples/cute-dogs-server/src/ui-react.tsx | 187 --------------- examples/cute-dogs-server/src/ui-vanilla.ts | 131 ----------- examples/cute-dogs-server/ui-raw.html | 12 - examples/cute-dogs-server/ui-react.html | 12 - examples/cute-dogs-server/ui-vanilla.html | 12 - 10 files changed, 121 insertions(+), 699 deletions(-) create mode 100644 examples/cute-dogs-server/random-dog.html create mode 100644 examples/cute-dogs-server/src/random-dog.tsx delete mode 100644 examples/cute-dogs-server/src/ui-raw.ts delete mode 100644 examples/cute-dogs-server/src/ui-react.tsx delete mode 100644 examples/cute-dogs-server/src/ui-vanilla.ts delete mode 100644 examples/cute-dogs-server/ui-raw.html delete mode 100644 examples/cute-dogs-server/ui-react.html delete mode 100644 examples/cute-dogs-server/ui-vanilla.html diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json index c21ac38d..c3517cf6 100644 --- a/examples/cute-dogs-server/package.json +++ b/examples/cute-dogs-server/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "NODE_ENV=development npm run build && npm run server", - "build": "concurrently 'INPUT=ui-raw.html vite build' 'INPUT=ui-vanilla.html vite build' 'INPUT=ui-react.html vite build'", + "build": "concurrently 'INPUT=random-dog.html vite build'", "server": "bun server.ts" }, "dependencies": { diff --git a/examples/cute-dogs-server/random-dog.html b/examples/cute-dogs-server/random-dog.html new file mode 100644 index 00000000..2a50ffd9 --- /dev/null +++ b/examples/cute-dogs-server/random-dog.html @@ -0,0 +1,12 @@ + + + + + + MCP Random Dog + + +
+ + + diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index b329d6b1..ab465b86 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -1,7 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import express, { Request, Response } from "express"; import { randomUUID } from "node:crypto"; -import { z } from "zod"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolResult, @@ -30,17 +29,15 @@ const loadHtml = async (name: string) => { const getServer = async () => { const server = new McpServer( { - name: "simple-mcp-server", + name: "cute-dogs-mcp-server", version: "1.0.0", }, { capabilities: { logging: {} } }, ); // Load HTML for both UIs - const [rawHtml, vanillaHtml, reactHtml] = await Promise.all([ - loadHtml("ui-raw"), - loadHtml("ui-vanilla"), - loadHtml("ui-react"), + const [randomDogHtml] = await Promise.all([ + loadHtml("random-dog"), ]); const registerResource = (resource: Resource, htmlContent: string) => { @@ -62,123 +59,33 @@ const getServer = async () => { }; { - const rawResource = registerResource( + const randomDogResource = registerResource( { - name: "ui-raw-template", - uri: "ui://raw", - title: "Raw UI Template", - description: "A simple raw HTML UI", + name: "random-dog-template", + uri: "ui://random-dog", + title: "Random Dog Template", + description: "A random dog UI", mimeType: "text/html+mcp", }, - rawHtml, + randomDogHtml, ); server.registerTool( - "create-ui-raw", + "get-random-dog", { - title: "Raw UI", - description: "A tool that returns a raw HTML UI (no Apps SDK runtime)", - inputSchema: { - message: z.string().describe("Message to display"), - }, - _meta: { - [RESOURCE_URI_META_KEY]: rawResource.uri, - }, - }, - async ({ message }): Promise => ({ - content: [{ type: "text", text: JSON.stringify({ message }) }], - structuredContent: { message }, - }), - ); - } - - { - const vanillaResource = registerResource( - { - name: "ui-vanilla-template", - uri: "ui://vanilla", - title: "Vanilla UI Template", - description: "A simple vanilla JS UI", - mimeType: "text/html+mcp", - }, - vanillaHtml, - ); - - server.registerTool( - "create-ui-vanilla", - { - title: "Vanilla UI", - description: "A tool that returns a vanilla TS + Apps SDK UI", - inputSchema: { - message: z.string().describe("Message to display"), - }, + title: "Random Dog", + description: "A tool that returns a random dog", _meta: { - [RESOURCE_URI_META_KEY]: vanillaResource.uri, + [RESOURCE_URI_META_KEY]: randomDogResource.uri, }, }, - async ({ message }): Promise => ({ - content: [{ type: "text", text: JSON.stringify({ message }) }], - structuredContent: { message }, + async (): Promise => ({ + content: [{ type: "text", text: JSON.stringify({ message: "Random dog" }) }], + structuredContent: { message: "Random dog" }, }), ); } - { - const reactResource = registerResource( - { - name: "ui-react-template", - uri: "ui://react", - title: "React UI Template", - description: "A React-based UI", - mimeType: "text/html+mcp", - }, - reactHtml, - ); - - server.registerTool( - "create-ui-react", - { - title: "React UI", - description: "A tool that returns a React-based UI", - inputSchema: { - message: z.string().describe("Message to display"), - }, - _meta: { - [RESOURCE_URI_META_KEY]: reactResource.uri, - }, - }, - async ({ message }): Promise => ({ - content: [{ type: "text", text: JSON.stringify({ message }) }], - structuredContent: { message }, - }), - ); - } - - // --- Common tool: get-weather --- - server.registerTool( - "get-weather", - { - title: "Get Weather", - description: "Returns current weather for a location", - inputSchema: { - location: z.string().describe("Location to get weather for"), - }, - }, - async ({ location }): Promise => { - const temperature = 25; - const condition = "sunny"; - return { - content: [ - { - type: "text", - text: `The weather in ${location} is ${condition}, ${temperature}°C.`, - }, - ], - structuredContent: { temperature, condition }, - }; - }, - ); - return server; }; diff --git a/examples/cute-dogs-server/src/random-dog.tsx b/examples/cute-dogs-server/src/random-dog.tsx new file mode 100644 index 00000000..8aa2d35e --- /dev/null +++ b/examples/cute-dogs-server/src/random-dog.tsx @@ -0,0 +1,92 @@ +/** + * @file App that displays a random dog image from the Dog CEO API. + */ +import { useState, useEffect, useCallback } from "react"; +import { createRoot } from "react-dom/client"; + +interface DogApiResponse { + message: string; + status: string; +} + +export function RandomDogApp() { + const [dogImageUrl, setDogImageUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchRandomDog = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch("https://dog.ceo/api/breeds/image/random"); + const data: DogApiResponse = await response.json(); + + if (data.status === "success" && data.message) { + setDogImageUrl(data.message); + } else { + setError("Failed to fetch dog image"); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch dog image"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRandomDog(); + }, [fetchRandomDog]); + + return ( +
+

Random Dog

+ + + + {error && ( +
+ Error: {error} +
+ )} + + {dogImageUrl && ( +
+ Random dog +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/ui-raw.ts b/examples/cute-dogs-server/src/ui-raw.ts deleted file mode 100644 index 1cf2b2ea..00000000 --- a/examples/cute-dogs-server/src/ui-raw.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * @file App that does NOT depend on Apps SDK runtime. - * - * The Raw UI example has no runtime dependency to the Apps SDK - * but still imports its types for static type safety. - * Types can be just stripped, e.g. w/ the command line: - * - * - * npx esbuild src/ui-raw.ts --bundle --outfile=dist/ui-raw.js --minify --sourcemap --platform=browser - * - * - * We implement a barebones JSON-RPC message sender/receiver (see `app` object below), - * but without timeouts or runtime type validation of any kind - * (for that, use the Apps SDK / see ui-vanilla.ts or ui-react.ts). - */ - -import type { - McpUiInitializeRequest, - McpUiInitializeResult, - McpUiInitializedNotification, - McpUiToolResultNotification, - McpUiHostContextChangedNotification, - McpUiToolInputNotification, - McpUiSizeChangeNotification, - McpUiMessageRequest, - McpUiMessageResult, - McpUiOpenLinkRequest, - McpUiOpenLinkResult, -} from "@modelcontextprotocol/ext-apps"; - -import type { - CallToolRequest, - CallToolResult, - JSONRPCMessage, - LoggingMessageNotification, -} from "@modelcontextprotocol/sdk/types.js"; - -const app = (() => { - type Sendable = { method: string; params: any }; - - let nextId = 1; - - return { - sendRequest({ method, params }: T) { - const id = nextId++; - window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); - return new Promise((resolve, reject) => { - window.addEventListener("message", function listener(event) { - const data: JSONRPCMessage = event.data; - if (event.data?.id === id) { - window.removeEventListener("message", listener); - if (event.data?.result) { - resolve(event.data.result as Result); - } else if (event.data?.error) { - reject(new Error(event.data.error)); - } - } else { - reject(new Error(`Unsupported message: ${JSON.stringify(data)}`)); - } - }); - }); - }, - sendNotification({ method, params }: T) { - window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*"); - }, - onNotification( - method: T["method"], - handler: (params: T["params"]) => void, - ) { - window.addEventListener("message", function listener(event) { - if (event.data?.method === method) { - handler(event.data.params); - } - }); - }, - }; -})(); - -window.addEventListener("load", async () => { - const root = document.getElementById("root")!; - const appendText = (textContent: string, opts = {}) => { - root.appendChild( - Object.assign(document.createElement("div"), { - textContent, - ...opts, - }), - ); - }; - const appendError = (error: unknown) => - appendText( - `Error: ${error instanceof Error ? error.message : String(error)}`, - { style: "color: red;" }, - ); - - app.onNotification( - "ui/notifications/tool-input", - async (params) => { - appendText(`Tool call input: ${JSON.stringify(params)}`); - }, - ); - app.onNotification( - "ui/notifications/tool-result", - async (params) => { - appendText(`Tool call result: ${JSON.stringify(params)}`); - }, - ); - app.onNotification( - "ui/notifications/host-context-changed", - async (params) => { - appendText(`Host context changed: ${JSON.stringify(params)}`); - }, - ); - - const initializeResult = await app.sendRequest< - McpUiInitializeRequest, - McpUiInitializeResult - >({ - method: "ui/initialize", - params: { - appCapabilities: {}, - appInfo: { name: "My UI", version: "1.0.0" }, - protocolVersion: "2025-06-18", - }, - }); - - appendText(`Initialize result: ${JSON.stringify(initializeResult)}`); - - app.sendNotification({ - method: "ui/notifications/initialized", - params: {}, - }); - - new ResizeObserver(() => { - const rect = ( - document.body.parentElement ?? document.body - ).getBoundingClientRect(); - const width = Math.ceil(rect.width); - const height = Math.ceil(rect.height); - app.sendNotification({ - method: "ui/notifications/size-change", - params: { width, height }, - }); - }).observe(document.body); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Get Weather (Tool)", - onclick: async () => { - try { - const result = await app.sendRequest( - { - method: "tools/call", - params: { - name: "get-weather", - arguments: { location: "Tokyo" }, - }, - }, - ); - - appendText(`Weather tool result: ${JSON.stringify(result)}`); - } catch (e) { - appendError(e); - } - }, - }), - ); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Notify Cart Updated", - onclick: async () => { - app.sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: "cart-updated", - }, - }); - }, - }), - ); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Prompt Weather in Tokyo", - onclick: async () => { - try { - const { isError } = await app.sendRequest< - McpUiMessageRequest, - McpUiMessageResult - >({ - method: "ui/message", - params: { - role: "user", - content: [ - { - type: "text", - text: "What is the weather in Tokyo?", - }, - ], - }, - }); - - appendText(`Message result: ${isError ? "error" : "success"}`); - } catch (e) { - appendError(e); - } - }, - }), - ); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Open Link to Google", - onclick: async () => { - try { - const { isError } = await app.sendRequest< - McpUiOpenLinkRequest, - McpUiOpenLinkResult - >({ - method: "ui/open-link", - params: { - url: "https://www.google.com", - }, - }); - appendText(`Link result: ${isError ? "error" : "success"}`); - } catch (e) { - appendError(e); - } - }, - }), - ); - - console.log("Initialized with host info:", initializeResult.hostInfo); -}); diff --git a/examples/cute-dogs-server/src/ui-react.tsx b/examples/cute-dogs-server/src/ui-react.tsx deleted file mode 100644 index a8239e93..00000000 --- a/examples/cute-dogs-server/src/ui-react.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/** - * @file App that demonstrates a few features using React + the Apps SDK. - */ -import { useState, useCallback } from "react"; -import { createRoot } from "react-dom/client"; -import { - useApp, - McpUiSizeChangeNotificationSchema, - McpUiToolResultNotificationSchema, -} from "@modelcontextprotocol/ext-apps/react"; -import type { - CallToolResult, - Implementation, -} from "@modelcontextprotocol/sdk/types.js"; - -const APP_INFO: Implementation = { - name: "MCP UI React Example Client", - version: "1.0.0", -}; - -export function McpClientApp() { - const [toolResults, setToolResults] = useState([]); - const [messages, setMessages] = useState([]); - - const { app, isConnected, error } = useApp({ - appInfo: APP_INFO, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = async (params) => { - setToolResults((prev) => [...prev, params]); - }; - }, - }); - - const handleGetWeather = useCallback(async () => { - if (!app) return; - try { - const result = await app.callServerTool({ - name: "get-weather", - arguments: { city: "Tokyo" }, - }); - setMessages((prev) => [ - ...prev, - `Weather tool result: ${JSON.stringify(result)}`, - ]); - } catch (e) { - setMessages((prev) => [...prev, `Tool call error: ${e}`]); - } - }, [app]); - - const handleNotifyCart = useCallback(async () => { - if (!app) return; - await app.sendLog({ level: "info", data: "cart-updated" }); - setMessages((prev) => [...prev, "Notification sent: cart-updated"]); - }, [app]); - - const handlePromptWeather = useCallback(async () => { - if (!app) return; - const signal = AbortSignal.timeout(5000); - try { - const { isError } = await app.sendMessage( - { - role: "user", - content: [ - { - type: "text", - text: "What is the weather in Tokyo?", - }, - ], - }, - { signal }, - ); - setMessages((prev) => [ - ...prev, - `Prompt result: ${isError ? "error" : "success"}`, - ]); - } catch (e) { - if (signal.aborted) { - setMessages((prev) => [...prev, "Prompt request timed out"]); - return; - } - setMessages((prev) => [...prev, `Prompt error: ${e}`]); - } - }, [app]); - - const handleOpenLink = useCallback(async () => { - if (!app) return; - const { isError } = await app.sendOpenLink({ - url: "https://www.google.com", - }); - setMessages((prev) => [ - ...prev, - `Open link result: ${isError ? "error" : "success"}`, - ]); - }, [app]); - - if (error) { - return ( -
Error connecting: {error.message}
- ); - } - - if (!isConnected) { - return
Connecting...
; - } - - return ( -
-

MCP UI Client (React)

- -
- - - - - - - -
- - {toolResults.length > 0 && ( -
-

Tool Results:

- {toolResults.map((result, i) => ( -
- isError: {String(result.isError ?? false)} -
- content: {JSON.stringify(result.content)} -
- {result.structuredContent && ( - <> - structuredContent:{" "} - {JSON.stringify(result.structuredContent)} - - )} -
- ))} -
- )} - - {messages.length > 0 && ( -
-

Messages:

- {messages.map((msg, i) => ( -
- {msg} -
- ))} -
- )} -
- ); -} - -window.addEventListener("load", () => { - const root = document.getElementById("root"); - if (!root) { - throw new Error("Root element not found"); - } - - createRoot(root).render(); -}); diff --git a/examples/cute-dogs-server/src/ui-vanilla.ts b/examples/cute-dogs-server/src/ui-vanilla.ts deleted file mode 100644 index f536a45b..00000000 --- a/examples/cute-dogs-server/src/ui-vanilla.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @file Demonstrate a few Apps SDK features. - * - * The vanilla (no React) UI uses the Apps SDK. - * - * The Apps SDK offers advantages over the Raw UI example, - * such as ability to set timeouts, strong runtime type validation - * and simpler methods for each request/response interaction. - */ -import { - App, - PostMessageTransport, - McpUiToolInputNotificationSchema, - McpUiToolResultNotificationSchema, - McpUiHostContextChangedNotificationSchema, -} from "@modelcontextprotocol/ext-apps"; - -window.addEventListener("load", async () => { - const root = document.getElementById("root")!; - const appendText = (textContent: string, opts = {}) => { - root.appendChild( - Object.assign(document.createElement("div"), { - textContent, - ...opts, - }), - ); - }; - const appendError = (error: unknown) => - appendText( - `Error: ${error instanceof Error ? error.message : String(error)}`, - { style: "color: red;" }, - ); - - const app = new App({ - name: "MCP UI Client (Vanilla)", - version: "1.0.0", - }); - - app.ontoolinput = (params) => { - appendText(`Tool call input received: ${JSON.stringify(params.arguments)}`); - }; - app.ontoolresult = ({ content, structuredContent, isError }) => { - appendText( - `Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`, - ); - }; - app.onhostcontextchanged = (params) => { - appendText(`Host context changed: ${JSON.stringify(params)}`); - }; - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Get Weather (Tool)", - onclick: async () => { - try { - const result = await app.callServerTool({ - name: "get-weather", - arguments: { location: "Tokyo" }, - }); - appendText(`Weather tool result: ${JSON.stringify(result)}`); - } catch (e) { - appendError(e); - } - }, - }), - ); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Notify Cart Updated", - onclick: async () => { - try { - await app.sendLog({ - level: "info", - data: "cart-updated", - }); - } catch (e) { - appendError(e); - } - }, - }), - ); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Prompt Weather in Tokyo", - onclick: async () => { - const signal = AbortSignal.timeout(5000); - try { - const { isError } = await app.sendMessage( - { - role: "user", - content: [ - { - type: "text", - text: "What is the weather in Tokyo?", - }, - ], - }, - { signal }, - ); - appendText(`Prompt result: ${isError ? "error" : "success"}`); - } catch (e) { - if (signal.aborted) { - appendError("Prompt request timed out"); - return; - } - appendError(e); - } - }, - }), - ); - - root.appendChild( - Object.assign(document.createElement("button"), { - textContent: "Open Link to Google", - onclick: async () => { - try { - const { isError } = await app.sendOpenLink({ - url: "https://www.google.com", - }); - appendText(`Open link result: ${isError ? "error" : "success"}`); - } catch (e) { - appendError(e); - } - }, - }), - ); - - await app.connect(new PostMessageTransport(window.parent)); -}); diff --git a/examples/cute-dogs-server/ui-raw.html b/examples/cute-dogs-server/ui-raw.html deleted file mode 100644 index cf320849..00000000 --- a/examples/cute-dogs-server/ui-raw.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - MCP UI Client (Raw) - - -
- - - diff --git a/examples/cute-dogs-server/ui-react.html b/examples/cute-dogs-server/ui-react.html deleted file mode 100644 index c0e7fd7b..00000000 --- a/examples/cute-dogs-server/ui-react.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - MCP UI Client (React) - - -
- - - diff --git a/examples/cute-dogs-server/ui-vanilla.html b/examples/cute-dogs-server/ui-vanilla.html deleted file mode 100644 index ecec6c87..00000000 --- a/examples/cute-dogs-server/ui-vanilla.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - MCP UI Client (Vanilla) - - -
- - - From ea8502d19001514bed97673cb410dd005415e3d8 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 12:16:24 -0700 Subject: [PATCH 03/14] cute dogs --- examples/cute-dogs-server/server.ts | 8 ++++---- examples/cute-dogs-server/src/random-dog.tsx | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index ab465b86..474ce154 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -36,9 +36,7 @@ const getServer = async () => { ); // Load HTML for both UIs - const [randomDogHtml] = await Promise.all([ - loadHtml("random-dog"), - ]); + const [randomDogHtml] = await Promise.all([loadHtml("random-dog")]); const registerResource = (resource: Resource, htmlContent: string) => { server.registerResource( @@ -80,7 +78,9 @@ const getServer = async () => { }, }, async (): Promise => ({ - content: [{ type: "text", text: JSON.stringify({ message: "Random dog" }) }], + content: [ + { type: "text", text: JSON.stringify({ message: "Random dog" }) }, + ], structuredContent: { message: "Random dog" }, }), ); diff --git a/examples/cute-dogs-server/src/random-dog.tsx b/examples/cute-dogs-server/src/random-dog.tsx index 8aa2d35e..20ad43aa 100644 --- a/examples/cute-dogs-server/src/random-dog.tsx +++ b/examples/cute-dogs-server/src/random-dog.tsx @@ -20,7 +20,7 @@ export function RandomDogApp() { try { const response = await fetch("https://dog.ceo/api/breeds/image/random"); const data: DogApiResponse = await response.json(); - + if (data.status === "success" && data.message) { setDogImageUrl(data.message); } else { @@ -59,9 +59,7 @@ export function RandomDogApp() { {error && ( -
- Error: {error} -
+
Error: {error}
)} {dogImageUrl && ( From d7f603bb3c6a073d66744f4c94a7080ce0c3dc9f Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 12:33:12 -0700 Subject: [PATCH 04/14] Random dog --- examples/cute-dogs-server/server.ts | 64 ++++++++++- examples/cute-dogs-server/src/random-dog.tsx | 115 +++++++++++++++---- 2 files changed, 149 insertions(+), 30 deletions(-) diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index 474ce154..afcf8e2c 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -1,6 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import express, { Request, Response } from "express"; import { randomUUID } from "node:crypto"; +import { z } from "zod"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolResult, @@ -57,6 +58,7 @@ const getServer = async () => { }; { + // Tool 1: UI widget tool - loads the interactive UI const randomDogResource = registerResource( { name: "random-dog-template", @@ -69,23 +71,75 @@ const getServer = async () => { ); server.registerTool( - "get-random-dog", + "show-random-dog-ui", { - title: "Random Dog", - description: "A tool that returns a random dog", + title: "Show Random Dog UI", + description: "Loads an interactive UI widget for browsing random dog images", _meta: { [RESOURCE_URI_META_KEY]: randomDogResource.uri, }, }, async (): Promise => ({ content: [ - { type: "text", text: JSON.stringify({ message: "Random dog" }) }, + { type: "text", text: JSON.stringify({ message: "Random dog UI loaded" }) }, ], - structuredContent: { message: "Random dog" }, + structuredContent: { message: "Random dog UI loaded" }, }), ); } + { + // Tool 2: Standalone dog image fetcher - no UI, just returns the image data + server.registerTool( + "get-dog-image", + { + title: "Get Dog Image", + description: "Get a random dog image or a random image from a specific breed. Returns the image URL and metadata.", + inputSchema: { + breed: z.string().optional().describe("Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed."), + }, + }, + async ({ breed }): Promise => { + try { + let apiUrl: string; + if (breed) { + // Get random image from specific breed + apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random`; + } else { + // Get completely random dog image + apiUrl = "https://dog.ceo/api/breeds/image/random"; + } + + const response = await fetch(apiUrl); + const data = await response.json(); + + if (data.status === "success" && data.message) { + return { + content: [ + { type: "text", text: JSON.stringify(data) }, + ], + structuredContent: data, + }; + } else { + return { + content: [ + { type: "text", text: JSON.stringify({ error: "Failed to fetch dog image", status: data.status }) }, + ], + isError: true, + }; + } + } catch (error) { + return { + content: [ + { type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }) }, + ], + isError: true, + }; + } + }, + ); + } + return server; }; diff --git a/examples/cute-dogs-server/src/random-dog.tsx b/examples/cute-dogs-server/src/random-dog.tsx index 20ad43aa..8dc2dd3f 100644 --- a/examples/cute-dogs-server/src/random-dog.tsx +++ b/examples/cute-dogs-server/src/random-dog.tsx @@ -1,8 +1,15 @@ /** - * @file App that displays a random dog image from the Dog CEO API. + * @file App that displays a random dog image from the Dog CEO API via MCP tool. */ import { useState, useEffect, useCallback } from "react"; import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; + +const APP_INFO: Implementation = { + name: "Random Dog App", + version: "1.0.0", +}; interface DogApiResponse { message: string; @@ -13,15 +20,41 @@ export function RandomDogApp() { const [dogImageUrl, setDogImageUrl] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [breed, setBreed] = useState(""); + + const { app, isConnected, error: appError } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + }); + + const fetchDog = useCallback(async (breedParam?: string) => { + if (!app || !isConnected) return; - const fetchRandomDog = useCallback(async () => { setLoading(true); setError(null); try { - const response = await fetch("https://dog.ceo/api/breeds/image/random"); - const data: DogApiResponse = await response.json(); + const result = await app.callServerTool({ + name: "get-dog-image", + arguments: breedParam ? { breed: breedParam } : {}, + }); - if (data.status === "success" && data.message) { + if (result.isError) { + setError("Failed to fetch dog image"); + return; + } + + // Extract the message from structuredContent or content + let data: DogApiResponse | null = null; + if (result.structuredContent && typeof result.structuredContent === "object") { + data = result.structuredContent as unknown as DogApiResponse; + } else if (result.content && result.content.length > 0) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && typeof textContent.text === "string") { + data = JSON.parse(textContent.text) as DogApiResponse; + } + } + + if (data && data.status === "success" && data.message) { setDogImageUrl(data.message); } else { setError("Failed to fetch dog image"); @@ -31,32 +64,64 @@ export function RandomDogApp() { } finally { setLoading(false); } - }, []); + }, [app, isConnected]); useEffect(() => { - fetchRandomDog(); - }, [fetchRandomDog]); + if (isConnected && app) { + fetchDog(); + } + }, [isConnected, app, fetchDog]); + + if (appError) { + return ( +
+ Error connecting: {appError.message} +
+ ); + } + + if (!isConnected) { + return
Connecting...
; + } return (

Random Dog

- +
+ + setBreed(e.target.value)} + placeholder="e.g., hound, retriever, husky" + style={{ + padding: "8px", + fontSize: "14px", + width: "300px", + marginRight: "10px", + border: "1px solid #ccc", + borderRadius: "4px", + }} + /> + +
{error && (
Error: {error}
@@ -66,7 +131,7 @@ export function RandomDogApp() {
Random dog Date: Sat, 29 Nov 2025 12:36:30 -0700 Subject: [PATCH 05/14] server --- examples/cute-dogs-server/server.ts | 38 +++++++-- examples/cute-dogs-server/src/random-dog.tsx | 90 ++++++++++++-------- 2 files changed, 82 insertions(+), 46 deletions(-) diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index afcf8e2c..80e148a3 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -74,14 +74,18 @@ const getServer = async () => { "show-random-dog-ui", { title: "Show Random Dog UI", - description: "Loads an interactive UI widget for browsing random dog images", + description: + "Loads an interactive UI widget for browsing random dog images", _meta: { [RESOURCE_URI_META_KEY]: randomDogResource.uri, }, }, async (): Promise => ({ content: [ - { type: "text", text: JSON.stringify({ message: "Random dog UI loaded" }) }, + { + type: "text", + text: JSON.stringify({ message: "Random dog UI loaded" }), + }, ], structuredContent: { message: "Random dog UI loaded" }, }), @@ -94,9 +98,15 @@ const getServer = async () => { "get-dog-image", { title: "Get Dog Image", - description: "Get a random dog image or a random image from a specific breed. Returns the image URL and metadata.", + description: + "Get a random dog image or a random image from a specific breed. Returns the image URL and metadata.", inputSchema: { - breed: z.string().optional().describe("Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed."), + breed: z + .string() + .optional() + .describe( + "Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed.", + ), }, }, async ({ breed }): Promise => { @@ -115,15 +125,19 @@ const getServer = async () => { if (data.status === "success" && data.message) { return { - content: [ - { type: "text", text: JSON.stringify(data) }, - ], + content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data, }; } else { return { content: [ - { type: "text", text: JSON.stringify({ error: "Failed to fetch dog image", status: data.status }) }, + { + type: "text", + text: JSON.stringify({ + error: "Failed to fetch dog image", + status: data.status, + }), + }, ], isError: true, }; @@ -131,7 +145,13 @@ const getServer = async () => { } catch (error) { return { content: [ - { type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : "Unknown error" }) }, + { + type: "text", + text: JSON.stringify({ + error: + error instanceof Error ? error.message : "Unknown error", + }), + }, ], isError: true, }; diff --git a/examples/cute-dogs-server/src/random-dog.tsx b/examples/cute-dogs-server/src/random-dog.tsx index 8dc2dd3f..7add3808 100644 --- a/examples/cute-dogs-server/src/random-dog.tsx +++ b/examples/cute-dogs-server/src/random-dog.tsx @@ -22,49 +22,59 @@ export function RandomDogApp() { const [error, setError] = useState(null); const [breed, setBreed] = useState(""); - const { app, isConnected, error: appError } = useApp({ + const { + app, + isConnected, + error: appError, + } = useApp({ appInfo: APP_INFO, capabilities: {}, }); - const fetchDog = useCallback(async (breedParam?: string) => { - if (!app || !isConnected) return; - - setLoading(true); - setError(null); - try { - const result = await app.callServerTool({ - name: "get-dog-image", - arguments: breedParam ? { breed: breedParam } : {}, - }); - - if (result.isError) { - setError("Failed to fetch dog image"); - return; - } + const fetchDog = useCallback( + async (breedParam?: string) => { + if (!app || !isConnected) return; + + setLoading(true); + setError(null); + try { + const result = await app.callServerTool({ + name: "get-dog-image", + arguments: breedParam ? { breed: breedParam } : {}, + }); + + if (result.isError) { + setError("Failed to fetch dog image"); + return; + } - // Extract the message from structuredContent or content - let data: DogApiResponse | null = null; - if (result.structuredContent && typeof result.structuredContent === "object") { - data = result.structuredContent as unknown as DogApiResponse; - } else if (result.content && result.content.length > 0) { - const textContent = result.content.find((c) => c.type === "text"); - if (textContent && typeof textContent.text === "string") { - data = JSON.parse(textContent.text) as DogApiResponse; + // Extract the message from structuredContent or content + let data: DogApiResponse | null = null; + if ( + result.structuredContent && + typeof result.structuredContent === "object" + ) { + data = result.structuredContent as unknown as DogApiResponse; + } else if (result.content && result.content.length > 0) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && typeof textContent.text === "string") { + data = JSON.parse(textContent.text) as DogApiResponse; + } } - } - if (data && data.status === "success" && data.message) { - setDogImageUrl(data.message); - } else { - setError("Failed to fetch dog image"); + if (data && data.status === "success" && data.message) { + setDogImageUrl(data.message); + } else { + setError("Failed to fetch dog image"); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch dog image"); + } finally { + setLoading(false); } - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch dog image"); - } finally { - setLoading(false); - } - }, [app, isConnected]); + }, + [app, isConnected], + ); useEffect(() => { if (isConnected && app) { @@ -89,7 +99,9 @@ export function RandomDogApp() {

Random Dog

-
From 5b0b06670e8225e397b03f70a57870551cb75209 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 16:45:25 -0700 Subject: [PATCH 06/14] cute dogs --- examples/cute-dogs-server/package.json | 2 +- examples/cute-dogs-server/server.ts | 49 ++--- .../{random-dog.html => show-dog-image.html} | 4 +- examples/cute-dogs-server/src/random-dog.tsx | 171 ------------------ .../cute-dogs-server/src/show-dog-image.tsx | 58 ++++++ 5 files changed, 75 insertions(+), 209 deletions(-) rename examples/cute-dogs-server/{random-dog.html => show-dog-image.html} (66%) delete mode 100644 examples/cute-dogs-server/src/random-dog.tsx create mode 100644 examples/cute-dogs-server/src/show-dog-image.tsx diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json index c3517cf6..aa305db6 100644 --- a/examples/cute-dogs-server/package.json +++ b/examples/cute-dogs-server/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "NODE_ENV=development npm run build && npm run server", - "build": "concurrently 'INPUT=random-dog.html vite build'", + "build": "concurrently 'INPUT=show-dog-image.html vite build'", "server": "bun server.ts" }, "dependencies": { diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index 80e148a3..209990af 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -37,7 +37,7 @@ const getServer = async () => { ); // Load HTML for both UIs - const [randomDogHtml] = await Promise.all([loadHtml("random-dog")]); + const [showDogImageHtml] = await Promise.all([loadHtml("show-dog-image")]); const registerResource = (resource: Resource, htmlContent: string) => { server.registerResource( @@ -58,48 +58,23 @@ const getServer = async () => { }; { - // Tool 1: UI widget tool - loads the interactive UI const randomDogResource = registerResource( { - name: "random-dog-template", - uri: "ui://random-dog", - title: "Random Dog Template", - description: "A random dog UI", + name: "show-dog-image-template", + uri: "ui://show-dog-image", + title: "Show Dog Image Template", + description: "A show dog image UI", mimeType: "text/html+mcp", }, - randomDogHtml, + showDogImageHtml, ); server.registerTool( - "show-random-dog-ui", + "show-dog-image", { - title: "Show Random Dog UI", + title: "Show Dog Image", description: - "Loads an interactive UI widget for browsing random dog images", - _meta: { - [RESOURCE_URI_META_KEY]: randomDogResource.uri, - }, - }, - async (): Promise => ({ - content: [ - { - type: "text", - text: JSON.stringify({ message: "Random dog UI loaded" }), - }, - ], - structuredContent: { message: "Random dog UI loaded" }, - }), - ); - } - - { - // Tool 2: Standalone dog image fetcher - no UI, just returns the image data - server.registerTool( - "get-dog-image", - { - title: "Get Dog Image", - description: - "Get a random dog image or a random image from a specific breed. Returns the image URL and metadata.", + "Show a dog image in an interactive UI widget.", inputSchema: { breed: z .string() @@ -108,6 +83,9 @@ const getServer = async () => { "Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed.", ), }, + _meta: { + [RESOURCE_URI_META_KEY]: randomDogResource.uri, + }, }, async ({ breed }): Promise => { try { @@ -122,11 +100,12 @@ const getServer = async () => { const response = await fetch(apiUrl); const data = await response.json(); + const dogBreed = data.message.split("/")[4]; if (data.status === "success" && data.message) { return { content: [{ type: "text", text: JSON.stringify(data) }], - structuredContent: data, + structuredContent: {...data, breed: dogBreed}, }; } else { return { diff --git a/examples/cute-dogs-server/random-dog.html b/examples/cute-dogs-server/show-dog-image.html similarity index 66% rename from examples/cute-dogs-server/random-dog.html rename to examples/cute-dogs-server/show-dog-image.html index 2a50ffd9..1b450168 100644 --- a/examples/cute-dogs-server/random-dog.html +++ b/examples/cute-dogs-server/show-dog-image.html @@ -3,10 +3,10 @@ - MCP Random Dog + MCP Show Dog Image
- + diff --git a/examples/cute-dogs-server/src/random-dog.tsx b/examples/cute-dogs-server/src/random-dog.tsx deleted file mode 100644 index 7add3808..00000000 --- a/examples/cute-dogs-server/src/random-dog.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @file App that displays a random dog image from the Dog CEO API via MCP tool. - */ -import { useState, useEffect, useCallback } from "react"; -import { createRoot } from "react-dom/client"; -import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; - -const APP_INFO: Implementation = { - name: "Random Dog App", - version: "1.0.0", -}; - -interface DogApiResponse { - message: string; - status: string; -} - -export function RandomDogApp() { - const [dogImageUrl, setDogImageUrl] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [breed, setBreed] = useState(""); - - const { - app, - isConnected, - error: appError, - } = useApp({ - appInfo: APP_INFO, - capabilities: {}, - }); - - const fetchDog = useCallback( - async (breedParam?: string) => { - if (!app || !isConnected) return; - - setLoading(true); - setError(null); - try { - const result = await app.callServerTool({ - name: "get-dog-image", - arguments: breedParam ? { breed: breedParam } : {}, - }); - - if (result.isError) { - setError("Failed to fetch dog image"); - return; - } - - // Extract the message from structuredContent or content - let data: DogApiResponse | null = null; - if ( - result.structuredContent && - typeof result.structuredContent === "object" - ) { - data = result.structuredContent as unknown as DogApiResponse; - } else if (result.content && result.content.length > 0) { - const textContent = result.content.find((c) => c.type === "text"); - if (textContent && typeof textContent.text === "string") { - data = JSON.parse(textContent.text) as DogApiResponse; - } - } - - if (data && data.status === "success" && data.message) { - setDogImageUrl(data.message); - } else { - setError("Failed to fetch dog image"); - } - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to fetch dog image"); - } finally { - setLoading(false); - } - }, - [app, isConnected], - ); - - useEffect(() => { - if (isConnected && app) { - fetchDog(); - } - }, [isConnected, app, fetchDog]); - - if (appError) { - return ( -
- Error connecting: {appError.message} -
- ); - } - - if (!isConnected) { - return
Connecting...
; - } - - return ( -
-

Random Dog

- -
- - setBreed(e.target.value)} - placeholder="e.g., hound, retriever, husky" - style={{ - padding: "8px", - fontSize: "14px", - width: "300px", - marginRight: "10px", - border: "1px solid #ccc", - borderRadius: "4px", - }} - /> - -
- - {error && ( -
Error: {error}
- )} - - {dogImageUrl && ( -
- {breed.trim() -
- )} -
- ); -} - -window.addEventListener("load", () => { - const root = document.getElementById("root"); - if (!root) { - throw new Error("Root element not found"); - } - - createRoot(root).render(); -}); diff --git a/examples/cute-dogs-server/src/show-dog-image.tsx b/examples/cute-dogs-server/src/show-dog-image.tsx new file mode 100644 index 00000000..856ac6b6 --- /dev/null +++ b/examples/cute-dogs-server/src/show-dog-image.tsx @@ -0,0 +1,58 @@ +/** + * @file App that displays a random dog image from the Dog CEO API via MCP tool. + */ +import { useState, useEffect, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { CallToolResult, Implementation } from "@modelcontextprotocol/sdk/types.js"; + +const APP_INFO: Implementation = { + name: "Show Dog Image App", + version: "1.0.0", +}; + +export function ShowDogImageApp() { + const [dogImageUrl, setDogImageUrl] = useState(null); + const [dogBreed, setDogBreed] = useState(null); + + const { app } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (toolResult) => { + const toolResultData = toolResult.structuredContent as { message: string, breed: string }; + setDogImageUrl(toolResultData.message); + setDogBreed(toolResultData.breed); + }; + }, + }); + + const openDogBreedLink = useCallback(async () => { + if (!app) return; + await app.sendOpenLink({ + url: `https://www.google.com/search?q=${dogBreed}`, + }); + }, [app, dogBreed]); + + return ( +
+

Show Dog Image

+ {dogImageUrl && dogBreed && ( +
+ {dogBreed} +

Breed: {dogBreed}

+ +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); From 365f636c948f78c04b089da5c2e25510d05eea70 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 17:07:48 -0700 Subject: [PATCH 07/14] dog images --- examples/cute-dogs-server/package.json | 2 +- examples/cute-dogs-server/server.ts | 160 ++++++++++-------- .../cute-dogs-server/show-all-breeds.html | 12 ++ .../cute-dogs-server/src/show-all-breeds.tsx | 132 +++++++++++++++ .../cute-dogs-server/src/show-dog-image.tsx | 10 +- 5 files changed, 247 insertions(+), 69 deletions(-) create mode 100644 examples/cute-dogs-server/show-all-breeds.html create mode 100644 examples/cute-dogs-server/src/show-all-breeds.tsx diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json index aa305db6..62845001 100644 --- a/examples/cute-dogs-server/package.json +++ b/examples/cute-dogs-server/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "NODE_ENV=development npm run build && npm run server", - "build": "concurrently 'INPUT=show-dog-image.html vite build'", + "build": "concurrently 'INPUT=show-dog-image.html vite build' 'INPUT=show-all-breeds.html vite build'", "server": "bun server.ts" }, "dependencies": { diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index 209990af..32253763 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -37,7 +37,7 @@ const getServer = async () => { ); // Load HTML for both UIs - const [showDogImageHtml] = await Promise.all([loadHtml("show-dog-image")]); + const [showDogImageHtml, showAllBreedsHtml] = await Promise.all([loadHtml("show-dog-image"), loadHtml("show-all-breeds")]); const registerResource = (resource: Resource, htmlContent: string) => { server.registerResource( @@ -57,87 +57,115 @@ const getServer = async () => { return resource; }; - { - const randomDogResource = registerResource( - { - name: "show-dog-image-template", - uri: "ui://show-dog-image", - title: "Show Dog Image Template", - description: "A show dog image UI", - mimeType: "text/html+mcp", - }, - showDogImageHtml, - ); + const randomDogResource = registerResource( + { + name: "show-random-dog-image-template", + uri: "ui://show-random-dog-image", + title: "Show Dog Image Template", + description: "A show dog image UI", + mimeType: "text/html+mcp", + }, + showDogImageHtml, + ); - server.registerTool( - "show-dog-image", - { - title: "Show Dog Image", - description: - "Show a dog image in an interactive UI widget.", - inputSchema: { - breed: z - .string() - .optional() - .describe( - "Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed.", - ), - }, - _meta: { - [RESOURCE_URI_META_KEY]: randomDogResource.uri, - }, + const showAllBreedsResource = registerResource( + { + name: "show-all-breeds-template", + uri: "ui://show-all-breeds", + title: "Show All Breeds Template", + description: "A show all breeds UI", + mimeType: "text/html+mcp", + }, + showAllBreedsHtml, + ); + + server.registerTool( + "show-random-dog-image", + { + title: "Show Dog Image", + description: "Show a dog image in an interactive UI widget.", + inputSchema: { + breed: z + .string() + .optional() + .describe( + "Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed.", + ), + }, + _meta: { + [RESOURCE_URI_META_KEY]: randomDogResource.uri, }, - async ({ breed }): Promise => { - try { - let apiUrl: string; - if (breed) { - // Get random image from specific breed - apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random`; - } else { - // Get completely random dog image - apiUrl = "https://dog.ceo/api/breeds/image/random"; - } + }, + async ({ breed }): Promise => { + try { + let apiUrl: string; + if (breed) { + // Get random image from specific breed + apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random`; + } else { + // Get completely random dog image + apiUrl = "https://dog.ceo/api/breeds/image/random"; + } - const response = await fetch(apiUrl); - const data = await response.json(); - const dogBreed = data.message.split("/")[4]; + const response = await fetch(apiUrl); + const data = await response.json(); + const dogBreed = data.message.split("/")[4]; - if (data.status === "success" && data.message) { - return { - content: [{ type: "text", text: JSON.stringify(data) }], - structuredContent: {...data, breed: dogBreed}, - }; - } else { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Failed to fetch dog image", - status: data.status, - }), - }, - ], - isError: true, - }; - } - } catch (error) { + if (data.status === "success" && data.message) { + return { + content: [{ type: "text", text: JSON.stringify(data) }], + structuredContent: { ...data, breed: dogBreed }, + }; + } else { return { content: [ { type: "text", text: JSON.stringify({ - error: - error instanceof Error ? error.message : "Unknown error", + error: "Failed to fetch dog image", + status: data.status, }), }, ], isError: true, }; } + } catch (error) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: + error instanceof Error ? error.message : "Unknown error", + }), + }, + ], + isError: true, + }; + } + }, + ); + + server.registerTool( + "show-all-breeds", + { + title: "Show All Breeds", + description: "Show all breeds in an interactive UI widget.", + _meta: { + [RESOURCE_URI_META_KEY]: showAllBreedsResource.uri, }, - ); - } + }, + async (): Promise => { + const response = await fetch("https://dog.ceo/api/breeds/list/all"); + const data = await response.json(); + const breeds = Object.keys(data.message); + return { + content: [{ type: "text", text: JSON.stringify({ breeds }) }], + structuredContent: { breeds }, + }; + }, + ); return server; }; diff --git a/examples/cute-dogs-server/show-all-breeds.html b/examples/cute-dogs-server/show-all-breeds.html new file mode 100644 index 00000000..851aab65 --- /dev/null +++ b/examples/cute-dogs-server/show-all-breeds.html @@ -0,0 +1,12 @@ + + + + + + Show all breeds + + +
+ + + diff --git a/examples/cute-dogs-server/src/show-all-breeds.tsx b/examples/cute-dogs-server/src/show-all-breeds.tsx new file mode 100644 index 00000000..ae161082 --- /dev/null +++ b/examples/cute-dogs-server/src/show-all-breeds.tsx @@ -0,0 +1,132 @@ +/** + * @file App that displays all dog breeds and allows selecting one to show in chat. + */ +import { useState, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; + +const APP_INFO: Implementation = { + name: "Show All Breeds App", + version: "1.0.0", +}; + +export function ShowAllBreedsApp() { + const [breeds, setBreeds] = useState([]); + const [selectedBreed, setSelectedBreed] = useState(null); + + const { app } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (toolResult) => { + const toolResultData = toolResult.structuredContent as { + breeds?: string[]; + }; + if (toolResultData.breeds) { + setBreeds(toolResultData.breeds); + } + }; + }, + }); + + const handleBreedClick = useCallback( + async (breed: string) => { + if (!app) return; + + setSelectedBreed(breed); + try { + await app.sendMessage({ + role: "user", + content: [ + { + type: "text", + text: `Show me a ${breed}`, + }, + ], + }); + } catch (e) { + console.error("Failed to send message:", e); + } + }, + [app], + ); + + + return ( +
+

All Dog Breeds

+

+ Click on a breed to show it in the chat +

+ {selectedBreed && ( +
+ Selected: {selectedBreed} - Message sent to chat! +
+ )} + {breeds.length === 0 ? ( +
+ Waiting for breeds data... +
+ ) : ( +
+ {breeds.map((breed) => ( + + ))} +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/show-dog-image.tsx b/examples/cute-dogs-server/src/show-dog-image.tsx index 856ac6b6..42479cf6 100644 --- a/examples/cute-dogs-server/src/show-dog-image.tsx +++ b/examples/cute-dogs-server/src/show-dog-image.tsx @@ -4,7 +4,10 @@ import { useState, useEffect, useCallback } from "react"; import { createRoot } from "react-dom/client"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { CallToolResult, Implementation } from "@modelcontextprotocol/sdk/types.js"; +import type { + CallToolResult, + Implementation, +} from "@modelcontextprotocol/sdk/types.js"; const APP_INFO: Implementation = { name: "Show Dog Image App", @@ -20,7 +23,10 @@ export function ShowDogImageApp() { capabilities: {}, onAppCreated: (app) => { app.ontoolresult = async (toolResult) => { - const toolResultData = toolResult.structuredContent as { message: string, breed: string }; + const toolResultData = toolResult.structuredContent as { + message: string; + breed: string; + }; setDogImageUrl(toolResultData.message); setDogBreed(toolResultData.breed); }; From 8aee683a4479f668962b7fb4c333081af3d0c4fb Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 17:09:39 -0700 Subject: [PATCH 08/14] t --- examples/cute-dogs-server/server.ts | 8 ++- .../cute-dogs-server/src/show-all-breeds.tsx | 57 +++++++++---------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index 32253763..3cb8942e 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -37,7 +37,10 @@ const getServer = async () => { ); // Load HTML for both UIs - const [showDogImageHtml, showAllBreedsHtml] = await Promise.all([loadHtml("show-dog-image"), loadHtml("show-all-breeds")]); + const [showDogImageHtml, showAllBreedsHtml] = await Promise.all([ + loadHtml("show-dog-image"), + loadHtml("show-all-breeds"), + ]); const registerResource = (resource: Resource, htmlContent: string) => { server.registerResource( @@ -136,8 +139,7 @@ const getServer = async () => { { type: "text", text: JSON.stringify({ - error: - error instanceof Error ? error.message : "Unknown error", + error: error instanceof Error ? error.message : "Unknown error", }), }, ], diff --git a/examples/cute-dogs-server/src/show-all-breeds.tsx b/examples/cute-dogs-server/src/show-all-breeds.tsx index ae161082..c2c197b2 100644 --- a/examples/cute-dogs-server/src/show-all-breeds.tsx +++ b/examples/cute-dogs-server/src/show-all-breeds.tsx @@ -52,7 +52,6 @@ export function ShowAllBreedsApp() { [app], ); - return (

All Dog Breeds

@@ -87,34 +86,34 @@ export function ShowAllBreedsApp() { }} > {breeds.map((breed) => ( - + ))}
)} From 726de071ef0effa62c150407711bc511c68184c7 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 17:39:37 -0700 Subject: [PATCH 09/14] Widget working --- examples/cute-dogs-server/server.ts | 74 +++++++++ .../cute-dogs-server/src/show-dog-image.tsx | 153 +++++++++++++++++- 2 files changed, 222 insertions(+), 5 deletions(-) diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index 3cb8942e..cb30f84d 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -169,6 +169,80 @@ const getServer = async () => { }, ); + server.registerTool( + "get-more-images", + { + title: "Get More Images", + description: + "Get multiple random dog images from a specific breed. Returns an array of image URLs.", + inputSchema: { + breed: z + .string() + .describe( + "The dog breed name (e.g., 'hound', 'retriever'). Required parameter.", + ), + count: z + .number() + .int() + .min(1) + .max(10) + .optional() + .default(3) + .describe( + "Number of images to fetch (1-10). Defaults to 3 if not provided.", + ), + }, + }, + async ({ breed, count = 3 }): Promise => { + try { + const apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random/${count}`; + const response = await fetch(apiUrl); + const data = await response.json(); + + if (data.status === "success" && Array.isArray(data.message)) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ breed, images: data.message }), + }, + ], + structuredContent: { + breed, + images: data.message, + }, + }; + } else { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: "Failed to fetch images", + status: data.status, + message: data.message, + }), + }, + ], + isError: true, + }; + } + } catch (error) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: error instanceof Error ? error.message : "Unknown error", + }), + }, + ], + isError: true, + }; + } + }, + ); + return server; }; diff --git a/examples/cute-dogs-server/src/show-dog-image.tsx b/examples/cute-dogs-server/src/show-dog-image.tsx index 42479cf6..890b1aa5 100644 --- a/examples/cute-dogs-server/src/show-dog-image.tsx +++ b/examples/cute-dogs-server/src/show-dog-image.tsx @@ -1,11 +1,10 @@ /** * @file App that displays a random dog image from the Dog CEO API via MCP tool. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useCallback } from "react"; import { createRoot } from "react-dom/client"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; import type { - CallToolResult, Implementation, } from "@modelcontextprotocol/sdk/types.js"; @@ -17,6 +16,10 @@ const APP_INFO: Implementation = { export function ShowDogImageApp() { const [dogImageUrl, setDogImageUrl] = useState(null); const [dogBreed, setDogBreed] = useState(null); + const [moreImages, setMoreImages] = useState([]); + const [loadingMoreImages, setLoadingMoreImages] = useState(false); + const [hasFetchedMoreImages, setHasFetchedMoreImages] = useState(false); + const [imageCount, setImageCount] = useState(0); const { app } = useApp({ appInfo: APP_INFO, @@ -29,6 +32,9 @@ export function ShowDogImageApp() { }; setDogImageUrl(toolResultData.message); setDogBreed(toolResultData.breed); + setMoreImages([]); + setHasFetchedMoreImages(false); + setImageCount(0); }; }, }); @@ -40,14 +46,151 @@ export function ShowDogImageApp() { }); }, [app, dogBreed]); + const handleGetMoreImages = useCallback( + async (breed: string) => { + if (!app || !breed) return; + + setLoadingMoreImages(true); + try { + const result = await app.callServerTool({ + name: "get-more-images", + arguments: { breed, count: 3 }, + }); + + if (result.isError) { + setLoadingMoreImages(false); + setHasFetchedMoreImages(true); + return; + } + + // Extract images from structuredContent or content + let imagesData: string[] = []; + if ( + result.structuredContent && + typeof result.structuredContent === "object" + ) { + const data = result.structuredContent as { images?: string[] }; + imagesData = data.images || []; + } else if (result.content && result.content.length > 0) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && typeof textContent.text === "string") { + const parsed = JSON.parse(textContent.text) as { + images?: string[]; + }; + imagesData = parsed.images || []; + } + } + + // Append new images to existing ones + setMoreImages((prev) => [...prev, ...imagesData]); + setImageCount((prev) => prev + imagesData.length); + setLoadingMoreImages(false); + setHasFetchedMoreImages(true); + } catch (e) { + console.error("Failed to fetch more images:", e); + setLoadingMoreImages(false); + setHasFetchedMoreImages(true); + } + }, + [app], + ); + return (

Show Dog Image

{dogImageUrl && dogBreed && (
- {dogBreed} -

Breed: {dogBreed}

- + {dogBreed} +

+ Breed: {dogBreed} +

+
+ + +
+ {moreImages.length > 0 && ( +
+ + More {dogBreed} Images ({imageCount} total): + +
+ {moreImages.map((imageUrl, index) => ( + {`${dogBreed} + ))} +
+
+ )} + {hasFetchedMoreImages && moreImages.length === 0 && ( +
+ Failed to load more images. +
+ )}
)}
From 85ba691a1f49a78095a7d4240509aa5d478b5439 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 18:00:09 -0700 Subject: [PATCH 10/14] fix server code --- examples/cute-dogs-server/helpers.ts | 139 +++++++++++++ examples/cute-dogs-server/server.ts | 189 ++++++------------ .../cute-dogs-server/src/show-dog-image.tsx | 7 +- 3 files changed, 205 insertions(+), 130 deletions(-) create mode 100644 examples/cute-dogs-server/helpers.ts diff --git a/examples/cute-dogs-server/helpers.ts b/examples/cute-dogs-server/helpers.ts new file mode 100644 index 00000000..86715fa2 --- /dev/null +++ b/examples/cute-dogs-server/helpers.ts @@ -0,0 +1,139 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CallToolResult, + ReadResourceResult, + Resource, +} from "@modelcontextprotocol/sdk/types.js"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const distDir = path.join(__dirname, "dist"); + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Load HTML file from dist directory + */ +export const loadHtml = async (name: string): Promise => { + const htmlPath = path.join(distDir, `${name}.html`); + return fs.readFile(htmlPath, "utf-8"); +}; + +/** + * Create an error result for tool calls + */ +export const createErrorResult = ( + error: unknown, + message: string, +): CallToolResult => { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: error instanceof Error ? error.message : message, + }), + }, + ], + isError: true, + }; +}; + + +// ============================================================================ +// Dog CEO API Helpers +// ============================================================================ + +interface DogApiResponse { + status: string; + message: string | string[]; +} + +/** + * Fetch a random dog image (optionally for a specific breed) + */ +export const fetchRandomDogImage = async ( + breed?: string, +): Promise<{ message: string; breed: string }> => { + const apiUrl = breed + ? `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random` + : "https://dog.ceo/api/breeds/image/random"; + + const response = await fetch(apiUrl); + const data = (await response.json()) as DogApiResponse; + + if (data.status !== "success" || !data.message) { + throw new Error("Failed to fetch dog image"); + } + + const dogBreed = (data.message as string).split("/")[4]; + return { message: data.message as string, breed: dogBreed }; +}; + +/** + * Fetch all dog breeds + */ +export const fetchAllBreeds = async (): Promise => { + const response = await fetch("https://dog.ceo/api/breeds/list/all"); + const data = (await response.json()) as DogApiResponse; + + if (data.status !== "success" || typeof data.message !== "object") { + throw new Error("Failed to fetch breeds"); + } + + return Object.keys(data.message); +}; + +/** + * Fetch multiple random images for a breed + */ +export const fetchBreedImages = async ( + breed: string, + count: number, +): Promise => { + const apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random/${count}`; + const response = await fetch(apiUrl); + const data = (await response.json()) as DogApiResponse; + + if (data.status !== "success" || !Array.isArray(data.message)) { + throw new Error("Failed to fetch images"); + } + + return data.message; +}; + +// ============================================================================ +// Resource Registration +// ============================================================================ + +/** + * Register a UI resource with the server + */ +export const registerResource = ( + server: McpServer, + resource: Resource, + htmlContent: string, +): Resource => { + server.registerResource( + resource.name, + resource.uri, + resource, + async (): Promise => ({ + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType, + text: htmlContent, + }, + ], + }), + ); + return resource; +}; + + diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index cb30f84d..e497fba3 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -3,64 +3,54 @@ import express, { Request, Response } from "express"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { - CallToolResult, - isInitializeRequest, - ReadResourceResult, - Resource, -} from "@modelcontextprotocol/sdk/types.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; import cors from "cors"; -import path from "node:path"; -import fs from "node:fs/promises"; -import { fileURLToPath } from "node:url"; import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps"; +import { + loadHtml, + registerResource, + fetchRandomDogImage, + fetchAllBreeds, + fetchBreedImages, + createErrorResult, +} from "./helpers.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Load both UI HTML files from dist/ -const distDir = path.join(__dirname, "dist"); -const loadHtml = async (name: string) => { - const htmlPath = path.join(distDir, `${name}.html`); - return fs.readFile(htmlPath, "utf-8"); -}; +// ============================================================================ +// Server Setup +// ============================================================================ -// Create an MCP server with both UI tools +/** + * Create and configure the MCP server + */ const getServer = async () => { const server = new McpServer( { name: "cute-dogs-mcp-server", version: "1.0.0", + icons: [ + { + src: "https://dog.ceo/img/dog-api-logo.svg", + mimeType: "image/svg+xml", + }, + ], + }, + { + capabilities: { logging: {} }, + instructions: + "This server shows images of dogs. View all dog breeds with the `show-all-breeds` tool and widget. The show-dog-image tool and widget shows the image. The show-dog-image widget can call the `get-more-images` tool to get more images of the same breed.", }, - { capabilities: { logging: {} } }, ); - // Load HTML for both UIs + // Load HTML files const [showDogImageHtml, showAllBreedsHtml] = await Promise.all([ loadHtml("show-dog-image"), loadHtml("show-all-breeds"), ]); - const registerResource = (resource: Resource, htmlContent: string) => { - server.registerResource( - resource.name, - resource.uri, - resource, - async (): Promise => ({ - contents: [ - { - uri: resource.uri, - mimeType: resource.mimeType, - text: htmlContent, - }, - ], - }), - ); - return resource; - }; - + // Register resources const randomDogResource = registerResource( + server, { name: "show-random-dog-image-template", uri: "ui://show-random-dog-image", @@ -72,6 +62,7 @@ const getServer = async () => { ); const showAllBreedsResource = registerResource( + server, { name: "show-all-breeds-template", uri: "ui://show-all-breeds", @@ -82,11 +73,12 @@ const getServer = async () => { showAllBreedsHtml, ); + // Register tools server.registerTool( "show-random-dog-image", { title: "Show Dog Image", - description: "Show a dog image in an interactive UI widget.", + description: "Show a dog image in an interactive UI widget. Do not show the image in the text response. The image will be shown in the UI widget.", inputSchema: { breed: z .string() @@ -99,52 +91,23 @@ const getServer = async () => { [RESOURCE_URI_META_KEY]: randomDogResource.uri, }, }, - async ({ breed }): Promise => { + async ({ breed }) => { try { - let apiUrl: string; - if (breed) { - // Get random image from specific breed - apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random`; - } else { - // Get completely random dog image - apiUrl = "https://dog.ceo/api/breeds/image/random"; - } - - const response = await fetch(apiUrl); - const data = await response.json(); - const dogBreed = data.message.split("/")[4]; - - if (data.status === "success" && data.message) { - return { - content: [{ type: "text", text: JSON.stringify(data) }], - structuredContent: { ...data, breed: dogBreed }, - }; - } else { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Failed to fetch dog image", - status: data.status, - }), - }, - ], - isError: true, - }; - } - } catch (error) { + const result = await fetchRandomDogImage(breed); return { content: [ { - type: "text", + type: "text" as const, text: JSON.stringify({ - error: error instanceof Error ? error.message : "Unknown error", + message: `Successfully fetched ${breed} image`, + status: "success", }), }, ], - isError: true, + structuredContent: { ...result, status: "success" }, }; + } catch (error) { + return createErrorResult(error, "Failed to fetch dog image"); } }, ); @@ -158,14 +121,16 @@ const getServer = async () => { [RESOURCE_URI_META_KEY]: showAllBreedsResource.uri, }, }, - async (): Promise => { - const response = await fetch("https://dog.ceo/api/breeds/list/all"); - const data = await response.json(); - const breeds = Object.keys(data.message); - return { - content: [{ type: "text", text: JSON.stringify({ breeds }) }], - structuredContent: { breeds }, - }; + async () => { + try { + const breeds = await fetchAllBreeds(); + return { + content: [{ type: "text" as const, text: JSON.stringify({ breeds }) }], + structuredContent: { breeds }, + }; + } catch (error) { + return createErrorResult(error, "Failed to fetch breeds"); + } }, ); @@ -185,60 +150,28 @@ const getServer = async () => { .number() .int() .min(1) - .max(10) + .max(30) .optional() .default(3) .describe( - "Number of images to fetch (1-10). Defaults to 3 if not provided.", + "Number of images to fetch (1-30). Defaults to 3 if not provided.", ), }, }, - async ({ breed, count = 3 }): Promise => { + async ({ breed, count = 3 }) => { try { - const apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random/${count}`; - const response = await fetch(apiUrl); - const data = await response.json(); - - if (data.status === "success" && Array.isArray(data.message)) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ breed, images: data.message }), - }, - ], - structuredContent: { - breed, - images: data.message, - }, - }; - } else { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Failed to fetch images", - status: data.status, - message: data.message, - }), - }, - ], - isError: true, - }; - } - } catch (error) { + const images = await fetchBreedImages(breed, count); return { content: [ { - type: "text", - text: JSON.stringify({ - error: error instanceof Error ? error.message : "Unknown error", - }), + type: "text" as const, + text: JSON.stringify({ breed, images }), }, ], - isError: true, + structuredContent: { breed, images }, }; + } catch (error) { + return createErrorResult(error, "Failed to fetch images"); } }, ); @@ -246,6 +179,10 @@ const getServer = async () => { return server; }; +// ============================================================================ +// Express Server Setup +// ============================================================================ + const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3001; diff --git a/examples/cute-dogs-server/src/show-dog-image.tsx b/examples/cute-dogs-server/src/show-dog-image.tsx index 890b1aa5..c1fb160d 100644 --- a/examples/cute-dogs-server/src/show-dog-image.tsx +++ b/examples/cute-dogs-server/src/show-dog-image.tsx @@ -4,9 +4,7 @@ import { useState, useCallback } from "react"; import { createRoot } from "react-dom/client"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { - Implementation, -} from "@modelcontextprotocol/sdk/types.js"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; const APP_INFO: Implementation = { name: "Show Dog Image App", @@ -18,7 +16,8 @@ export function ShowDogImageApp() { const [dogBreed, setDogBreed] = useState(null); const [moreImages, setMoreImages] = useState([]); const [loadingMoreImages, setLoadingMoreImages] = useState(false); - const [hasFetchedMoreImages, setHasFetchedMoreImages] = useState(false); + const [hasFetchedMoreImages, setHasFetchedMoreImages] = + useState(false); const [imageCount, setImageCount] = useState(0); const { app } = useApp({ From a9de008cbde9d244dfccb96d0405ba1df0b34771 Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 18:05:14 -0700 Subject: [PATCH 11/14] Rename widgets --- ...w-all-breeds.html => all-breeds-view.html} | 2 +- ...how-dog-image.html => dog-image-view.html} | 2 +- examples/cute-dogs-server/helpers.ts | 3 - examples/cute-dogs-server/package.json | 2 +- examples/cute-dogs-server/server.ts | 19 +- .../cute-dogs-server/src/all-breeds-view.tsx | 112 +++++++++ .../cute-dogs-server/src/dog-image-view.tsx | 213 ++++++++++++++++++ .../cute-dogs-server/src/show-all-breeds.tsx | 131 ----------- .../cute-dogs-server/src/show-dog-image.tsx | 206 ----------------- 9 files changed, 339 insertions(+), 351 deletions(-) rename examples/cute-dogs-server/{show-all-breeds.html => all-breeds-view.html} (79%) rename examples/cute-dogs-server/{show-dog-image.html => dog-image-view.html} (79%) create mode 100644 examples/cute-dogs-server/src/all-breeds-view.tsx create mode 100644 examples/cute-dogs-server/src/dog-image-view.tsx delete mode 100644 examples/cute-dogs-server/src/show-all-breeds.tsx delete mode 100644 examples/cute-dogs-server/src/show-dog-image.tsx diff --git a/examples/cute-dogs-server/show-all-breeds.html b/examples/cute-dogs-server/all-breeds-view.html similarity index 79% rename from examples/cute-dogs-server/show-all-breeds.html rename to examples/cute-dogs-server/all-breeds-view.html index 851aab65..d699c739 100644 --- a/examples/cute-dogs-server/show-all-breeds.html +++ b/examples/cute-dogs-server/all-breeds-view.html @@ -7,6 +7,6 @@
- + diff --git a/examples/cute-dogs-server/show-dog-image.html b/examples/cute-dogs-server/dog-image-view.html similarity index 79% rename from examples/cute-dogs-server/show-dog-image.html rename to examples/cute-dogs-server/dog-image-view.html index 1b450168..0c89593a 100644 --- a/examples/cute-dogs-server/show-dog-image.html +++ b/examples/cute-dogs-server/dog-image-view.html @@ -7,6 +7,6 @@
- + diff --git a/examples/cute-dogs-server/helpers.ts b/examples/cute-dogs-server/helpers.ts index 86715fa2..deecf42b 100644 --- a/examples/cute-dogs-server/helpers.ts +++ b/examples/cute-dogs-server/helpers.ts @@ -44,7 +44,6 @@ export const createErrorResult = ( }; }; - // ============================================================================ // Dog CEO API Helpers // ============================================================================ @@ -135,5 +134,3 @@ export const registerResource = ( ); return resource; }; - - diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json index 62845001..2a2fca3e 100644 --- a/examples/cute-dogs-server/package.json +++ b/examples/cute-dogs-server/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "start": "NODE_ENV=development npm run build && npm run server", - "build": "concurrently 'INPUT=show-dog-image.html vite build' 'INPUT=show-all-breeds.html vite build'", + "build": "concurrently 'INPUT=dog-image-view.html vite build' 'INPUT=all-breeds-view.html vite build'", "server": "bun server.ts" }, "dependencies": { diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts index e497fba3..4b5d6c53 100644 --- a/examples/cute-dogs-server/server.ts +++ b/examples/cute-dogs-server/server.ts @@ -38,14 +38,14 @@ const getServer = async () => { { capabilities: { logging: {} }, instructions: - "This server shows images of dogs. View all dog breeds with the `show-all-breeds` tool and widget. The show-dog-image tool and widget shows the image. The show-dog-image widget can call the `get-more-images` tool to get more images of the same breed.", + "This server shows images of dogs. View all dog breeds with the `all-breeds-view` tool and widget. The dog-image-view tool and widget shows the image. The dog-image-view widget can call the `get-more-images` tool to get more images of the same breed.", }, ); // Load HTML files const [showDogImageHtml, showAllBreedsHtml] = await Promise.all([ - loadHtml("show-dog-image"), - loadHtml("show-all-breeds"), + loadHtml("dog-image-view"), + loadHtml("all-breeds-view"), ]); // Register resources @@ -64,8 +64,8 @@ const getServer = async () => { const showAllBreedsResource = registerResource( server, { - name: "show-all-breeds-template", - uri: "ui://show-all-breeds", + name: "all-breeds-view-template", + uri: "ui://all-breeds-view", title: "Show All Breeds Template", description: "A show all breeds UI", mimeType: "text/html+mcp", @@ -78,7 +78,8 @@ const getServer = async () => { "show-random-dog-image", { title: "Show Dog Image", - description: "Show a dog image in an interactive UI widget. Do not show the image in the text response. The image will be shown in the UI widget.", + description: + "Show a dog image in an interactive UI widget. Do not show the image in the text response. The image will be shown in the UI widget.", inputSchema: { breed: z .string() @@ -113,7 +114,7 @@ const getServer = async () => { ); server.registerTool( - "show-all-breeds", + "all-breeds-view", { title: "Show All Breeds", description: "Show all breeds in an interactive UI widget.", @@ -125,7 +126,9 @@ const getServer = async () => { try { const breeds = await fetchAllBreeds(); return { - content: [{ type: "text" as const, text: JSON.stringify({ breeds }) }], + content: [ + { type: "text" as const, text: JSON.stringify({ breeds }) }, + ], structuredContent: { breeds }, }; } catch (error) { diff --git a/examples/cute-dogs-server/src/all-breeds-view.tsx b/examples/cute-dogs-server/src/all-breeds-view.tsx new file mode 100644 index 00000000..168753e2 --- /dev/null +++ b/examples/cute-dogs-server/src/all-breeds-view.tsx @@ -0,0 +1,112 @@ +/** + * @file App that displays all dog breeds and allows selecting one to show in chat. + */ +import { useState, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; + +const APP_INFO: Implementation = { + name: "Show All Breeds App", + version: "1.0.0", +}; + +const styles = { + container: { padding: "20px", fontFamily: "system-ui, sans-serif" }, + subtitle: { marginBottom: "20px", color: "#666" }, + selectedBanner: { + padding: "10px", + marginBottom: "20px", + backgroundColor: "#e3f2fd", + borderRadius: "4px", + color: "#1976d2", + }, + waiting: { padding: "20px", color: "#666" }, + breedsGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", + gap: "10px", + maxHeight: "600px", + overflowY: "auto" as const, + }, + breedButton: (isSelected: boolean) => ({ + padding: "12px 16px", + fontSize: "14px", + cursor: "pointer", + backgroundColor: isSelected ? "#1976d2" : "#f5f5f5", + color: isSelected ? "white" : "#333", + border: "1px solid #ddd", + borderRadius: "4px", + textTransform: "capitalize" as const, + transition: "all 0.2s", + }), +}; + +export function AllBreedsViewApp() { + const [breeds, setBreeds] = useState([]); + const [selectedBreed, setSelectedBreed] = useState(null); + + const { app } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (toolResult) => { + const breeds = (toolResult.structuredContent as { breeds?: string[] }) + ?.breeds; + if (breeds) setBreeds(breeds); + }; + }, + }); + + const handleBreedClick = useCallback( + async (breed: string) => { + if (!app) return; + setSelectedBreed(breed); + try { + await app.sendMessage({ + role: "user", + content: [{ type: "text", text: `Show me a ${breed}` }], + }); + } catch (e) { + console.error("Failed to send message:", e); + } + }, + [app], + ); + + return ( +
+

All Dog Breeds

+

Click on a breed to show it in the chat

+ {selectedBreed && ( +
+ Selected: {selectedBreed} - Message sent to chat! +
+ )} + {breeds.length === 0 ? ( +
Waiting for breeds data...
+ ) : ( +
+ {breeds.map((breed) => ( + + ))} +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/dog-image-view.tsx b/examples/cute-dogs-server/src/dog-image-view.tsx new file mode 100644 index 00000000..df8b5f5e --- /dev/null +++ b/examples/cute-dogs-server/src/dog-image-view.tsx @@ -0,0 +1,213 @@ +/** + * @file App that displays a random dog image from the Dog CEO API via MCP tool. + */ +import { useState, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; + +const APP_INFO: Implementation = { + name: "Show Dog Image App", + version: "1.0.0", +}; + +const styles = { + container: { padding: "20px", fontFamily: "system-ui, sans-serif" }, + image: { + maxWidth: "100%", + height: "auto", + borderRadius: "8px", + boxShadow: "0 2px 8px rgba(0,0,0,0.1)", + marginBottom: "15px", + }, + breedLabel: { fontSize: "18px", marginBottom: "15px" }, + buttonRow: { + display: "flex", + gap: "10px", + marginBottom: "15px", + flexWrap: "wrap" as const, + }, + button: (disabled = false) => ({ + padding: "10px 20px", + fontSize: "14px", + cursor: disabled ? "not-allowed" : "pointer", + backgroundColor: disabled ? "#ccc" : "#007bff", + color: "white", + border: "none", + borderRadius: "4px", + }), + buttonSuccess: (disabled = false) => ({ + padding: "10px 20px", + fontSize: "14px", + cursor: disabled ? "not-allowed" : "pointer", + backgroundColor: disabled ? "#ccc" : "#28a745", + color: "white", + border: "none", + borderRadius: "4px", + textTransform: "capitalize" as const, + }), + imagesSection: { marginTop: "20px" }, + imagesGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", + gap: "15px", + }, + thumbnailImage: { + width: "100%", + height: "auto", + borderRadius: "8px", + boxShadow: "0 2px 8px rgba(0,0,0,0.1)", + objectFit: "cover" as const, + }, + errorMessage: { marginTop: "15px", fontSize: "13px", color: "#666" }, +}; + +/** + * Extract images from tool result (structuredContent or content) + */ +const extractImages = (result: { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; +}): string[] => { + if (result.structuredContent && typeof result.structuredContent === "object") { + const data = result.structuredContent as { images?: string[] }; + return data.images || []; + } + const textContent = result.content?.find((c) => c.type === "text"); + if (textContent?.text) { + try { + const parsed = JSON.parse(textContent.text) as { images?: string[] }; + return parsed.images || []; + } catch { + return []; + } + } + return []; +}; + +export function DogImageViewApp() { + const [dogImageUrl, setDogImageUrl] = useState(null); + const [dogBreed, setDogBreed] = useState(null); + const [moreImages, setMoreImages] = useState([]); + const [loadingMoreImages, setLoadingMoreImages] = useState(false); + const [hasFetchedMoreImages, setHasFetchedMoreImages] = useState(false); + + const { app } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (toolResult) => { + const data = toolResult.structuredContent as { + message?: string; + breed?: string; + }; + if (data.message && data.breed) { + setDogImageUrl(data.message); + setDogBreed(data.breed); + setMoreImages([]); + setHasFetchedMoreImages(false); + } + }; + }, + }); + + const openDogBreedLink = useCallback(async () => { + if (!app) return; + await app.sendOpenLink({ + url: `https://www.google.com/search?q=${dogBreed}`, + }); + }, [app, dogBreed]); + + const handleGetMoreImages = useCallback( + async (breed: string) => { + if (!app || !breed) return; + + setLoadingMoreImages(true); + try { + const result = await app.callServerTool({ + name: "get-more-images", + arguments: { breed, count: 3 }, + }); + + if (result.isError) { + setHasFetchedMoreImages(true); + return; + } + + const newImages = extractImages(result); + setMoreImages((prev) => [...prev, ...newImages]); + setHasFetchedMoreImages(true); + } catch (e) { + console.error("Failed to fetch more images:", e); + setHasFetchedMoreImages(true); + } finally { + setLoadingMoreImages(false); + } + }, + [app], + ); + + const imageCount = moreImages.length; + + return ( +
+

Show Dog Image

+ {dogImageUrl && dogBreed && ( +
+ {dogBreed} +

+ Breed: {dogBreed} +

+
+ + +
+ {moreImages.length > 0 && ( +
+ + More {dogBreed} Images ({imageCount} total): + +
+ {moreImages.map((imageUrl, index) => ( + {`${dogBreed} + ))} +
+
+ )} + {hasFetchedMoreImages && moreImages.length === 0 && ( +
Failed to load more images.
+ )} +
+ )} +
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/show-all-breeds.tsx b/examples/cute-dogs-server/src/show-all-breeds.tsx deleted file mode 100644 index c2c197b2..00000000 --- a/examples/cute-dogs-server/src/show-all-breeds.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @file App that displays all dog breeds and allows selecting one to show in chat. - */ -import { useState, useCallback } from "react"; -import { createRoot } from "react-dom/client"; -import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; - -const APP_INFO: Implementation = { - name: "Show All Breeds App", - version: "1.0.0", -}; - -export function ShowAllBreedsApp() { - const [breeds, setBreeds] = useState([]); - const [selectedBreed, setSelectedBreed] = useState(null); - - const { app } = useApp({ - appInfo: APP_INFO, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = async (toolResult) => { - const toolResultData = toolResult.structuredContent as { - breeds?: string[]; - }; - if (toolResultData.breeds) { - setBreeds(toolResultData.breeds); - } - }; - }, - }); - - const handleBreedClick = useCallback( - async (breed: string) => { - if (!app) return; - - setSelectedBreed(breed); - try { - await app.sendMessage({ - role: "user", - content: [ - { - type: "text", - text: `Show me a ${breed}`, - }, - ], - }); - } catch (e) { - console.error("Failed to send message:", e); - } - }, - [app], - ); - - return ( -
-

All Dog Breeds

-

- Click on a breed to show it in the chat -

- {selectedBreed && ( -
- Selected: {selectedBreed} - Message sent to chat! -
- )} - {breeds.length === 0 ? ( -
- Waiting for breeds data... -
- ) : ( -
- {breeds.map((breed) => ( - - ))} -
- )} -
- ); -} - -window.addEventListener("load", () => { - const root = document.getElementById("root"); - if (!root) { - throw new Error("Root element not found"); - } - - createRoot(root).render(); -}); diff --git a/examples/cute-dogs-server/src/show-dog-image.tsx b/examples/cute-dogs-server/src/show-dog-image.tsx deleted file mode 100644 index c1fb160d..00000000 --- a/examples/cute-dogs-server/src/show-dog-image.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * @file App that displays a random dog image from the Dog CEO API via MCP tool. - */ -import { useState, useCallback } from "react"; -import { createRoot } from "react-dom/client"; -import { useApp } from "@modelcontextprotocol/ext-apps/react"; -import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; - -const APP_INFO: Implementation = { - name: "Show Dog Image App", - version: "1.0.0", -}; - -export function ShowDogImageApp() { - const [dogImageUrl, setDogImageUrl] = useState(null); - const [dogBreed, setDogBreed] = useState(null); - const [moreImages, setMoreImages] = useState([]); - const [loadingMoreImages, setLoadingMoreImages] = useState(false); - const [hasFetchedMoreImages, setHasFetchedMoreImages] = - useState(false); - const [imageCount, setImageCount] = useState(0); - - const { app } = useApp({ - appInfo: APP_INFO, - capabilities: {}, - onAppCreated: (app) => { - app.ontoolresult = async (toolResult) => { - const toolResultData = toolResult.structuredContent as { - message: string; - breed: string; - }; - setDogImageUrl(toolResultData.message); - setDogBreed(toolResultData.breed); - setMoreImages([]); - setHasFetchedMoreImages(false); - setImageCount(0); - }; - }, - }); - - const openDogBreedLink = useCallback(async () => { - if (!app) return; - await app.sendOpenLink({ - url: `https://www.google.com/search?q=${dogBreed}`, - }); - }, [app, dogBreed]); - - const handleGetMoreImages = useCallback( - async (breed: string) => { - if (!app || !breed) return; - - setLoadingMoreImages(true); - try { - const result = await app.callServerTool({ - name: "get-more-images", - arguments: { breed, count: 3 }, - }); - - if (result.isError) { - setLoadingMoreImages(false); - setHasFetchedMoreImages(true); - return; - } - - // Extract images from structuredContent or content - let imagesData: string[] = []; - if ( - result.structuredContent && - typeof result.structuredContent === "object" - ) { - const data = result.structuredContent as { images?: string[] }; - imagesData = data.images || []; - } else if (result.content && result.content.length > 0) { - const textContent = result.content.find((c) => c.type === "text"); - if (textContent && typeof textContent.text === "string") { - const parsed = JSON.parse(textContent.text) as { - images?: string[]; - }; - imagesData = parsed.images || []; - } - } - - // Append new images to existing ones - setMoreImages((prev) => [...prev, ...imagesData]); - setImageCount((prev) => prev + imagesData.length); - setLoadingMoreImages(false); - setHasFetchedMoreImages(true); - } catch (e) { - console.error("Failed to fetch more images:", e); - setLoadingMoreImages(false); - setHasFetchedMoreImages(true); - } - }, - [app], - ); - - return ( -
-

Show Dog Image

- {dogImageUrl && dogBreed && ( -
- {dogBreed} -

- Breed: {dogBreed} -

-
- - -
- {moreImages.length > 0 && ( -
- - More {dogBreed} Images ({imageCount} total): - -
- {moreImages.map((imageUrl, index) => ( - {`${dogBreed} - ))} -
-
- )} - {hasFetchedMoreImages && moreImages.length === 0 && ( -
- Failed to load more images. -
- )} -
- )} -
- ); -} - -window.addEventListener("load", () => { - const root = document.getElementById("root"); - if (!root) { - throw new Error("Root element not found"); - } - - createRoot(root).render(); -}); From 0535d27aada7c54d35c6d37bc97af28baed2839f Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 18:21:39 -0700 Subject: [PATCH 12/14] Cute dogs --- examples/cute-dogs-server/components.json | 19 ++ examples/cute-dogs-server/package.json | 8 + examples/cute-dogs-server/postcss.config.js | 7 + .../cute-dogs-server/src/all-breeds-view.tsx | 98 ++++----- .../src/components/ui/alert.tsx | 59 ++++++ .../src/components/ui/badge.tsx | 36 ++++ .../src/components/ui/button.tsx | 54 +++++ .../src/components/ui/card.tsx | 79 +++++++ .../cute-dogs-server/src/dog-image-view.tsx | 200 +++++++++--------- examples/cute-dogs-server/src/lib/utils.ts | 7 + examples/cute-dogs-server/src/styles.css | 60 ++++++ examples/cute-dogs-server/tailwind.config.js | 54 +++++ examples/cute-dogs-server/tsconfig.json | 6 +- examples/cute-dogs-server/vite.config.ts | 6 + 14 files changed, 539 insertions(+), 154 deletions(-) create mode 100644 examples/cute-dogs-server/components.json create mode 100644 examples/cute-dogs-server/postcss.config.js create mode 100644 examples/cute-dogs-server/src/components/ui/alert.tsx create mode 100644 examples/cute-dogs-server/src/components/ui/badge.tsx create mode 100644 examples/cute-dogs-server/src/components/ui/button.tsx create mode 100644 examples/cute-dogs-server/src/components/ui/card.tsx create mode 100644 examples/cute-dogs-server/src/lib/utils.ts create mode 100644 examples/cute-dogs-server/src/styles.css create mode 100644 examples/cute-dogs-server/tailwind.config.js diff --git a/examples/cute-dogs-server/components.json b/examples/cute-dogs-server/components.json new file mode 100644 index 00000000..a0f79257 --- /dev/null +++ b/examples/cute-dogs-server/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/styles.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} + diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json index 2a2fca3e..5e1ba9bb 100644 --- a/examples/cute-dogs-server/package.json +++ b/examples/cute-dogs-server/package.json @@ -11,8 +11,13 @@ "dependencies": { "@modelcontextprotocol/ext-apps": "../..", "@modelcontextprotocol/sdk": "^1.22.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.454.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", "zod": "^3.25.0" }, "devDependencies": { @@ -21,9 +26,12 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", "concurrently": "^9.2.1", "cors": "^2.8.5", "express": "^5.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", "typescript": "^5.7.2", "vite": "^6.0.0", "vite-plugin-singlefile": "^2.3.0" diff --git a/examples/cute-dogs-server/postcss.config.js b/examples/cute-dogs-server/postcss.config.js new file mode 100644 index 00000000..1d926516 --- /dev/null +++ b/examples/cute-dogs-server/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/examples/cute-dogs-server/src/all-breeds-view.tsx b/examples/cute-dogs-server/src/all-breeds-view.tsx index 168753e2..6870d203 100644 --- a/examples/cute-dogs-server/src/all-breeds-view.tsx +++ b/examples/cute-dogs-server/src/all-breeds-view.tsx @@ -1,47 +1,21 @@ /** * @file App that displays all dog breeds and allows selecting one to show in chat. */ +import "./styles.css"; import { useState, useCallback } from "react"; import { createRoot } from "react-dom/client"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; const APP_INFO: Implementation = { name: "Show All Breeds App", version: "1.0.0", }; -const styles = { - container: { padding: "20px", fontFamily: "system-ui, sans-serif" }, - subtitle: { marginBottom: "20px", color: "#666" }, - selectedBanner: { - padding: "10px", - marginBottom: "20px", - backgroundColor: "#e3f2fd", - borderRadius: "4px", - color: "#1976d2", - }, - waiting: { padding: "20px", color: "#666" }, - breedsGrid: { - display: "grid", - gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", - gap: "10px", - maxHeight: "600px", - overflowY: "auto" as const, - }, - breedButton: (isSelected: boolean) => ({ - padding: "12px 16px", - fontSize: "14px", - cursor: "pointer", - backgroundColor: isSelected ? "#1976d2" : "#f5f5f5", - color: isSelected ? "white" : "#333", - border: "1px solid #ddd", - borderRadius: "4px", - textTransform: "capitalize" as const, - transition: "all 0.2s", - }), -}; - export function AllBreedsViewApp() { const [breeds, setBreeds] = useState([]); const [selectedBreed, setSelectedBreed] = useState(null); @@ -75,29 +49,47 @@ export function AllBreedsViewApp() { ); return ( -
-

All Dog Breeds

-

Click on a breed to show it in the chat

- {selectedBreed && ( -
- Selected: {selectedBreed} - Message sent to chat! -
- )} - {breeds.length === 0 ? ( -
Waiting for breeds data...
- ) : ( -
- {breeds.map((breed) => ( - - ))} +
+
+
+

Dog Breeds

+

+ Select a breed to view in chat +

- )} + + {selectedBreed && ( + + + + {selectedBreed} + + Message sent to chat + + + )} + + {breeds.length === 0 ? ( + + +

Loading breeds...

+
+
+ ) : ( +
+ {breeds.map((breed) => ( + + ))} +
+ )} +
); } diff --git a/examples/cute-dogs-server/src/components/ui/alert.tsx b/examples/cute-dogs-server/src/components/ui/alert.tsx new file mode 100644 index 00000000..094c1ce6 --- /dev/null +++ b/examples/cute-dogs-server/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; + diff --git a/examples/cute-dogs-server/src/components/ui/badge.tsx b/examples/cute-dogs-server/src/components/ui/badge.tsx new file mode 100644 index 00000000..654ba378 --- /dev/null +++ b/examples/cute-dogs-server/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; + diff --git a/examples/cute-dogs-server/src/components/ui/button.tsx b/examples/cute-dogs-server/src/components/ui/button.tsx new file mode 100644 index 00000000..499ef5d7 --- /dev/null +++ b/examples/cute-dogs-server/src/components/ui/button.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + - -
- {moreImages.length > 0 && ( -
- - More {dogBreed} Images ({imageCount} total): - -
- {moreImages.map((imageUrl, index) => ( - {`${dogBreed} - ))} +
+
+ {dogImageUrl && dogBreed ? ( +
+ + +
+ {dogBreed} + +
+
+
+ {dogBreed}
-
- )} - {hasFetchedMoreImages && moreImages.length === 0 && ( -
Failed to load more images.
- )} -
- )} + + + + {hasFetchedMoreImages && moreImages.length === 0 && ( + + + Failed to load more images. + + + )} + + + + {moreImages.length > 0 && ( +
+
+

+ More {dogBreed} Images +

+ {imageCount} total +
+
+ {moreImages.map((imageUrl, index) => ( + +
+ {`${dogBreed} +
+
+ ))} +
+
+ )} +
+ ) : ( + + +

Waiting for dog image...

+
+
+ )} +
); } diff --git a/examples/cute-dogs-server/src/lib/utils.ts b/examples/cute-dogs-server/src/lib/utils.ts new file mode 100644 index 00000000..6fcf374b --- /dev/null +++ b/examples/cute-dogs-server/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + diff --git a/examples/cute-dogs-server/src/styles.css b/examples/cute-dogs-server/src/styles.css new file mode 100644 index 00000000..88890ba5 --- /dev/null +++ b/examples/cute-dogs-server/src/styles.css @@ -0,0 +1,60 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + diff --git a/examples/cute-dogs-server/tailwind.config.js b/examples/cute-dogs-server/tailwind.config.js new file mode 100644 index 00000000..ab0f0da2 --- /dev/null +++ b/examples/cute-dogs-server/tailwind.config.js @@ -0,0 +1,54 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: [ + "./src/**/*.{ts,tsx}", + "./*.html", + ], + theme: { + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; + diff --git a/examples/cute-dogs-server/tsconfig.json b/examples/cute-dogs-server/tsconfig.json index a4c834a6..e5a86550 100644 --- a/examples/cute-dogs-server/tsconfig.json +++ b/examples/cute-dogs-server/tsconfig.json @@ -14,7 +14,11 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/examples/cute-dogs-server/vite.config.ts b/examples/cute-dogs-server/vite.config.ts index 28d7e5bf..7876e40e 100644 --- a/examples/cute-dogs-server/vite.config.ts +++ b/examples/cute-dogs-server/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { viteSingleFile } from "vite-plugin-singlefile"; +import path from "path"; const INPUT = process.env.INPUT; if (!INPUT) { @@ -11,6 +12,11 @@ const isDevelopment = process.env.NODE_ENV === "development"; export default defineConfig({ plugins: [react(), viteSingleFile()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, build: { sourcemap: isDevelopment ? "inline" : undefined, cssMinify: !isDevelopment, From cc4bf3acb31a9c5bbb943f3a0f3eb1699cd18ecf Mon Sep 17 00:00:00 2001 From: Matthew Wang Date: Sat, 29 Nov 2025 18:32:45 -0700 Subject: [PATCH 13/14] README --- examples/cute-dogs-server/README.md | 101 ++++++++++++++++++ examples/cute-dogs-server/components.json | 1 - examples/cute-dogs-server/postcss.config.js | 1 - .../src/components/ui/alert.tsx | 3 +- .../src/components/ui/badge.tsx | 6 +- .../src/components/ui/button.tsx | 8 +- .../src/components/ui/card.tsx | 14 ++- .../cute-dogs-server/src/dog-image-view.tsx | 4 +- examples/cute-dogs-server/src/lib/utils.ts | 1 - examples/cute-dogs-server/tailwind.config.js | 6 +- 10 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 examples/cute-dogs-server/README.md diff --git a/examples/cute-dogs-server/README.md b/examples/cute-dogs-server/README.md new file mode 100644 index 00000000..aa2b7905 --- /dev/null +++ b/examples/cute-dogs-server/README.md @@ -0,0 +1,101 @@ +# Cute Dogs MCP Server (MCP Apps UI) + +An MCP (Model Context Protocol) server + MCP Apps UI that provides interactive widgets and tools for browsing and viewing dog images from the [Dog CEO API](https://dog.ceo/dog-api/). + +This example server demonstrates the full MCP Apps capabilities with a React example: +- Render React UI widgets. The UI is hydrated by data passed in from the MCP tool via `structuredContent` +- Call tool within widget +- Send follow up message +- Open external link + +## Installation + +Install all dependencies: + +```bash +npm i +``` + +Then start the server + +```bash +npm run start +``` + +The terminal will then print out text `MCP Server listening on http://localhost:3001/mcp`. Connect to the MCP server with the localhost link. + +## Tools + +The server provides three MCP tools: + +### 1. `show-random-dog-image` + +Shows a dog image in an interactive UI widget. The image is displayed in the widget, not in the text response. + +**Parameters:** +- `breed` (optional): Dog breed name (e.g., `"hound"`, `"retriever"`). If not provided, returns a random dog from any breed. + +**Widget:** `dog-image-view` + +**Example:** +```json +{ + "name": "show-random-dog-image", + "arguments": { + "breed": "hound" + } +} +``` + +### 2. `all-breeds-view` + +Shows all available dog breeds in an interactive UI widget. Users can click on any breed to send a message to the chat requesting that breed. + +**Parameters:** None + +**Widget:** `all-breeds-view` + +**Example:** +```json +{ + "name": "all-breeds-view", + "arguments": {} +} +``` + +### 3. `get-more-images` + +Fetches multiple random dog images from a specific breed. Returns an array of image URLs. This tool is typically called from within the `dog-image-view` widget to load additional images. + +**Parameters:** +- `breed` (required): The dog breed name (e.g., `"hound"`, `"retriever"`) +- `count` (optional): Number of images to fetch (1-30). Defaults to 3 if not provided. + +**Widget:** None (programmatic tool) + +**Example:** +```json +{ + "name": "get-more-images", + "arguments": { + "breed": "hound", + "count": 5 + } +} +``` + +## How this server is compiled + +1. **Source Files** → React components written in TypeScript/TSX: + - `src/dog-image-view.tsx` - The React component for displaying dog images + - `src/all-breeds-view.tsx` - The React component for showing all breeds + +2. **HTML Entry Points** → Simple HTML files that load the React components: + - `dog-image-view.html` - Loads `dog-image-view.tsx` via a script tag + - `all-breeds-view.html` - Loads `all-breeds-view.tsx` via a script tag + +3. **Vite Build Process** → When you run `npm run build`: + - Vite compiles each HTML file separately (using the `INPUT` environment variable) + - It bundles all React code, CSS, and dependencies into a single HTML file + - Outputs go to `dist/dog-image-view.html` and `dist/all-breeds-view.html` + - These are self-contained, ready-to-serve HTML files \ No newline at end of file diff --git a/examples/cute-dogs-server/components.json b/examples/cute-dogs-server/components.json index a0f79257..f4ca71e1 100644 --- a/examples/cute-dogs-server/components.json +++ b/examples/cute-dogs-server/components.json @@ -16,4 +16,3 @@ "ui": "@/components/ui" } } - diff --git a/examples/cute-dogs-server/postcss.config.js b/examples/cute-dogs-server/postcss.config.js index 1d926516..2aa7205d 100644 --- a/examples/cute-dogs-server/postcss.config.js +++ b/examples/cute-dogs-server/postcss.config.js @@ -4,4 +4,3 @@ export default { autoprefixer: {}, }, }; - diff --git a/examples/cute-dogs-server/src/components/ui/alert.tsx b/examples/cute-dogs-server/src/components/ui/alert.tsx index 094c1ce6..d964627c 100644 --- a/examples/cute-dogs-server/src/components/ui/alert.tsx +++ b/examples/cute-dogs-server/src/components/ui/alert.tsx @@ -15,7 +15,7 @@ const alertVariants = cva( defaultVariants: { variant: "default", }, - } + }, ); const Alert = React.forwardRef< @@ -56,4 +56,3 @@ const AlertDescription = React.forwardRef< AlertDescription.displayName = "AlertDescription"; export { Alert, AlertTitle, AlertDescription }; - diff --git a/examples/cute-dogs-server/src/components/ui/badge.tsx b/examples/cute-dogs-server/src/components/ui/badge.tsx index 654ba378..b6faebca 100644 --- a/examples/cute-dogs-server/src/components/ui/badge.tsx +++ b/examples/cute-dogs-server/src/components/ui/badge.tsx @@ -19,11 +19,12 @@ const badgeVariants = cva( defaultVariants: { variant: "default", }, - } + }, ); export interface BadgeProps - extends React.HTMLAttributes, + extends + React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { @@ -33,4 +34,3 @@ function Badge({ className, variant, ...props }: BadgeProps) { } export { Badge, badgeVariants }; - diff --git a/examples/cute-dogs-server/src/components/ui/button.tsx b/examples/cute-dogs-server/src/components/ui/button.tsx index 499ef5d7..75c42185 100644 --- a/examples/cute-dogs-server/src/components/ui/button.tsx +++ b/examples/cute-dogs-server/src/components/ui/button.tsx @@ -28,11 +28,12 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } + }, ); export interface ButtonProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } @@ -46,9 +47,8 @@ const Button = React.forwardRef( {...props} /> ); - } + }, ); Button.displayName = "Button"; export { Button, buttonVariants }; - diff --git a/examples/cute-dogs-server/src/components/ui/card.tsx b/examples/cute-dogs-server/src/components/ui/card.tsx index 8f017a0a..d305169e 100644 --- a/examples/cute-dogs-server/src/components/ui/card.tsx +++ b/examples/cute-dogs-server/src/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef< ref={ref} className={cn( "rounded-lg border bg-card text-card-foreground shadow-sm", - className + className, )} {...props} /> @@ -36,7 +36,7 @@ const CardTitle = React.forwardRef< ref={ref} className={cn( "text-2xl font-semibold leading-none tracking-tight", - className + className, )} {...props} /> @@ -75,5 +75,11 @@ const CardFooter = React.forwardRef< )); CardFooter.displayName = "CardFooter"; -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; - +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/examples/cute-dogs-server/src/dog-image-view.tsx b/examples/cute-dogs-server/src/dog-image-view.tsx index 9e4adabc..102617d5 100644 --- a/examples/cute-dogs-server/src/dog-image-view.tsx +++ b/examples/cute-dogs-server/src/dog-image-view.tsx @@ -115,7 +115,9 @@ export function DogImageViewApp() {
- {dogBreed} + + {dogBreed} +