Dec 16, 2025
11 min read

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:

  1. A web interface that renders in an iframe within the ChatGPT interface, displaying your app's output and interactions.
  2. 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:

  1. Build the MCP app
  2. Deploy the MCP app on Koyeb
  3. Add your app to ChatGPT
  4. Try it out

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:

  1. A web component that gets rendered in an iframe in the ChatGPT interface.
  2. 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 implementation
  • StreamableHTTPServerTransport - Handles HTTP/SSE transport for MCP protocol
  • z (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 conversation
  • structuredContent - 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
  • _meta can 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 URI
  • openai/toolInvocation/invoking - Status text while running
  • openai/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_globals event
  • Tool output arrives in event.detail.globals.toolOutput
  • structuredContent from tool response maps to toolOutput in 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:

  1. Select GitHub as the deployment method.
  2. 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.
  3. From the Build options section, choose Dockerfile
  4. Choose a Small CPU for your Service in the location of your choice.
  5. 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:

  1. Enable OpenAI's developer mode under Settings → Apps & Connectors → Advanced settings in ChatGPT.
  2. Click the Create button to add a connector under Settings → Connectors and paste your Koyeb public URL + /mcp URL (e.g. https://<subdomain>.koyeb.app/mcp).
  3. 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

  1. In ChatGPT, open a new chat.
  2. Click the More (+) menu, and select your app, Koyeb MCP JavaScript.
  3. 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:


Deploy AI apps to production in minutes

Get started
Koyeb is a developer-friendly serverless platform to deploy apps globally. No-ops, servers, or infrastructure management.
All systems operational
© Koyeb