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.

16 stars

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

$curl -o ~/.claude/skills/mcpserver-migrate-mcpapps/SKILL.md --create-dirs "https://raw.githubusercontent.com/diegosouzapw/awesome-omni-skill/main/skills/development/mcpserver-migrate-mcpapps/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/mcpserver-migrate-mcpapps/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How mcpserver-migrate-mcpapps Compares

Feature / Agentmcpserver-migrate-mcpappsStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/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

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

Migrate a project into another, ensuring functionality and validating complete content transfer. Use for monorepo consolidation, template upgrades, or codebase mergers.

migrate-to-skills

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

Migrate existing AI instruction files to PromptScript format

bgo

10
from diegosouzapw/awesome-omni-skill

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.

Coding & Development

modern-python-standards

16
from diegosouzapw/awesome-omni-skill

Strict adherence to modern (3.11+), idiomatic, and type-safe Python development.

modern-python

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

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

16
from diegosouzapw/awesome-omni-skill

React Native best practices, hooks, navigation ve performance optimization.