Use OpenAI Apps SDK in ChatGPT with MCP deployed on Koyeb
Introduction
OpenAI's Apps SDK enables you to extend ChatGPT with custom tools that connect to your own data sources and services. By building apps with the Apps SDK, you can add specialized functionality to ChatGPT—from querying databases and calling APIs to processing files and integrating with third-party platforms.
Building an app for ChatGPT with the Apps SDK requires two components:
- A web interface that renders in an iframe within the ChatGPT interface, displaying your app's output and interactions.
- An MCP server that exposes your app's capabilities (tools) to ChatGPT using the Model Context Protocol (MCP), an open standard for secure connections between AI applications and external systems.
In this tutorial, you'll build a todo app with Node.js that adds and updates todos at your request. You'll then deploy your app on Koyeb and connect it to ChatGPT, giving you a foundation for building more sophisticated custom tools.
Requirements
You will need:
- A Koyeb account: Use Koyeb to deploy and run your MCP server.
- Node.js and NPM installed on your machine
- An OpenAI subscription that suppors using the Apps SDK. See the OpenAI documentation for details on which plans support connecting apps to ChatGPT.
Steps
To connect an MCP server to the OpenAI Apps SDK, we'll take the following steps:
Build the app
The Apps SDK uses the Model Context Protocol (MCP) to expose your app to ChatGPT. We will build both components required to use our app with ChatGPT:
- A web component that gets rendered in an iframe in the ChatGPT interface.
- An MCP server that is used to expose your app and define your app’s capabilities (tools) to ChatGPT.
Complete the following steps to build the app that will be deployed on Koyeb. Alternatively you can fork the code from GitHub.
To start, create a new directory called example-mcp-server to contain the projct and change to that directory:
mkdir example-mcp-server
cd example-mcp-server
Open the example-mcp-server folder in the IDE of your choice.
Create a web interface
In the example-mcp-server directory, create a directory called public. Add a file to example-mcp-server/public called todo-widget.html:
mkdir public
touch public/todo-widget.html
This file will be used for the web component of the app. Add the following code to widget.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Todo List</title>
<style>
:root {
color: #0b0b0f;
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
html, body {
width: 100%;
min-height: 100%;
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
background: #f6f8fb;
}
main {
width: 100%;
max-width: 400px;
min-height: 200px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
}
h2 {
margin: 0 0 16px;
font-size: 1.25rem;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: #f8f9fa;
border-radius: 8px;
gap: 12px;
}
.todo-item.completed {
background: #e8f5e9;
}
.todo-checkbox {
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid #ddd;
flex-shrink: 0;
}
.todo-item.completed .todo-checkbox {
background: #4caf50;
border-color: #4caf50;
position: relative;
}
.todo-item.completed .todo-checkbox::after {
content: "✓";
color: white;
position: absolute;
top: -2px;
left: 3px;
font-size: 14px;
}
.todo-title {
flex: 1;
font-size: 0.95rem;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #666;
}
.empty-state {
text-align: center;
padding: 32px;
color: #999;
}
</style>
</head>
<body>
<main>
<h2>Todo List</h2>
<ul class="todo-list" id="todoList"></ul>
</main>
<script type="module">
const todoListEl = document.querySelector("#todoList");
const render = () => {
console.log("Render called");
console.log("window.openai:", window.openai);
// Try structuredContent first (for compatibility), then toolOutput
const structuredContent = window.openai?.structuredContent || window.openai?.toolOutput;
console.log("structuredContent:", structuredContent);
if (!structuredContent || !structuredContent.tasks) {
todoListEl.innerHTML = '<div class="empty-state">No todos yet. Add one to get started!</div>';
return;
}
const tasks = structuredContent.tasks;
console.log("tasks:", tasks);
if (tasks.length === 0) {
todoListEl.innerHTML = '<div class="empty-state">No todos yet. Add one to get started!</div>';
return;
}
todoListEl.innerHTML = tasks.map(task => `
<li class="todo-item ${task.completed ? 'completed' : ''}">
<div class="todo-checkbox"></div>
<div class="todo-title">${task.title}</div>
</li>
`).join('');
};
const handleSetGlobals = (event) => {
console.log("openai:set_globals event received:", event.detail);
console.log("Full globals object:", JSON.stringify(event.detail?.globals, null, 2));
const globals = event.detail?.globals;
// Check for either structuredContent or toolOutput
if (globals?.structuredContent !== undefined || globals?.toolOutput !== undefined) {
// Update window.openai with the globals
if (globals.toolOutput) {
window.openai = window.openai || {};
window.openai.toolOutput = globals.toolOutput;
}
render();
} else {
console.warn("structuredContent not found in globals. Available keys:", Object.keys(globals || {}));
}
};
window.addEventListener("openai:set_globals", handleSetGlobals, {
passive: true,
});
render();
</script>
</body>
</html>
Create the JavaScript code
For the MCP server component of our app, we will build a JavaScript application.
Install dependences
Koyeb automatically detects Node.js applications when the application contains a package.json file in the root of your project directory.
In the example-mcp-server folder, use the following command to install the required packages:
npm install @modelcontextprotocol/sdk zod
Create server.js and add code
Create a file called server.js at the root of your project. The following steps break down the code to add to your server.js file, including their purpose:
1. Set Up Dependencies
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
Key Components:
McpServer- Core MCP server implementationStreamableHTTPServerTransport- Handles HTTP/SSE transport for MCP protocolz(Zod) - Schema validation for tool inputs
2. Load Widget HTML
const todoHtml = readFileSync("public/todo-widget.html", "utf8");
Load the widget HTML file that will be rendered in ChatGPT's iframe.
3. Define Input Schemas
const addTodoInputSchema = {
title: z.string().min(1),
};
const completeTodoInputSchema = {
id: z.string().min(1),
};
Use Zod schemas to validate tool inputs and provide type safety.
4. Create Shared State
let todos = [];
let nextId = 1;
Store todos in memory. In stateless mode, a new server instance handles each request, but the todos array persists at module level across requests.
5. Build Response Helper
const replyWithTodos = (message) => ({
content: message ? [{ type: "text", text: message }] : [],
structuredContent: { tasks: todos },
});
Important: Return both:
content- Text message shown in ChatGPT conversationstructuredContent- Structured data passed to the widget
6. Create MCP Server Instance
function createTodoServer() {
const server = new McpServer({ name: "todo-app", version: "0.1.0" });
// ...register resources and tools
return server;
}
7. Register the Widget Resource
server.registerResource(
"todo-widget",
"ui://widget/todo.html",
{},
async () => ({
contents: [
{
uri: "ui://widget/todo.html",
mimeType: "text/html+skybridge",
text: todoHtml,
_meta: { "openai/widgetPrefersBorder": true },
},
],
})
);
Key Points:
- URI uses
ui://widget/scheme for widgets - MIME type must be
text/html+skybridge - Returns the HTML content as text
_metacan customize widget appearance
8. Register Tools with Widget Metadata
server.registerTool(
"add_todo",
{
title: "Add todo",
description: "Creates a todo item with the given title.",
inputSchema: addTodoInputSchema,
_meta: {
"openai/outputTemplate": "ui://widget/todo.html",
"openai/toolInvocation/invoking": "Adding todo",
"openai/toolInvocation/invoked": "Added todo",
},
},
async (args) => {
const title = args?.title?.trim?.() ?? "";
if (!title) return replyWithTodos("Missing title.");
const todo = { id: `todo-${nextId++}`, title, completed: false };
todos = [...todos, todo];
return replyWithTodos(`Added "${todo.title}".`);
}
);
Critical _meta Fields:
openai/outputTemplate- Links tool to widget URIopenai/toolInvocation/invoking- Status text while runningopenai/toolInvocation/invoked- Status text when complete
9. Set Up HTTP Server with CORS
const httpServer = createServer(async (req, res) => {
// Handle OPTIONS for CORS preflight
if (req.method === "OPTIONS" && url.pathname === MCP_PATH) {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "content-type, mcp-session-id",
"Access-Control-Expose-Headers": "Mcp-Session-Id",
});
res.end();
return;
}
CORS headers are required for OpenAI Apps SDK to communicate with the server.
10. Create Transport and Connect
const server = createTodoServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
enableJsonResponse: true,
});
await server.connect(transport);
await transport.handleRequest(req, res);
Transport Options:
sessionIdGenerator: undefined- Stateless mode (no sessions)enableJsonResponse: true- Support JSON responses in addition to SSE
11. Widget Implementation
In public/todo-widget.html:
const handleSetGlobals = (event) => {
const globals = event.detail?.globals;
// Data comes in globals.toolOutput
if (globals?.toolOutput) {
window.openai = window.openai || {};
window.openai.toolOutput = globals.toolOutput;
render();
}
};
window.addEventListener("openai:set_globals", handleSetGlobals, {
passive: true,
});
Key Widget Concepts:
- Listen for
openai:set_globalsevent - Tool output arrives in
event.detail.globals.toolOutput structuredContentfrom tool response maps totoolOutputin widget- Update UI when new data arrives
Create Dockerfile and add code
We've now added the main code of the application. To dockerize the app for deployment on Koyeb, we will create a basic Dockerfile.
Create a Dockerfile and add the following:
FROM node:20-slim
WORKDIR /app
COPY package.json .
RUN npm install
COPY server.js .
COPY public/ ./public/
EXPOSE 8080
ENV PORT=8080
CMD ["node", "server.js"]
Deploy the MCP application on Koyeb
To deploy an application on Koyeb, you can use GitHub, a project directory, or a container registry.
Push the app to GitHub
In the project directory, initialize a new Git repository by running the following command:
git init
You will use this repository to version the application code and push the changes to a GitHub repository. Run the following commands to commit and push changes to your GitHub repository, replacing the GitHub username and repository name with values from your account and the GitHub repo name:
git add requirements.txt main.py
git commit -m "Initial commit"
git remote add origin git@github.com:<YOUR_GITHUB_USERNAME>/<YOUR_REPOSITORY_NAME>.git
git push -u origin main
Deploy to Koyeb using the GitHub repo URL
To deploy the MCP app on Koyeb using the control panel, on the Overview tab, click Create Web Service and follow these steps:
- Select GitHub as the deployment method.
- Choose the GitHub repository and branch containing your application code. Alternatively, you can enter our public MCP Server for OpenAI Apps SDK
repository into the Public GitHub repository:
https://github.com/koyeb/example-mcp-server-js-openai-apps-sdk. - From the Build options section, choose Dockerfile
- Choose a Small CPU for your Service in the location of your choice.
- Click the Deploy button.
This creates a Koyeb App and Service which builds and deploys your application on Koyeb. Take note of your public application URL, as you will need it to connect to the OpenAI Apps SDK.
Add your app to ChatGPT
With your app now running on Koyeb, you can add it to ChatGPT using these steps:
- Enable OpenAI's developer mode under Settings → Apps & Connectors → Advanced settings in ChatGPT.
- Click the Create button to add a connector under Settings → Connectors and paste your Koyeb public URL +
/mcpURL (e.g.https://<subdomain>.koyeb.app/mcp). - Name the connector Koyeb MCP JavaScript, provide an optional short description, select No authentication from the Authentication dropdown menu, and click Create.
Try out your app
- In ChatGPT, open a new chat.
- Click the More (+) menu, and select your app, Koyeb MCP JavaScript.
- Test the todo list by giving prompts like "Add 'create an OpenAI apps SDK application' to my todo list".
Conclusion
You successfully created an MCP server deployed on Koyeb and connected it to ChatGPT using the OpenAI Apps SDK. This demonstrates how easy it is to extend ChatGPT with custom functionality hosted on scalable infrastructure.
From here, you can expand your MCP server with more sophisticated tools—such as database queries, API integrations, or data processing capabilities—to create powerful AI-driven applications. The combination of MCP's standardized protocol and Koyeb's deployment platform makes it simple to build and scale custom AI tools that integrate seamlessly with ChatGPT.
Next Steps
To go further with OpenAI or MCP, check out the following tutorials:

