Skill v1.0.0
currentTrusted Publisher100/100version: "1.0.0" name: mcpserver-migrate-mcpapps 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/api/documents/Migrate_OpenAI_App.html
- Patterns: https://modelcontextprotocol.github.io/ext-apps/api/documents/Patterns.html
- Quickstart: https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html
- React module: https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html
- Examples: https://github.com/modelcontextprotocol/ext-apps/tree/main/examples
Packages
| Package | Where | Purpose | |
|---|---|---|---|
@modelcontextprotocol/ext-apps | Server + Widgets | Core MCP Apps SDK | |
@modelcontextprotocol/ext-apps/server | Server | registerAppTool, registerAppResource, RESOURCE_MIME_TYPE | |
@modelcontextprotocol/ext-apps/react | Widgets | useApp, useHostStyleVariables, useDocumentTheme, useHostFonts | |
@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: ["app"] (visible to app only, hidden from model) | |
_meta["openai/visibility"]: "public" | _meta.ui.visibility: ["app", "model"] (visible to both) |
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) | await 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) | await app.requestDisplayMode({ mode }) (takes { mode: string }) | |
window.openai.locale | app.getHostContext()?.locale | |
window.openai.maxHeight | app.getHostContext()?.viewport?.maxHeight | |
window.openai.safeArea | app.getHostContext()?.safeAreaInsets | |
window.openai.sendFollowUpMessage({ prompt }) | await app.sendMessage({ role: "user", content: [{ type: "text", text: prompt }] }) | |
window.openai.openExternal({ href }) | await app.openLink({ url: href }) | |
window.openai.notifyIntrinsicHeight(height) | app.sendSizeChanged({ width, height }) (auto by default via autoResize) | |
window.addEventListener("openai:set_globals", ...) | app.onhostcontextchanged = (ctx) => { ... } | |
| N/A | app.ontoolinputpartial = (params) => { ... } (streaming partial args) | |
| N/A | app.ontoolcancelled = (params) => { ... } | |
| N/A | app.onteardown = async () => { ... } | |
| N/A | await app.updateModelContext({ content: [...] }) |
Client (Widget): React Hook
| Before | After | |
|---|---|---|
useOpenAiGlobal("toolOutput") | useMcpToolData<T>() (custom hook wrapping useApp) | |
useOpenAiGlobal("theme") | useMcpTheme() (custom hook returning "light" / "dark") |
Client (Widget): useApp Hook API
The useApp hook from @modelcontextprotocol/ext-apps/react has the following signature:
interface UseAppOptions {appInfo: { name: string; version: string };capabilities: McpUiAppCapabilities; // usually {}onAppCreated?: (app: App) => void; // register handlers BEFORE connect}interface AppState {app: App | null; // null while connectingisConnected: boolean;error: Error | null;}function useApp(options: UseAppOptions): AppState;
Important: Event handlers (ontoolresult, onhostcontextchanged, ontoolinput, etc.) must be set in the onAppCreated callback, which fires before the connection handshake. The app in the returned AppState is App | null, so always use optional chaining (app?.callServerTool(...)).
Client (Widget): Built-in React Hooks
The SDK also provides these hooks (no custom wrapper needed):
| Hook | Purpose | ||
|---|---|---|---|
useHostStyleVariables(app) | Applies host CSS variables + theme to <html> | ||
useDocumentTheme(app) | Reactive "light" \ | "dark" from host context | |
useHostFonts(app) | Injects host font @font-face CSS | ||
useAutoResize(app) | Manual control when App is created outside useApp |
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 useApp hook.
Key API rules:
useApptakes{ appInfo: { name, version }, capabilities: {}, onAppCreated? }- Event handlers (
ontoolresult,onhostcontextchanged) must be registered inonAppCreated onAppCreatedfires before connection, so use refs to bridge into React stateuseAppreturns{ app: App | null, isConnected: boolean, error: Error | null }
import React, { createContext, useContext, useEffect, useRef, useState } from "react";import { useApp, type McpUiHostContext } from "@modelcontextprotocol/ext-apps/react";import type { App } from "@modelcontextprotocol/ext-apps";interface McpAppContextValue {app: App | null;isConnected: boolean;toolData: unknown;theme: "light" | "dark";hostContext: McpUiHostContext | undefined;}const McpAppContext = createContext<McpAppContextValue | null>(null);export function McpAppProvider({ name, children }: { name: string; children: React.ReactNode }) {const [toolData, setToolData] = useState<unknown>(null);const [theme, setTheme] = useState<"light" | "dark">("light");const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>(undefined);// Refs so the onAppCreated callback can update React stateconst setToolDataRef = useRef(setToolData);const setThemeRef = useRef(setTheme);const setHostContextRef = useRef(setHostContext);setToolDataRef.current = setToolData;setThemeRef.current = setTheme;setHostContextRef.current = setHostContext;const { app, isConnected, error } = useApp({appInfo: { name, version: "1.0.0" },capabilities: {},onAppCreated: (app) => {app.ontoolresult = (result) => {if (result?.structuredContent) {setToolDataRef.current(result.structuredContent);}};app.onhostcontextchanged = (ctx) => {setHostContextRef.current((prev) => ({ ...prev, ...ctx }));if (ctx?.theme === "dark" || ctx?.theme === "light") {setThemeRef.current(ctx.theme);}};},});// Set initial host context after connectionuseEffect(() => {if (app) {const initial = app.getHostContext();if (initial) {setHostContext(initial);if (initial.theme === "dark" || initial.theme === "light") {setTheme(initial.theme);}}}}, [app]);return (<McpAppContext.Provider value={{ app, isConnected, 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 { 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:
// BEFOREconst toolOutput = useOpenAiGlobal("toolOutput");window.openai.callTool("update-item", { id: "1", status: "done" });window.openai.requestDisplayMode(window.openai.displayMode === "expanded" ? "default" : "expanded");// AFTERconst toolData = useMcpToolData<MyDataType>();const { app, hostContext } = useMcpApp();// app is App | null — use optional chainingawait app?.callServerTool({ name: "update-item", arguments: { id: "1", status: "done" } });await app?.requestDisplayMode({mode: hostContext?.displayMode === "fullscreen" ? "inline" : "fullscreen"});
Note: requestDisplayMode takes an object { mode: string }, not a raw string. Display modes are "inline" | "fullscreen" | "pip". Check hostContext?.availableDisplayModes before requesting.
Fullscreen toggle checklist:
useCallbackdeps must includeappandhostContext?.displayMode— an empty[]captures the initialnull/undefinedvalues and the MCP SDK path silently fails every time- Guard with
if (app)(not optional chainingapp?.requestDisplayMode(...)) so a missingappfalls through to the browser fallback instead of returningundefinedand exiting - Sync
isFullscreenstate fromhostContext.displayModevia auseEffect— otherwise the button icon won't update when the host confirms the mode change
// Sync fullscreen state from MCP host context changesuseEffect(() => {if (hostContext?.displayMode !== undefined) {setIsFullscreen(hostContext.displayMode === "fullscreen");}}, [hostContext?.displayMode]);const toggleFullscreen = useCallback(async () => {// 1. MCP Apps SDKtry {if (app) {const current = hostContext?.displayMode;await app.requestDisplayMode({ mode: current === "fullscreen" ? "inline" : "fullscreen" });return;}} catch { /* not available */ }// 2. Browser Fullscreen APItry {if (!document.fullscreenElement) {await document.documentElement.requestFullscreen();} else {await document.exitFullscreen();}return;} catch { /* sandboxed */ }// 3. CSS fallbacksetIsFullscreen((prev) => !prev);}, [app, hostContext?.displayMode]);
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 McpServerconst transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined,enableJsonResponse: true,});await server.connect(transport);await transport.handleRequest(req, res, req.body);});
7. Build & Test
npm run install:allnpm run build:widgetsnpm 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 in onAppCreated callback 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 | ||
useApp({ name }) type errors | Must use useApp({ appInfo: { name, version }, capabilities: {} }) — not { name } | ||
app.ontoolresult = ... fails | Event handlers must be registered inside onAppCreated, not on the AppState return value | ||
app.requestDisplayMode("fullscreen") | Takes { mode: "fullscreen" } — an object, not a raw string | ||
app is null at call site | useApp returns `{ app: App \ | null } — use optional chaining: app?.callServerTool(...)` | |
visibility: ["widget"] | The correct value is ["app"], not ["widget"] | ||
| Fullscreen button does nothing | useCallback deps must include app and hostContext?.displayMode — empty [] causes stale closure where app is always null | ||
| Fullscreen icon doesn't toggle | Add useEffect to sync isFullscreen from hostContext.displayMode — otherwise only browser fullscreenchange events update it |
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 |