update simple-server example
This commit is contained in:
parent
c8bdca6935
commit
60cf8ce176
12 changed files with 3599 additions and 240 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
3128
examples/simple-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
examples/simple-server/package.json
Normal file
33
examples/simple-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
269
examples/simple-server/server.ts
Normal file
269
examples/simple-server/server.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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",
|
||||
143
examples/simple-server/src/ui-vanilla.ts
Normal file
143
examples/simple-server/src/ui-vanilla.ts
Normal 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);
|
||||
});
|
||||
20
examples/simple-server/tsconfig.json
Normal file
20
examples/simple-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
examples/simple-server/ui-react.html
Normal file
12
examples/simple-server/ui-react.html
Normal 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>
|
||||
12
examples/simple-server/ui-vanilla.html
Normal file
12
examples/simple-server/ui-vanilla.html
Normal 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>
|
||||
18
examples/simple-server/vite.config.ts
Normal file
18
examples/simple-server/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue