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); })();