Awesome-omni-skill mcpserver-migrate-mcpapps
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.
install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/mcpserver-migrate-mcpapps" ~/.claude/skills/diegosouzapw-awesome-omni-skill-mcpserver-migrate-mcpapps && rm -rf "$T"
manifest:
skills/development/mcpserver-migrate-mcpapps/SKILL.mdsource content
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
MIME type for widget resourcestext/html+skybridge - Widget code references
globals (e.g.window.openai
,window.openai.callTool
,window.openai.toolOutput
)window.openai.theme - Server code uses flat
or_meta["openai/outputTemplate"]
keys_meta["openai/widgetAccessible"] - 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 |
|---|---|---|
| Server + Widgets | Core MCP Apps SDK |
| Server | , , |
| Widgets | , , , |
| Server | MCP protocol SDK (keep existing) |
| Server | Schema definitions for |
Migration Mapping
MIME Type
| Before | After |
|---|---|
| (use constant) |
Server: _meta
Keys
_meta| OpenAI flat key | MCP Apps nested key |
|---|---|
(URI string) | (URI string) |
| (visible to app only, hidden from model) |
| (visible to both) |
Server: Class & Helpers
| Before | After |
|---|---|
| |
| |
| or |
| |
| Manual tool/resource list handlers | Automatic via + 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 () | MCP Apps ( from ) |
|---|---|
| |
| |
( / ) | ( / ) |
| |
| (takes ) |
| |
| |
| |
| |
| |
| (auto by default via ) |
| |
| N/A | (streaming partial args) |
| N/A | |
| N/A | |
| N/A | |
Client (Widget): React Hook
| Before | After |
|---|---|
| (custom hook wrapping ) |
| (custom hook returning / ) |
Client (Widget): useApp
Hook API
useAppThe
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 connecting isConnected: 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 |
|---|---|
| Applies host CSS variables + theme to |
| Reactive | from host context |
| Injects host font CSS |
| Manual control when App is created outside |
Step-by-Step Migration Process
1. Update Dependencies
Server
— add:package.json
"@modelcontextprotocol/ext-apps": "^1.0.0", "zod": "^3.25.0"
Widgets
— add:package.json
"@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:
takesuseApp{ appInfo: { name, version }, capabilities: {}, onAppCreated? }- Event handlers (
,ontoolresult
) must be registered inonhostcontextchangedonAppCreated
fires before connection, so use refs to bridge into React stateonAppCreated
returnsuseApp{ 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 state const 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 connection useEffect(() => { 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
)
main.tsxWrap 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 is App | null — use optional chaining await 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:
deps must includeuseCallback
andapp
— an emptyhostContext?.displayMode
captures the initial[]
/null
values and the MCP SDK path silently fails every timeundefined- Guard with
(not optional chainingif (app)
) so a missingapp?.requestDisplayMode(...)
falls through to the browser fallback instead of returningapp
and exitingundefined - Sync
state fromisFullscreen
via ahostContext.displayMode
— otherwise the button icon won't update when the host confirms the mode changeuseEffect
// Sync fullscreen state from MCP host context changes useEffect(() => { if (hostContext?.displayMode !== undefined) { setIsFullscreen(hostContext.displayMode === "fullscreen"); } }, [hostContext?.displayMode]); const toggleFullscreen = useCallback(async () => { // 1. MCP Apps SDK try { if (app) { const current = hostContext?.displayMode; await app.requestDisplayMode({ mode: current === "fullscreen" ? "inline" : "fullscreen" }); return; } } catch { /* not available */ } // 2. Browser Fullscreen API try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); } else { await document.exitFullscreen(); } return; } catch { /* sandboxed */ } // 3. CSS fallback setIsFullscreen((prev) => !prev); }, [app, hostContext?.displayMode]);
5. Rewrite Server (mcp-server.ts
)
mcp-server.ts- Replace
withServerMcpServer - Replace manual
withsetRequestHandler
/registerAppTool
/registerAppResourceserver.tool() - Use
instead ofRESOURCE_MIME_TYPE"text/html+skybridge" - Use
schemas for tool input definitionszod - Return
(object) alongsidestructuredContent
(text array) from widget toolscontent
6. Update Server Entry Point (index.ts
)
index.tsSwitch 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 |
|---|---|
| You missed replacing a reference in a widget component |
| Widget shows but no data | Ensure is returned from the tool handler (not just ) |
| Theme not updating | Wire up in callback and call |
type errors | Import from , use for |
| SSE gateway errors | Set on |
| Resource not found by host | Ensure the in exactly matches the URI in |
type errors | Must use — not |
fails | Event handlers must be registered inside , not on the return value |
| Takes — an object, not a raw string |
is null at call site | returns — use optional chaining: |
| The correct value is , not |
| Fullscreen button does nothing | deps must include and — empty causes stale closure where is always |
| Fullscreen icon doesn't toggle | Add to sync from — otherwise only browser events update it |
Files Typically Changed
| File | Change |
|---|---|
| Add , |
| Add |
| Full rewrite: + + |
| Update imports, now returns |
| New file: MCP Apps React context |
| Update import to use |
| Wrap in , use |
| Replace all calls |
| Can be deleted after migration |