update simple-server example

This commit is contained in:
Olivier Chafik 2025-11-21 13:33:57 +00:00
commit 60cf8ce176
No known key found for this signature in database
12 changed files with 3599 additions and 240 deletions

View file

@ -1,24 +0,0 @@
{
"name": "simple-app-react",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "file:../..",
"@modelcontextprotocol/sdk": "^1.20.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.0"
}
}

View file

@ -1,14 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
// Output single HTML file with inlined JS for easy distribution
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});

3128
examples/simple-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
{
"name": "simple-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "rm -rf dist && VITE_ENTRY=ui-vanilla vite build && VITE_ENTRY=ui-react vite build",
"preview": "vite preview",
"server": "tsx server.ts",
"start": "npm run build && npm run server"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "file:../..",
"@modelcontextprotocol/sdk": "^1.20.1",
"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",
"cors": "^2.8.5",
"express": "^5.1.0",
"tsx": "^4.20.6",
"typescript": "^5.7.2",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}

View file

@ -0,0 +1,269 @@
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";
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 [vanillaHtml, reactHtml] = await Promise.all([
loadHtml("ui-vanilla"),
loadHtml("ui-react"),
]);
// --- Vanilla UI Resource & Tool ---
const vanillaResource: Resource = {
name: "ui-vanilla-template",
uri: "ui://vanilla",
title: "Vanilla UI Template",
description: "A simple vanilla JS UI",
mimeType: "text/html",
};
server.registerResource(
vanillaResource.name,
vanillaResource.uri,
vanillaResource,
async (): Promise<ReadResourceResult> => ({
contents: [
{
uri: vanillaResource.uri,
mimeType: vanillaResource.mimeType,
text: vanillaHtml,
},
],
}),
);
server.registerTool(
"create-ui-vanilla",
{
title: "Vanilla UI",
description: "A tool that returns a simple vanilla JS UI",
inputSchema: {
message: z.string().describe("Message to display"),
},
_meta: {
"ui/resourceUri": vanillaResource.uri,
},
},
async ({ message }): Promise<CallToolResult> => ({
content: [{ type: "text", text: JSON.stringify({ message }) }],
structuredContent: { message },
}),
);
// --- React UI Resource & Tool ---
const reactResource: Resource = {
name: "ui-react-template",
uri: "ui://react",
title: "React UI Template",
description: "A React-based UI",
mimeType: "text/html",
};
server.registerResource(
reactResource.name,
reactResource.uri,
reactResource,
async (): Promise<ReadResourceResult> => ({
contents: [
{
uri: reactResource.uri,
mimeType: reactResource.mimeType,
text: 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: {
"ui/resourceUri": reactResource.uri,
},
},
async ({ message }): Promise<CallToolResult> => ({
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<CallToolResult> => {
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`);
console.log(` Tools: create-ui-vanilla, create-ui-react, get-weather`);
});
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);
});

View file

@ -1,14 +1,14 @@
import { useState, useCallback } from "react";
import { createRoot } from "react-dom/client";
import { useApp } from "@modelcontextprotocol/ext-apps/react/useApp";
import {
useApp,
McpUiSizeChangeNotificationSchema,
McpUiToolResultNotificationSchema,
} from "@modelcontextprotocol/ext-apps/react";
import type {
CallToolResult,
Implementation,
} from "@modelcontextprotocol/sdk/types.js";
import {
McpUiSizeChangeNotificationSchema,
McpUiToolResultNotificationSchema,
} from "@modelcontextprotocol/ext-apps/types";
const APP_INFO: Implementation = {
name: "MCP UI React Example Client",

View file

@ -0,0 +1,143 @@
import {
App,
PostMessageTransport,
McpUiToolInputNotificationSchema,
McpUiSizeChangeNotificationSchema,
McpUiToolResultNotificationSchema,
} 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 transport = new PostMessageTransport(window.parent);
const app = new App({
name: "MCP UI Client (Vanilla)",
version: "1.0.0",
});
app.setNotificationHandler(
McpUiToolResultNotificationSchema,
async ({ params: { content, structuredContent, isError } }) => {
appendText(
`Tool call result received: isError=${isError}, content=${content}, structuredContent=${JSON.stringify(structuredContent)}`,
);
},
);
app.setNotificationHandler(
McpUiSizeChangeNotificationSchema,
async ({ params: { width, height } }) => {
appendText(
`Size change notification received: width=${width}, height=${height}`,
);
},
);
app.setNotificationHandler(
McpUiToolInputNotificationSchema,
async ({ params }) => {
appendText(
`Tool call input received: ${JSON.stringify(params.arguments)}`,
);
},
);
document.body.addEventListener("resize", () => {
app.sendSizeChange({
width: document.body.scrollWidth,
height: document.body.scrollHeight,
});
});
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(transport);
});

View file

@ -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"]
}

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP UI Client (React)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/ui-react.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP UI Client (Vanilla)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/ui-vanilla.ts"></script>
</body>
</html>

View file

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
import { resolve } from "path";
// Get the entry from environment variable, default to ui-react
const entry = process.env.VITE_ENTRY || "ui-react";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
rollupOptions: {
input: resolve(__dirname, `${entry}.html`),
},
outDir: `dist`,
emptyOutDir: false,
},
});