For the complete documentation index, see llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
メインナビゲーション
Theme
概要 ChatGPT と Codex のユーザー向けドキュメント ユースケース

MCP サーバーと UI のクイックスタート

ChatGPT と Codex で使える MCP サーバーを構築します。必要に応じて ChatGPT 用の UI も追加できます。

はじめに

プラグインは Model Context Protocol(MCP) を使って、サーバーが提供する機能を ChatGPT と Codex から利用できるようにします。このチュートリアルでは、次の 2 つを使います。

  1. ツールを定義し、ChatGPT と Codex に公開する MCP サーバー
  2. ChatGPT 内の iframe に表示する Web コンポーネント(任意)

ChatGPT は、UI のオープン標準である MCP Apps を実装しています。一度作成した UI は、MCP Apps に対応する他のホストでも動作します。

このクイックスタートでは、タスクを追加して完了にする基本的なワークフローを作成します。UI のマークアップ、CSS、JavaScript は 1 つの HTML ファイルにまとめます。

React を使ったより高度な実装例は、GitHub のサンプルリポジトリを参照してください。

Web コンポーネントの作成

この手順は任意です。ツールだけが必要で、ChatGPT に UI を表示しない場合は、MCP サーバーの構築に進んでください。その場合、UI リソースは登録しません。

まず、新しいディレクトリに public/todo-widget.html というファイルを作成します。関連付けられた MCP ツールが UI を返すと、ChatGPT がその 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 コンポーネントでの MCP Apps の利用

新しい UI には MCP Apps のホストブリッジを使います。postMessage を介して JSON-RPC メッセージをやり取りし、ui/* 通知や tools/call などのメソッドを利用します。

まず、各ホストに共通する MCP Apps の仕組みで UI が動作することを確認します。標準仕様の範囲外の機能が必要な場合に限り、window.openai を介して ChatGPT の拡張機能を追加します。詳しくは、MCP サーバーへの UI の追加を参照してください。

MCP サーバーの構築

Python または Node 向けの公式 MCP SDK をインストールし、サーバーを作成して /mcp エンドポイントを公開します。

このクイックスタートでは Node SDK を使います。

Python を使う場合は、GitHub のサンプルリポジトリで Python SDK を使った MCP サーバーの実装例を参照してください。

次のコマンドで Node SDK、MCP Apps のヘルパー、zod パッケージをインストールします。

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

UI リソースを備えた MCP サーバー

ChatGPT が UI を操作できるように、コンポーネントのバンドルをリソースとして登録し、モデルが呼び出せるツール(例:add_todo と complete_todo)も登録します。

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-plugin-server",
    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 / に応答し、/mcp への CORS プリフライトリクエストも処理します。まだ使っていない OAuth のディスカバリ用ルートには 404 Not Found を返します。これにより、認証を設定せずに開発を進めても、ChatGPT に 502 エラーが表示されません。

ローカルでの実行

React などの Web フレームワークを使う場合は、HTML テンプレートに埋め込めるように、コンポーネントをビルドして静的アセットを生成します。通常は npm run build などのビルドコマンドを実行すると、コンパイル済みのアセットが入った dist ディレクトリが生成されます。

このクイックスタートでは HTML をそのまま使うため、ビルドは不要です。

MCP サーバーが http://localhost:<port>/mcp でリクエストを受け付けるように起動します。起動コマンドは server.js(または server.ts)があるディレクトリで実行します。

"type": "module" が package.json ファイルに含まれていることを確認します。

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

次のコマンドでサーバーを起動します。

node server.js

サーバーの準備が整うと、Todo MCP server listening on http://localhost:8787/mcp と表示されます。

MCP Inspector でのテスト

MCP Inspector を使って、サーバーをローカルでテストできます。

npx @modelcontextprotocol/inspector@latest

このコマンドを実行すると、MCP Inspector の画面が開きます。Streamable HTTP を選択し、http://localhost:8787/mcp を入力して接続します。これでサーバーをテストし、ツールの応答を確認できます。

MCP Inspector の画面

インターネットへのサーバーの公開

開発中に ChatGPT からサーバーにアクセスするには、サーバーをインターネットに公開する必要があります。ngrok などのツールを使うと、ローカルサーバーへのトンネルを作成できます。

ngrok http <port>

これで https://<subdomain>.ngrok.app のような公開 URL が発行され、ChatGPT からサーバーにアクセスできるようになります。

ChatGPT で MCP サーバーに接続するときは、公開 URL の末尾に /mcp を付けて指定します(例:https://<subdomain>.ngrok.app/mcp)。

ChatGPT での MCP サーバーへの接続

MCP サーバーと Web コンポーネントがローカルで動作したら、次の手順で ChatGPT からサーバーに接続します。

  1. ChatGPT のプラグインページを開き、プラスボタンを選択してから、カスタム MCP サーバーを追加を選択します。
  2. トンネルまたはデプロイ先の HTTPS URL に /mcp を付けたもの(例:https://<subdomain>.ngrok.app/mcp)を貼り付け、接続に名前を付けて認証を設定します。
  3. リスクに関する警告を確認し、理解したうえで続行するを選択してから、プラグインとして作成を選択します。
  4. 作成したプラグインをインストールし、新しいチャットを開きます。プロンプト入力欄に @ を入力してプラグインを選択し、モデルに「『本を読む』というタスクを追加して」などと依頼します。ChatGPT はツールのペイロードをストリーミングするので、入力と出力を確認できます。

次のステップ

ここからは、UI/UX、プロンプト、ツールのメタデータを調整しながら、プラグイン全体の使い勝手を改善できます。

MCP サーバーのツール、メタデータ、関連設定を変更するたびに、プラグインの接続情報を更新してください。chatgpt.com/plugins のプラグイン詳細ページから更新できます。

一般公開に向けて準備する際は、プラグインの提出、プラグインのガイドライン、プラグインのユースケースの検討を確認してください。UI を作成する場合は、UI ガイドラインも参考にできます。

基本を理解したら、より高機能な UI の構築、必要に応じたユーザー認証、状態管理に進めます。