From 4ea90d19325b6747d8be00f0a4c10ac10ef21d1c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 16 Dec 2025 22:29:04 +0000 Subject: [PATCH 01/10] feat: experimental OpenAI Apps SDK compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transparent support for OpenAI's Apps SDK environment alongside MCP. - `transport.ts` - OpenAITransport implementing MCP Transport interface - `types.ts` - TypeScript types for OpenAI Apps SDK (`window.openai`) - `transport.test.ts` - Comprehensive tests - Add `experimentalOAICompatibility` option (default: `true`) - Auto-detect platform: check for `window.openai` → use OpenAI, else MCP - `connect()` creates appropriate transport automatically - Add `experimentalOAICompatibility` prop to `UseAppOptions` - Pass through to App constructor Apps work transparently in both environments: ```typescript // Works in both MCP hosts and ChatGPT const app = new App(appInfo, capabilities); await app.connect(); // Auto-detects platform // Force MCP-only mode const app = new App(appInfo, capabilities, { experimentalOAICompatibility: false }); ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app.ts | 89 ++++-- src/openai/transport.test.ts | 354 +++++++++++++++++++++++ src/openai/transport.ts | 538 +++++++++++++++++++++++++++++++++++ src/openai/types.ts | 244 ++++++++++++++++ src/react/useApp.tsx | 62 ++-- 5 files changed, 1243 insertions(+), 44 deletions(-) create mode 100644 src/openai/transport.test.ts create mode 100644 src/openai/transport.ts create mode 100644 src/openai/types.ts diff --git a/src/app.ts b/src/app.ts index 129b5802..7d31858d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,7 +16,6 @@ import { PingRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; -import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, @@ -47,8 +46,12 @@ import { McpUiRequestDisplayModeResultSchema, } from "./types"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { PostMessageTransport } from "./message-transport"; +import { OpenAITransport, isOpenAIEnvironment } from "./openai/transport.js"; export { PostMessageTransport } from "./message-transport"; +export { OpenAITransport, isOpenAIEnvironment } from "./openai/transport"; +export * from "./openai/types"; export * from "./types"; export { applyHostStyleVariables, @@ -101,7 +104,7 @@ export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; * * @see ProtocolOptions from @modelcontextprotocol/sdk for inherited options */ -type AppOptions = ProtocolOptions & { +export type AppOptions = ProtocolOptions & { /** * Automatically report size changes to the host using ResizeObserver. * @@ -112,6 +115,19 @@ type AppOptions = ProtocolOptions & { * @default true */ autoResize?: boolean; + + /** + * Enable experimental OpenAI compatibility. + * + * When enabled (default), the App will auto-detect the environment: + * - If `window.openai` exists → use OpenAI Apps SDK + * - Otherwise → use MCP Apps protocol via PostMessageTransport + * + * Set to `false` to force MCP-only mode. + * + * @default true + */ + experimentalOAICompatibility?: boolean; }; type RequestHandlerExtra = Parameters< @@ -220,7 +236,10 @@ export class App extends Protocol { constructor( private _appInfo: Implementation, private _capabilities: McpUiAppCapabilities = {}, - private options: AppOptions = { autoResize: true }, + private options: AppOptions = { + autoResize: true, + experimentalOAICompatibility: true, + }, ) { super(options); @@ -989,50 +1008,73 @@ export class App extends Protocol { return () => resizeObserver.disconnect(); } + /** + * Create the default transport based on detected platform. + * @internal + */ + private createDefaultTransport(): Transport { + const experimentalOAI = this.options?.experimentalOAICompatibility ?? true; + if (experimentalOAI && isOpenAIEnvironment()) { + return new OpenAITransport(); + } + return new PostMessageTransport(window.parent, window.parent); + } + /** * Establish connection with the host and perform initialization handshake. * * This method performs the following steps: - * 1. Connects the transport layer - * 2. Sends `ui/initialize` request with app info and capabilities - * 3. Receives host capabilities and context in response - * 4. Sends `ui/notifications/initialized` notification - * 5. Sets up auto-resize using {@link setupSizeChangedNotifications} if enabled (default) + * 1. Auto-detects platform if no transport is provided + * 2. Connects the transport layer + * 3. Sends `ui/initialize` request with app info and capabilities + * 4. Receives host capabilities and context in response + * 5. Sends `ui/notifications/initialized` notification + * 6. Sets up auto-resize using {@link setupSizeChangedNotifications} if enabled (default) + * 7. For OpenAI mode: delivers initial tool input/result from window.openai * * If initialization fails, the connection is automatically closed and an error * is thrown. * - * @param transport - Transport layer (typically PostMessageTransport) + * @param transport - Optional transport layer. If not provided, auto-detects + * based on the `platform` option: + * - `'openai'` or `window.openai` exists → uses {@link OpenAITransport} + * - `'mcp'` or no `window.openai` → uses {@link PostMessageTransport} * @param options - Request options for the initialize request * * @throws {Error} If initialization fails or connection is lost * - * @example Connect with PostMessageTransport + * @example Auto-detect platform (recommended) * ```typescript * const app = new App( * { name: "MyApp", version: "1.0.0" }, * {} * ); * - * try { - * await app.connect(new PostMessageTransport(window.parent)); - * console.log("Connected successfully!"); - * } catch (error) { - * console.error("Failed to connect:", error); - * } + * // Auto-detects: OpenAI if window.openai exists, MCP otherwise + * await app.connect(); + * ``` + * + * @example Explicit MCP transport + * ```typescript + * await app.connect(new PostMessageTransport(window.parent)); + * ``` + * + * @example Explicit OpenAI transport + * ```typescript + * await app.connect(new OpenAITransport()); * ``` * * @see {@link McpUiInitializeRequest} for the initialization request structure * @see {@link McpUiInitializedNotification} for the initialized notification - * @see {@link PostMessageTransport} for the typical transport implementation + * @see {@link PostMessageTransport} for MCP-compatible hosts + * @see {@link OpenAITransport} for OpenAI/ChatGPT hosts */ override async connect( - transport: Transport = new PostMessageTransport( - window.parent, - window.parent, - ), + transport?: Transport, options?: RequestOptions, ): Promise { + transport ??= this.createDefaultTransport(); + await super.connect(transport); try { @@ -1064,6 +1106,11 @@ export class App extends Protocol { if (this.options?.autoResize) { this.setupSizeChangedNotifications(); } + + // For OpenAI mode: deliver initial state from window.openai + if (transport instanceof OpenAITransport) { + transport.deliverInitialState(); + } } catch (error) { // Disconnect if initialization fails. void this.close(); diff --git a/src/openai/transport.test.ts b/src/openai/transport.test.ts new file mode 100644 index 00000000..01911e09 --- /dev/null +++ b/src/openai/transport.test.ts @@ -0,0 +1,354 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { OpenAITransport, isOpenAIEnvironment } from "./transport"; +import type { OpenAIGlobal, WindowWithOpenAI } from "./types"; + +describe("isOpenAIEnvironment", () => { + const originalWindow = globalThis.window; + + afterEach(() => { + // Restore original window + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = originalWindow; + } + }); + + test("returns false when window is undefined", () => { + delete (globalThis as { window?: unknown }).window; + expect(isOpenAIEnvironment()).toBe(false); + }); + + test("returns false when window.openai is undefined", () => { + (globalThis as { window?: unknown }).window = {}; + expect(isOpenAIEnvironment()).toBe(false); + }); + + test("returns true when window.openai is an object", () => { + (globalThis as { window?: unknown }).window = { + openai: {}, + }; + expect(isOpenAIEnvironment()).toBe(true); + }); +}); + +describe("OpenAITransport", () => { + let mockOpenAI: OpenAIGlobal; + + beforeEach(() => { + mockOpenAI = { + theme: "dark", + locale: "en-US", + displayMode: "inline", + maxHeight: 600, + toolInput: { location: "Tokyo" }, + toolOutput: { temperature: 22 }, + callTool: mock(() => + Promise.resolve({ content: { result: "success" } }), + ) as unknown as OpenAIGlobal["callTool"], + sendFollowUpMessage: mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["sendFollowUpMessage"], + openExternal: mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["openExternal"], + notifyIntrinsicHeight: mock( + () => {}, + ) as unknown as OpenAIGlobal["notifyIntrinsicHeight"], + }; + + (globalThis as { window?: unknown }).window = { + openai: mockOpenAI, + }; + }); + + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + }); + + test("throws when window.openai is not available", () => { + delete (globalThis as { window?: unknown }).window; + expect(() => new OpenAITransport()).toThrow( + "OpenAITransport requires window.openai", + ); + }); + + test("constructs successfully when window.openai is available", () => { + const transport = new OpenAITransport(); + expect(transport).toBeDefined(); + }); + + test("start() completes without error", async () => { + const transport = new OpenAITransport(); + await expect(transport.start()).resolves.toBeUndefined(); + }); + + test("close() calls onclose callback", async () => { + const transport = new OpenAITransport(); + const onclose = mock(() => {}); + transport.onclose = onclose; + + await transport.close(); + + expect(onclose).toHaveBeenCalled(); + }); + + describe("ui/initialize request", () => { + test("returns synthesized host info from window.openai", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2025-11-21", + appInfo: { name: "TestApp", version: "1.0.0" }, + appCapabilities: {}, + }, + }); + + // Wait for microtask to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { + hostInfo: { name: "ChatGPT", version: "1.0.0" }, + hostContext: { + theme: "dark", + locale: "en-US", + displayMode: "inline", + }, + }, + }); + }); + }); + + describe("tools/call request", () => { + test("delegates to window.openai.callTool()", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { + name: "get_weather", + arguments: { location: "Tokyo" }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.callTool).toHaveBeenCalledWith("get_weather", { + location: "Tokyo", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 2, + result: expect.any(Object), + }); + }); + + test("returns error when callTool is not available", async () => { + delete mockOpenAI.callTool; + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "test_tool" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 3, + error: { + code: -32601, + message: expect.stringContaining("not supported"), + }, + }); + }); + }); + + describe("ui/message request", () => { + test("delegates to window.openai.sendFollowUpMessage()", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 4, + method: "ui/message", + params: { + role: "user", + content: [{ type: "text", text: "Hello!" }], + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.sendFollowUpMessage).toHaveBeenCalledWith({ + prompt: "Hello!", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 4, + result: {}, + }); + }); + }); + + describe("ui/open-link request", () => { + test("delegates to window.openai.openExternal()", async () => { + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 5, + method: "ui/open-link", + params: { url: "https://example.com" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.openExternal).toHaveBeenCalledWith({ + href: "https://example.com", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 5, + result: {}, + }); + }); + }); + + describe("ui/request-display-mode request", () => { + test("delegates to window.openai.requestDisplayMode()", async () => { + mockOpenAI.requestDisplayMode = mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["requestDisplayMode"]; + + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 6, + method: "ui/request-display-mode", + params: { mode: "fullscreen" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockOpenAI.requestDisplayMode).toHaveBeenCalledWith({ + mode: "fullscreen", + }); + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 6, + result: { mode: "fullscreen" }, + }); + }); + }); + + describe("ui/notifications/size-changed notification", () => { + test("delegates to window.openai.notifyIntrinsicHeight()", async () => { + const transport = new OpenAITransport(); + + await transport.send({ + jsonrpc: "2.0", + method: "ui/notifications/size-changed", + params: { width: 400, height: 300 }, + }); + + expect(mockOpenAI.notifyIntrinsicHeight).toHaveBeenCalledWith(300); + }); + }); + + describe("deliverInitialState", () => { + test("delivers tool input notification", async () => { + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolInputNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-input", + ); + expect(toolInputNotification).toMatchObject({ + jsonrpc: "2.0", + method: "ui/notifications/tool-input", + params: { arguments: { location: "Tokyo" } }, + }); + }); + + test("delivers tool result notification", async () => { + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ); + expect(toolResultNotification).toBeDefined(); + }); + + test("does not deliver notifications when data is missing", async () => { + delete mockOpenAI.toolInput; + delete mockOpenAI.toolOutput; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(messages).toHaveLength(0); + }); + }); +}); diff --git a/src/openai/transport.ts b/src/openai/transport.ts new file mode 100644 index 00000000..399ef949 --- /dev/null +++ b/src/openai/transport.ts @@ -0,0 +1,538 @@ +/** + * Transport adapter for OpenAI Apps SDK (window.openai) compatibility. + * + * This transport allows MCP Apps to run in OpenAI's ChatGPT environment by + * translating between the MCP Apps protocol and the OpenAI Apps SDK APIs. + * + * @see https://developers.openai.com/apps-sdk/build/chatgpt-ui/ + */ + +import { + JSONRPCMessage, + JSONRPCRequest, + JSONRPCNotification, + RequestId, +} from "@modelcontextprotocol/sdk/types.js"; +import { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; +import { OpenAIGlobal, getOpenAIGlobal, isOpenAIEnvironment } from "./types.js"; +import { LATEST_PROTOCOL_VERSION, McpUiHostContext } from "../spec.types.js"; + +/** + * JSON-RPC success response message. + * @internal + */ +interface JSONRPCSuccessResponse { + jsonrpc: "2.0"; + id: RequestId; + result: Record; +} + +/** + * JSON-RPC error response message. + * @internal + */ +interface JSONRPCErrorResponse { + jsonrpc: "2.0"; + id: RequestId; + error: { code: number; message: string; data?: unknown }; +} + +/** + * Check if a message is a JSON-RPC request (has method and id). + */ +function isRequest(message: JSONRPCMessage): message is JSONRPCRequest { + return "method" in message && "id" in message; +} + +/** + * Check if a message is a JSON-RPC notification (has method but no id). + */ +function isNotification( + message: JSONRPCMessage, +): message is JSONRPCNotification { + return "method" in message && !("id" in message); +} + +/** + * Transport implementation that bridges MCP Apps protocol to OpenAI Apps SDK. + * + * This transport enables MCP Apps to run seamlessly in ChatGPT by: + * - Synthesizing initialization responses from window.openai properties + * - Mapping tool calls to window.openai.callTool() + * - Mapping messages to window.openai.sendFollowUpMessage() + * - Mapping link opens to window.openai.openExternal() + * - Reporting size changes via window.openai.notifyIntrinsicHeight() + * + * ## Usage + * + * Typically you don't create this transport directly. The App will create + * it automatically when `experimentalOAICompatibility` is enabled (default) + * and `window.openai` is detected. + * + * ```typescript + * import { App } from '@modelcontextprotocol/ext-apps'; + * + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * await app.connect(); // Auto-detects OpenAI environment + * ``` + * + * ## Manual Usage + * + * For advanced use cases, you can create the transport directly: + * + * ```typescript + * import { App, OpenAITransport } from '@modelcontextprotocol/ext-apps'; + * + * const app = new App({ name: "MyApp", version: "1.0.0" }, {}); + * await app.connect(new OpenAITransport()); + * ``` + * + * @see {@link App.connect} for automatic transport selection + * @see {@link PostMessageTransport} for MCP-compatible hosts + */ +export class OpenAITransport implements Transport { + private openai: OpenAIGlobal; + private _closed = false; + + /** + * Create a new OpenAITransport. + * + * @throws {Error} If window.openai is not available + * + * @example + * ```typescript + * if (isOpenAIEnvironment()) { + * const transport = new OpenAITransport(); + * await app.connect(transport); + * } + * ``` + */ + constructor() { + const openai = getOpenAIGlobal(); + if (!openai) { + throw new Error( + "OpenAITransport requires window.openai to be available. " + + "This transport should only be used in OpenAI/ChatGPT environments.", + ); + } + this.openai = openai; + } + + /** + * Begin listening for messages. + * + * In OpenAI mode, there's no event-based message flow to start. + * The data is pre-populated in window.openai properties. + */ + async start(): Promise { + // Nothing to do - window.openai is already available and populated + } + + /** + * Send a JSON-RPC message. + * + * Requests are handled by mapping to window.openai methods. + * Notifications are handled for size changes; others are no-ops. + * + * @param message - JSON-RPC message to send + * @param _options - Send options (unused) + */ + async send( + message: JSONRPCMessage, + _options?: TransportSendOptions, + ): Promise { + if (this._closed) { + throw new Error("Transport is closed"); + } + + if (isRequest(message)) { + // Handle requests - map to window.openai methods and synthesize responses + const response = await this.handleRequest(message); + // Deliver response asynchronously to maintain message ordering + queueMicrotask(() => this.onmessage?.(response)); + } else if (isNotification(message)) { + // Handle notifications + this.handleNotification(message); + } + // Responses are ignored - we don't receive requests from OpenAI + } + + /** + * Handle an outgoing JSON-RPC request by mapping to window.openai. + */ + private async handleRequest( + request: JSONRPCRequest, + ): Promise { + const { method, id, params } = request; + + try { + switch (method) { + case "ui/initialize": + return this.handleInitialize(id); + + case "tools/call": + return await this.handleToolCall( + id, + params as { name: string; arguments?: Record }, + ); + + case "ui/message": + return await this.handleMessage( + id, + params as { role: string; content: unknown[] }, + ); + + case "ui/open-link": + return await this.handleOpenLink(id, params as { url: string }); + + case "ui/request-display-mode": + return await this.handleRequestDisplayMode( + id, + params as { mode: string }, + ); + + case "ping": + return this.createSuccessResponse(id, {}); + + default: + return this.createErrorResponse( + id, + -32601, + `Method not supported in OpenAI mode: ${method}`, + ); + } + } catch (error) { + return this.createErrorResponse( + id, + -32603, + error instanceof Error ? error.message : String(error), + ); + } + } + + /** + * Handle ui/initialize request by synthesizing response from window.openai. + */ + private handleInitialize(id: RequestId): JSONRPCSuccessResponse { + // Safely extract userAgent - could be string or object + let userAgent: string | undefined; + if (typeof this.openai.userAgent === "string") { + userAgent = this.openai.userAgent; + } else if ( + this.openai.userAgent && + typeof this.openai.userAgent === "object" + ) { + userAgent = JSON.stringify(this.openai.userAgent); + } + + // Safely extract safeAreaInsets - only include if all values are present + let safeAreaInsets: McpUiHostContext["safeAreaInsets"]; + const sa = this.openai.safeArea; + if ( + sa && + typeof sa.top === "number" && + typeof sa.right === "number" && + typeof sa.bottom === "number" && + typeof sa.left === "number" + ) { + safeAreaInsets = sa; + } + + const hostContext: McpUiHostContext = { + theme: this.openai.theme, + locale: this.openai.locale, + displayMode: this.openai.displayMode, + viewport: this.openai.maxHeight + ? { width: 0, height: 0, maxHeight: this.openai.maxHeight } + : undefined, + safeAreaInsets, + userAgent, + }; + + return this.createSuccessResponse(id, { + protocolVersion: LATEST_PROTOCOL_VERSION, + hostInfo: { + name: "ChatGPT", + version: "1.0.0", + }, + hostCapabilities: { + serverTools: {}, + openLinks: {}, + logging: {}, + }, + hostContext, + }); + } + + /** + * Handle tools/call request by delegating to window.openai.callTool(). + */ + private async handleToolCall( + id: RequestId, + params: { name: string; arguments?: Record }, + ): Promise { + if (!this.openai.callTool) { + return this.createErrorResponse( + id, + -32601, + "Tool calls are not supported in this OpenAI environment", + ); + } + + const result = await this.openai.callTool(params.name, params.arguments); + + // Handle different response formats from OpenAI + // Could be { content: [...] }, { structuredContent: ... }, or the raw data + let content: { type: string; text: string }[]; + if (Array.isArray(result.content)) { + // Clean up content items - remove null values for annotations/_meta + content = result.content.map((item: unknown) => { + if ( + typeof item === "object" && + item !== null && + "type" in item && + "text" in item + ) { + const typedItem = item as { + type: string; + text: string; + annotations?: unknown; + _meta?: unknown; + }; + return { type: typedItem.type, text: typedItem.text }; + } + return { type: "text", text: JSON.stringify(item) }; + }); + } else if (result.structuredContent !== undefined) { + content = [ + { type: "text", text: JSON.stringify(result.structuredContent) }, + ]; + } else if (result.content !== undefined) { + content = [{ type: "text", text: JSON.stringify(result.content) }]; + } else { + // The result itself might be the structured content + content = [{ type: "text", text: JSON.stringify(result) }]; + } + + return this.createSuccessResponse(id, { + content, + isError: result.isError, + }); + } + + /** + * Handle ui/message request by delegating to window.openai.sendFollowUpMessage(). + */ + private async handleMessage( + id: RequestId, + params: { role: string; content: unknown[] }, + ): Promise { + if (!this.openai.sendFollowUpMessage) { + return this.createErrorResponse( + id, + -32601, + "Sending messages is not supported in this OpenAI environment", + ); + } + + // Extract text content from the message + const textContent = params.content + .filter( + (c): c is { type: "text"; text: string } => + typeof c === "object" && + c !== null && + (c as { type?: string }).type === "text", + ) + .map((c) => c.text) + .join("\n"); + + await this.openai.sendFollowUpMessage({ prompt: textContent }); + + return this.createSuccessResponse(id, {}); + } + + /** + * Handle ui/open-link request by delegating to window.openai.openExternal(). + */ + private async handleOpenLink( + id: RequestId, + params: { url: string }, + ): Promise { + if (!this.openai.openExternal) { + return this.createErrorResponse( + id, + -32601, + "Opening external links is not supported in this OpenAI environment", + ); + } + + await this.openai.openExternal({ href: params.url }); + + return this.createSuccessResponse(id, {}); + } + + /** + * Handle ui/request-display-mode by delegating to window.openai.requestDisplayMode(). + */ + private async handleRequestDisplayMode( + id: RequestId, + params: { mode: string }, + ): Promise { + if (!this.openai.requestDisplayMode) { + return this.createErrorResponse( + id, + -32601, + "Display mode changes are not supported in this OpenAI environment", + ); + } + + const mode = params.mode as "inline" | "pip" | "fullscreen"; + await this.openai.requestDisplayMode({ mode }); + + return this.createSuccessResponse(id, { mode }); + } + + /** + * Handle an outgoing notification. + */ + private handleNotification(notification: JSONRPCNotification): void { + const { method, params } = notification; + + switch (method) { + case "ui/notifications/size-changed": + this.handleSizeChanged(params as { width?: number; height?: number }); + break; + + case "ui/notifications/initialized": + // No-op - OpenAI doesn't need this notification + break; + + case "notifications/message": + // Log messages - could be sent to console in OpenAI mode + console.log("[MCP App Log]", params); + break; + + default: + // Ignore unknown notifications + break; + } + } + + /** + * Handle size changed notification by calling window.openai.notifyIntrinsicHeight(). + */ + private handleSizeChanged(params: { width?: number; height?: number }): void { + if (this.openai.notifyIntrinsicHeight && params.height !== undefined) { + this.openai.notifyIntrinsicHeight(params.height); + } + } + + /** + * Create a success JSON-RPC response. + */ + private createSuccessResponse( + id: RequestId, + result: Record, + ): JSONRPCSuccessResponse { + return { + jsonrpc: "2.0", + id, + result, + }; + } + + /** + * Create an error JSON-RPC response. + */ + private createErrorResponse( + id: RequestId, + code: number, + message: string, + ): JSONRPCErrorResponse { + return { + jsonrpc: "2.0", + id, + error: { code, message }, + }; + } + + /** + * Deliver initial tool input and result notifications. + * + * Called by App after connection to deliver pre-populated data from + * window.openai as notifications that the app's handlers expect. + * + * @internal + */ + deliverInitialState(): void { + // Deliver tool input if available + if (this.openai.toolInput !== undefined) { + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: "2.0", + method: "ui/notifications/tool-input", + params: { arguments: this.openai.toolInput }, + } as JSONRPCNotification); + }); + } + + // Deliver tool output if available + if (this.openai.toolOutput !== undefined) { + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: { + content: Array.isArray(this.openai.toolOutput) + ? this.openai.toolOutput + : [ + { + type: "text", + text: JSON.stringify(this.openai.toolOutput), + }, + ], + }, + } as JSONRPCNotification); + }); + } + } + + /** + * Close the transport. + */ + async close(): Promise { + this._closed = true; + this.onclose?.(); + } + + /** + * Called when the transport is closed. + */ + onclose?: () => void; + + /** + * Called when an error occurs. + */ + onerror?: (error: Error) => void; + + /** + * Called when a message is received. + */ + onmessage?: (message: JSONRPCMessage) => void; + + /** + * Session identifier (unused in OpenAI mode). + */ + sessionId?: string; + + /** + * Callback to set the negotiated protocol version. + */ + setProtocolVersion?: (version: string) => void; +} + +// Re-export utility functions +export { isOpenAIEnvironment, getOpenAIGlobal }; diff --git a/src/openai/types.ts b/src/openai/types.ts new file mode 100644 index 00000000..435823f9 --- /dev/null +++ b/src/openai/types.ts @@ -0,0 +1,244 @@ +/** + * Type definitions for the OpenAI Apps SDK's window.openai object. + * + * These types describe the API surface that ChatGPT injects into widget iframes. + * When running in OpenAI mode, the {@link OpenAITransport} uses these APIs to + * communicate with the ChatGPT host. + * + * @see https://developers.openai.com/apps-sdk/build/chatgpt-ui/ + */ + +/** + * Display mode for the widget in ChatGPT. + */ +export type OpenAIDisplayMode = "inline" | "pip" | "fullscreen"; + +/** + * Theme setting from the ChatGPT host. + */ +export type OpenAITheme = "light" | "dark"; + +/** + * Safe area insets for the widget viewport. + */ +export interface OpenAISafeArea { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Result of a tool call via window.openai.callTool(). + * + * Note: The exact return type isn't fully documented by OpenAI. + * Based on observed behavior, it returns structured content. + */ +export interface OpenAIToolCallResult { + /** Structured content from the tool (may be any shape) */ + structuredContent?: unknown; + /** Legacy content field (for compatibility) */ + content?: unknown; + /** Whether the tool call resulted in an error */ + isError?: boolean; +} + +/** + * The window.openai object injected by ChatGPT into widget iframes. + * + * This interface describes the API surface available to widgets running + * in the ChatGPT environment. + */ +export interface OpenAIGlobal { + // ───────────────────────────────────────────────────────────────────────── + // State & Data Properties + // ───────────────────────────────────────────────────────────────────────── + + /** + * Tool arguments passed when invoking the tool. + * Pre-populated when the widget loads. + */ + toolInput?: Record; + + /** + * Structured content returned by the MCP server. + * Pre-populated when the widget loads (if tool has completed). + */ + toolOutput?: unknown; + + /** + * The `_meta` payload from tool response (widget-only, hidden from model). + */ + toolResponseMetadata?: Record; + + /** + * Persisted UI state snapshot between renders. + * Set via setWidgetState(), rehydrated on subsequent renders. + */ + widgetState?: unknown; + + /** + * Current theme setting. + */ + theme?: OpenAITheme; + + /** + * Current display mode of the widget. + */ + displayMode?: OpenAIDisplayMode; + + /** + * Maximum height available for the widget. + */ + maxHeight?: number; + + /** + * Safe area insets for the widget. + */ + safeArea?: OpenAISafeArea; + + /** + * Current view mode. + */ + view?: string; + + /** + * User agent string from the host. + */ + userAgent?: string; + + /** + * Locale setting (BCP 47 language tag). + */ + locale?: string; + + // ───────────────────────────────────────────────────────────────────────── + // State Management Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Persist UI state synchronously after interactions. + * State is scoped to this widget instance and rehydrated on re-renders. + * + * @param state - State object to persist + */ + setWidgetState?(state: unknown): void; + + // ───────────────────────────────────────────────────────────────────────── + // Tool & Chat Integration Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Invoke another MCP tool from the widget. + * + * @param name - Name of the tool to call + * @param args - Arguments to pass to the tool + * @returns Promise resolving to the tool result + */ + callTool?( + name: string, + args?: Record, + ): Promise; + + /** + * Inject a user message into the conversation. + * + * @param options - Message options + * @param options.prompt - The message text to send + */ + sendFollowUpMessage?(options: { prompt: string }): Promise; + + // ───────────────────────────────────────────────────────────────────────── + // File Operations + // ───────────────────────────────────────────────────────────────────────── + + /** + * Upload a user-selected file. + * + * @param file - File to upload + * @returns Promise resolving to the file ID + */ + uploadFile?(file: File): Promise<{ fileId: string }>; + + /** + * Retrieve a temporary download URL for a file. + * + * @param options - File options + * @param options.fileId - ID of the file to download + * @returns Promise resolving to the download URL + */ + getFileDownloadUrl?(options: { fileId: string }): Promise<{ url: string }>; + + // ───────────────────────────────────────────────────────────────────────── + // Layout & Display Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Request a display mode change (inline, pip, fullscreen). + * + * @param options - Display mode options + * @param options.mode - Requested display mode + */ + requestDisplayMode?(options: { mode: OpenAIDisplayMode }): Promise; + + /** + * Spawn a ChatGPT-owned modal. + */ + requestModal?(options: unknown): Promise; + + /** + * Report dynamic widget height to the host. + * + * @param height - Height in pixels + */ + notifyIntrinsicHeight?(height: number): void; + + /** + * Close the widget from the UI. + */ + requestClose?(): void; + + // ───────────────────────────────────────────────────────────────────────── + // Navigation Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Open a vetted external link in a new tab. + * + * @param options - Link options + * @param options.href - URL to open + */ + openExternal?(options: { href: string }): Promise; +} + +/** + * Window type augmentation for OpenAI environment. + */ +export interface WindowWithOpenAI { + openai: OpenAIGlobal; +} + +/** + * Detect if the current environment has window.openai available. + * + * @returns true if running in OpenAI/ChatGPT environment + */ +export function isOpenAIEnvironment(): boolean { + return ( + typeof window !== "undefined" && + typeof (window as unknown as WindowWithOpenAI).openai === "object" && + (window as unknown as WindowWithOpenAI).openai !== null + ); +} + +/** + * Get the window.openai object if available. + * + * @returns The OpenAI global object, or undefined if not in OpenAI environment + */ +export function getOpenAIGlobal(): OpenAIGlobal | undefined { + if (isOpenAIEnvironment()) { + return (window as unknown as WindowWithOpenAI).openai; + } + return undefined; +} diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 73f2812e..111f8591 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -1,16 +1,12 @@ import { useEffect, useState } from "react"; import { Implementation } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client"; -import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; +import { App, McpUiAppCapabilities } from "../app"; export * from "../app"; /** * Options for configuring the useApp hook. * - * Note: This interface does NOT expose App options like `autoResize`. - * The hook creates the App with default options (autoResize: true). If you need - * custom App options, create the App manually instead of using this hook. - * * @see {@link useApp} for the hook that uses these options * @see {@link useAutoResize} for manual auto-resize control with custom App options */ @@ -19,6 +15,18 @@ export interface UseAppOptions { appInfo: Implementation; /** Features and capabilities this app provides */ capabilities: McpUiAppCapabilities; + /** + * Enable experimental OpenAI compatibility. + * + * When enabled (default), the App will auto-detect the environment: + * - If `window.openai` exists → use OpenAI Apps SDK + * - Otherwise → use MCP Apps protocol via PostMessageTransport + * + * Set to `false` to force MCP-only mode. + * + * @default true + */ + experimentalOAICompatibility?: boolean; /** * Called after App is created but before connection. * @@ -60,14 +68,18 @@ export interface AppState { * React hook to create and connect an MCP App. * * This hook manages the complete lifecycle of an {@link App}: creation, connection, - * and cleanup. It automatically creates a {@link PostMessageTransport} to window.parent - * and handles initialization. + * and cleanup. It automatically detects the platform (MCP or OpenAI) and uses the + * appropriate transport. + * + * **Cross-Platform Support**: The hook supports both MCP-compatible hosts and + * OpenAI's ChatGPT environment. By default, it auto-detects the platform. + * Set `experimentalOAICompatibility: false` to force MCP-only mode. * * **Important**: The hook intentionally does NOT re-run when options change * to avoid reconnection loops. Options are only used during the initial mount. * * **Note**: This is part of the optional React integration. The core SDK - * (App, PostMessageTransport) is framework-agnostic and can be + * (App, PostMessageTransport, OpenAITransport) is framework-agnostic and can be * used with any UI framework or vanilla JavaScript. * * @param options - Configuration for the app @@ -75,22 +87,18 @@ export interface AppState { * initialization, the `error` field will contain the error (typically connection * timeouts, initialization handshake failures, or transport errors). * - * @example Basic usage + * @example Basic usage (auto-detects platform) * ```typescript - * import { useApp, McpUiToolInputNotificationSchema } from '@modelcontextprotocol/ext-apps/react'; + * import { useApp } from '@modelcontextprotocol/ext-apps/react'; * * function MyApp() { * const { app, isConnected, error } = useApp({ * appInfo: { name: "MyApp", version: "1.0.0" }, * capabilities: {}, * onAppCreated: (app) => { - * // Register handlers before connection - * app.setNotificationHandler( - * McpUiToolInputNotificationSchema, - * (notification) => { - * console.log("Tool input:", notification.params.arguments); - * } - * ); + * app.ontoolinput = (params) => { + * console.log("Tool input:", params.arguments); + * }; * }, * }); * @@ -100,12 +108,22 @@ export interface AppState { * } * ``` * + * @example Force MCP-only mode + * ```typescript + * const { app } = useApp({ + * appInfo: { name: "MyApp", version: "1.0.0" }, + * capabilities: {}, + * experimentalOAICompatibility: false, // Disable OpenAI auto-detection + * }); + * ``` + * * @see {@link App.connect} for the underlying connection method * @see {@link useAutoResize} for manual auto-resize control when using custom App options */ export function useApp({ appInfo, capabilities, + experimentalOAICompatibility = true, onAppCreated, }: UseAppOptions): AppState { const [app, setApp] = useState(null); @@ -117,16 +135,14 @@ export function useApp({ async function connect() { try { - const transport = new PostMessageTransport( - window.parent, - window.parent, - ); - const app = new App(appInfo, capabilities); + const app = new App(appInfo, capabilities, { + experimentalOAICompatibility, + }); // Register handlers BEFORE connecting onAppCreated?.(app); - await app.connect(transport); + await app.connect(); if (mounted) { setApp(app); From 28a39246495ee5b6efd563686e7ae741095476a5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 16 Dec 2025 23:25:00 +0000 Subject: [PATCH 02/10] feat: add cross-platform support for OpenAI Apps SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dynamic capability detection based on window.openai availability - Report availableDisplayModes when requestDisplayMode is available - Include toolResponseMetadata as _meta in tool-result notification - registerAppTool adds openai/outputTemplate metadata automatically - registerAppResource registers both MCP and OpenAI (+skybridge) variants - Preserve custom MIME types in OpenAI resource callback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/openai/transport.test.ts | 95 ++++++++++++++++++++ src/openai/transport.ts | 28 ++++-- src/server/index.test.ts | 163 +++++++++++++++++++++++++++++------ src/server/index.ts | 72 +++++++++++++++- 4 files changed, 323 insertions(+), 35 deletions(-) diff --git a/src/openai/transport.test.ts b/src/openai/transport.test.ts index 01911e09..800073ca 100644 --- a/src/openai/transport.test.ts +++ b/src/openai/transport.test.ts @@ -128,6 +128,75 @@ describe("OpenAITransport", () => { }, }); }); + + test("dynamically reports capabilities based on available methods", async () => { + // Remove callTool to test dynamic detection + delete mockOpenAI.callTool; + + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2025-11-21", + appInfo: { name: "TestApp", version: "1.0.0" }, + appCapabilities: {}, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const result = (response as { result: { hostCapabilities: unknown } }) + .result.hostCapabilities as Record; + + // serverTools should NOT be present since callTool is missing + expect(result.serverTools).toBeUndefined(); + // openLinks should be present since openExternal exists + expect(result.openLinks).toBeDefined(); + // logging is always available + expect(result.logging).toBeDefined(); + }); + + test("includes availableDisplayModes when requestDisplayMode is available", async () => { + mockOpenAI.requestDisplayMode = mock(() => + Promise.resolve(), + ) as unknown as OpenAIGlobal["requestDisplayMode"]; + + const transport = new OpenAITransport(); + let response: unknown; + transport.onmessage = (msg) => { + response = msg; + }; + + await transport.send({ + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2025-11-21", + appInfo: { name: "TestApp", version: "1.0.0" }, + appCapabilities: {}, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(response).toMatchObject({ + jsonrpc: "2.0", + id: 1, + result: { + hostContext: { + availableDisplayModes: ["inline", "pip", "fullscreen"], + }, + }, + }); + }); }); describe("tools/call request", () => { @@ -334,6 +403,32 @@ describe("OpenAITransport", () => { expect(toolResultNotification).toBeDefined(); }); + test("includes _meta from toolResponseMetadata in tool result", async () => { + mockOpenAI.toolResponseMetadata = { widgetId: "abc123", version: 2 }; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ); + expect(toolResultNotification).toMatchObject({ + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: { + _meta: { widgetId: "abc123", version: 2 }, + }, + }); + }); + test("does not deliver notifications when data is missing", async () => { delete mockOpenAI.toolInput; delete mockOpenAI.toolOutput; diff --git a/src/openai/transport.ts b/src/openai/transport.ts index 399ef949..8c5cfb84 100644 --- a/src/openai/transport.ts +++ b/src/openai/transport.ts @@ -245,6 +245,10 @@ export class OpenAITransport implements Transport { theme: this.openai.theme, locale: this.openai.locale, displayMode: this.openai.displayMode, + // If requestDisplayMode is available, ChatGPT supports all three modes + availableDisplayModes: this.openai.requestDisplayMode + ? ["inline", "pip", "fullscreen"] + : undefined, viewport: this.openai.maxHeight ? { width: 0, height: 0, maxHeight: this.openai.maxHeight } : undefined, @@ -252,17 +256,29 @@ export class OpenAITransport implements Transport { userAgent, }; + // Dynamically determine capabilities based on what window.openai supports + const hostCapabilities: Record = { + // Logging is always available (we map to console.log) + logging: {}, + }; + + // Only advertise serverTools if callTool is available + if (this.openai.callTool) { + hostCapabilities.serverTools = {}; + } + + // Only advertise openLinks if openExternal is available + if (this.openai.openExternal) { + hostCapabilities.openLinks = {}; + } + return this.createSuccessResponse(id, { protocolVersion: LATEST_PROTOCOL_VERSION, hostInfo: { name: "ChatGPT", version: "1.0.0", }, - hostCapabilities: { - serverTools: {}, - openLinks: {}, - logging: {}, - }, + hostCapabilities, hostContext, }); } @@ -494,6 +510,8 @@ export class OpenAITransport implements Transport { text: JSON.stringify(this.openai.toolOutput), }, ], + // Include _meta from toolResponseMetadata if available + _meta: this.openai.toolResponseMetadata, }, } as JSONRPCNotification); }); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d5e0a80a..e4425583 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -4,6 +4,8 @@ import { registerAppResource, RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE, + OPENAI_RESOURCE_SUFFIX, + OPENAI_MIME_TYPE, } from "./index"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -53,6 +55,34 @@ describe("registerAppTool", () => { expect(capturedHandler).toBe(handler); }); + it("should add openai/outputTemplate metadata for cross-platform compatibility", () => { + let capturedConfig: Record | undefined; + + const mockServer = { + registerTool: mock( + (_name: string, config: Record, _handler: unknown) => { + capturedConfig = config; + }, + ), + }; + + registerAppTool( + mockServer as unknown as Pick, + "my-tool", + { + _meta: { + [RESOURCE_URI_META_KEY]: "ui://test/widget.html", + }, + }, + async () => ({ content: [{ type: "text" as const, text: "ok" }] }), + ); + + const meta = capturedConfig?._meta as Record; + expect(meta["openai/outputTemplate"]).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + }); + describe("backward compatibility", () => { it("should set legacy key when _meta.ui.resourceUri is provided", () => { let capturedConfig: Record | undefined; @@ -196,18 +226,18 @@ describe("registerAppTool", () => { }); describe("registerAppResource", () => { - it("should register a resource with default MIME type", () => { - let capturedName: string | undefined; - let capturedUri: string | undefined; - let capturedConfig: Record | undefined; + it("should register both MCP and OpenAI resources", () => { + const registrations: Array<{ + name: string; + uri: string; + config: Record; + }> = []; const mockServer = { registerTool: mock(() => {}), registerResource: mock( (name: string, uri: string, config: Record) => { - capturedName = name; - capturedUri = uri; - capturedConfig = config; + registrations.push({ name, uri, config }); }, ), }; @@ -233,21 +263,32 @@ describe("registerAppResource", () => { callback, ); - expect(mockServer.registerResource).toHaveBeenCalledTimes(1); - expect(capturedName).toBe("My Resource"); - expect(capturedUri).toBe("ui://test/widget.html"); - expect(capturedConfig?.mimeType).toBe(RESOURCE_MIME_TYPE); - expect(capturedConfig?.description).toBe("A test resource"); + // Should register TWO resources (MCP + OpenAI) + expect(mockServer.registerResource).toHaveBeenCalledTimes(2); + + // First: MCP resource + expect(registrations[0].name).toBe("My Resource"); + expect(registrations[0].uri).toBe("ui://test/widget.html"); + expect(registrations[0].config.mimeType).toBe(RESOURCE_MIME_TYPE); + expect(registrations[0].config.description).toBe("A test resource"); + + // Second: OpenAI resource + expect(registrations[1].name).toBe("My Resource (OpenAI)"); + expect(registrations[1].uri).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + expect(registrations[1].config.mimeType).toBe(OPENAI_MIME_TYPE); + expect(registrations[1].config.description).toBe("A test resource"); }); - it("should allow custom MIME type to override default", () => { - let capturedConfig: Record | undefined; + it("should allow custom MIME type to override default for MCP resource", () => { + const registrations: Array<{ config: Record }> = []; const mockServer = { registerTool: mock(() => {}), registerResource: mock( (_name: string, _uri: string, config: Record) => { - capturedConfig = config; + registrations.push({ config }); }, ), }; @@ -271,12 +312,16 @@ describe("registerAppResource", () => { }), ); - // Custom mimeType should override the default - expect(capturedConfig?.mimeType).toBe("text/html"); + // MCP resource should use custom mimeType + expect(registrations[0].config.mimeType).toBe("text/html"); + // OpenAI resource should always use skybridge MIME type + expect(registrations[1].config.mimeType).toBe(OPENAI_MIME_TYPE); }); - it("should call the callback when handler is invoked", async () => { - let capturedHandler: (() => Promise) | undefined; + it("should transform OpenAI resource callback to use skybridge MIME type", async () => { + let mcpHandler: (() => Promise) | undefined; + let openaiHandler: (() => Promise) | undefined; + let callCount = 0; const mockServer = { registerTool: mock(() => {}), @@ -287,12 +332,17 @@ describe("registerAppResource", () => { _config: unknown, handler: () => Promise, ) => { - capturedHandler = handler; + if (callCount === 0) { + mcpHandler = handler; + } else { + openaiHandler = handler; + } + callCount++; }, ), }; - const expectedResult = { + const callback = mock(async () => ({ contents: [ { uri: "ui://test/widget.html", @@ -300,8 +350,7 @@ describe("registerAppResource", () => { text: "content", }, ], - }; - const callback = mock(async () => expectedResult); + })); registerAppResource( mockServer as unknown as Pick, @@ -311,10 +360,70 @@ describe("registerAppResource", () => { callback, ); - expect(capturedHandler).toBeDefined(); - const result = await capturedHandler!(); + // MCP handler should return original content + const mcpResult = (await mcpHandler!()) as { + contents: Array<{ uri: string; mimeType: string }>; + }; + expect(mcpResult.contents[0].mimeType).toBe(RESOURCE_MIME_TYPE); + + // OpenAI handler should return with skybridge MIME type + const openaiResult = (await openaiHandler!()) as { + contents: Array<{ uri: string; mimeType: string }>; + }; + expect(openaiResult.contents[0].uri).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + expect(openaiResult.contents[0].mimeType).toBe(OPENAI_MIME_TYPE); + }); + + it("should preserve custom MIME types in OpenAI resource callback", async () => { + let openaiHandler: (() => Promise) | undefined; + let callCount = 0; + + const mockServer = { + registerTool: mock(() => {}), + registerResource: mock( + ( + _name: string, + _uri: string, + _config: unknown, + handler: () => Promise, + ) => { + if (callCount === 1) { + openaiHandler = handler; + } + callCount++; + }, + ), + }; + + // Callback returns custom MIME type (not the default MCP App type) + const callback = mock(async () => ({ + contents: [ + { + uri: "ui://test/widget.html", + mimeType: "application/json", + text: "{}", + }, + ], + })); - expect(callback).toHaveBeenCalledTimes(1); - expect(result).toEqual(expectedResult); + registerAppResource( + mockServer as unknown as Pick, + "My Resource", + "ui://test/widget.html", + { _meta: { ui: {} } }, + callback, + ); + + // OpenAI handler should preserve the custom MIME type + const openaiResult = (await openaiHandler!()) as { + contents: Array<{ uri: string; mimeType: string }>; + }; + expect(openaiResult.contents[0].uri).toBe( + "ui://test/widget.html" + OPENAI_RESOURCE_SUFFIX, + ); + // Custom MIME type should be preserved, not converted to skybridge + expect(openaiResult.contents[0].mimeType).toBe("application/json"); }); }); diff --git a/src/server/index.ts b/src/server/index.ts index 720cf658..a94281af 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,16 @@ /** * Server Helpers for MCP Apps. * + * These utilities register tools and resources that work with both + * MCP-compatible hosts and OpenAI's ChatGPT Apps SDK. + * + * ## Cross-Platform Support + * + * | Feature | MCP Apps | OpenAI Apps SDK | + * |---------|----------|-----------------| + * | Tool metadata | `_meta.ui.resourceUri` | `_meta["openai/outputTemplate"]` | + * | Resource MIME | `text/html;profile=mcp-app` | `text/html+skybridge` | + * * @module server-helpers */ @@ -26,6 +36,17 @@ import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE }; export type { ResourceMetadata, ToolCallback, ReadResourceCallback }; +/** + * OpenAI skybridge URI suffix. + * Appended to resource URIs for OpenAI-specific resource registration. + */ +export const OPENAI_RESOURCE_SUFFIX = "+skybridge"; + +/** + * OpenAI skybridge MIME type. + */ +export const OPENAI_MIME_TYPE = "text/html+skybridge"; + /** * Tool configuration (same as McpServer.registerTool). */ @@ -50,7 +71,7 @@ export interface McpUiAppToolConfig extends ToolConfig { | { /** * URI of the UI resource to display for this tool. - * This is converted to `_meta["ui/resourceUri"]`. + * This is converted to `_meta.ui.resourceUri`. * * @example "ui://weather/widget.html" * @@ -125,15 +146,31 @@ export function registerAppTool< normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } }; } + // Get the resource URI after normalization + const resourceUri = (normalizedMeta.ui as McpUiToolMeta | undefined) + ?.resourceUri; + + // Add OpenAI outputTemplate metadata for cross-platform compatibility + if (resourceUri) { + normalizedMeta = { + ...normalizedMeta, + "openai/outputTemplate": resourceUri + OPENAI_RESOURCE_SUFFIX, + }; + } + server.registerTool(name, { ...config, _meta: normalizedMeta }, handler); } /** - * Register an app resource with the MCP server. + * Register an app resource with dual MCP/OpenAI support. * * This is a convenience wrapper around `server.registerResource` that: * - Defaults the MIME type to "text/html;profile=mcp-app" - * - Provides a cleaner API matching the SDK's callback signature + * - Registers both MCP and OpenAI variants for cross-platform compatibility + * + * Registers two resources: + * 1. MCP resource at the base URI with `text/html;profile=mcp-app` MIME type + * 2. OpenAI resource at URI+skybridge with `text/html+skybridge` MIME type * * @param server - The MCP server instance * @param name - Human-readable resource name @@ -164,6 +201,9 @@ export function registerAppResource( config: McpUiAppResourceConfig, readCallback: ReadResourceCallback, ): void { + const openaiUri = uri + OPENAI_RESOURCE_SUFFIX; + + // Register MCP resource (text/html;profile=mcp-app) server.registerResource( name, uri, @@ -174,4 +214,30 @@ export function registerAppResource( }, readCallback, ); + + // Register OpenAI resource (text/html+skybridge) + // Re-uses the same callback but returns with OpenAI MIME type + server.registerResource( + name + " (OpenAI)", + openaiUri, + { + ...config, + // Force OpenAI MIME type + mimeType: OPENAI_MIME_TYPE, + }, + async (resourceUri, extra) => { + const result = await readCallback(resourceUri, extra); + // Transform contents to use OpenAI MIME type + return { + contents: result.contents.map((content) => ({ + ...content, + uri: content.uri + OPENAI_RESOURCE_SUFFIX, + mimeType: + content.mimeType === RESOURCE_MIME_TYPE + ? OPENAI_MIME_TYPE + : content.mimeType, + })), + }; + }, + ); } From 38e098cbcadcadd53184976f4654e46bb5218f63 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 13:22:01 +0000 Subject: [PATCH 03/10] test: update Three.js golden snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The learn_threejs tool was added in #173, which adds a second option in the Tool dropdown. This updates the golden snapshot to match. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../e2e/servers.spec.ts-snapshots/threejs.png | Bin 37725 -> 21343 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/e2e/servers.spec.ts-snapshots/threejs.png b/tests/e2e/servers.spec.ts-snapshots/threejs.png index 683a77de457d63e0946b183339bbb72748251beb..fbbb8e7167f8b95ca197dc35baf2405661608017 100644 GIT binary patch literal 21343 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 NaMt`x@#$L+{twT!p=|&F literal 37725 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 Date: Wed, 17 Dec 2025 15:26:54 +0000 Subject: [PATCH 04/10] Fix Unknown message type error for tool notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change _meta: null to _meta: undefined in OpenAI transport - Register default no-op handlers for all tool notifications in App constructor The SDK's Protocol class throws 'Unknown message type' for unhandled notifications. Now all tool-related notifications have default handlers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app.ts | 8 ++++++-- src/openai/transport.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7d31858d..1935e374 100644 --- a/src/app.ts +++ b/src/app.ts @@ -248,9 +248,13 @@ export class App extends Protocol { return {}; }); - // Set up default handler to update _hostContext when notifications arrive. - // Users can override this by setting onhostcontextchanged. + // Set up default handlers for notifications. + // Users can override these by setting the corresponding on* properties. this.onhostcontextchanged = () => {}; + this.ontoolinput = () => {}; + this.ontoolinputpartial = () => {}; + this.ontoolresult = () => {}; + this.ontoolcancelled = () => {}; } /** diff --git a/src/openai/transport.ts b/src/openai/transport.ts index 8c5cfb84..c05c6326 100644 --- a/src/openai/transport.ts +++ b/src/openai/transport.ts @@ -510,8 +510,8 @@ export class OpenAITransport implements Transport { text: JSON.stringify(this.openai.toolOutput), }, ], - // Include _meta from toolResponseMetadata if available - _meta: this.openai.toolResponseMetadata, + // Include _meta from toolResponseMetadata if available (use undefined not null) + _meta: this.openai.toolResponseMetadata ?? undefined, }, } as JSONRPCNotification); }); From 4e1380aab5dc2189b5ccb79a4e238dd8333a5a32 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:30:22 +0000 Subject: [PATCH 05/10] Add tests for notification handler fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test that null _meta is converted to undefined in OpenAI transport - Test that default no-op handlers accept tool notifications without error 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app-bridge.test.ts | 15 +++++++++++++++ src/openai/transport.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 1e55f6bd..cbc9698a 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -215,6 +215,21 @@ describe("App <-> AppBridge integration", () => { expect(receivedCancellations[0]).toEqual({}); }); + it("tool notifications work with default no-op handlers", async () => { + // Don't set any custom handlers - use defaults + await app.connect(appTransport); + + // These should not throw (default handlers silently accept them) + // Just verify they complete without error + await bridge.sendToolInput({ arguments: {} }); + await bridge.sendToolInputPartial({ arguments: {} }); + await bridge.sendToolResult({ content: [{ type: "text", text: "ok" }] }); + await bridge.sendToolCancelled({}); + + // If we got here without throwing, the test passes + expect(true).toBe(true); + }); + it("setHostContext triggers app.onhostcontextchanged", async () => { const receivedContexts: unknown[] = []; app.onhostcontextchanged = (params) => { diff --git a/src/openai/transport.test.ts b/src/openai/transport.test.ts index 800073ca..37ecc6dd 100644 --- a/src/openai/transport.test.ts +++ b/src/openai/transport.test.ts @@ -429,6 +429,29 @@ describe("OpenAITransport", () => { }); }); + test("converts null _meta to undefined in tool result", async () => { + // Simulate null being set (e.g., from JSON parsing where null is valid) + (mockOpenAI as unknown as { toolResponseMetadata: null }).toolResponseMetadata = null; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ) as { params?: { _meta?: unknown } } | undefined; + expect(toolResultNotification).toBeDefined(); + // _meta should be undefined, not null (SDK rejects null) + expect(toolResultNotification?.params?._meta).toBeUndefined(); + }); + test("does not deliver notifications when data is missing", async () => { delete mockOpenAI.toolInput; delete mockOpenAI.toolOutput; From f10e1780e367ce1644aa549cdafca1d6e2668c48 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:50:03 +0000 Subject: [PATCH 06/10] Fix null toolOutput being sent as text 'null' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check for both null and undefined before delivering tool-result notification. Previously null passed through and was stringified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/openai/transport.test.ts | 26 +++++++++++++++++++++++++- src/openai/transport.ts | 4 ++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/openai/transport.test.ts b/src/openai/transport.test.ts index 37ecc6dd..962a0c6c 100644 --- a/src/openai/transport.test.ts +++ b/src/openai/transport.test.ts @@ -431,7 +431,9 @@ describe("OpenAITransport", () => { test("converts null _meta to undefined in tool result", async () => { // Simulate null being set (e.g., from JSON parsing where null is valid) - (mockOpenAI as unknown as { toolResponseMetadata: null }).toolResponseMetadata = null; + ( + mockOpenAI as unknown as { toolResponseMetadata: null } + ).toolResponseMetadata = null; const transport = new OpenAITransport(); const messages: unknown[] = []; @@ -452,6 +454,28 @@ describe("OpenAITransport", () => { expect(toolResultNotification?.params?._meta).toBeUndefined(); }); + test("does not deliver tool-result when toolOutput is null", async () => { + // Simulate null being set (e.g., from JSON parsing) + (mockOpenAI as unknown as { toolOutput: null }).toolOutput = null; + + const transport = new OpenAITransport(); + const messages: unknown[] = []; + transport.onmessage = (msg) => { + messages.push(msg); + }; + + transport.deliverInitialState(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const toolResultNotification = messages.find( + (m: unknown) => + (m as { method?: string }).method === "ui/notifications/tool-result", + ); + // Should NOT deliver tool-result when toolOutput is null + expect(toolResultNotification).toBeUndefined(); + }); + test("does not deliver notifications when data is missing", async () => { delete mockOpenAI.toolInput; delete mockOpenAI.toolOutput; diff --git a/src/openai/transport.ts b/src/openai/transport.ts index c05c6326..55ca6272 100644 --- a/src/openai/transport.ts +++ b/src/openai/transport.ts @@ -495,8 +495,8 @@ export class OpenAITransport implements Transport { }); } - // Deliver tool output if available - if (this.openai.toolOutput !== undefined) { + // Deliver tool output if available (check for both null and undefined) + if (this.openai.toolOutput != null) { queueMicrotask(() => { this.onmessage?.({ jsonrpc: "2.0", From e554054c95bd0ef93da37bd9c95259f00f71c430 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:55:49 +0000 Subject: [PATCH 07/10] Fix double-stringification of toolOutput in OpenAI transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle different shapes of toolOutput from ChatGPT: - Array of content blocks: use directly - Single content block {type, text}: wrap in array - Object with just {text}: extract and wrap - Other: stringify as fallback This prevents double-stringification when ChatGPT passes content in different formats. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/openai/transport.ts | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/openai/transport.ts b/src/openai/transport.ts index 55ca6272..7ca34678 100644 --- a/src/openai/transport.ts +++ b/src/openai/transport.ts @@ -498,18 +498,39 @@ export class OpenAITransport implements Transport { // Deliver tool output if available (check for both null and undefined) if (this.openai.toolOutput != null) { queueMicrotask(() => { + // Normalize toolOutput to MCP content array format + let content: Array<{ type: string; text?: string; [key: string]: unknown }>; + const output = this.openai.toolOutput; + + if (Array.isArray(output)) { + // Already an array of content blocks + content = output; + } else if ( + typeof output === "object" && + output !== null && + "type" in output && + typeof (output as { type: unknown }).type === "string" + ) { + // Single content block object like {type: "text", text: "..."} + content = [output as { type: string; text?: string }]; + } else if ( + typeof output === "object" && + output !== null && + "text" in output && + typeof (output as { text: unknown }).text === "string" + ) { + // Object with just text field - treat as text content + content = [{ type: "text", text: (output as { text: string }).text }]; + } else { + // Unknown shape - stringify it + content = [{ type: "text", text: JSON.stringify(output) }]; + } + this.onmessage?.({ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { - content: Array.isArray(this.openai.toolOutput) - ? this.openai.toolOutput - : [ - { - type: "text", - text: JSON.stringify(this.openai.toolOutput), - }, - ], + content, // Include _meta from toolResponseMetadata if available (use undefined not null) _meta: this.openai.toolResponseMetadata ?? undefined, }, From a5ad9e47a645893559e87e9984cba4a9df29f00a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:56:55 +0000 Subject: [PATCH 08/10] Add structuredContent support to OpenAI transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When toolOutput contains structuredContent, include it in the tool-result notification. Also auto-extract structuredContent from plain objects that aren't content arrays. This allows apps to access structured data directly without parsing JSON from text content. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/openai/transport.ts | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/openai/transport.ts b/src/openai/transport.ts index 7ca34678..bc87b7fe 100644 --- a/src/openai/transport.ts +++ b/src/openai/transport.ts @@ -498,11 +498,38 @@ export class OpenAITransport implements Transport { // Deliver tool output if available (check for both null and undefined) if (this.openai.toolOutput != null) { queueMicrotask(() => { - // Normalize toolOutput to MCP content array format - let content: Array<{ type: string; text?: string; [key: string]: unknown }>; + // Normalize toolOutput to MCP CallToolResult format + let content: Array<{ + type: string; + text?: string; + [key: string]: unknown; + }>; + let structuredContent: Record | undefined; const output = this.openai.toolOutput; - if (Array.isArray(output)) { + // Check if output is already a CallToolResult-like object with content/structuredContent + if ( + typeof output === "object" && + output !== null && + ("content" in output || "structuredContent" in output) + ) { + const result = output as { + content?: unknown; + structuredContent?: Record; + }; + // Prefer structuredContent if available + if (result.structuredContent !== undefined) { + structuredContent = result.structuredContent; + // Generate content from structuredContent if not provided + content = Array.isArray(result.content) + ? result.content + : [{ type: "text", text: JSON.stringify(result.structuredContent) }]; + } else if (Array.isArray(result.content)) { + content = result.content; + } else { + content = [{ type: "text", text: JSON.stringify(output) }]; + } + } else if (Array.isArray(output)) { // Already an array of content blocks content = output; } else if ( @@ -521,6 +548,10 @@ export class OpenAITransport implements Transport { ) { // Object with just text field - treat as text content content = [{ type: "text", text: (output as { text: string }).text }]; + } else if (typeof output === "object" && output !== null) { + // Plain object - use as structuredContent and generate text content + structuredContent = output as Record; + content = [{ type: "text", text: JSON.stringify(output) }]; } else { // Unknown shape - stringify it content = [{ type: "text", text: JSON.stringify(output) }]; @@ -531,6 +562,7 @@ export class OpenAITransport implements Transport { method: "ui/notifications/tool-result", params: { content, + structuredContent, // Include _meta from toolResponseMetadata if available (use undefined not null) _meta: this.openai.toolResponseMetadata ?? undefined, }, From 35dc00a748bbdfbb76655cc15d8fe32156e42fc7 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 7 Jan 2026 16:59:41 +0000 Subject: [PATCH 09/10] style: format OpenAI transport --- src/openai/transport.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/openai/transport.ts b/src/openai/transport.ts index bc87b7fe..f4c9c6aa 100644 --- a/src/openai/transport.ts +++ b/src/openai/transport.ts @@ -523,7 +523,12 @@ export class OpenAITransport implements Transport { // Generate content from structuredContent if not provided content = Array.isArray(result.content) ? result.content - : [{ type: "text", text: JSON.stringify(result.structuredContent) }]; + : [ + { + type: "text", + text: JSON.stringify(result.structuredContent), + }, + ]; } else if (Array.isArray(result.content)) { content = result.content; } else { From d6048d8b017641652c56a7a960df888f50c27c25 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 8 Jan 2026 16:08:44 +0000 Subject: [PATCH 10/10] fix: include autoResize option in useApp hook The React useApp hook was overriding the entire options object when passing experimentalOAICompatibility, causing autoResize to be undefined instead of true. This prevented automatic size notifications from being set up. --- src/react/useApp.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/react/useApp.tsx b/src/react/useApp.tsx index 111f8591..12bcf86d 100644 --- a/src/react/useApp.tsx +++ b/src/react/useApp.tsx @@ -137,6 +137,7 @@ export function useApp({ try { const app = new App(appInfo, capabilities, { experimentalOAICompatibility, + autoResize: true, }); // Register handlers BEFORE connecting