From 6eda9c4137dea4c02ee8a640a14f942dc4ddf97e Mon Sep 17 00:00:00 2001 From: Julian Dice <19397727+windoze95@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:18:44 -0500 Subject: [PATCH] fix(mcp): render the widget in ChatGPT via the window.openai runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget only spoke the MCP Apps postMessage bridge, and blocked on an awaited ui/initialize response. In ChatGPT the tool CALLS worked but the widget sat on its 'Warming up the kitchen…' loading note forever, because ChatGPT's Apps runtime delivers tool output via window.openai (globals + the 'openai:set_globals' event), not that bridge — and it never answers ui/initialize, so the await hung. - Add a window.openai path: render from window.openai.toolOutput on mount and on 'openai:set_globals' (event.detail.globals.toolOutput); apply theme from the globals. - Route widget-initiated tool calls through window.openai.callTool and link-opens through window.openai.openExternal when present. - Make the MCP Apps ui/initialize handshake NON-BLOCKING (.then/.catch instead of await) so a host that never answers it can't freeze the widget. Claude/MCP-Apps rendering path is otherwise unchanged. JS syntax-checked; go build/vet + mcpserver tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/mcpserver/widget/app.html | 70 +++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/internal/mcpserver/widget/app.html b/internal/mcpserver/widget/app.html index 644cc9d..6081f53 100644 --- a/internal/mcpserver/widget/app.html +++ b/internal/mcpserver/widget/app.html @@ -520,7 +520,7 @@ } if (view.sourceURL) { const link = el("button", "linkbtn", domainOf(view.sourceURL) || "source"); - link.onclick = () => bridge.request("ui/open-link", { url: view.sourceURL }); + link.onclick = () => openLink(view.sourceURL); actions.appendChild(link); } headings.appendChild(actions); @@ -578,6 +578,12 @@ * Tool calls initiated from the widget * ------------------------------------------------------------------ */ async function callTool(name, args) { + const oa = openAIHost(); + if (oa && typeof oa.callTool === "function") { + // ChatGPT Apps runtime: returns { structuredContent, content }. + const next = await oa.callTool(name, args); + return (next && next.structuredContent) || {}; + } const result = await bridge.request("tools/call", { name, arguments: args }); if (result && result.isError) { const text = (result.content || []).map((c) => c.text).filter(Boolean).join(" "); @@ -586,6 +592,17 @@ return (result && result.structuredContent) || {}; } +// openLink sends the user to an external URL via whichever host bridge is active. +function openLink(url) { + if (!url) return; + const oa = openAIHost(); + if (oa && typeof oa.openExternal === "function") { + try { oa.openExternal({ href: url }); return; } catch (e) { /* fall through */ } + try { oa.openExternal(url); return; } catch (e) { /* fall through */ } + } + bridge.request("ui/open-link", { url }).catch(() => {}); +} + async function openPreview(url) { if (!url) return; skeleton(); @@ -625,6 +642,26 @@ } } +// openAIHost returns ChatGPT's Apps runtime object when the widget is embedded in +// ChatGPT, else null. ChatGPT delivers the tool's structured output via +// window.openai (globals + the "openai:set_globals" event), NOT the MCP Apps +// postMessage bridge — so without this the widget would sit on its loading note +// in ChatGPT forever. +function openAIHost() { + return (typeof window !== "undefined" && window.openai) ? window.openai : null; +} + +// applyOpenAIGlobals renders from the ChatGPT runtime globals: theme + the tool's +// structured output, which is exactly the shape render() expects. +function applyOpenAIGlobals(globals) { + if (!globals) return; + if (globals.theme === "dark" || globals.theme === "light") { + document.documentElement.setAttribute("data-theme", globals.theme); + } + if (globals.toolOutput) render(globals.toolOutput); +} + +// MCP Apps postMessage host (Claude and other ext-apps hosts). bridge.on("ui/notifications/tool-result", (params) => { if (params && params.structuredContent) render(params.structuredContent); }); @@ -633,6 +670,11 @@ applyHostContext(params && params.hostContext); }); +// ChatGPT Apps runtime host: tool output + theme arrive via window.openai globals. +window.addEventListener("openai:set_globals", (event) => { + applyOpenAIGlobals(event && event.detail && event.detail.globals); +}); + const resizeObserver = new ResizeObserver(() => { bridge.notify("ui/notifications/size-changed", { width: document.documentElement.scrollWidth, @@ -640,19 +682,23 @@ }); }); -(async function main() { - try { - const init = await bridge.request("ui/initialize", { - protocolVersion: "2026-01-26", - clientInfo: { name: "saltybytes-widget", version: "1.0.0" }, - capabilities: {}, - }); +(function main() { + // ChatGPT: window.openai is present at mount — render immediately from whatever + // is already set, then let "openai:set_globals" drive updates. + const oa = openAIHost(); + if (oa) applyOpenAIGlobals(oa); + // MCP Apps host (Claude, etc.): run the postMessage handshake, but NON-BLOCKING + // so a host that never answers ui/initialize (ChatGPT) can't leave the widget + // stuck on its loading note. + bridge.request("ui/initialize", { + protocolVersion: "2026-01-26", + clientInfo: { name: "saltybytes-widget", version: "1.0.0" }, + capabilities: {}, + }).then((init) => { applyHostContext(init && init.hostContext); bridge.notify("ui/notifications/initialized"); - resizeObserver.observe(document.body); - } catch { - /* Host without MCP Apps support: leave the static note visible. */ - } + }).catch(() => { /* not an MCP Apps postMessage host */ }); + resizeObserver.observe(document.body); })();