Build, Test & Debug ChatGPT Apps Locally in MCPJam
Build, test, and debug ChatGPT Apps and plugin extensions locally with MCPJam. Create an MCP server, React UI, and CSV viewer, no ngrok or paid seat.
In this guide9 sectionsHide
Building a ChatGPT App can be difficult when you can’t see what’s happening between your UI, MCP server, and ChatGPT. Testing the complete experience inside ChatGPT requires developer mode.
You also need a way for ChatGPT to reach your MCP server. That usually means running a tunnel and refreshing a chat window every time you change a line of code.
With MCPJam, you can build and debug the app on your machine, with no tunnel and no paid seat:
- Part 1: Build a task board from scratch: an MCP server, three tools, and a React UI. Then add a plugin extension: a file viewer that shows any
.csvas a table image, plus a version the model can call on the web. - Part 2: Test and debug both with MCPJam: rendering, the app bridge, the file viewer, CSP, message tracing, fixed-input tool calls, model testing, and a pre-submission checklist.
Your app is the MCP server and UI you build. A plugin is the package people install, share, and publish in ChatGPT, and it wraps your app. A plugin extension is an extra place a plugin's UI can appear outside the chat, such as the sidebar, a panel beside a conversation, or a file viewer. An event is a notification your server sends ChatGPT when something happens, such as a task being completed.
You can find the complete code on GitHub.
How ChatGPT Apps and plugin extensions fit together
ChatGPT implements the open MCP Apps standard, which gives an app UI a portable foundation across compatible hosts. Actual behavior can still differ because each host implements the app bridge and its own optional features differently. Check Can I Use MCP? to compare MCP and MCP Apps capabilities before relying on host-specific features.
Swipe to inspect the full diagram →

ChatGPT continues to support compatibility aliases for the following MCP Apps fields and bridge methods. New UIs should prefer the shared MCP Apps equivalents:
Swipe to see all columns →
| Goal | MCP Apps standard | ChatGPT compatibility alias |
|---|---|---|
| Link a tool to a UI resource | _meta.ui.resourceUri | _meta["openai/outputTemplate"] |
| Receive tool input | ui/initialize + ui/notifications/tool-input | window.openai.toolInput |
| Receive tool results | ui/notifications/tool-result | window.openai.toolOutput |
| Call a tool from the UI | tools/call | window.openai.callTool |
| Send a follow-up message | ui/message | window.openai.sendFollowUpMessage |
Our task board uses the MCP Apps bridge for its portable behavior. In ChatGPT, it optionally uses window.openai.widgetState and window.openai.setWidgetState() to preserve the selected task between widget renders.
Plugins and plugin extensions
ChatGPT Apps are now packaged, installed, and published as plugins, and plugins can use plugin extensions to appear outside the chat transcript: as an app in ChatGPT's sidebar, a panel beside the conversation, a viewer for supported file types, or results in composer @-mentions. ChatGPT also supports the proposed MCP Events spec, so plugins can trigger automations when something happens in a connected app.
All of these build on the MCP server and MCP Apps UI you'll write here. Part 1 adds one of them to the task board's server: a file viewer for .csv files. OpenAI's extensions guide covers the rest.
What you need
MCPJam Inspector is open source and runs on your machine, so you can build and debug the whole app without a ChatGPT account. Only the last two steps need extra access:
Swipe to see all columns →
| Steps | What you do | What you need |
|---|---|---|
| 1 to 15 | Build the app, render both UIs, trace messages, and run fixed-input tool calls | Node.js 22+ |
| 16 | Check which tools a model calls for a prompt | A model: MCPJam's hosted models or your own API credentials |
| 17 (optional) | Run the app inside ChatGPT | ChatGPT developer mode and a tunnel |
Part 1: Build the app and a plugin extension
You'll build the task board first, then add a CSV file viewer to the same server and serve both over HTTP.
Before you start
Make sure you have:
- Node.js 22+ (
node --version). Both the app and MCPJam run on it. - npm, which ships with Node.
1. Set up the project
Keep the server and the UI code in separate folders. component.tsx is the task board UI, and csv-viewer.ts is the file viewer UI you'll add in step 7:
task-board
├── server
│ ├── package.json
│ └── src
│ ├── index.ts
│ └── server.ts
└── web
├── package.json
└── src
├── component.tsx
└── csv-viewer.tsInstall dependencies for each:
Scroll code horizontally →
mkdir -p task-board/server/src task-board/web/src
cd task-board/server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/node @modelcontextprotocol/ext-apps zod express
npm install -D typescript tsx @types/express @types/node
cd ../web
npm init -y
npm install react@^18 react-dom@^18
npm install -D typescript esbuild @types/react@^18 @types/react-dom@^18The official MCP TypeScript SDK v2 splits into scoped packages: @modelcontextprotocol/server is the server half, and @modelcontextprotocol/node adapts it to Node frameworks like Express. @modelcontextprotocol/ext-apps handles registering the UI resource.
Once installation finishes, you should have two node_modules folders and two package.json files. If npm pkg set errors, your npm is too old. Upgrade Node and try again. Add a server/tsconfig.json, and a "typecheck": "tsc --noEmit" script in server/package.json, so you can catch package mismatches:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"skipLibCheck": false,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}2. Create the server and its data
Create server/src/server.ts. Our server will expose three tools, which you'll register in step 3:
list_tasksreads tasks.complete_taskmarks one as done.show_task_boardrenders the UI.
This file holds the in-memory tasks fixture (t1, t2, t3) used throughout this article, and initializes the McpServer:
Scroll code horizontally →
// server/src/server.ts
import { McpServer } from "@modelcontextprotocol/server";
import {
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { readFileSync } from "node:fs";
import { z } from "zod";
const WIDGET_URI = "ui://task-board/v1.html";
const TaskSchema = z.object({
id: z.string(),
title: z.string(),
done: z.boolean(),
});
const TaskListSchema = z.object({ tasks: z.array(TaskSchema) });
// In-memory data for the tutorial. Use a real data store in production.
const tasks = [
{ id: "t1", title: "Write the launch post", done: false },
{ id: "t2", title: "Review the pricing page", done: false },
{ id: "t3", title: "Book the team offsite", done: true },
];
export function createServer() {
const server = new McpServer(
{ name: "task-board", version: "1.0.0" },
{
// These instructions reach the model alongside your tool descriptions.
instructions:
"Call list_tasks to get tasks. To show tasks visually, call list_tasks first, then pass its tasks to show_task_board.",
}
);
// The data tools, list_tasks and complete_task, are registered here (step 3).
registerRenderTool(server);
registerWidget(server);
return server;
}3. Define the tools
Keep data tools separate from render tools (decoupled pattern), so ChatGPT doesn't re-render the iframe after every tool call.
Here's list_tasks, registered inside createServer() from step 2:
Scroll code horizontally →
server.registerTool(
"list_tasks",
{
title: "List tasks",
description:
"Use this when the user wants to find or review their tasks. Returns tasks with id, title, and done status.",
inputSchema: z.object({ status: z.enum(["open", "done"]).optional() }),
outputSchema: TaskListSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ status }) => {
const result = tasks.filter((t) =>
status === undefined ? true : status === "done" ? t.done : !t.done
);
return {
structuredContent: { tasks: result },
content: [{ type: "text", text: `Found ${result.length} tasks.` }],
};
}
);The description helps the model understand what the tool does and when to call it. The annotations describe its behavior. Here, readOnlyHint is true because this tool can't change anything. structuredContent holds the task data that the model reads and the UI renders.
complete_task follows the same pattern with readOnlyHint: false. It returns isError: true when the ID doesn't exist, and the full task list when it succeeds:
Scroll code horizontally →
// Data tool: changes state, called by the model or from the UI.
server.registerTool(
"complete_task",
{
title: "Complete task",
description: "Use this when the user wants to mark a task as done.",
inputSchema: z.object({ taskId: z.string() }),
outputSchema: TaskListSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
},
},
async ({ taskId }) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) {
return {
isError: true,
content: [{ type: "text", text: `No task found with id ${taskId}.` }],
};
}
task.done = true;
return {
structuredContent: { tasks },
content: [{ type: "text", text: `Marked "${task.title}" as done.` }],
};
}
);The render tool is the only one that points at the UI. It lives in its own function, below createServer():
Scroll code horizontally →
function registerRenderTool(server: McpServer) {
server.registerTool(
"show_task_board",
{
title: "Show task board",
description:
"Render the task board UI. Always call list_tasks first, then pass its tasks to this tool.",
inputSchema: TaskListSchema,
outputSchema: TaskListSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
ui: { resourceUri: WIDGET_URI },
"openai/toolInvocation/invoking": "Opening task board…",
"openai/toolInvocation/invoked": "Task board ready.",
},
},
async ({ tasks }) => ({
structuredContent: { tasks },
content: [{ type: "text", text: `Showing ${tasks.length} tasks.` }],
})
);
}The description tells the model to call list_tasks first, matching the server instructions from step 2. Repeating the order in both places helps the model use the tools correctly. In step 10, we'll see how to confirm the client actually received them.
4. Build the UI
The React component in web/src/component.tsx does four things:
- It completes the MCP Apps handshake: a
ui/initializerequest, then aui/notifications/initializednotification. Hosts send tool input and results only after that.
- It subscribes to
ui/notifications/tool-resultand re-renders when the client pushes new tool output.
- It sends a
tools/callrequest forcomplete_taskwhen you click Done.
- It saves the selected row with
window.openai.setWidgetState. Widget state is the small blob of UI state the client stores for you and hands back on re-render, so a selection isn't lost when that happens.
The bridge code itself is short. Requests go out through postMessage, while a single listener handles both replies and client notifications. The excerpt below includes the module-level declarations that the rest of the file depends on. The complete version is in the repo.
Scroll code horizontally →
type Task = { id: string; title: string; done: boolean };
type ToolResult = { structuredContent?: { tasks?: Task[] } } | null;
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
let nextId = 1;
const listeners = new Set<(result: ToolResult) => void>();
function request(method: string, params: unknown): Promise<any> {
const id = nextId++;
window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
}
window.addEventListener(
"message",
(event) => {
if (event.source !== window.parent) return;
const msg = event.data;
if (!msg || msg.jsonrpc !== "2.0") return;
// Responses to our own requests
if (msg.id !== undefined && pending.has(msg.id)) {
const p = pending.get(msg.id)!;
pending.delete(msg.id);
msg.error ? p.reject(msg.error) : p.resolve(msg.result);
return;
}
// Tool results pushed by the host
if (msg.method === "ui/notifications/tool-result") {
listeners.forEach((fn) => fn(msg.params ?? null));
}
},
{ passive: true }
);The component subscribes to tool results first, then starts the handshake, so it can't miss the first result:
Scroll code horizontally →
function useToolResult() {
const [result, setResult] = useState<ToolResult>(null);
useEffect(() => {
listeners.add(setResult);
// MCP Apps handshake: the host sends tool input and results only after
// `initialized`, so subscribe first to avoid missing the first result.
request("ui/initialize", {
protocolVersion: "2026-01-26",
appInfo: { name: "task-board", version: "1.0.0" },
appCapabilities: {},
})
.then(() =>
window.parent.postMessage(
{ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} },
"*"
)
)
.catch((error) => console.error("MCP Apps handshake failed", error));
return () => {
listeners.delete(setResult);
};
}, []);
return result;
}Clicking Done calls the server and renders whatever comes back:
Scroll code horizontally →
async function complete(id: string) {
try {
const result = await request("tools/call", {
name: "complete_task",
arguments: { taskId: id },
});
if (result?.isError) throw new Error("Tool returned an error");
setTasks(result.structuredContent.tasks);
setError(null);
} catch {
setError("Couldn't update that task. Please try again.");
}
}The server is the source of truth for task data. Instead of changing its local task list, the component renders the data returned by the server. This keeps the UI in sync with the backing store.
Widget state only stores the selected row. The component accesses it with optional chaining (window.openai?.setWidgetState?.(...)) so it can still run in clients that don't provide window.openai.
Don't skip the handshake. A host must not send anything to the UI before initialized, so without it, the board renders but never receives its tasks.
5. Bundle and register the UI
Add a build script to web/package.json:
Scroll code horizontally →
{
"scripts": {
"build": "esbuild src/component.tsx --bundle --format=esm --jsx=automatic --minify --outfile=dist/component.js"
}
}--minify also switches React to its production build, which shrinks the bundle from about 1 MB to about 140 KB. That matters because the server inlines the whole bundle into the UI resource.
From task-board/web, run npm run build. Then register the bundle as a resource in server/src/server.ts, using WIDGET_URI and the imports from step 2:
Scroll code horizontally →
function registerWidget(server: McpServer) {
const component = readFileSync(
new URL("../../web/dist/component.js", import.meta.url),
"utf8"
);
registerAppResource(server, "task-board", WIDGET_URI, {}, async () => ({
contents: [
{
uri: WIDGET_URI,
mimeType: RESOURCE_MIME_TYPE,
text: `<div id="root"></div><script type="module">${component}</script>`,
_meta: {
ui: {
prefersBorder: true,
// This widget makes no external requests, so both lists are empty.
csp: { connectDomains: [], resourceDomains: [] },
},
},
},
],
}));
}Clients cache the UI resource by its URI, so it doubles as a cache key. When you ship a breaking change to the component, bump it (v1.html → v2.html), or clients will keep serving the old bundle.
6. Add a file viewer plugin extension
Plugin extensions let your app appear outside the chat transcript. Now you'll add one to the same server: a file viewer that takes over when someone opens a .csv in ChatGPT and draws the file as a table image in the side panel, in place of ChatGPT's built-in viewer.
File viewers don't run in every ChatGPT client, so in step 8 you'll also expose the same viewer as a tool the model can call with CSV text.
A file viewer is a tool with a file entrypoint in _meta["openai/ui"]. When a user opens a matching file, ChatGPT calls the tool with the file's name and an opaque resourceUri, then renders the tool's UI in the side panel.
In server/src/server.ts, add the viewer's URI below WIDGET_URI:
Scroll code horizontally →
const CSV_VIEWER_URI = "ui://task-board/csv-viewer-v1.html";Then add the tool below registerWidget():
Scroll code horizontally →
function registerCsvFileViewer(server: McpServer) {
server.registerTool(
"open_csv_file",
{
title: "Table image",
description: "Show an opened CSV file as a table image.",
inputSchema: z.object({
file: z.object({ name: z.string(), resourceUri: z.string() }),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
// "app" hides the tool from the model. ChatGPT ignores this when it
// opens the tool as a file viewer.
ui: { resourceUri: CSV_VIEWER_URI, visibility: ["app"] },
"openai/ui": {
entrypoints: [{ type: "file", extensions: [".csv"] }],
},
},
},
async ({ file }) => ({
content: [{ type: "text", text: `Showing ${file.name} as a table image.` }],
})
);
}Three details in this declaration:
- Extensions start with a dot. The
extensionslist uses the file-extension form of HTML'sacceptattribute, so write".csv", not"csv". resourceUriisn't a path. ChatGPT handles reads for it itself, so the file never reaches your server. That's why the tool only returns a short message, and why the UI does the reading (step 7).visibility: ["app"]hides the tool from the model. ChatGPT ignores it when it opens the viewer, so the viewer still works, but the model won't call it by mistake.
Register it, along with the pieces from the next two steps, at the end of createServer():
registerRenderTool(server);
registerWidget(server);
registerCsvFileViewer(server);
registerCsvImageTool(server);
registerCsvViewerResource(server);
return server;7. Build the viewer UI
The viewer is a second bundle, web/src/csv-viewer.ts. It draws a single canvas and keeps no UI state. It uses the same postMessage bridge and ui/initialize handshake as the task board in step 4, and then does three things:
- It listens for
ui/notifications/tool-input, where the host delivers the tool's arguments. - For an opened file, it reads the contents with
resources/read. - It parses the CSV and draws it on a
<canvas>, using ChatGPT's light or dark theme.
Both tools deliver their arguments through the same notification:
Scroll code horizontally →
// Both tools deliver their arguments here: { file } when ChatGPT opens a
// CSV, { csv, name } when the model calls show_csv_image.
if (msg.method === "ui/notifications/tool-input") {
loadTable(msg.params?.arguments).then(show).catch(showError);
}loadTable() handles both shapes. For an opened file, it asks the host for the contents:
Scroll code horizontally →
async function loadTable(args: any): Promise<Table> {
if (typeof args?.file?.resourceUri === "string") {
// ChatGPT answers resources/read for opened files itself; the URI is
// opaque and never reaches the MCP server.
const result = await request("resources/read", { uri: args.file.resourceUri });
const content = result?.contents?.[0];
const text =
typeof content?.text === "string"
? content.text
: typeof content?.blob === "string"
? decodeBase64(content.blob)
: undefined;
if (text === undefined) throw new Error("the file has no readable contents");
return { name: args.file.name, rows: parseCsv(text) };
}
if (typeof args?.csv === "string") {
return { name: typeof args.name === "string" ? args.name : undefined, rows: parseCsv(args.csv) };
}
throw new Error("expected an opened file or CSV text");
}The CSV parser and the canvas drawing are in csv-viewer.ts. To keep the canvas within browser size limits, the viewer draws at most 200 rows and 30 columns (MAX_ROWS and MAX_COLUMNS in that file) and adds a caption with how many rows and columns it showed out of the total. It draws on the canvas directly rather than converting it to a PNG <img>, because an image loaded from a data: URL can be blocked by the host's content security policy.
Update the build script in web/package.json so it bundles both UIs:
Scroll code horizontally →
{
"scripts": {
"build": "esbuild src/component.tsx src/csv-viewer.ts --bundle --format=esm --jsx=automatic --minify --outdir=dist"
}
}From task-board/web, run npm run build again. You should see both component.js and csv-viewer.js in web/dist.
Then register the bundle as a second UI resource in server/src/server.ts:
Scroll code horizontally →
function registerCsvViewerResource(server: McpServer) {
registerAppResource(server, "csv-viewer", CSV_VIEWER_URI, {}, async () => {
// Read on demand, so a missing bundle only breaks the viewer, not the board.
const viewer = readFileSync(
new URL("../../web/dist/csv-viewer.js", import.meta.url),
"utf8"
);
return {
contents: [
{
uri: CSV_VIEWER_URI,
mimeType: RESOURCE_MIME_TYPE,
text: `<div id="root"></div><script type="module">${viewer}</script>`,
_meta: {
ui: {
prefersBorder: true,
csp: { connectDomains: [], resourceDomains: [] },
},
},
},
],
};
});
}Unlike the task board in step 5, this resource reads its bundle on demand, so a missing csv-viewer.js only breaks the viewer, not the board.
8. Let the model open the viewer on the web
The viewer UI draws any CSV text it receives, whether it read the text from an opened file or got it in a tool's arguments. So the same UI can back a second tool that the model calls with CSV text:
Scroll code horizontally →
function registerCsvImageTool(server: McpServer) {
server.registerTool(
"show_csv_image",
{
title: "Show CSV as image",
description:
"Use this when the user wants to see CSV data as a table image. Pass the full CSV text, including the header row.",
inputSchema: z.object({
csv: z.string().min(1),
name: z.string().optional().describe("File name to show above the image, if known."),
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
ui: { resourceUri: CSV_VIEWER_URI },
"openai/toolInvocation/invoking": "Drawing table…",
"openai/toolInvocation/invoked": "Table image ready.",
},
},
async ({ name }) => ({
content: [{ type: "text", text: `Showing ${name ?? "the CSV"} as a table image.` }],
})
);
}When a user attaches a CSV and asks to see it as an image, the model reads the file and passes its text as csv. The UI receives { csv, name } through the same tool-input notification and draws the same table.
The trade-off is that the model has to copy the whole file into the tool call, so very large CSVs are slow or get cut short. The file viewer doesn't have that problem, because it reads the file directly.
9. Serve it over HTTP
ChatGPT connects over Streamable HTTP. You can choose the endpoint, but /mcp is the common convention. In server/src/index.ts, createMcpHandler builds a fresh server per request, so the endpoint stays stateless without any transport wiring of your own:
Scroll code horizontally →
import express from "express";
import { createMcpHandler } from "@modelcontextprotocol/server";
import {
toNodeHandler,
localhostHostValidation,
localhostOriginValidation,
} from "@modelcontextprotocol/node";
import { createServer } from "./server.js";
// A fresh server per request keeps the endpoint stateless. One factory serves
// every supported protocol revision, so older clients keep working.
const handler = createMcpHandler(() => createServer(), {
onerror: (error) => console.error(error),
});
// DNS rebinding protection: only answer requests whose Host and Origin headers
// point at localhost. Allow your real domain instead when you deploy.
const checkHost = localhostHostValidation();
const checkOrigin = localhostOriginValidation();
const app = express();
// No express.json() here: toNodeHandler reads the request stream itself.
app.post(
"/mcp",
(req, res, next) => {
if (!checkHost(req, res) || !checkOrigin(req, res)) return;
next();
},
toNodeHandler(handler)
);
app.listen(3000, () => {
console.log("Task board MCP server: http://localhost:3000/mcp");
});createMcpHandler returns a fetch-shaped handler that resolves to a web Response, which Express can't consume directly, so toNodeHandler adapts it and handles write backpressure on streamed SSE responses for you.
There's no express.json() in this file, and that's deliberate. toNodeHandler reads the request stream itself, so a body parser in front of it would consume the body first and leave the handler with nothing.
The two validation guards are DNS rebinding protection. A local endpoint should only answer requests whose Host and Origin headers point at localhost; swap them for your own domain when you deploy behind one.
Add a start script to server/package.json so the command doesn't depend on your working directory:
{
"scripts": {
"start": "tsx src/index.ts"
}
}From task-board/server, run npm start.

You should see Task board MCP server: http://localhost:3000/mcp in the terminal. ChatGPT can't reach localhost, so testing there would need a public tunnel. In Part 2, MCPJam runs on your machine and connects to the server directly.
Part 2: Test and debug with MCPJam
MCPJam Inspector is an open-source dev tool for ChatGPT Apps, MCP Apps, and MCP servers. It emulates the ChatGPT client, so your UI renders and calls tools the same way it would there. It also shows you the JSON-RPC traffic that’s normally hidden.
You'll start by calling the tools directly, without a model. Then you'll check which tools a model picks, and finish with an optional pass in ChatGPT.
10. Launch MCPJam and connect
Leave the server running. In a new terminal, from anywhere:
npx @mcpjam/inspector@latestMCPJam requires Node.js 22+. If you'd rather not use the terminal, install the desktop app for macOS or Windows, or run MCPJam from Docker.

In the Servers tab, add an HTTP server at http://localhost:3000/mcp.

You should see three things in the server info view:
- The server initialized.
- Your
instructionsfrom step 2, shown verbatim. - All five tools listed with the schemas and annotations you wrote: the three task board tools and the two CSV tools.
Swipe to inspect the full diagram →

If you don't, a connection error almost always means the server isn't running or is on a different port. Missing tools mean their registerTool calls never ran, so check createServer().
11. Render the task board
Go to Playground, open the Tools tab, and invoke show_task_board manually. The tool's inputSchema is { tasks: z.array(TaskSchema) }, so the input is an object with a tasks key, not a bare array:
Scroll code horizontally →
{
"tasks": [
{ "id": "t1", "title": "Write the launch post", "done": false },
{ "id": "t2", "title": "Review the pricing page", "done": false },
{ "id": "t3", "title": "Book the team offsite", "done": true }
]
}Swipe to inspect the full diagram →

You should see the board render immediately in the widget emulator. Start with manual calls rather than chat, since that takes the model out of the picture while you check the UI. Each tool result also has panels where you can inspect the raw input, output, and errors.
Swipe to inspect the full diagram →

If nothing shows up, check these in order:
- Does the tool's
resourceUriexactly match the registered URI? - Is the MIME type
text/html;profile=mcp-app? - Does
web/dist/component.jsexist? The server rereads it on every request, so a rebuild takes effect without a restart. - What do the error panel and your browser's DevTools console say?
12. Interact with UI and watch the messages
Click Done on "Write the launch post", then switch to the Trace view and select the complete_task request row. MCPJam shows both JSON-RPC and window.openai messages, so you can follow the click end-to-end:
- The UI sends
tools/callforcomplete_task. - The request reaches your server.
- The server responds with the updated tasks.
- The task gets a strikethrough.
If something breaks, the first missing step tells you where to look:
- No
tools/callin the trace: check whether the click handler is firing. - The call appears, but the server never sees it: check the tool name and arguments.
- The server answers but the UI doesn't update: check how the UI handles the response.
Next, click a task to select it and re-render the board. The selection should survive, confirming that widget state is working.
13. Render the CSV viewer
In the Playground, invoke show_csv_image with this input:
Scroll code horizontally →
{
"name": "parts.csv",
"csv": "part,qty,notes\nM6 bolt,40,\"Zinc, coarse\"\nWasher,120,\n"
}You should see a table image with three columns and two rows. Zinc, coarse stays in one cell because it's quoted.
Swipe to inspect the full diagram →

Switch MCPJam's theme emulation between light and dark. The table should redraw in the new colors.
open_csv_file needs an opened file, and MCPJam doesn't open file viewers. When the viewer sends resources/read, MCPJam forwards the request to your server instead, so have the server serve the task list as a CSV. In server/src/server.ts, add its URI below CSV_VIEWER_URI:
const TASKS_CSV_URI = "tasks://tasks.csv";Then add the resource and a helper that formats the tasks:
Scroll code horizontally →
function registerTasksCsvResource(server: McpServer) {
server.registerResource(
"tasks-csv",
TASKS_CSV_URI,
{ title: "Tasks as CSV", mimeType: "text/csv" },
async (uri) => ({
contents: [{ uri: uri.href, mimeType: "text/csv", text: tasksCsv() }],
})
);
}
function tasksCsv(): string {
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
const rows = tasks.map((t) => [quote(t.id), quote(t.title), t.done].join(","));
return ["id,title,done", ...rows].join("\n");
}Register it at the end of createServer(), after registerCsvViewerResource(server);:
registerTasksCsvResource(server);Stop the server with Ctrl+C, run npm start again, and reconnect in MCPJam. Then invoke open_csv_file with:
Scroll code horizontally →
{ "file": { "name": "tasks.csv", "resourceUri": "tasks://tasks.csv" } }You should see the three tasks as a table image. Trace shows the viewer's resources/read request going to your server, which answers with tasks.csv.
Swipe to inspect the full diagram →

14. Check your CSP
A Content Security Policy (CSP) controls which domains your embedded UI can connect to or load resources from. Since your UI runs in an iframe, the client applies one, and your UI can only reach the domains it declares in _meta.ui.csp:
connectDomainscovers API calls.resourceDomainscovers scripts, styles, fonts, and images.frameDomainscovers embedded iframes, and nested frames are blocked unless you list them.
Neither the task board nor the CSV viewer makes external requests, so both declare empty lists. That's also why the viewer in step 7 draws on a canvas instead of loading a data: URL image. In larger apps common issues include:
- a font CDN you forgot to list,
- images on a separate bucket,
- a script loaded at runtime instead of bundled.
MCPJam's CSP testing shows what gets blocked. In the host context toolbar, set CSP mode to Strict (the default is Permissive), and a badge counts the blocked requests. Declare only the domains you use. Plugin review compares the policy you declare against what your UI does at runtime, so a policy broader than your UI needs is a review risk.
15. Run fixed-input tool calls
Chat-driven testing is nondeterministic, so the same prompt can produce different arguments across runs. With fixed-input calls, you invoke each tool directly with arguments you choose. You can also pass deliberately invalid inputs to test error handling. Include edge cases such as missing IDs and empty results.
These expectations assume the three-task fixture from step 2, with only t3 done. Tasks live in memory, and the restart in step 13 reset them. If you've completed a task since, restart the server again:
Swipe to see all columns →
| Tool | Input | Expected |
|---|---|---|
list_tasks | {} | All three tasks |
list_tasks | { "status": "done" } | Only "Book the team offsite" |
list_tasks | { "status": "archived" } | Schema validation error |
complete_task | { "taskId": "t2" } | Updated list with t2 done |
complete_task | { "taskId": "nope" } | isError: true with a clear message |
complete_task | {} | Schema validation error |
show_task_board | { "tasks": [] } | "Showing 0 tasks." |
show_csv_image | { "csv": "" } | Schema validation error |
Compare each output against your declared schema. If a test catches a bug, replay the same call with mcpjam tools call from the MCPJam CLI, so you can run the case automatically in future tests.
16. Test how the model uses your tools
Steps 11 through 15 verify that your tools work. This step verifies that a model reading your descriptions picks the right tool at the right time. In Playground, switch to ChatGPT and try a few prompts:
- "Show me my open tasks" should call
list_tasks, thenshow_task_board. - "Show this CSV as a table image", followed by a few pasted CSV lines, should call
show_csv_imagewith that text. - "What's a good productivity method?" shouldn't call anything.
If the model skips list_tasks or fires tools when it shouldn't, look at your tool descriptions, server instructions, and parameter names before touching your handlers.
Pre-submission checklist
Before you submit your plugin, walk through this list:
Swipe to see all columns →
| What to solve | Where to troubleshoot it in MCPJam |
|---|---|
| UI doesn't render or throws | Playground + error panel |
| Layout breaks on mobile or in dark mode | Device and theme emulation |
| Content clips inline | ChatGPT client preview |
| CSP too broad, or blocking the UI | CSP testing |
| Output doesn't match the schema | Fixed-input calls + raw output |
| Selection lost on re-render | Widget State tab in the widget debug panel |
| File viewer can't read the file | open_csv_file with a sample resource + Trace |
| Wrong tool, or wrong order | Playground + Trace |
| Stale UI after a change | Check that the resource URI was bumped |
For the review side, work through OpenAI's testing checklist too.
17. Try it in ChatGPT (optional)
To try it in ChatGPT, turn on developer mode and add your server as a plugin on ChatGPT's Plugins page, using a tunnel URL. The localhost guards from step 9 reject other hostnames, so have the tunnel rewrite the Host header (with ngrok, ngrok http 3000 --host-header=rewrite). If ChatGPT doesn't list your latest tools after you change the server, refresh the plugin's connection, or remove the plugin and add it again.
Start a new chat and run the prompts from step 16. Then attach a CSV and ask "Show this CSV as a table image." The model calls show_csv_image, and the image appears in the chat.
To see the file viewer itself, open any .csv in a ChatGPT client that supports file viewers. OpenAI's extensions guide lists which clients do. Your table image replaces ChatGPT's own viewer in the side panel.
Notify ChatGPT with MCP Events
Plugins can also tell ChatGPT when something happens, instead of waiting for a tool call. With MCP Events, your server adds events to its capabilities and describes its events in events/list. When a user asks for something like "Whenever I finish a task, draft a one-line status update," ChatGPT calls events/subscribe with a callback URL and a signing secret. Your server verifies the callback with a signed challenge, then sends each matching event to it, signed with Standard Webhooks, and ChatGPT acts on it in that chat. The example repo includes a task.completed event in server/src/events.ts that you can start from.
What else MCPJam can do
This guide used the local Inspector, but that's only part of the platform. Once your app works locally, MCPJam covers what happens when real users and real releases get involved:
- Swarms: describe your users, and MCPJam generates personas and goals, then runs simulated users through your server in parallel across ChatGPT, Claude, Cursor, and other clients. For the task board we just built, that could be a manager reviewing open work, a teammate closing out tasks, and someone asking for a task that doesn't exist. You can compare how each client behaves side by side.
- User Testing: share a preview link with real testers, including guests, and collect per-turn ratings as they use your app. MCPJam turns those sessions into findings and insights, so you can measure sentiment and spot usability gaps before launch.
- Evals: turn workflows like "Show me my open tasks" from step 16 into repeatable test suites. You can re-run them across your chosen clients and track pass rate, latency, token usage, and tool calls over time.
- CI/CD gates: run those evals, along with protocol conformance and OAuth and security checks, on every pull request. If a change drops the pass rate below your threshold, the merge is blocked.
All of this works with local servers through the desktop app, the CLI, or the SDK. So the fixed-input cases from step 15 and the model tests from step 16 can grow into a regression suite that runs on every release.
What MCPJam can't tell you about ChatGPT Apps and plugin extensions
MCPJam can catch most issues locally, but a few things still need the real client:
- ChatGPT-only features: the MCP Apps behavior you test locally carries over, but features layered on top of the standard through
window.openai, such as checkout and file handling, are worth confirming in ChatGPT itself. - Plugin extensions: sidebar apps, conversation panels, file viewers, composer @-mentions, and event-driven automations are ChatGPT surfaces. You can call the tools behind them and render their UI in MCPJam, but confirm where they appear and how they behave in ChatGPT. Each surface runs in a different set of ChatGPT clients, so check OpenAI's extensions guide before you pick where to test.
- Review decisions: while final review criteria rest with OpenAI, validating locally with MCPJam helps catch protocol mismatches, UI rendering issues, and edge cases before you submit your plugin for review.
So if you have developer mode, do the final pass in ChatGPT from step 17 before you submit your plugin. By then, you've fixed what MCPJam can catch, so this pass only needs to cover the items above.
Start testing your ChatGPT Apps and plugin extensions
Clone the example repo and launch MCPJam with:
npx @mcpjam/inspector@latestFAQ
How do I create a ChatGPT App?
Build an MCP server with the official SDK, define focused tools with schemas and annotations, and optionally link a UI resource through _meta.ui.resourceUri. Serve it over Streamable HTTP, conventionally at /mcp.
Should I use window.openai or the MCP Apps bridge?
Use the MCP Apps bridge (ui/* over postMessage) by default. Reach for window.openai only for ChatGPT-specific features like widget state, file handling, and checkout.
What are ChatGPT plugin extensions?
They let a plugin appear outside the chat transcript: as an app in ChatGPT's sidebar, a panel beside a conversation, a viewer for supported file types, or results in composer @-mentions. Sidebar apps and panels are declared on an MCP Apps tool through _meta["openai/ui"].entrypoints. OpenAI's extensions guide lists which ChatGPT clients support each surface.
How do I add a sidebar app or conversation panel with plugin extensions?
Register a tool that accepts {} as its arguments, point _meta.ui.resourceUri at your UI, and set _meta["openai/ui"].entrypoints to [{ type: "global" }] for the sidebar, [{ type: "thread" }] for a conversation panel, or both. OpenAI's extensions guide has complete examples.
How do I build a custom file viewer in ChatGPT?
Register a tool whose _meta["openai/ui"].entrypoints includes { type: "file", extensions: [".csv"] }, and point _meta.ui.resourceUri at your viewer UI. ChatGPT calls the tool with the file's name and an opaque resourceUri, and your UI reads the contents with resources/read. File viewers don't run in every ChatGPT client, so pair one with a tool the model can call with the file's text. The task board in this guide does both for .csv files.
How do I send events from my MCP server to ChatGPT?
Add events to your server's capabilities and implement events/list, events/subscribe, and events/unsubscribe. When a user asks ChatGPT to monitor an event, ChatGPT subscribes with a callback URL and a whsec_ signing secret. Your server verifies the callback, then POSTs each matching event to it, signed with Standard Webhooks. The example repo's server/src/events.ts has a complete task.completed event.
Can I test ChatGPT plugin extensions with MCPJam?
Partly. MCPJam renders an extension's UI when you invoke its tool directly, and Trace shows the requests the UI makes. For a file viewer, have your server serve a sample file as a resource, and pass that resource's URI as the file's resourceUri. MCPJam doesn't open sidebar apps, conversation panels, file viewers, or composer @-mentions themselves, so confirm those in ChatGPT.
Can I test a ChatGPT App without a ChatGPT subscription?
Yes. MCPJam emulates the ChatGPT client locally, so you can render UI, call tools, and test state without one. Model testing needs a model, which MCPJam provides or lets you connect with your own API credentials, and a final check inside ChatGPT needs developer mode.
Do I need ngrok to test ChatGPT Apps?
Not for local development, since MCPJam connects straight to localhost. MCPJam also ships a built-in tunnel for when you do need a public URL.
Why isn't my ChatGPT App UI rendering?
Usually it comes down to a resourceUri that doesn't match the registered resource, the wrong MIME type (it should be text/html;profile=mcp-app), or a missing bundle.
Can I test ChatGPT Apps and Claude MCP Apps with the same tool?
Yes. MCPJam's Playground renders both with one renderer, whether a tool declares openai/outputTemplate, _meta.ui.resourceUri, or both.
Can I use MCPJam's hosted app with a localhost server?
No. The hosted app only accepts HTTPS URLs. Use npx @mcpjam/inspector@latest or the desktop app instead.