Skip to content

Commit d0c69dc

Browse files
ochafikclaude
andcommitted
feat: add cross-platform support for OpenAI Apps SDK
- 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 <[email protected]>
1 parent 95679a2 commit d0c69dc

File tree

4 files changed

+323
-35
lines changed

4 files changed

+323
-35
lines changed

src/openai/transport.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,75 @@ describe("OpenAITransport", () => {
128128
},
129129
});
130130
});
131+
132+
test("dynamically reports capabilities based on available methods", async () => {
133+
// Remove callTool to test dynamic detection
134+
delete mockOpenAI.callTool;
135+
136+
const transport = new OpenAITransport();
137+
let response: unknown;
138+
transport.onmessage = (msg) => {
139+
response = msg;
140+
};
141+
142+
await transport.send({
143+
jsonrpc: "2.0",
144+
id: 1,
145+
method: "ui/initialize",
146+
params: {
147+
protocolVersion: "2025-11-21",
148+
appInfo: { name: "TestApp", version: "1.0.0" },
149+
appCapabilities: {},
150+
},
151+
});
152+
153+
await new Promise((resolve) => setTimeout(resolve, 0));
154+
155+
const result = (response as { result: { hostCapabilities: unknown } })
156+
.result.hostCapabilities as Record<string, unknown>;
157+
158+
// serverTools should NOT be present since callTool is missing
159+
expect(result.serverTools).toBeUndefined();
160+
// openLinks should be present since openExternal exists
161+
expect(result.openLinks).toBeDefined();
162+
// logging is always available
163+
expect(result.logging).toBeDefined();
164+
});
165+
166+
test("includes availableDisplayModes when requestDisplayMode is available", async () => {
167+
mockOpenAI.requestDisplayMode = mock(() =>
168+
Promise.resolve(),
169+
) as unknown as OpenAIGlobal["requestDisplayMode"];
170+
171+
const transport = new OpenAITransport();
172+
let response: unknown;
173+
transport.onmessage = (msg) => {
174+
response = msg;
175+
};
176+
177+
await transport.send({
178+
jsonrpc: "2.0",
179+
id: 1,
180+
method: "ui/initialize",
181+
params: {
182+
protocolVersion: "2025-11-21",
183+
appInfo: { name: "TestApp", version: "1.0.0" },
184+
appCapabilities: {},
185+
},
186+
});
187+
188+
await new Promise((resolve) => setTimeout(resolve, 0));
189+
190+
expect(response).toMatchObject({
191+
jsonrpc: "2.0",
192+
id: 1,
193+
result: {
194+
hostContext: {
195+
availableDisplayModes: ["inline", "pip", "fullscreen"],
196+
},
197+
},
198+
});
199+
});
131200
});
132201

133202
describe("tools/call request", () => {
@@ -334,6 +403,32 @@ describe("OpenAITransport", () => {
334403
expect(toolResultNotification).toBeDefined();
335404
});
336405

406+
test("includes _meta from toolResponseMetadata in tool result", async () => {
407+
mockOpenAI.toolResponseMetadata = { widgetId: "abc123", version: 2 };
408+
409+
const transport = new OpenAITransport();
410+
const messages: unknown[] = [];
411+
transport.onmessage = (msg) => {
412+
messages.push(msg);
413+
};
414+
415+
transport.deliverInitialState();
416+
417+
await new Promise((resolve) => setTimeout(resolve, 0));
418+
419+
const toolResultNotification = messages.find(
420+
(m: unknown) =>
421+
(m as { method?: string }).method === "ui/notifications/tool-result",
422+
);
423+
expect(toolResultNotification).toMatchObject({
424+
jsonrpc: "2.0",
425+
method: "ui/notifications/tool-result",
426+
params: {
427+
_meta: { widgetId: "abc123", version: 2 },
428+
},
429+
});
430+
});
431+
337432
test("does not deliver notifications when data is missing", async () => {
338433
delete mockOpenAI.toolInput;
339434
delete mockOpenAI.toolOutput;

src/openai/transport.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,24 +245,40 @@ export class OpenAITransport implements Transport {
245245
theme: this.openai.theme,
246246
locale: this.openai.locale,
247247
displayMode: this.openai.displayMode,
248+
// If requestDisplayMode is available, ChatGPT supports all three modes
249+
availableDisplayModes: this.openai.requestDisplayMode
250+
? ["inline", "pip", "fullscreen"]
251+
: undefined,
248252
viewport: this.openai.maxHeight
249253
? { width: 0, height: 0, maxHeight: this.openai.maxHeight }
250254
: undefined,
251255
safeAreaInsets,
252256
userAgent,
253257
};
254258

259+
// Dynamically determine capabilities based on what window.openai supports
260+
const hostCapabilities: Record<string, unknown> = {
261+
// Logging is always available (we map to console.log)
262+
logging: {},
263+
};
264+
265+
// Only advertise serverTools if callTool is available
266+
if (this.openai.callTool) {
267+
hostCapabilities.serverTools = {};
268+
}
269+
270+
// Only advertise openLinks if openExternal is available
271+
if (this.openai.openExternal) {
272+
hostCapabilities.openLinks = {};
273+
}
274+
255275
return this.createSuccessResponse(id, {
256276
protocolVersion: LATEST_PROTOCOL_VERSION,
257277
hostInfo: {
258278
name: "ChatGPT",
259279
version: "1.0.0",
260280
},
261-
hostCapabilities: {
262-
serverTools: {},
263-
openLinks: {},
264-
logging: {},
265-
},
281+
hostCapabilities,
266282
hostContext,
267283
});
268284
}
@@ -494,6 +510,8 @@ export class OpenAITransport implements Transport {
494510
text: JSON.stringify(this.openai.toolOutput),
495511
},
496512
],
513+
// Include _meta from toolResponseMetadata if available
514+
_meta: this.openai.toolResponseMetadata,
497515
},
498516
} as JSONRPCNotification);
499517
});

0 commit comments

Comments
 (0)