Skip to content

Instantly share code, notes, and snippets.

@emcmanus
Last active October 12, 2025 20:44
Show Gist options
  • Save emcmanus/953a8e92aef840a8c3e3602469a7bb59 to your computer and use it in GitHub Desktop.
Save emcmanus/953a8e92aef840a8c3e3602469a7bb59 to your computer and use it in GitHub Desktop.
ChatGPT App SDK – Minimal React Application (single-file)
{
"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"
}
}
/* 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