APM

>Agent Skill

@microsoft/mcpserver-migrate-mcpapps

skilldevelopment

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.

reactgitapi-designdocumentation
apm::install
$apm install @microsoft/mcpserver-migrate-mcpapps
apm::skill.md
---
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

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