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!
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:
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:
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: