Skip to content
mcpserver logo

Skill: Migrate OpenAI Apps SDK → MCP Apps

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.

SKILL.md

Full skill instructions

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-appsServer + WidgetsCore MCP Apps SDK
@modelcontextprotocol/​sdkServerMCP protocol SDK (keep existing)
zodServerSchema definitions for McpServer.tool()

Migration Mapping

MIME Type

BeforeAfter
text/​html+skybridgetext/​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: ["widget"]

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.toolOutputapp.ontoolresult = (result) => result.structuredContent
window.openai.callTool(name, args)app.callServerTool({ name, arguments: args })
window.openai.theme ("light" / "dark")app.getHostContext()?.theme ("light" / "dark")
window.openai.displayModeapp.getHostContext()?.displayMode
window.openai.requestDisplayMode(mode)app.requestDisplayMode(mode)
window.addEventListener("openai:set_globals", ...)app.onhostcontextchanged = (ctx) => { ... }
N/​Aapp.onteardown = () => { ... }

Client (Widget): React Hook

BeforeAfter
useOpenAiGlobal("toolOutput")useMcpToolData<T>() (custom hook wrapping useApp)
useOpenAiGlobal("theme")useMcpTheme() (custom hook returning "light" / "dark")

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 App class:

import React, { createContext, useContext, useEffect, useState, useRef } from "react";
import { useApp } from "@modelcontextprotocol/​ext-apps/​react";

interface McpAppContextValue {
  app: ReturnType<typeof useApp>;
  toolData: unknown;
  theme: "light" | "dark";
  hostContext: { theme?: string; displayMode?: string } | null;
}

const McpAppContext = createContext<McpAppContextValue | null>(null);

export function McpAppProvider({ name, children }: { name: string; children: React.ReactNode }) {
  const app = useApp({ name });
  const [toolData, setToolData] = useState<unknown>(null);
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const [hostContext, setHostContext] = useState<{ theme?: string; displayMode?: string } | null>(null);

  useEffect(() => {
    app.ontoolresult = (result: any) => {
      if (result?.structuredContent) setToolData(result.structuredContent);
    };
    app.onhostcontextchanged = (ctx: any) => {
      setHostContext(ctx);
      if (ctx?.theme === "dark" || ctx?.theme === "light") setTheme(ctx.theme);
    };
    const initial = app.getHostContext?.();
    if (initial) {
      setHostContext(initial);
      if (initial.theme === "dark" || initial.theme === "light") setTheme(initial.theme);
    }
  }, [app]);

  return (
    <McpAppContext.Provider value={{ app, 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 { toolData, 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.callServerTool({ name: "update-item", arguments: { id: "1", status: "done" } });
app.requestDisplayMode(
  hostContext?.displayMode === "expanded" ? "default" : "expanded"
);

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 undefinedYou 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 and call setTheme()
registerAppTool type errorsImport 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

Files Typically Changed

FileChange
server/​package.jsonAdd @modelcontextprotocol/​ext-apps, zod
widgets/​package.jsonAdd @modelcontextprotocol/​ext-apps
server/​src/​mcp-server.tsFull rewrite: McpServer + registerAppTool + registerAppResource
server/​src/​index.tsUpdate imports, createMcpServer() now returns McpServer
widgets/​src/​hooks/​useMcpApp.tsxNew file: MCP Apps React context
widgets/​src/​hooks/​useThemeColors.tsUpdate import to use useMcpTheme
widgets/​src/​**/​main.tsxWrap in McpAppProvider, use useMcpTheme
widgets/​src/​**/​*.tsxReplace all window.openai.* calls
widgets/​src/​hooks/​useOpenAiGlobal.tsCan be deleted after migration