Introduction
Les plugins utilisent le Model Context Protocol (MCP) pour permettre à ChatGPT et à Codex d’accéder aux fonctionnalités d’un serveur. Ce tutoriel utilise deux éléments :
- Un serveur MCP qui définit des outils et les met à la disposition de ChatGPT et de Codex.
- Un composant web facultatif qui s’affiche dans une iframe au sein de ChatGPT.
ChatGPT prend en charge le standard ouvert MCP Apps pour les interfaces utilisateur. Vous pouvez ainsi créer votre interface une seule fois et l’exécuter sur différents hôtes compatibles avec MCP Apps.
Dans ce guide, vous allez créer un workflow simple pour gérer une liste de tâches. Son interface tient dans un seul fichier HTML qui regroupe le balisage, le CSS et le JavaScript.
Pour découvrir des exemples plus avancés utilisant React, consultez le dépôt d’exemples sur GitHub.
Créez un composant web
Cette étape est facultative. Si vous avez uniquement besoin d’outils, sans interface dans ChatGPT, passez à la section Créez un serveur MCP et n’enregistrez aucune ressource d’interface utilisateur.
Dans un nouveau répertoire, créez un fichier nommé public/todo-widget.html.
ChatGPT affichera l’interface lorsque l’outil MCP associé la renverra.
Ce fichier contiendra le composant web à afficher dans l’interface de ChatGPT.
Ajoutez le contenu suivant :
<!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>Utilisez MCP Apps dans votre composant web
Pour toute nouvelle interface, utilisez la passerelle MCP Apps de l’hôte. Elle échange des messages JSON-RPC via postMessage et utilise des notifications ui/* ainsi que des méthodes comme tools/call.
Une fois que votre composant fonctionne avec le mécanisme standard de MCP Apps, ajoutez les extensions facultatives de ChatGPT via window.openai uniquement si vous avez besoin de fonctionnalités que le standard ne couvre pas. Pour en savoir plus, consultez Ajoutez une interface utilisateur à votre serveur MCP.
Créez un serveur MCP
Installez le SDK MCP officiel pour Python ou Node afin de créer un serveur et d’exposer un point de terminaison /mcp.
Dans ce guide, vous allez utiliser le SDK pour Node.
Si vous utilisez Python, consultez notre dépôt d’exemples sur GitHub. Vous y trouverez un exemple de serveur MCP qui utilise le SDK pour Python.
Installez le SDK pour Node, les utilitaires MCP Apps et le paquet zod avec la commande suivante :
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod
Serveur MCP avec des ressources d’interface utilisateur
Enregistrez une ressource pour le bundle de votre composant, ainsi que les outils que le modèle peut appeler (par exemple, add_todo et complete_todo). ChatGPT pourra ainsi piloter l’interface.
Créez un fichier nommé server.js et collez-y l’exemple suivant, qui utilise le SDK pour Node :
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}`
);
});Ce code répond aussi à GET / pour les contrôles de disponibilité, traite les requêtes préliminaires CORS sur /mcp et renvoie 404 Not Found pour les routes de découverte OAuth que vous n’utilisez pas encore. ChatGPT évite ainsi d’afficher des erreurs 502 pendant que vous développez votre plugin sans authentification.
Exécutez le serveur en local
Si vous utilisez un framework web comme React, compilez votre composant en fichiers statiques pour que le modèle HTML puisse en intégrer directement le contenu.
En général, une commande de compilation comme npm run build produit un répertoire dist qui contient les fichiers compilés.
Dans ce guide, vous utilisez du HTML simple. Aucune compilation n’est nécessaire.
Démarrez le serveur MCP à l’adresse http://localhost:<port>/mcp depuis le répertoire qui contient server.js (ou server.ts).
Vérifiez que "type": "module" figure dans votre fichier package.json :
{
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.20.2",
"@modelcontextprotocol/ext-apps": "^1.0.1",
"zod": "^3.25.76"
}
}
Lancez ensuite le serveur avec la commande suivante :
node server.js
Une fois prêt, le serveur devrait afficher Todo MCP server listening on http://localhost:8787/mcp.
Testez le serveur avec MCP Inspector
Vous pouvez utiliser MCP Inspector pour tester votre serveur en local.
npx @modelcontextprotocol/inspector@latest
Cette commande ouvre l’interface de MCP Inspector. Sélectionnez Streamable HTTP, saisissez http://localhost:8787/mcp et connectez-vous pour tester votre serveur et examiner les réponses de ses outils.

Rendez votre serveur accessible depuis Internet
Pour que ChatGPT puisse accéder à votre serveur pendant le développement, vous devez le rendre accessible depuis Internet. Vous pouvez utiliser un outil comme ngrok pour ouvrir un tunnel vers votre serveur local.
ngrok http <port>
Vous obtiendrez une URL publique de la forme https://<subdomain>.ngrok.app qui vous permettra d’accéder à votre serveur depuis ChatGPT.
Lorsque vous connectez votre serveur MCP dans ChatGPT, indiquez l’URL publique suivie du chemin /mcp (par exemple, https://<subdomain>.ngrok.app/mcp).
Connectez votre serveur MCP dans ChatGPT
Une fois que votre serveur MCP et votre composant web fonctionnent en local, connectez le serveur dans ChatGPT :
- Ouvrez la page des plugins ChatGPT, sélectionnez le bouton +, puis Ajouter un serveur MCP personnalisé.
- Collez l’URL HTTPS de votre tunnel ou de votre déploiement en incluant le chemin
/mcp(par exemple,https://<subdomain>.ngrok.app/mcp), donnez un nom à la connexion et configurez l’authentification. - Lisez l’avertissement sur les risques, sélectionnez Je comprends et je souhaite continuer, puis Créer en tant que plugin.
- Installez le plugin ainsi créé, ouvrez une nouvelle discussion, saisissez
@dans la zone de saisie et sélectionnez le plugin. Envoyez un prompt au modèle, par exemple « Ajoute à ma liste une nouvelle tâche pour lire mon livre ». ChatGPT affichera les données échangées avec les outils au fur et à mesure, ce qui vous permettra de vérifier leurs entrées et leurs sorties.
Étapes suivantes
Vous pouvez maintenant affiner l’interface, l’expérience utilisateur, les prompts et les métadonnées des outils, ainsi que l’expérience globale du plugin.
Actualisez la connexion du plugin après chaque modification du serveur MCP (outils, métadonnées ou configuration associée). Pour cela, ouvrez la fiche du plugin sur chatgpt.com/plugins.
Avant de diffuser votre plugin auprès du public, consultez les pages Soumettez vos plugins, Consignes relatives aux plugins et Explorez les cas d’usage des plugins. Si vous créez une interface, consultez également les consignes de conception des interfaces utilisateur.
Une fois les bases acquises, vous pouvez créer une interface plus riche, authentifier les utilisateurs si nécessaire et gérer l’état.