Skip to content

Commit 6a0f006

Browse files
authored
Support MCP apps extension (#48)
* experimental MCP apps extension support * add mcp apps extension middleware * add demo * Update MCPAppsActivityRenderer.tsx * hook up demo server * properly clone agent * move middleware to AG-UI project * Add MCPAppsActivityRenderer by default * add AG-UI package to demo * move mcp apps to a separate endpoint * update AG-UI version
1 parent 4e6965c commit 6a0f006

File tree

19 files changed

+4718
-296
lines changed

19 files changed

+4718
-296
lines changed

apps/react/demo/mcp-apps/package-lock.json

Lines changed: 2889 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "mcp-apps-demo",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"start": "NODE_ENV=development npm run build && npm run server",
8+
"build": "INPUT=ui-raw.html vite build",
9+
"server": "npx tsx server.ts"
10+
},
11+
"dependencies": {
12+
"@modelcontextprotocol/sdk": "^1.22.0",
13+
"zod": "^3.25.0"
14+
},
15+
"devDependencies": {
16+
"@types/cors": "^2.8.17",
17+
"@types/express": "^5.0.0",
18+
"@types/node": "^22.0.0",
19+
"cors": "^2.8.5",
20+
"express": "^5.1.0",
21+
"tsx": "^4.0.0",
22+
"typescript": "^5.7.2",
23+
"vite": "^6.0.0",
24+
"vite-plugin-singlefile": "^2.3.0"
25+
}
26+
}

apps/react/demo/mcp-apps/server.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import express, { Request, Response } from "express";
3+
import { randomUUID } from "node:crypto";
4+
import { z } from "zod";
5+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6+
import {
7+
CallToolResult,
8+
isInitializeRequest,
9+
ReadResourceResult,
10+
Resource,
11+
} from "@modelcontextprotocol/sdk/types.js";
12+
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
13+
import cors from "cors";
14+
import path from "node:path";
15+
import fs from "node:fs/promises";
16+
import { fileURLToPath } from "node:url";
17+
18+
// Define the resource URI meta key inline (from MCP Apps Extension protocol)
19+
const RESOURCE_URI_META_KEY = "ui/resourceUri";
20+
21+
const __filename = fileURLToPath(import.meta.url);
22+
const __dirname = path.dirname(__filename);
23+
24+
// Load UI HTML file from dist/
25+
const distDir = path.join(__dirname, "dist");
26+
const loadHtml = async (name: string) => {
27+
const htmlPath = path.join(distDir, `${name}.html`);
28+
return fs.readFile(htmlPath, "utf-8");
29+
};
30+
31+
// Create an MCP server with UI tools
32+
const getServer = async () => {
33+
const server = new McpServer(
34+
{
35+
name: "mcp-apps-demo-server",
36+
version: "1.0.0",
37+
},
38+
{ capabilities: { logging: {} } },
39+
);
40+
41+
// Load HTML for the raw UI
42+
const rawHtml = await loadHtml("ui-raw");
43+
44+
const registerResource = (resource: Resource, htmlContent: string) => {
45+
server.registerResource(
46+
resource.name,
47+
resource.uri,
48+
resource,
49+
async (): Promise<ReadResourceResult> => ({
50+
contents: [
51+
{
52+
uri: resource.uri,
53+
mimeType: resource.mimeType,
54+
text: htmlContent,
55+
},
56+
],
57+
}),
58+
);
59+
return resource;
60+
};
61+
62+
// Register the raw UI resource and tool
63+
{
64+
const rawResource = registerResource(
65+
{
66+
name: "ui-raw-template",
67+
uri: "ui://raw",
68+
title: "Raw UI Template",
69+
description: "A simple raw HTML UI",
70+
mimeType: "text/html+mcp",
71+
},
72+
rawHtml,
73+
);
74+
75+
server.registerTool(
76+
"create-ui-raw",
77+
{
78+
title: "Raw UI",
79+
description: "A tool that returns a raw HTML UI (no Apps SDK runtime)",
80+
inputSchema: {
81+
message: z.string().describe("Message to display"),
82+
},
83+
_meta: {
84+
[RESOURCE_URI_META_KEY]: rawResource.uri,
85+
},
86+
},
87+
async ({ message }): Promise<CallToolResult> => ({
88+
content: [{ type: "text", text: JSON.stringify({ message }) }],
89+
structuredContent: { message },
90+
}),
91+
);
92+
}
93+
94+
// Register the get-weather tool (no UI resource, for testing from within UI)
95+
server.registerTool(
96+
"get-weather",
97+
{
98+
title: "Get Weather",
99+
description: "Returns current weather for a location",
100+
inputSchema: {
101+
location: z.string().describe("Location to get weather for"),
102+
},
103+
},
104+
async ({ location }): Promise<CallToolResult> => {
105+
const temperature = 25;
106+
const condition = "sunny";
107+
return {
108+
content: [
109+
{
110+
type: "text",
111+
text: `The weather in ${location} is ${condition}, ${temperature}°C.`,
112+
},
113+
],
114+
structuredContent: { temperature, condition },
115+
};
116+
},
117+
);
118+
119+
return server;
120+
};
121+
122+
const MCP_PORT = process.env.MCP_PORT
123+
? parseInt(process.env.MCP_PORT, 10)
124+
: 3001;
125+
126+
const app = express();
127+
app.use(express.json());
128+
app.use(
129+
cors({
130+
origin: "*",
131+
exposedHeaders: ["Mcp-Session-Id"],
132+
}),
133+
);
134+
135+
// Map to store transports by session ID
136+
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
137+
138+
const mcpPostHandler = async (req: Request, res: Response) => {
139+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
140+
141+
try {
142+
let transport: StreamableHTTPServerTransport;
143+
if (sessionId && transports[sessionId]) {
144+
transport = transports[sessionId];
145+
} else if (!sessionId && isInitializeRequest(req.body)) {
146+
const eventStore = new InMemoryEventStore();
147+
transport = new StreamableHTTPServerTransport({
148+
sessionIdGenerator: () => randomUUID(),
149+
eventStore,
150+
onsessioninitialized: (sessionId) => {
151+
console.log(`Session initialized: ${sessionId}`);
152+
transports[sessionId] = transport;
153+
},
154+
});
155+
156+
transport.onclose = () => {
157+
const sid = transport.sessionId;
158+
if (sid && transports[sid]) {
159+
console.log(`Session closed: ${sid}`);
160+
delete transports[sid];
161+
}
162+
};
163+
164+
const server = await getServer();
165+
await server.connect(transport);
166+
await transport.handleRequest(req, res, req.body);
167+
return;
168+
} else {
169+
res.status(400).json({
170+
jsonrpc: "2.0",
171+
error: { code: -32000, message: "Bad Request: No valid session ID" },
172+
id: null,
173+
});
174+
return;
175+
}
176+
177+
await transport.handleRequest(req, res, req.body);
178+
} catch (error) {
179+
console.error("Error handling MCP request:", error);
180+
if (!res.headersSent) {
181+
res.status(500).json({
182+
jsonrpc: "2.0",
183+
error: { code: -32603, message: "Internal server error" },
184+
id: null,
185+
});
186+
}
187+
}
188+
};
189+
190+
app.post("/mcp", mcpPostHandler);
191+
192+
app.get("/mcp", async (req: Request, res: Response) => {
193+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
194+
if (!sessionId || !transports[sessionId]) {
195+
res.status(400).send("Invalid or missing session ID");
196+
return;
197+
}
198+
const transport = transports[sessionId];
199+
await transport.handleRequest(req, res);
200+
});
201+
202+
app.delete("/mcp", async (req: Request, res: Response) => {
203+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
204+
if (!sessionId || !transports[sessionId]) {
205+
res.status(400).send("Invalid or missing session ID");
206+
return;
207+
}
208+
try {
209+
const transport = transports[sessionId];
210+
await transport.handleRequest(req, res);
211+
} catch (error) {
212+
console.error("Error handling session termination:", error);
213+
if (!res.headersSent) {
214+
res.status(500).send("Error processing session termination");
215+
}
216+
}
217+
});
218+
219+
app.listen(MCP_PORT, () => {
220+
console.log(`MCP Server listening on http://localhost:${MCP_PORT}/mcp`);
221+
});
222+
223+
process.on("SIGINT", async () => {
224+
console.log("Shutting down...");
225+
for (const sessionId in transports) {
226+
try {
227+
await transports[sessionId].close();
228+
delete transports[sessionId];
229+
} catch (error) {
230+
console.error(`Error closing session ${sessionId}:`, error);
231+
}
232+
}
233+
process.exit(0);
234+
});

0 commit comments

Comments
 (0)