English
主导航

快速入门

构建你的第一个应用并将其连接到 ChatGPT。

简介

使用 Apps SDK 构建的应用通过 模型上下文协议 (MCP) 连接到 ChatGPT。要使用 Apps SDK 为 ChatGPT 构建应用,你需要:

  1. 一个 Model Context Protocol (MCP) 服务器(必需),用于定义你的应用的功能(工具)并将其暴露给 ChatGPT。
  2. (可选)一个使用你选择的框架构建的 Web 组件,如果你需要 UI,它将在 ChatGPT 内部的 iframe 中渲染。

ChatGPT 实现了开放的 MCP Apps UI 标准,因此你只需构建一次 UI,即可在所有兼容 MCP Apps 的宿主中运行它。

在本快速入门中,我们将构建一个简单的待办事项列表应用,它包含在一个单独的 HTML 文件中,将标记、CSS 和 JavaScript 集中在一起。

要查看使用 React 的更多高级示例,请参见 GitHub 上的示例代码库.

构建 Web 组件

此步骤为可选。如果你只需要工具而不需要 ChatGPT UI,请直接跳到 构建 MCP 服务器 并且不要注册 UI 资源。

首先,让我们创建一个名为 public/todo-widget.html 的文件,放在一个新目录中,该文件将作为由 ChatGPT 中的 Apps SDK 渲染的 UI。此文件将包含要在 ChatGPT 界面中渲染的 Web 组件。

添加以下内容:

<!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: 360px;
        min-height: 260px;
        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;
      }

      form {
        display: flex;
        gap: 8px;
        margin-bottom: 16px;
      }

      form input {
        flex: 1;
        padding: 10px 12px;
        border-radius: 10px;
        border: 1px solid #cad3e0;
        font-size: 0.95rem;
      }

      form button {
        border: none;
        border-radius: 10px;
        background: #111bf5;
        color: white;
        font-weight: 600;
        padding: 0 16px;
        cursor: pointer;
      }

      form button:disabled {
        opacity: 0.7;
        cursor: not-allowed;
      }

      input[type="checkbox"] {
        accent-color: #111bf5;
      }

      ul {
        list-style: none;
        padding: 0;
        margin: 0;
        display: flex;
        flex-direction: column;
        gap: 8px;
      }

      li {
        background: #f2f4fb;
        border-radius: 12px;
        padding: 10px 14px;
        display: flex;
        align-items: center;
        gap: 10px;
      }

      li span {
        flex: 1;
      }

      li[data-completed="true"] span {
        text-decoration: line-through;
        color: #6c768a;
      }

      li[data-busy="true"] {
        opacity: 0.7;
      }
    </style>
  </head>
  <body>
    <main>
      <h2>Todo list</h2>
      <form id="add-form" autocomplete="off">
        <input id="todo-input" name="title" placeholder="Add a task" />
        <button type="submit">Add</button>
      </form>
      <ul id="todo-list"></ul>
    </main>

    <script type="module">
      const listEl = document.querySelector("#todo-list");
      const formEl = document.querySelector("#add-form");
      const inputEl = document.querySelector("#todo-input");
      const addButtonEl = formEl.querySelector('button[type="submit"]');
      const addButtonText = addButtonEl.textContent;

      let tasks = [];
      let isAdding = false;
      const busyTodoIds = new Set();

      const render = () => {
        listEl.innerHTML = "";
        tasks.forEach((task) => {
          const li = document.createElement("li");
          li.dataset.id = task.id;
          li.dataset.completed = String(Boolean(task.completed));
          li.dataset.busy = String(busyTodoIds.has(task.id));

          const label = document.createElement("label");
          label.style.display = "flex";
          label.style.alignItems = "center";
          label.style.gap = "10px";

          const checkbox = document.createElement("input");
          checkbox.type = "checkbox";
          checkbox.checked = Boolean(task.completed);
          checkbox.disabled = busyTodoIds.has(task.id);

          const span = document.createElement("span");
          span.textContent = task.title;

          label.appendChild(checkbox);
          label.appendChild(span);
          li.appendChild(label);
          listEl.appendChild(li);
        });
      };

      const updateFromResponse = (response) => {
        if (response?.structuredContent?.tasks) {
          tasks = response.structuredContent.tasks;
          render();
        }
      };

      // MCP Apps standard bridge: JSON-RPC messages over postMessage.
      //
      // - Initialize the bridge with `ui/initialize`.
      // - Confirm readiness with `ui/notifications/initialized`.
      // - Call tools with `tools/call`.
      // - Listen for `ui/notifications/tool-result` to react to model-initiated tool calls.
      let rpcId = 0;
      const pendingRequests = new Map();

      const rpcNotify = (method, params) => {
        window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
      };

      const rpcRequest = (method, params) =>
        new Promise((resolve, reject) => {
          const id = ++rpcId;
          pendingRequests.set(id, { resolve, reject });
          window.parent.postMessage(
            { jsonrpc: "2.0", id, method, params },
            "*"
          );
        });

      window.addEventListener(
        "message",
        (event) => {
          if (event.source !== window.parent) return;
          const message = event.data;
          if (!message || message.jsonrpc !== "2.0") return;

          // Responses
          if (typeof message.id === "number") {
            const pending = pendingRequests.get(message.id);
            if (!pending) return;
            pendingRequests.delete(message.id);

            if (message.error) {
              pending.reject(message.error);
              return;
            }

            pending.resolve(message.result);
            return;
          }

          // Notifications
          if (typeof message.method !== "string") return;
          if (message.method === "ui/notifications/tool-result") {
            updateFromResponse(message.params);
          }
        },
        { passive: true }
      );

      const initializeBridge = async () => {
        const appInfo = { name: "todo-widget", version: "0.1.0" };
        const appCapabilities = {};
        const protocolVersion = "2026-01-26";

        try {
          await rpcRequest("ui/initialize", {
            appInfo,
            appCapabilities,
            protocolVersion,
          });
          rpcNotify("ui/notifications/initialized", {});
        } catch (error) {
          console.error("Failed to initialize the MCP Apps bridge:", error);
          throw error;
        }
      };

      const bridgeReady = initializeBridge();

      const callTodoTool = async (name, payload) => {
        await bridgeReady;
        const response = await rpcRequest("tools/call", {
          name,
          arguments: payload,
        });
        updateFromResponse(response);
      };

      formEl.addEventListener("submit", async (event) => {
        event.preventDefault();
        const title = inputEl.value.trim();
        if (!title || isAdding) return;

        isAdding = true;
        addButtonEl.disabled = true;
        addButtonEl.textContent = "Adding…";

        try {
          await callTodoTool("add_todo", { title });
          inputEl.value = "";
        } catch (error) {
          console.error("Failed to add todo:", error);
        } finally {
          isAdding = false;
          addButtonEl.disabled = false;
          addButtonEl.textContent = addButtonText;
        }
      });

      listEl.addEventListener("change", async (event) => {
        const checkbox = event.target;
        if (!checkbox.matches('input[type="checkbox"]')) return;
        const id = checkbox.closest("li")?.dataset.id;
        if (!id) return;

        if (!checkbox.checked) {
          checkbox.checked = true;
          return;
        }

        if (busyTodoIds.has(id)) return;
        busyTodoIds.add(id);
        checkbox.disabled = true;
        const rowEl = checkbox.closest("li");
        if (rowEl) rowEl.dataset.busy = "true";

        try {
          await callTodoTool("complete_todo", { id });
        } catch (error) {
          console.error("Failed to complete todo:", error);
        } finally {
          busyTodoIds.delete(id);
          render();
        }
      });

      render();
    </script>
  </body>
</html>

在你的 Web 组件中使用 Apps SDK

对于新应用,请使用 MCP Apps 宿主桥接:基于以下方式的 JSON-RPC postMessage with ui/* 通知和方法,例如 tools/call.

ChatGPT 继续支持 Apps SDK 兼容性及可选的 ChatGPT 扩展。有关详细信息,请参阅 ChatGPT 中的 MCP Apps 兼容性.

构建 MCP 服务器

安装官方的 Python 或 Node MCP SDK 来创建服务器并暴露一个 /mcp endpoint.

在本快速入门中,我们将使用 Node SDK.

如果你使用的是 Python,请参阅我们的 GitHub 上的示例代码库 以查看使用 Python SDK 的 MCP 服务器示例。

使用以下命令安装 Node SDK、MCP Apps 辅助工具和 Zod:

npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod

带有 Apps SDK 资源的 MCP 服务器

为你的组件包和模型可以调用的工具(例如 add_todo and complete_todo),以便 ChatGPT 能够驱动 UI。

创建一个名为 server.js 的文件,并粘贴以下使用 Node SDK 的示例:

import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const todoHtml = readFileSync("public/todo-widget.html", "utf8");

const addTodoInputSchema = {
  title: z.string().min(1),
};

const completeTodoInputSchema = {
  id: z.string().min(1),
};

const todoOutputSchema = {
  tasks: z.array(
    z.object({
      id: z.string(),
      title: z.string(),
      completed: z.boolean(),
    })
  ),
};

let todos = [];
let nextId = 1;

const replyWithTodos = (message) => ({
  content: message ? [{ type: "text", text: message }] : [],
  structuredContent: { tasks: todos },
});

function createTodoServer() {
  const server = new McpServer({ name: "todo-app", version: "0.1.0" });

  registerAppResource(
    server,
    "todo-widget",
    "ui://widget/todo.html",
    {},
    async () => ({
      contents: [
        {
          uri: "ui://widget/todo.html",
          mimeType: RESOURCE_MIME_TYPE,
          text: todoHtml,
        },
      ],
    })
  );

  registerAppTool(
    server,
    "add_todo",
    {
      title: "Add todo",
      description: "Creates a todo item with the given title.",
      inputSchema: addTodoInputSchema,
      outputSchema: todoOutputSchema,
      _meta: {
        ui: { resourceUri: "ui://widget/todo.html" },
      },
    },
    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}".`);
    }
  );

  registerAppTool(
    server,
    "complete_todo",
    {
      title: "Complete todo",
      description: "Marks a todo as done by id.",
      inputSchema: completeTodoInputSchema,
      outputSchema: todoOutputSchema,
      _meta: {
        ui: { resourceUri: "ui://widget/todo.html" },
      },
    },
    async (args) => {
      const id = args?.id;
      if (!id) return replyWithTodos("Missing todo id.");
      const todo = todos.find((task) => task.id === id);
      if (!todo) {
        return replyWithTodos(`Todo ${id} was not found.`);
      }

      todos = todos.map((task) =>
        task.id === id ? { ...task, completed: true } : task
      );

      return replyWithTodos(`Completed "${todo.title}".`);
    }
  );

  return server;
}

const port = Number(process.env.PORT ?? 8787);
const MCP_PATH = "/mcp";

const httpServer = createServer(async (req, res) => {
  if (!req.url) {
    res.writeHead(400).end("Missing URL");
    return;
  }

  const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);

  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;
  }

  if (req.method === "GET" && url.pathname === "/") {
    res.writeHead(200, { "content-type": "text/plain" }).end("Todo MCP server");
    return;
  }

  const MCP_METHODS = new Set(["POST", "GET", "DELETE"]);
  if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) {
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");

    const server = createTodoServer();
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined, // stateless mode
      enableJsonResponse: true,
    });

    res.on("close", () => {
      transport.close();
      server.close();
    });

    try {
      await server.connect(transport);
      await transport.handleRequest(req, res);
    } catch (error) {
      console.error("Error handling MCP request:", error);
      if (!res.headersSent) {
        res.writeHead(500).end("Internal server error");
      }
    }
    return;
  }

  res.writeHead(404).end("Not Found");
});

httpServer.listen(port, () => {
  console.log(
    `Todo MCP server listening on http://localhost:${port}${MCP_PATH}`
  );
});

该代码片段还会响应 GET / 进行健康检查,处理 OAuth 发现路由(你尚未使用)的 CORS 预检请求。这可以防止 ChatGPT 的连接器向导在未配置身份验证的情况下迭代时出现 502 错误。 /mcp and nested routes like /mcp/actions,并返回 404 Not Found 本地运行

如果你使用的是 React 之类的 Web 框架,请将组件构建为静态资产,以便 HTML 模板可以内联它们。通常,你可以运行以下构建命令:

目录中包含你编译后的资产。 npm run build to produce a dist 在本快速入门中,由于我们使用的是原生 HTML,因此无需构建。

在以下端口启动 MCP 服务器:

,从包含 http://localhost:<port>/mcp 的目录中运行 server.js (or server.ts).

确保你已经安装了 "type": "module" in your package.json file:

{
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.20.2",
    "@modelcontextprotocol/ext-apps": "^1.0.1",
    "zod": "^3.25.76"
  }
}

然后使用以下命令启动服务器:

node server.js

The server should print Todo MCP server listening on http://localhost:8787/mcp 一旦准备就绪。

使用 MCP Inspector 测试

图像生成端点 MCP Inspector 在本地测试你的服务器。

npx @modelcontextprotocol/inspector@latest --server-url http://localhost:8787/mcp --transport http

这将会打开一个浏览器窗口并显示 MCP Inspector 界面。你可以使用它来测试你的服务器并查看工具响应。

MCP Inspector

将你的服务器暴露到公共互联网

为了让 ChatGPT 在开发期间能够访问你的服务器,你需要将其暴露到公共互联网。你可以使用诸如 ngrok 的工具来为你的本地服务器开启隧道。

ngrok http <port>

这将为你提供一个类似 https://<subdomain>.ngrok.app 的公共 URL,你可以使用它从 ChatGPT 访问你的服务器。

当你添加连接器时,请提供带有 /mcp 路径的公共 URL(例如 https://<subdomain>.ngrok.app/mcp).

将你的应用添加到 ChatGPT

一旦你的 MCP 服务器和 Web 组件在本地成功运行,你就可以通过以下步骤将你的应用添加到 ChatGPT:

  1. 启用 开发者模式设置 → 应用与连接器 → 高级设置 in ChatGPT.
  2. 点击右上角的 创建 按钮以在 设置 → 连接器 并粘贴来自你的隧道或部署的 HTTPS + /mcp URL(例如 https://<subdomain>.ngrok.app/mcp).
  3. 为连接器命名,提供简短描述,然后点击 创建.
Add your connector to ChatGPT
  1. 打开一个新对话,从 更多 菜单(点击 + 按钮后可访问)中添加你的连接器,并提示模型(例如,“添加一个新任务来读我的书”)。ChatGPT 将以流式传输工具负载,以便你可以确认输入和输出。

Add your connector to a conversation

后续步骤

接下来,你可以迭代 UI/UX、提示词、工具元数据以及整体体验。

在每次更改 MCP 服务器(工具、元数据等)后刷新连接器。你可以通过点击 刷新 按钮来完成此操作,位于 设置 → 连接器 选中你的连接器之后。

在准备提交时,请查阅 ChatGPT 应用提交指南 and 研究你的用例。如果您正在构建 UI,您还可以查看 设计指南.

了解基础知识后,你可以利用 Apps SDK 来 构建 ChatGPT UI 使用 Apps SDK 基本组件, 对用户进行身份验证 如有需要,并 持久化状态.