Building ChatGPT apps 

The ChatGPT Apps SDK allows developers to extend ChatGPT with custom functionality by adding new tool calls and UI widgets to the ChatGPT interface. ChatGPT apps require a backend app for running an MCP server and optionally an authorization system that you can use Gadget to host!

Gadget supports:

  • easy setup of an MCP server for powering ChatGPT Apps SDK calls
  • easy OAuth 2.1 authentication provider setup for authenticating ChatGPT users with your app's backend
  • easy setup of ChatGPT Apps widgets that render UX for end users using React and powered by your Gadget app's data

Quick start 

Gadget has a forkable app template with a working MCP server and OAuth 2.1 authentication provider setup. Fork the template to get all the setup below done already!

Fork template on Gadget 

Building MCP servers 

Gadget apps can host MCP servers that any MCP client can connect to, including OpenAI's ChatGPT Apps SDK.

To setup an MCP server, you can use the @modelcontextprotocol/sdk TypeScript package for rich support for the MCP protocol. You can use HTTP Routes to host the MCP server's endpoints in your Gadget backend.

Gadget recommends building MCP servers using the StreamableHTTP transport approach, not the SSE transport approach. OpenAI's ChatGPT Apps SDK has the support for the StreamableHTTP transport, and it works best in serverless environments like Gadget.

Adding an MCP server to your Gadget app 

First, add the required NPM packages to your Gadget app:

terminal
yarn add @modelcontextprotocol/sdk zod@^3.24.2

Then, create a file that exports a new MCP server instance:

api/mcp.js
JavaScript
import { FastifyRequest } from "fastify"; import { api } from "gadget-server"; import type { Server } from "gadget-server"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import path from "path"; import { readFileSync } from "fs"; // Make a new MCP server instance for each request export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); mcpServer.registerTool( "sayHello", { title: "say hello", description: "print a hello world message", _meta: { "openai/toolInvocation/invoking": "Mentally prepping to say hello", "openai/toolInvocation/invoked": "Hello has been said", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Well hello there!", }, ], }; } ); return mcpServer; };
import { FastifyRequest } from "fastify"; import { api } from "gadget-server"; import type { Server } from "gadget-server"; import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import path from "path"; import { readFileSync } from "fs"; // Make a new MCP server instance for each request export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); mcpServer.registerTool( "sayHello", { title: "say hello", description: "print a hello world message", _meta: { "openai/toolInvocation/invoking": "Mentally prepping to say hello", "openai/toolInvocation/invoked": "Hello has been said", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Well hello there!", }, ], }; } ); return mcpServer; };

Then, set up the two required MCP HTTP routes that implement the MCP protocol. The first route implements the MCP protocol using the StreamableHTTP transport:

api/routes/mcp/POST.js
JavaScript
import { RouteHandler } from "gadget-server"; import { createMCPServer } from "../../mcp"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; /** * Route handler for POST /mcp */ const route: RouteHandler = async ({ request, reply, api, logger, connections }) => { const transport = new StreamableHTTPServerTransport({ // run in the stateless mode that doesn't need persistent sessions sessionIdGenerator: undefined, enableJsonResponse: true, }); const server = await createMCPServer(request); // Take control of the response to prevent Fastify from sending its own response // after the MCP transport has already sent one reply.hijack(); try { await server.connect(transport); await transport.handleRequest(request.raw, reply.raw, request.body); } catch (error) { console.error("Failed to start MCP server session", error); if (!reply.raw.headersSent) { reply.raw.writeHead(500).end("Failed to establish MCP server connection"); } } }; route.options = { cors: { // allow requests to the MCP server from any origin (like ChatGPT) origin: true, }, }; export default route;
import { RouteHandler } from "gadget-server"; import { createMCPServer } from "../../mcp"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; /** * Route handler for POST /mcp */ const route: RouteHandler = async ({ request, reply, api, logger, connections }) => { const transport = new StreamableHTTPServerTransport({ // run in the stateless mode that doesn't need persistent sessions sessionIdGenerator: undefined, enableJsonResponse: true, }); const server = await createMCPServer(request); // Take control of the response to prevent Fastify from sending its own response // after the MCP transport has already sent one reply.hijack(); try { await server.connect(transport); await transport.handleRequest(request.raw, reply.raw, request.body); } catch (error) { console.error("Failed to start MCP server session", error); if (!reply.raw.headersSent) { reply.raw.writeHead(500).end("Failed to establish MCP server connection"); } } }; route.options = { cors: { // allow requests to the MCP server from any origin (like ChatGPT) origin: true, }, }; export default route;

And the second route completes the implementation of the MCP protocol by handling GET /mcp requests using the same transport by re-using the same route handler:

api/routes/mcp/GET.js
JavaScript
import { RouteHandler } from "gadget-server"; import Route from "./POST"; // re-use the POST route's implementation of the MCP protocol export default Route;
import { RouteHandler } from "gadget-server"; import Route from "./POST"; // re-use the POST route's implementation of the MCP protocol export default Route;

Your app is now hosting an MCP server at https://<your-app>--development.gadget.app/mcp!

Testing your MCP server 

You can test your MCP server by using the MCP Inspector, or using the ChatGPT app in your browser. Gadget recommends using the MCP Inspector to test your MCP server to start, as it provides a more detailed view of the MCP server's capabilities and better error logging.

To run the MCP Inspector, you can use the following command:

terminal
npx @modelcontextprotocol/inspector --cli https://<your-app>--development.gadget.app/mcp

This will open the MCP Inspector in your browser. You can then use the MCP Inspector to test your MCP server.

Make sure you select the StreamableHTTP transport in the MCP Inspector, as this is the MCP transport style set up by the above HTTP routes.

Adding MCP tools 

You can add tools to your MCP server by using the registerTool method that run server-side JavaScript and return a response to the MCP client. You can read and write data using your Gadget app's api object, make calls to external APIs, or anything else you'd like.

For example, you can add a tool that returns a random number:

api/mcp.js
JavaScript
mcpServer.registerTool( "randomNumber", { title: "random number", description: "return a random number", }, async () => { return { structuredContent: {}, content: [ { type: "text", text: `The random number is ${Math.floor(Math.random() * 100)}`, }, ], }; } );
mcpServer.registerTool( "randomNumber", { title: "random number", description: "return a random number", }, async () => { return { structuredContent: {}, content: [ { type: "text", text: `The random number is ${Math.floor(Math.random() * 100)}`, }, ], }; } );

You can also build responses that read data from your Gadget app's database:

api/mcp.js
JavaScript
import { api } from "gadget-server"; mcpServer.registerTool( "listTodos", { title: "list todos", description: "list all todos", }, async () => { const todos = await api.todo.findMany({ first: 10 }); return { structuredContent: { todos: todos.map((todo) => ({ id: todo.id, title: todo.title, })), }, content: [ { type: "text", text: `The todos are ${todos.map((todo) => todo.title).join(", ")}`, }, ], }; } );
import { api } from "gadget-server"; mcpServer.registerTool( "listTodos", { title: "list todos", description: "list all todos", }, async () => { const todos = await api.todo.findMany({ first: 10 }); return { structuredContent: { todos: todos.map((todo) => ({ id: todo.id, title: todo.title, })), }, content: [ { type: "text", text: `The todos are ${todos.map((todo) => todo.title).join(", ")}`, }, ], }; } );

Adding ChatGPT Apps widgets 

ChatGPT Apps can render UI widgets in the responses to users, showing them rich UI in addition to plain text responses. You can use Gadget to build these widgets using React code and components in your Gadget app's web folder. Gadget will automatically generate widget resource bundles for the ChatGPT Apps SDK to use, complete with hot module reloading and easy bundling for production.

A ChatGPT Apps SDK Widget is an HTML file, served up to the ChatGPT Apps SDK as an ui:// resource by your MCP server. You can't serve widgets as normal HTML files over the internet, and instead you must expose them as resources on your MCP server. You must also set up CORS on your MCP server to allow the ChatGPT web sandbox to access your widgets source code cross-origin.

Installing the vite-plugin-chatgpt-widgets package 

Gadget apps hosting ChatGPT Apps widgets need to install the vite-plugin-chatgpt-widgets package, and add it to their Vite configuration:

terminal
yarn add vite-plugin-chatgpt-widgets

And then add it to your Vite configuration, and set the required server.cors properties:

vite.config.mjs
JavaScript
import { defineConfig } from "vite"; import { gadget } from "gadget-server/vite"; import { reactRouter } from "@react-router/dev/vite"; import path from "path"; import { chatGPTWidgetPlugin } from "vite-plugin-chatgpt-widgets"; export default defineConfig({ plugins: [ gadget(), reactRouter(), // mount the chatGPTWidgetPlugin to your Vite config chatGPTWidgetPlugin({ baseUrl: process.env.GADGET_APP_URL, }), ], resolve: { alias: { "@": path.resolve(__dirname, "./web"), }, }, server: { cors: { // ensure both the Gadget app and the ChatGPT web sandbox can access your widgets source code cross-origin origin: [process.env.GADGET_APP_URL, "https://web-sandbox.oaiusercontent.com"], }, }, });
import { defineConfig } from "vite"; import { gadget } from "gadget-server/vite"; import { reactRouter } from "@react-router/dev/vite"; import path from "path"; import { chatGPTWidgetPlugin } from "vite-plugin-chatgpt-widgets"; export default defineConfig({ plugins: [ gadget(), reactRouter(), // mount the chatGPTWidgetPlugin to your Vite config chatGPTWidgetPlugin({ baseUrl: process.env.GADGET_APP_URL, }), ], resolve: { alias: { "@": path.resolve(__dirname, "./web"), }, }, server: { cors: { // ensure both the Gadget app and the ChatGPT web sandbox can access your widgets source code cross-origin origin: [process.env.GADGET_APP_URL, "https://web-sandbox.oaiusercontent.com"], }, }, });

Once the vite plugin is installed, you can add all the built widgets to your MCP server's resources:

api/mcp.js
JavaScript
import { getWidgets } from "vite-plugin-chatgpt-widgets"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { FastifyRequest } from "fastify"; import path from "path"; export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); // Get the vite dev server instance from Gadget const devServer = await ( request.server as any ).frontendServerManager?.devServerManager?.getServer(); // Get the HTML snippet for each widget const widgets = await getWidgets( "web/chatgpt-widgets", devServer && process.env["NODE_ENV"] != "production" ? { devServer } : { manifestPath: path.resolve( process.cwd(), ".gadget/remix-dist/build/client/.vite/manifest.json" ), } ); // Register each widget's HTML snippet as a resource for exposure to ChatGPT for (const widget of widgets) { const resourceName = `widget-${widget.name.toLowerCase()}`; const resourceUri = `ui://widget/${widget.name}.html`; mcpServer.registerResource( resourceName, resourceUri, { title: widget.name, description: `ChatGPT widget for ${widget.name}`, }, async () => { return { contents: [ { uri: resourceUri, mimeType: "text/html+skybridge", text: widget.content, }, ], }; } ); } // set up tools, prompts, etc return mcpServer; };
import { getWidgets } from "vite-plugin-chatgpt-widgets"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { FastifyRequest } from "fastify"; import path from "path"; export const createMCPServer = async (request: FastifyRequest) => { const mcpServer = new McpServer({ name: "example-mcp", version: "1.0.0", }); // Get the vite dev server instance from Gadget const devServer = await ( request.server as any ).frontendServerManager?.devServerManager?.getServer(); // Get the HTML snippet for each widget const widgets = await getWidgets( "web/chatgpt-widgets", devServer && process.env["NODE_ENV"] != "production" ? { devServer } : { manifestPath: path.resolve( process.cwd(), ".gadget/remix-dist/build/client/.vite/manifest.json" ), } ); // Register each widget's HTML snippet as a resource for exposure to ChatGPT for (const widget of widgets) { const resourceName = `widget-${widget.name.toLowerCase()}`; const resourceUri = `ui://widget/${widget.name}.html`; mcpServer.registerResource( resourceName, resourceUri, { title: widget.name, description: `ChatGPT widget for ${widget.name}`, }, async () => { return { contents: [ { uri: resourceUri, mimeType: "text/html+skybridge", text: widget.content, }, ], }; } ); } // set up tools, prompts, etc return mcpServer; };

Adding a new widget 

Once vite-plugin-chatgpt-widgets is installed and configured, you can add ChatGPT Apps widgets to your Gadget app's web/chatgpt-widgets folder. Each file in this folder will be automatically bundled and made available to the ChatGPT Apps SDK.

For example, you can add a widget that displays a "Hello World" message, and then use it in a ChatGPT Apps response to a tool call:

web/chatgpt-widgets/HelloWorld.jsx
JavaScript
const HelloWorldWidget = () => { return ( <div> <h1>Hello World</h1> </div> ); }; export default HelloWorldWidget;
const HelloWorldWidget = () => { return ( <div> <h1>Hello World</h1> </div> ); }; export default HelloWorldWidget;
api/mcp.js
JavaScript
mcpServer.registerTool( "helloWorld", { title: "hello world", description: "display a hello world message", _meta: { // tell ChatGPT to render the web/chatgpt-widgets/HelloWorld.tsx widget when this tool is invoked "openai/outputTemplate": "ui://widget/HelloWorld.html", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Hello World", }, ], }; } );
mcpServer.registerTool( "helloWorld", { title: "hello world", description: "display a hello world message", _meta: { // tell ChatGPT to render the web/chatgpt-widgets/HelloWorld.tsx widget when this tool is invoked "openai/outputTemplate": "ui://widget/HelloWorld.html", }, }, async () => { return { structuredContent: {}, content: [ { type: "text", text: "Hello World", }, ], }; } );

Was this page helpful?