はじめに
プラグインは Model Context Protocol(MCP) を使って、サーバーが提供する機能を ChatGPT と Codex から利用できるようにします。このチュートリアルでは、次の 2 つを使います。
- ツールを定義し、ChatGPT と Codex に公開する MCP サーバー
- 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 を入力して接続します。これでサーバーをテストし、ツールの応答を確認できます。

インターネットへのサーバーの公開
開発中に 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 からサーバーに接続します。
- ChatGPT のプラグインページを開き、プラスボタンを選択してから、カスタム MCP サーバーを追加を選択します。
- トンネルまたはデプロイ先の HTTPS URL に
/mcpを付けたもの(例:https://<subdomain>.ngrok.app/mcp)を貼り付け、接続に名前を付けて認証を設定します。 - リスクに関する警告を確認し、理解したうえで続行するを選択してから、プラグインとして作成を選択します。
- 作成したプラグインをインストールし、新しいチャットを開きます。プロンプト入力欄に
@を入力してプラグインを選択し、モデルに「『本を読む』というタスクを追加して」などと依頼します。ChatGPT はツールのペイロードをストリーミングするので、入力と出力を確認できます。
次のステップ
ここからは、UI/UX、プロンプト、ツールのメタデータを調整しながら、プラグイン全体の使い勝手を改善できます。
MCP サーバーのツール、メタデータ、関連設定を変更するたびに、プラグインの接続情報を更新してください。chatgpt.com/plugins のプラグイン詳細ページから更新できます。
一般公開に向けて準備する際は、プラグインの提出、プラグインのガイドライン、プラグインのユースケースの検討を確認してください。UI を作成する場合は、UI ガイドラインも参考にできます。
基本を理解したら、より高機能な UI の構築、必要に応じたユーザー認証、状態管理に進めます。