name: mcpserver description: "Migrates an MCP server with interactive widgets from the OpenAI Apps SDK (window.openai, text/html+skybridge) to the MCP Apps standard (@modelcontextprotocol/ext-apps), covering server-side and client-side changes."
Skill: Migrate OpenAI Apps SDK → MCP Apps
Migrate an MCP server with interactive widgets from the OpenAI Apps SDK (window.openai, text/html+skybridge, flat _meta["openai/..."] keys) to the MCP Apps standard (@modelcontextprotocol/ext-apps).
When to Use
Use this skill when:
- An MCP server uses
text/html+skybridgeMIME type for widget resources - Widget code references
window.openaiglobals (e.g.window.openai.callTool,window.openai.toolOutput,window.openai.theme) - Server code uses flat
_meta["openai/outputTemplate"]or_meta["openai/widgetAccessible"]keys - The goal is to make the server compatible with MCP Apps hosts (Claude, ChatGPT, Microsoft 365 Copilot, etc.)
References
- MCP Apps repo: https://github.com/modelcontextprotocol/ext-apps
- API docs: https://modelcontextprotocol.github.io/ext-apps/api/
- Migration guide: https://modelcontextprotocol.github.io/ext-apps/docs/migration/openai-apps
- Patterns: https://modelcontextprotocol.github.io/ext-apps/docs/patterns
Packages
| Package | Where | Purpose |
|---|---|---|
@modelcontextprotocol/ext-apps | Server + Widgets | Core MCP Apps SDK |
@modelcontextprotocol/sdk | Server | MCP protocol SDK (keep existing) |
zod | Server | Schema definitions for McpServer.tool() |
Migration Mapping
MIME Type
| Before | After |
|---|---|
text/html+skybridge | text/html;profile=mcp-app (use RESOURCE_MIME_TYPE constant) |
Server: _meta Keys
| OpenAI flat key | MCP Apps nested key |
|---|---|
_meta["openai/outputTemplate"] (URI string) | _meta.ui.resourceUri (URI string) |
_meta["openai/widgetAccessible"]: true | _meta.ui.visibility: ["widget"] |
Server: Class & Helpers
| Before | After |
|---|---|
import { Server } from "@modelcontextprotocol/sdk/server/index.js" | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" |
new Server({ name, version }, { capabilities }) | new McpServer({ name, version }) |
server.setRequestHandler(ListToolsRequestSchema, ...) | server.tool(name, desc, schema, handler) or registerAppTool(...) |
server.setRequestHandler(ReadResourceRequestSchema, ...) | registerAppResource(...) |
| Manual tool/resource list handlers | Automatic via McpServer + helpers |
Server: Tool Registration
Widget tools (tools that render UI) use registerAppTool:
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const WIDGET_URI = "ui://myapp/widget.html";
registerAppResource(server, "Widget Name", WIDGET_URI, {
mimeType: RESOURCE_MIME_TYPE,
description: "Description of the widget",
}, async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(widgetPath, "utf-8");
return { contents: [{ uri: WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text: html }] };
});
registerAppTool(server, "show-widget", {
title: "Show Widget",
description: "Displays the widget",
inputSchema: {
filter: z.string().optional().describe("Optional filter"),
},
annotations: { readOnlyHint: true },
_meta: { ui: { resourceUri: WIDGET_URI } },
}, async ({ filter }): Promise<CallToolResult> => {
const data = await fetchData(filter);
return {
content: [{ type: "text", text: `Loaded ${data.length} items.` }],
structuredContent: { items: data },
};
});
Data-only tools (no UI) use server.tool() directly:
server.tool("update-item", "Updates an item.", {
id: z.string().describe("Item ID"),
status: z.string().describe("New status"),
}, async ({ id, status }) => {
await db.update(id, { status });
return { content: [{ type: "text" as const, text: `Updated ${id}.` }] };
});
Client (Widget): Global API
OpenAI (window.openai) | MCP Apps (App from @modelcontextprotocol/ext-apps) |
|---|---|
window.openai.toolOutput | app.ontoolresult = (result) => result.structuredContent |
window.openai.callTool(name, args) | app.callServerTool({ name, arguments: args }) |
window.openai.theme ("light" / "dark") | app.getHostContext()?.theme ("light" / "dark") |
window.openai.displayMode | app.getHostContext()?.displayMode |
window.openai.requestDisplayMode(mode) | app.requestDisplayMode(mode) |
window.addEventListener("openai:set_globals", ...) | app.onhostcontextchanged = (ctx) => { ... } |
| N/A | app.onteardown = () => { ... } |
Client (Widget): React Hook
| Before | After |
|---|---|
useOpenAiGlobal("toolOutput") | useMcpToolData<T>() (custom hook wrapping useApp) |
useOpenAiGlobal("theme") | useMcpTheme() (custom hook returning "light" / "dark") |
Step-by-Step Migration Process
1. Update Dependencies
Server package.json — add:
"@modelcontextprotocol/ext-apps": "^1.0.0",
"zod": "^3.25.0"
Widgets package.json — add:
"@modelcontextprotocol/ext-apps": "^1.0.0"
2. Create MCP Apps React Context (Widgets)
Create a shared hook file (e.g. hooks/useMcpApp.tsx) that wraps the App class:
import React, { createContext, useContext, useEffect, useState, useRef } from "react";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
interface McpAppContextValue {
app: ReturnType<typeof useApp>;
toolData: unknown;
theme: "light" | "dark";
hostContext: { theme?: string; displayMode?: string } | null;
}
const McpAppContext = createContext<McpAppContextValue | null>(null);
export function McpAppProvider({ name, children }: { name: string; children: React.ReactNode }) {
const app = useApp({ name });
const [toolData, setToolData] = useState<unknown>(null);
const [theme, setTheme] = useState<"light" | "dark">("light");
const [hostContext, setHostContext] = useState<{ theme?: string; displayMode?: string } | null>(null);
useEffect(() => {
app.ontoolresult = (result: any) => {
if (result?.structuredContent) setToolData(result.structuredContent);
};
app.onhostcontextchanged = (ctx: any) => {
setHostContext(ctx);
if (ctx?.theme === "dark" || ctx?.theme === "light") setTheme(ctx.theme);
};
const initial = app.getHostContext?.();
if (initial) {
setHostContext(initial);
if (initial.theme === "dark" || initial.theme === "light") setTheme(initial.theme);
}
}, [app]);
return (
<McpAppContext.Provider value={{ app, toolData, theme, hostContext }}>
{children}
</McpAppContext.Provider>
);
}
export function useMcpApp() {
const ctx = useContext(McpAppContext);
if (!ctx) throw new Error("useMcpApp must be used within McpAppProvider");
return ctx;
}
export function useMcpToolData<T = unknown>(): T | null {
const { toolData } = useMcpApp();
return toolData as T | null;
}
export function useMcpTheme(): "light" | "dark" {
const { toolData, theme } = useMcpApp();
return theme;
}
3. Update Widget Entry Points (main.tsx)
Wrap the app in <McpAppProvider> instead of reading window.openai:
import { McpAppProvider, useMcpTheme } from "../hooks/useMcpApp";
function ThemedApp() {
const theme = useMcpTheme();
return (
<FluentProvider theme={theme === "dark" ? webDarkTheme : webLightTheme}>
<MyWidget />
</FluentProvider>
);
}
createRoot(document.getElementById("root")!).render(
<McpAppProvider name="My Widget">
<ThemedApp />
</McpAppProvider>
);
4. Update Widget Components
Replace all window.openai references:
// BEFORE
const toolOutput = useOpenAiGlobal("toolOutput");
window.openai.callTool("update-item", { id: "1", status: "done" });
window.openai.requestDisplayMode(
window.openai.displayMode === "expanded" ? "default" : "expanded"
);
// AFTER
const toolData = useMcpToolData<MyDataType>();
const { app, hostContext } = useMcpApp();
app.callServerTool({ name: "update-item", arguments: { id: "1", status: "done" } });
app.requestDisplayMode(
hostContext?.displayMode === "expanded" ? "default" : "expanded"
);
5. Rewrite Server (mcp-server.ts)
- Replace
ServerwithMcpServer - Replace manual
setRequestHandlerwithregisterAppTool/registerAppResource/server.tool() - Use
RESOURCE_MIME_TYPEinstead of"text/html+skybridge" - Use
zodschemas for tool input definitions - Return
structuredContent(object) alongsidecontent(text array) from widget tools
6. Update Server Entry Point (index.ts)
Switch from server.connect(transport) with a low-level Server to McpServer:
import { createMcpServer } from "./mcp-server.js";
app.all("/mcp", async (req, res) => {
const server = createMcpServer(); // returns McpServer
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
7. Build & Test
npm run install:all
npm run build:widgets
npm run dev:server
Verify with the MCP Inspector (npx @modelcontextprotocol/inspector) or connect from a host like Claude.
Common Pitfalls
| Issue | Fix |
|---|---|
window.openai is undefined | You missed replacing a window.openai reference in a widget component |
| Widget shows but no data | Ensure structuredContent is returned from the tool handler (not just content) |
| Theme not updating | Wire up app.onhostcontextchanged and call setTheme() |
registerAppTool type errors | Import from @modelcontextprotocol/ext-apps/server, use zod for inputSchema |
| SSE gateway errors | Set enableJsonResponse: true on StreamableHTTPServerTransport |
| Resource not found by host | Ensure the resourceUri in _meta.ui exactly matches the URI in registerAppResource |
Files Typically Changed
| File | Change |
|---|---|
server/package.json | Add @modelcontextprotocol/ext-apps, zod |
widgets/package.json | Add @modelcontextprotocol/ext-apps |
server/src/mcp-server.ts | Full rewrite: McpServer + registerAppTool + registerAppResource |
server/src/index.ts | Update imports, createMcpServer() now returns McpServer |
widgets/src/hooks/useMcpApp.tsx | New file: MCP Apps React context |
widgets/src/hooks/useThemeColors.ts | Update import to use useMcpTheme |
widgets/src/**/main.tsx | Wrap in McpAppProvider, use useMcpTheme |
widgets/src/**/*.tsx | Replace all window.openai.* calls |
widgets/src/hooks/useOpenAiGlobal.ts | Can be deleted after migration |