<< All versions

Skill v1.0.0

currentTrusted Publisher100/100
microsoft/mcp-interactiveui-samples/mcpserver-migrate-mcpapps
──Details
PublishedSeptember 30, 2026 at 07:13 AM
Content Hashsha256:a0e5b4fcef5d562f...
Git SHA
──Files
Files (1 file, 17.1 KB)
SKILL.md17.1 KBactive
SKILL.md · 431 lines · 17.1 KB

version: "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+skybridge MIME type for widget resources
  • Widget code references window.openai globals (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

PackageWherePurpose
@modelcontextprotocol/ext-appsServer + WidgetsCore MCP Apps SDK
@modelcontextprotocol/ext-apps/serverServerregisterAppTool, registerAppResource, RESOURCE_MIME_TYPE
@modelcontextprotocol/ext-apps/reactWidgetsuseApp, useHostStyleVariables, useDocumentTheme, useHostFonts
@modelcontextprotocol/sdkServerMCP protocol SDK (keep existing)
zodServerSchema definitions for McpServer.tool()

Migration Mapping

MIME Type

BeforeAfter
text/html+skybridgetext/html;profile=mcp-app (use RESOURCE_MIME_TYPE constant)

Server: _meta Keys

OpenAI flat keyMCP 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

BeforeAfter
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 handlersAutomatic via McpServer + helpers

Server: Tool Registration

Widget tools (tools that render UI) use registerAppTool:

typescript
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:

typescript
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.toolOutputapp.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.displayModeapp.getHostContext()?.displayMode
window.openai.requestDisplayMode(mode)await app.requestDisplayMode({ mode }) (takes { mode: string })
window.openai.localeapp.getHostContext()?.locale
window.openai.maxHeightapp.getHostContext()?.viewport?.maxHeight
window.openai.safeAreaapp.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/Aapp.ontoolinputpartial = (params) => { ... } (streaming partial args)
N/Aapp.ontoolcancelled = (params) => { ... }
N/Aapp.onteardown = async () => { ... }
N/Aawait app.updateModelContext({ content: [...] })

Client (Widget): React Hook

BeforeAfter
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:

typescript
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):

HookPurpose
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:

json
"@modelcontextprotocol/ext-apps": "^1.0.0",
"zod": "^3.25.0"

Widgets `package.json` — add:

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:

  • useApp takes { appInfo: { name, version }, capabilities: {}, onAppCreated? }
  • Event handlers (ontoolresult, onhostcontextchanged) must be registered in onAppCreated
  • onAppCreated fires before connection, so use refs to bridge into React state
  • useApp returns { app: App | null, isConnected: boolean, error: Error | null }
tsx
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)

Wrap the app in <McpAppProvider> instead of reading window.openai:

tsx
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:

typescript
// 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:

  • useCallback deps must include app and hostContext?.displayMode — an empty [] captures the initial null/undefined values and the MCP SDK path silently fails every time
  • Guard with if (app) (not optional chaining app?.requestDisplayMode(...)) so a missing app falls through to the browser fallback instead of returning undefined and exiting
  • Sync isFullscreen state from hostContext.displayMode via a useEffect — otherwise the button icon won't update when the host confirms the mode change
tsx
// 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)

  1. Replace Server with McpServer
  2. Replace manual setRequestHandler with registerAppTool / registerAppResource / server.tool()
  3. Use RESOURCE_MIME_TYPE instead of "text/html+skybridge"
  4. Use zod schemas for tool input definitions
  5. Return structuredContent (object) alongside content (text array) from widget tools

6. Update Server Entry Point (index.ts)

Switch from server.connect(transport) with a low-level Server to McpServer:

typescript
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

bash
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

IssueFix
window.openai is undefinedYou missed replacing a window.openai reference in a widget component
Widget shows but no dataEnsure structuredContent is returned from the tool handler (not just content)
Theme not updatingWire up app.onhostcontextchanged in onAppCreated callback and call setTheme()
registerAppTool type errorsImport from @modelcontextprotocol/ext-apps/server, use zod for inputSchema
SSE gateway errorsSet enableJsonResponse: true on StreamableHTTPServerTransport
Resource not found by hostEnsure the resourceUri in _meta.ui exactly matches the URI in registerAppResource
useApp({ name }) type errorsMust use useApp({ appInfo: { name, version }, capabilities: {} }) — not { name }
app.ontoolresult = ... failsEvent 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 siteuseApp returns `{ app: App \null } — use optional chaining: app?.callServerTool(...)`
visibility: ["widget"]The correct value is ["app"], not ["widget"]
Fullscreen button does nothinguseCallback deps must include app and hostContext?.displayMode — empty [] causes stale closure where app is always null
Fullscreen icon doesn't toggleAdd useEffect to sync isFullscreen from hostContext.displayMode — otherwise only browser fullscreenchange events update it

Files Typically Changed

FileChange
server/package.jsonAdd @modelcontextprotocol/ext-apps, zod
widgets/package.jsonAdd @modelcontextprotocol/ext-apps
server/src/mcp-server.tsFull rewrite: McpServer + registerAppTool + registerAppResource
server/src/index.tsUpdate imports, createMcpServer() now returns McpServer
widgets/src/hooks/useMcpApp.tsxNew file: MCP Apps React context
widgets/src/hooks/useThemeColors.tsUpdate import to use useMcpTheme
widgets/src/**/main.tsxWrap in McpAppProvider, use useMcpTheme
widgets/src/**/*.tsxReplace all window.openai.* calls
widgets/src/hooks/useOpenAiGlobal.tsCan be deleted after migration
All versions