Last active
October 12, 2025 20:44
-
-
Save emcmanus/953a8e92aef840a8c3e3602469a7bb59 to your computer and use it in GitHub Desktop.
ChatGPT App SDK – Minimal React Application (single-file)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "demo-mcp", | |
| "version": "1.0.0", | |
| "description": "A simple ChatGPT React app.", | |
| "main": "index.js", | |
| "scripts": { | |
| "start": "tsx watch server.ts", | |
| "test": "echo \"Error: no test specified\" && exit 1" | |
| }, | |
| "packageManager": "[email protected]", | |
| "dependencies": { | |
| "@modelcontextprotocol/sdk": "^1.20.0", | |
| "cors": "^2.8.5", | |
| "express": "^5.1.0", | |
| "tsx": "^4.20.6", | |
| "typescript": "^5.9.3", | |
| "zod": "^4.1.12" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* To run this server: | |
| * pnpm install | |
| * pnpm start | |
| */ | |
| import express, { Request, Response } from 'express'; | |
| import { randomUUID } from 'node:crypto'; | |
| import { z } from "zod/v3"; | |
| import { McpServer, } from '@modelcontextprotocol/sdk/server/mcp.js'; | |
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; | |
| import { isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js'; | |
| import cors from 'cors'; | |
| const MCP_PORT = 3000; | |
| /* -------------------------------------------------------------- | |
| * In-memory Event Store Implementation - not for production use. | |
| * -------------------------------------------------------------- */ | |
| import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; | |
| import { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; | |
| class InMemoryEventStore implements EventStore { | |
| private events: Map<string, { streamId: string; message: JSONRPCMessage }> = new Map(); | |
| private generateEventId(streamId: string): string { | |
| return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; | |
| } | |
| private getStreamIdFromEventId(eventId: string): string { | |
| const parts = eventId.split('_'); | |
| return parts.length > 0 ? parts[0] : ''; | |
| } | |
| async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> { | |
| const eventId = this.generateEventId(streamId); | |
| this.events.set(eventId, { streamId, message }); | |
| return eventId; | |
| } | |
| async replayEventsAfter( | |
| lastEventId: string, | |
| { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> } | |
| ): Promise<string> { | |
| if (!lastEventId || !this.events.has(lastEventId)) { | |
| return ''; | |
| } | |
| // Extract the stream ID from the event ID | |
| const streamId = this.getStreamIdFromEventId(lastEventId); | |
| if (!streamId) { | |
| return ''; | |
| } | |
| let foundLastEvent = false; | |
| // Sort events by eventId for chronological ordering | |
| const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); | |
| for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { | |
| // Only include events from the same stream | |
| if (eventStreamId !== streamId) { | |
| continue; | |
| } | |
| // Start sending events after we find the lastEventId | |
| if (eventId === lastEventId) { | |
| foundLastEvent = true; | |
| continue; | |
| } | |
| if (foundLastEvent) { | |
| await send(eventId, message); | |
| } | |
| } | |
| return streamId; | |
| } | |
| } | |
| /* --------------------------------------------------------------- */ | |
| const helloWidgetHtml = `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <style> body { font-family: system-ui, sans-serif; overflow: hidden } </style> | |
| </head> | |
| <body> | |
| <div id="root" style="height: 150px;"></div> | |
| <script type="module"> | |
| import React from "https://esm.sh/react@18"; | |
| import { createRoot } from "https://esm.sh/react-dom@18/client"; | |
| function App() { | |
| const [count, setCount] = React.useState(0); | |
| return React.createElement('div', null, | |
| React.createElement('h1', null, 'Hello React!'), | |
| React.createElement('p', null, 'This is a working single-file React application.'), | |
| React.createElement('button', { | |
| onClick: () => setCount(count + 1), | |
| style: { | |
| padding: '0.5rem 1rem', | |
| fontSize: '1rem', | |
| backgroundColor: '#2563eb', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '0.375rem', | |
| cursor: 'pointer' | |
| } | |
| }, 'Clicked ' + count + ' times') | |
| ); | |
| } | |
| const root = createRoot(document.getElementById('root')); | |
| root.render(React.createElement(App)); | |
| </script> | |
| </body> | |
| </html>`; | |
| // Create an MCP server with implementation details | |
| const getServer = () => { | |
| const server = new McpServer( | |
| { | |
| name: 'simple-streamable-http-server', | |
| version: '1.0.0', | |
| }, | |
| ); | |
| const widgetUri = "ui://widget/hello.html"; | |
| server.registerResource("hello-widget", widgetUri, {}, async () => ({ | |
| contents: [ | |
| { | |
| uri: widgetUri, | |
| mimeType: "text/html+skybridge", | |
| text: helloWidgetHtml, | |
| }, | |
| ], | |
| }) | |
| ); | |
| server.registerTool( | |
| "hello", | |
| { | |
| title: "Show Hello Widget", | |
| _meta: { | |
| "openai/outputTemplate": widgetUri, | |
| "openai/toolInvocation/invoking": "Displaying the widget", | |
| "openai/toolInvocation/invoked": "Displayed the widget" | |
| }, | |
| inputSchema: { tasks: z.string() } | |
| }, | |
| async () => { | |
| return { | |
| content: [{ type: "text", text: "Displayed the hello widget!" }], | |
| structuredContent: {} | |
| }; | |
| } | |
| ); | |
| return server; | |
| }; | |
| 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 } = {}; | |
| // MCP POST endpoint with optional auth | |
| const mcpPostHandler = async (req: Request, res: Response) => { | |
| const sessionId = req.headers['mcp-session-id'] as string | undefined; | |
| if (sessionId) { | |
| console.log(`Received MCP request for session: ${sessionId}`); | |
| } else { | |
| console.log('Request body:', req.body); | |
| } | |
| try { | |
| let transport: StreamableHTTPServerTransport; | |
| if (sessionId && transports[sessionId]) { | |
| // Reuse existing transport | |
| transport = transports[sessionId]; | |
| } else if (!sessionId && isInitializeRequest(req.body)) { | |
| // New initialization request | |
| const eventStore = new InMemoryEventStore(); | |
| transport = new StreamableHTTPServerTransport({ | |
| sessionIdGenerator: () => randomUUID(), | |
| eventStore, // Enable resumability | |
| onsessioninitialized: sessionId => { | |
| // Store the transport by session ID when session is initialized | |
| // This avoids race conditions where requests might come in before the session is stored | |
| console.log(`Session initialized with ID: ${sessionId}`); | |
| transports[sessionId] = transport; | |
| } | |
| }); | |
| // Set up onclose handler to clean up transport when closed | |
| transport.onclose = () => { | |
| const sid = transport.sessionId; | |
| if (sid && transports[sid]) { | |
| console.log(`Transport closed for session ${sid}, removing from transports map`); | |
| delete transports[sid]; | |
| } | |
| }; | |
| // Connect the transport to the MCP server BEFORE handling the request | |
| // so responses can flow back through the same transport | |
| const server = getServer(); | |
| await server.connect(transport); | |
| await transport.handleRequest(req, res, req.body); | |
| return; // Already handled | |
| } else { | |
| // Invalid request - no session ID or not initialization request | |
| res.status(400).json({ | |
| jsonrpc: '2.0', | |
| error: { | |
| code: -32000, | |
| message: 'Bad Request: No valid session ID provided' | |
| }, | |
| id: null | |
| }); | |
| return; | |
| } | |
| // Handle the request with existing transport - no need to reconnect | |
| // The existing transport is already connected to the server | |
| 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 | |
| }); | |
| } | |
| } | |
| }; | |
| // Set up routes with conditional auth middleware | |
| app.post('/mcp', mcpPostHandler); | |
| // Handle GET requests for SSE streams (using built-in support from StreamableHTTP) | |
| const mcpGetHandler = 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; | |
| } | |
| // Check for Last-Event-ID header for resumability | |
| const lastEventId = req.headers['last-event-id'] as string | undefined; | |
| if (lastEventId) { | |
| console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); | |
| } else { | |
| console.log(`Establishing new SSE stream for session ${sessionId}`); | |
| } | |
| const transport = transports[sessionId]; | |
| await transport.handleRequest(req, res); | |
| }; | |
| // Set up GET route with conditional auth middleware | |
| app.get('/mcp', mcpGetHandler); | |
| // Handle DELETE requests for session termination (according to MCP spec) | |
| const mcpDeleteHandler = 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; | |
| } | |
| console.log(`Received session termination request for session ${sessionId}`); | |
| 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'); | |
| } | |
| } | |
| }; | |
| // Set up DELETE route | |
| app.delete('/mcp', mcpDeleteHandler); | |
| const serverInstance = app.listen(MCP_PORT, error => { | |
| if (error) { | |
| console.error('Failed to start server:', error); | |
| process.exit(1); | |
| } | |
| console.log(` | |
| Server running on port 3000. Usage: | |
| 1. In a new terminal, run \`ngrok http ${MCP_PORT}\` (Install: \`brew install ngrok\`) | |
| 2. Open ChatGPT. Go to Settings > Apps & Connectors > Advanced. Enable Dev Mode. | |
| 3. In Apps & Connectors, click Create. Enter the following. | |
| a. Name: Demo | |
| b. MCP Server URL: (your ngrok URL + /mcp. E.g.: \`https://example.ngrok.app/mcp\`) | |
| c. Authentication: No Authentication | |
| d. Check "I trust this application." | |
| e. Click Create. | |
| 4. Open a new chat and enter: "call the tool: hello-widget" | |
| 5. If you update the widget HTML: Go to ChatGPT Settings > Apps & Connectors > Demo > Refresh. | |
| `); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment