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.
Best use case
mcpserver-migrate-mcpapps is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
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.
Teams using mcpserver-migrate-mcpapps should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/mcpserver-migrate-mcpapps/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How mcpserver-migrate-mcpapps Compares
| Feature / Agent | mcpserver-migrate-mcpapps | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
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.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# 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
| 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`:
```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.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:
```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):
| 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:
```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
| 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 |Related Skills
migrate
Guide migration to Astro from other frameworks or between Astro versions. Use when converting Next.js, Nuxt, Gatsby projects or upgrading Astro.
agent-ops-migrate
Migrate a project into another, ensuring functionality and validating complete content transfer. Use for monorepo consolidation, template upgrades, or codebase mergers.
migrate-to-skills
Convert 'Applied intelligently' Cursor rules (.cursor/rules/*.mdc) and slash commands (.cursor/commands/*.md) to Agent Skills format (.cursor/skills/). Use when the user wants to migrate rules or commands to skills, convert .mdc rules to SKILL.md format, or consolidate commands into the skills directory.
mcpserver
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.
migrate-to-promptscript
Migrate existing AI instruction files to PromptScript format
bgo
Automates the complete Blender build-go workflow, from building and packaging your extension/add-on to removing old versions, installing, enabling, and launching Blender for quick testing and iteration.
modern-python-standards
Strict adherence to modern (3.11+), idiomatic, and type-safe Python development.
modern-python
Modern Python tooling best practices using uv, ruff, ty, and pytest. Mandates the Trail of Bits Python coding standards for project setup, dependency management, linting, type checking, and testing. Based on patterns from trailofbits/cookiecutter-python.
modern-javascript-patterns
Master ES6+ features including async/await, destructuring, spread operators, arrow functions, promises, modules, iterators, generators, and functional programming patterns for writing clean, effici...
modern-java-backend-playbook
Enforces backend Java/Quarkus project standards including architecture layers, design patterns, code reuse, Lombok, TDD, exception handling, and modern Java features. Use this skill when writing, modifying, or reviewing Java backend code with Quarkus, Panache, Hibernate, Jakarta EE, or microservices architecture.
mocking-assistant
Creates stable mocks for APIs, services, and UI components using MSW (Mock Service Worker), fixture conventions, and example patterns. Use for "API mocking", "MSW", "test mocks", or "service mocking".
mobile_react_native
React Native best practices, hooks, navigation ve performance optimization.