Awesome-omni-skill 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.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/mcpserver-migrate-mcpapps" ~/.claude/skills/diegosouzapw-awesome-omni-skill-mcpserver-migrate-mcpapps && rm -rf "$T"
manifest: skills/development/mcpserver-migrate-mcpapps/SKILL.md
source content

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

PackageWherePurpose
@modelcontextprotocol/ext-apps
Server + WidgetsCore MCP Apps SDK
@modelcontextprotocol/ext-apps/server
Server
registerAppTool
,
registerAppResource
,
RESOURCE_MIME_TYPE
@modelcontextprotocol/ext-apps/react
Widgets
useApp
,
useHostStyleVariables
,
useDocumentTheme
,
useHostFonts
@modelcontextprotocol/sdk
ServerMCP protocol SDK (keep existing)
zod
ServerSchema definitions for
McpServer.tool()

Migration Mapping

MIME Type

BeforeAfter
text/html+skybridge
text/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
:

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

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:

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:

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

IssueFix
window.openai is undefined
You 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 errors
Import 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 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 toggleAdd
useEffect
to sync
isFullscreen
from
hostContext.displayMode
— otherwise only browser
fullscreenchange
events update it

Files Typically Changed

FileChange
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