@kolbo/mcp
Version:
Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code
320 lines (305 loc) • 16.1 kB
JavaScript
'use strict';
/**
* Minimal MCP Apps iframe bridge (io.modelcontextprotocol/ui, protocol 2026-01-26).
*
* Injected as an inline <script> into every ui://kolbo/* widget. Hand-rolled instead
* of shipping the 337KB @modelcontextprotocol/ext-apps browser bundle — implements the
* same JSON-RPC-over-postMessage handshake the official App class performs:
*
* widget → host request ui/initialize { appInfo, appCapabilities, protocolVersion }
* widget → host notification ui/notifications/initialized
* host → widget notification ui/notifications/tool-result | tool-input | host-context-changed
* widget → host request tools/call | ui/message | ui/open-link
* widget → host notification ui/notifications/size-changed
*
* Host-bound calls made before the handshake completes are queued (avoids the
* hidden-iframe race documented in claude-ai-mcp#61/#149).
*
* Exposed global: window.kolbo
* .ready(fn) — fn(hostContext) after handshake
* .onToolResult(fn) — fn(result) for ui/notifications/tool-result
* .onThemeChange(fn) — fn(hostContext) on host-context-changed
* .callTool(name, args) — Promise<CallToolResult>
* .sendMessage(text) — append a user chat message (returns Promise)
* .insertText(text) — paste into the host composer, do not send
* .openLink(url) — open external URL
* .copyText(text) — copy via the host clipboard (ui/copy-text)
* .notifySize() — report content size to host
*/
const BRIDGE_JS = `
(function () {
var nextId = 1;
var pending = {}; // id -> {resolve, reject}
var initialized = false;
var queue = []; // deferred host-bound sends until initialized
var hostContext = null;
var readyFns = [], toolResultFns = [], themeFns = [], toolInputFns = [];
var latestResult, latestInput;
function deliver(f, args) {
try { f.apply(null, args); }
catch (e) {
console.error('Kolbo widget render failed', e);
var card = document.querySelector('.k-card');
var notice = document.getElementById('kolbo-render-error');
if (card && !notice) {
notice = document.createElement('div');
notice.id = 'kolbo-render-error';
notice.setAttribute('role', 'alert');
notice.textContent = 'This preview could not update. Your generation may still be running. Reopen this card or check your Kolbo library.';
card.appendChild(notice);
}
}
}
function post(msg) { window.parent.postMessage(msg, '*'); }
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
var msg = { jsonrpc: '2.0', id: id, method: method, params: params || {} };
if (initialized || method === 'ui/initialize') post(msg);
else queue.push(msg);
});
}
function notify(method, params) {
var msg = { jsonrpc: '2.0', method: method, params: params || {} };
if (initialized || method === 'ui/notifications/initialized') post(msg);
else queue.push(msg);
}
window.addEventListener('message', function (ev) {
var m = ev.data;
if (!m || m.jsonrpc !== '2.0') return;
if (m.id != null && (m.result !== undefined || m.error !== undefined)) {
var p = pending[m.id];
if (!p) return;
delete pending[m.id];
if (m.error) p.reject(new Error(m.error.message || 'host error'));
else p.resolve(m.result);
return;
}
if (m.method === 'ui/notifications/tool-result') {
latestResult = m.params || {};
toolResultFns.forEach(function (f) { deliver(f, [latestResult]); });
} else if (m.method === 'ui/notifications/tool-input' || m.method === 'ui/notifications/tool-input-partial') {
// Fires while the tool is still RUNNING — lets widgets show a real
// "preparing" state instead of a blank card until the result lands.
latestInput = [(m.params && m.params.arguments) || {}, m.params || {}];
toolInputFns.forEach(function (f) { deliver(f, latestInput); });
} else if (m.method === 'ui/notifications/host-context-changed') {
hostContext = (m.params && m.params.hostContext) || m.params || hostContext;
themeFns.forEach(function (f) { try { f(hostContext); } catch (e) {} });
} else if (m.method === 'ui/resource-teardown' && m.id != null) {
post({ jsonrpc: '2.0', id: m.id, result: {} });
} else if (m.id != null) {
// Unknown host request — respond empty so the host isn't left hanging.
post({ jsonrpc: '2.0', id: m.id, result: {} });
}
});
request('ui/initialize', {
protocolVersion: '2026-01-26',
appInfo: { name: 'kolbo-widget', version: '1.0.0' },
appCapabilities: {}
}).then(function (res) {
if (oa) return; // A late JSON-RPC ack must not replace OpenAI host context.
hostContext = (res && res.hostContext) || null;
notify('ui/notifications/initialized');
initialized = true;
queue.forEach(post);
queue = [];
readyFns.forEach(function (f) { deliver(f, [hostContext]); });
}).catch(function () { /* host without apps support — widget stays static */ });
var fsMode = false; // fullscreen: the HOST owns layout — size reports there
// made the inline iframe balloon over the chat composer.
function notifySize() {
if (fsMode) return;
// Measure the widget card itself — documentElement.scrollHeight over-reports
// in some hosts and leaves a huge empty iframe below the card.
var card = document.querySelector('.k-card');
var rect = card ? card.getBoundingClientRect() : null;
var height = rect ? Math.ceil(rect.bottom + 8) : document.documentElement.scrollHeight;
// Ceiling comes from the SCREEN, never window.innerHeight. Inside an iframe
// innerHeight IS the height the host already granted, so clamping to it made
// the request a feedback loop: once the host capped us, we could never ask
// for more than the cap, and any content past it became an inner scrollbar
// that no amount of growing could clear. The host clamps too, so this is
// only a sanity ceiling.
var ceiling = (window.screen && window.screen.availHeight) || 1200;
height = Math.min(height, Math.max(ceiling, 500));
notify('ui/notifications/size-changed', {
width: document.documentElement.scrollWidth, height: height
});
markDraggable();
}
// Every image/video in a widget can be dragged straight into the host's
// composer. <img> is natively draggable but <video> is not, and neither sets
// a clean URL, so both get an explicit dragstart carrying text/uri-list —
// exactly the format the composer's drop handler already reads.
// Hooked off notifySize because that is the one thing every render path
// already calls, so new render sites are covered without touching them.
function markDraggable() {
var nodes = document.querySelectorAll('img[src], video[src]');
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node.getAttribute('data-kolbo-drag')) continue;
node.setAttribute('data-kolbo-drag', '1');
node.setAttribute('draggable', 'true');
node.addEventListener('dragstart', onMediaDragStart);
}
}
function onMediaDragStart(e) {
var node = e.currentTarget;
// Strip the #t=0.05 poster fragment videos carry, or the host would attach
// a URL the CDN answers differently.
var url = String(node.currentSrc || node.getAttribute('src') || '').split('#')[0];
if (!/^https?:/i.test(url) || !e.dataTransfer) return;
e.dataTransfer.setData('text/uri-list', url);
e.dataTransfer.setData('text/plain', url);
e.dataTransfer.effectAllowed = 'copy';
}
var sizeTimer = null;
function queueSize(delay) {
clearTimeout(sizeTimer);
sizeTimer = setTimeout(notifySize, delay || 120);
}
// With ResizeObserver present, attribute churn (class/style toggles) that
// actually changes layout is caught by RO on the card — observing attributes
// here would just re-fire on EVERY class/style write. Only watch attributes
// as a fallback when RO is unavailable.
new MutationObserver(function () { queueSize(120); })
.observe(document.documentElement, { childList: true, subtree: true, attributes: !window.ResizeObserver });
// DOM mutations don't fire when an <img>/<video> finishes loading and reflows
// the card — without this the host keeps the pre-image height and the card
// gets an inner scrollbar. ResizeObserver catches every layout change.
if (window.ResizeObserver) {
var ro = new ResizeObserver(function () { queueSize(60); });
ro.observe(document.documentElement);
ro.observe(document.body);
var card = document.querySelector('.k-card');
if (card) ro.observe(card);
}
// Belt and suspenders: media load events bubble as capture-phase 'load'.
document.addEventListener('load', function (e) {
var t = e.target && e.target.tagName;
if (t === 'IMG' || t === 'VIDEO') queueSize(60);
}, true);
document.addEventListener('loadedmetadata', function () { queueSize(60); }, true);
window.kolbo = {
ready: function (f) { if (initialized) deliver(f, [hostContext]); else readyFns.push(f); },
onToolResult: function (f) { toolResultFns.push(f); if (latestResult) deliver(f, [latestResult]); },
onToolInput: function (f) { toolInputFns.push(f); if (latestInput) deliver(f, latestInput); },
onThemeChange: function (f) { themeFns.push(f); },
callTool: function (name, args) { return request('tools/call', { name: name, arguments: args || {} }); },
sendMessage: function (text) {
return request('ui/message', { role: 'user', content: [{ type: 'text', text: text }] });
},
insertText: function (text) {
return request('ui/insert-text', { text: text });
},
openLink: function (url) { return request('ui/open-link', { url: url }); },
copyText: function (text) { return request('ui/copy-text', { text: text }); },
// Hand a piece of this widget's media to the host's composer. Dragging it
// out cannot work: a widget is a sandboxed cross-origin iframe, so a native
// HTML5 drag started in here never delivers its dataTransfer to the host
// document. Hosts that ignore this method simply do nothing.
attachMedia: function (url) { return request('ui/attach-media', { url: url }); },
updateModelContext: function (text) {
return request('ui/update-model-context', { content: [{ type: 'text', text: text }] });
},
// Resolves with the mode the host ACTUALLY granted ('inline'|'fullscreen'|'pip').
requestDisplayMode: function (mode) {
return request('ui/request-display-mode', { mode: mode });
},
notifySize: notifySize,
// Toggle fullscreen mode: suppresses size reports while the host owns the
// layout, and re-syncs the inline size on exit.
setFullscreen: function (on) {
fsMode = !!on;
if (!on) queueSize(60);
},
hostContext: function () { return hostContext; }
};
// ChatGPT (OpenAI Apps SDK) host. It mounts the same iframe but speaks no
// JSON-RPC: tool input / output / theme arrive as window.openai globals plus
// an 'openai:set_globals' DOM event, and actions are methods on that object.
// Without this adapter the card rendered, waited for a tool-input that never
// came, and sat on "Preparing" with no prompt, no settings and no result —
// even after the tool had returned. Everything here maps onto the same
// callbacks the JSON-RPC path drives, so widgets need no host-specific code.
var oa = null;
var oaInputSeen, oaOutputSeen;
function attachOpenAI(event) {
var current = window.openai;
if (!current || typeof current !== 'object') return;
oa = current;
var globals = event && event.detail && event.detail.globals;
var oaName = function () {
var meta = oa.toolResponseMetadata || {};
return oa.toolName || meta['kolbo/tool'] || (oa.toolOutput && oa.toolOutput.tool) || undefined;
};
var oaSync = function () {
var name = oaName();
// Same shape claude.ai hands ready(): toolInfo.tool.name is what widgets read.
hostContext = { theme: oa.theme, displayMode: oa.displayMode, toolInfo: { name: name, tool: { name: name }, arguments: oa.toolInput || {} } };
if (!initialized) {
initialized = true;
queue = [];
readyFns.forEach(function (f) { deliver(f, [hostContext]); });
} else {
themeFns.forEach(function (f) { try { f(hostContext); } catch (e) {} });
}
var input = globals && Object.prototype.hasOwnProperty.call(globals, 'toolInput') ? globals.toolInput : oa.toolInput;
var output = globals && Object.prototype.hasOwnProperty.call(globals, 'toolOutput') ? globals.toolOutput : oa.toolOutput;
var inputKey = JSON.stringify(input);
var metadata = globals && globals.toolResponseMetadata || oa.toolResponseMetadata || {};
var envelope = metadata.call_tool_result || metadata.mcp_tool_result;
var result = envelope && typeof envelope === 'object' ? Object.assign({}, envelope) : {};
if (output != null) result.structuredContent = output;
result._meta = Object.assign({}, result._meta || {}, metadata);
var outputKey = JSON.stringify(result);
if (input && inputKey !== oaInputSeen) {
oaInputSeen = inputKey;
latestInput = [input, { name: oaName(), arguments: input }];
toolInputFns.forEach(function (f) { deliver(f, latestInput); });
}
if ((output || envelope) && outputKey !== oaOutputSeen) {
oaOutputSeen = outputKey;
latestResult = result;
toolResultFns.forEach(function (f) { deliver(f, [latestResult]); });
}
};
var oaCall = function (method) { return typeof oa[method] === 'function'; };
window.kolbo.callTool = function (name, args) {
if (!oaCall('callTool')) return Promise.reject(new Error('host cannot call tools'));
return Promise.resolve(oa.callTool(name, args || {})).then(function (r) {
// The SDK resolves with the CallToolResult (content + structuredContent);
// some builds unwrap to the structured payload — normalise either.
if (r && (r.structuredContent || r.content)) return r;
return { structuredContent: r, content: [] };
});
};
window.kolbo.sendMessage = function (text) {
return oaCall('sendFollowUpMessage') ? Promise.resolve(oa.sendFollowUpMessage({ prompt: text })) : Promise.reject(new Error('unsupported'));
};
window.kolbo.insertText = function () { return Promise.reject(new Error('unsupported')); };
window.kolbo.openLink = function (url) {
return oaCall('openExternal') ? Promise.resolve(oa.openExternal({ href: url })) : Promise.resolve(window.open(url, '_blank'));
};
window.kolbo.copyText = function (t) {
return navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(t) : Promise.reject(new Error('unsupported'));
};
window.kolbo.attachMedia = function () { return Promise.reject(new Error('unsupported')); };
window.kolbo.updateModelContext = function () { return Promise.resolve(); };
window.kolbo.requestDisplayMode = function (mode) {
if (!oaCall('requestDisplayMode')) return Promise.resolve({ mode: 'inline' });
return Promise.resolve(oa.requestDisplayMode({ mode: mode })).then(function (r) { return { mode: (r && r.mode) || mode }; });
};
// Deferred: the widget's own script (which registers ready/onToolInput/
// onToolResult) runs AFTER this bridge block in the same document, and a
// synchronous sync here fired into empty listener lists and marked the
// input/output as seen.
oaSync();
}
window.addEventListener('openai:set_globals', attachOpenAI);
setTimeout(attachOpenAI, 0);
})();
`;
module.exports = { BRIDGE_JS };