From 95679a2421ac678a8bc9844f8119e02c8987606b Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 16 Dec 2025 22:29:04 +0000 Subject: [PATCH 1/8] 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. ## Changes ### New: `src/openai/` module - `transport.ts` - OpenAITransport implementing MCP Transport interface - `types.ts` - TypeScript types for OpenAI Apps SDK (`window.openai`) - `transport.test.ts` - Comprehensive tests ### Updated: `src/app.ts` - Add `experimentalOAICompatibility` option (default: `true`) - Auto-detect platform: check for `window.openai` → use OpenAI, else MCP - `connect()` creates appropriate transport automatically ### Updated: `src/react/useApp.tsx` - Add `experimentalOAICompatibility` prop to `UseAppOptions` - Pass through to App constructor ## Usage 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 | 86 ++++-- src/openai/transport.test.ts | 354 +++++++++++++++++++++++ src/openai/transport.ts | 538 +++++++++++++++++++++++++++++++++++ src/openai/types.ts | 244 ++++++++++++++++ src/react/useApp.tsx | 59 ++-- 5 files changed, 1243 insertions(+), 38 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 df8f16c4..c9b2716b 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, @@ -100,7 +103,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. * @@ -111,6 +114,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< @@ -219,7 +235,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); @@ -988,47 +1007,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); + } + /** * 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), + transport?: Transport, options?: RequestOptions, ): Promise { + transport ??= this.createDefaultTransport(); + await super.connect(transport); try { @@ -1060,6 +1105,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 ccfce3eb..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,13 +135,14 @@ export function useApp({ async function connect() { try { - const transport = new PostMessageTransport(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 d0c69dca11ede7ef1cb8cd21d29f917809c79afe Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 16 Dec 2025 23:25:00 +0000 Subject: [PATCH 2/8] 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 2191a72f..9607a3e5 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 */ @@ -23,6 +33,17 @@ import type { ZodRawShape } from "zod"; 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). */ @@ -47,7 +68,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" * @@ -118,15 +139,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 @@ -157,6 +194,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, @@ -167,4 +207,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 cae0505230ad8604bcee43f6ba0d65808f38e84c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 13:22:01 +0000 Subject: [PATCH 3/8] 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 4/8] 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 8278a132..b5b519b9 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 c7a125cab780b34a593fd7b70ef22e0282a99543 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:30:22 +0000 Subject: [PATCH 5/8] 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 969030fb..19951a38 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 25e7e430ae597652b43d04968906e0907f2a5d49 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:50:03 +0000 Subject: [PATCH 6/8] 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 acdeee37b690bd47f1b94e1ec2ecd2c9b68fb990 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:55:49 +0000 Subject: [PATCH 7/8] 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 f869e0a44a207851a072d32c200c0b9ae5002f44 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 17 Dec 2025 15:56:55 +0000 Subject: [PATCH 8/8] 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, },