mcpserver-migrate-mcpapps

star 1

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.

rabwill By rabwill schedule Updated 2/27/2026

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

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:

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

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:

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

Widgets package.json — add:

"@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 }
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:

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:

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

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
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
Install via CLI
npx skills add https://github.com/rabwill/mcp-interactive-ui-samples --skill mcpserver-migrate-mcpapps
Repository Details
star Stars 1
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator