@kolbo/mcp
Version:
Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code
840 lines (779 loc) • 37.3 kB
JavaScript
;
/**
* MCP Apps integration (io.modelcontextprotocol/ui) — Kolbo interactive widgets.
*
* Registers the ui://kolbo/* HTML resources and provides the helpers tool files
* use to attach widgets to results. Everything here is ADDITIVE: text-only hosts
* (Claude Code, Cursor, old clients) ignore `_meta` + `structuredContent` and see
* exactly the same text responses as before.
*/
const {
registerAppResource,
getUiCapability,
RESOURCE_MIME_TYPE,
RESOURCE_URI_META_KEY,
} = require('@modelcontextprotocol/ext-apps/server');
const { generationWidgetHtml } = require('./widgets/generation');
const { mediaGridWidgetHtml } = require('./widgets/mediaGrid');
const { catalogWidgetHtml } = require('./widgets/catalog');
const { transcriptWidgetHtml } = require('./widgets/transcript');
const { uploadWidgetHtml } = require('./widgets/upload');
const { fontUploadWidgetHtml } = require('./widgets/fontUpload');
const { listWidgetHtml } = require('./widgets/list');
const { HOST_MAP } = require('../cdn');
const { plansWidgetHtml } = require('./widgets/plans');
const { creditsWidgetHtml } = require('./widgets/credits');
const { ResourceTemplate } = require('@modelcontextprotocol/sdk/server/mcp.js');
const { McpError, ErrorCode } = require('@modelcontextprotocol/sdk/types.js');
const UI = {
generation: 'ui://kolbo/generation.html',
mediaGrid: 'ui://kolbo/media-grid.html',
catalog: 'ui://kolbo/catalog.html',
transcript: 'ui://kolbo/transcript.html',
upload: 'ui://kolbo/upload.html',
fontUpload: 'ui://kolbo/font-upload.html',
list: 'ui://kolbo/list.html',
plans: 'ui://kolbo/plans.html',
credits: 'ui://kolbo/credits.html',
};
const WIDGET_BUILDERS = {
[UI.generation]: generationWidgetHtml,
[UI.mediaGrid]: mediaGridWidgetHtml,
[UI.catalog]: catalogWidgetHtml,
[UI.transcript]: transcriptWidgetHtml,
[UI.upload]: uploadWidgetHtml,
[UI.fontUpload]: fontUploadWidgetHtml,
[UI.list]: listWidgetHtml,
[UI.plans]: plansWidgetHtml,
[UI.credits]: creditsWidgetHtml,
};
// Widgets are pure functions of source — build once per process.
const htmlCache = new Map();
function widgetHtml(uri) {
uri = String(uri).split('?')[0]; // versioned and bare URIs serve the same page
if (!htmlCache.has(uri)) htmlCache.set(uri, WIDGET_BUILDERS[uri]());
return htmlCache.get(uri);
}
// ChatGPT treats the widget URI as a permanent cache key: it fetched
// generation.html once per app link and kept serving that snapshot across every
// later release (1.87.9's ChatGPT fix never reached a card until the URI
// changed — verified 2026-09-06 via its widget endpoint, where only
// force_local=true returned the new HTML). OpenAI's own guidance is "treat the
// resource URI as a cache key; publish a new URI on every change", so the URI
// every tool declares and returns carries a content hash of the HTML it points
// at. The bare URI stays registered for hosts holding an older tools/list.
const crypto = require('crypto');
const versionCache = new Map();
function versionedUri(uri) {
if (!versionCache.has(uri)) {
const v = crypto.createHash('sha1').update(widgetHtml(uri)).digest('hex').slice(0, 10);
versionCache.set(uri, uri + '?v=' + v);
}
return versionCache.get(uri);
}
// Hosts apply a deny-by-default CSP to widget iframes — without this
// declaration EVERY external asset (generated images/videos on the CDN, model
// icons, Google Fonts) is silently blocked. resourceDomains maps to
// img/script/style/font/media-src; connectDomains to connect-src.
// Every media host cdn.js can hand a widget: each environment's Spaces origin
// AND the custom domain it gets rewritten to. DERIVED from HOST_MAP, never
// retyped — the two lists drifted, this one carrying production only, and the
// result was silent: 565 of 864 production voice documents still store
// development-bucket thumbnails, cdn.js rewrote them to media-dev.kolbo.ai,
// and the host's img-src blocked every one. A dev- or staging-pointed MCP had
// it worse — nothing with a picture in it rendered at all. Add an environment
// to HOST_MAP and its CSP entry now comes with it.
const KOLBO_MEDIA_DOMAINS = [...new Set(HOST_MAP.flat())].map((host) => `https://${host}`);
const WIDGET_CSP = {
resourceDomains: [
// Public hosts owned by Kolbo.
'https://api.kolbo.ai',
'https://app.kolbo.ai',
'https://cdn.kolbo.ai',
// Preset thumbnails and library media live on the media-* hosts (prod
// presets still reference media-dev); without these every preset tile
// rendered as a black box. All three are on kolbo-api's download allowlist.
'https://media.kolbo.ai',
'https://media-staging.kolbo.ai',
'https://media-dev.kolbo.ai',
...KOLBO_MEDIA_DOMAINS,
'https://kolbo-general-media.fra1.digitaloceanspaces.com',
'https://kolbo-general-media.fra1.cdn.digitaloceanspaces.com',
// Fonts used by the shared widget shell.
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
// Exact preview hosts returned by the production stock integrations.
'https://images.pexels.com',
'https://videos.pexels.com',
'https://images.unsplash.com',
'https://plus.unsplash.com',
'https://pixabay.com',
'https://cdn.pixabay.com',
'https://coverr.co',
'https://cdn.coverr.co',
'https://freesound.org',
'https://cdn.freesound.org',
'https://sketchfab.com',
'https://media.sketchfab.com',
'https://cdn.sketchfab.com',
'https://assets.sketchfab.com',
'https://sketchfab-prod-media.s3.amazonaws.com',
// Voice PREVIEW audio hosts. list_voices ships every voice with a
// preview_url and the card renders a real <audio> for it, but 150 of the
// 864 production voices store that preview on a provider host rather than a
// Kolbo bucket: 138 google voices on storage.googleapis.com and 12 on
// api.us.elevenlabs.io. media-src blocked both, so every google voice — the
// entire Hebrew set — rendered a player stuck at 0:00 / 0:00 with no error
// anywhere. The Spaces-hosted previews come free via HOST_MAP above; these
// two do not, because they are not ours.
'https://storage.googleapis.com',
'https://api.us.elevenlabs.io',
// Default SYNCI catalog project. Any production override must be reviewed
// and added here as an exact hostname before deployment.
'https://gfbpxdkripkbbrcvoyeh.supabase.co',
],
// connect-src — XHR/fetch FROM widget iframes. Used by the upload widget to
// POST files to /mcp/upload with its short-lived ticket.
connectDomains: [
'https://api.kolbo.ai',
// Since kolbo-api fbed4e7b5 (2026-08-18) the upload ticket points at the
// non-Cloudflare-proxied `upload-*` twin (500MB cap). Without these hosts
// the in-chat XHR is CSP-blocked and every claude.ai upload fails silently.
'https://upload-api.kolbo.ai',
],
// Nested iframes: NONE. The plans card used to iframe app.kolbo.ai/pricing;
// OpenAI rejected the ChatGPT app for it (2026-09-06: "frameDomains is
// reserved for limited cases where embedding a third-party experience is
// essential"). Every card renders from structuredContent and opens Kolbo in a
// new tab instead. Adding a host here re-triggers that rejection.
frameDomains: [],
};
/** Register all Kolbo widget resources on an McpServer. */
function registerApps(server) {
for (const [uri, name] of [
[UI.generation, 'Kolbo Generation Widget'],
[UI.mediaGrid, 'Kolbo Library Widget'],
[UI.catalog, 'Kolbo Model Catalog Widget'],
[UI.transcript, 'Kolbo Transcription Widget'],
[UI.upload, 'Kolbo Upload Widget'],
[UI.fontUpload, 'Kolbo Font Upload Widget'],
[UI.list, 'Kolbo List Widget'],
[UI.plans, 'Kolbo Plans Widget'],
[UI.credits, 'Kolbo Credit Balance Widget'],
]) {
for (const u of [versionedUri(uri), uri]) {
registerAppResource(
server, name + (u === uri ? ' (unversioned)' : ''), u,
{ mimeType: RESOURCE_MIME_TYPE, _meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } } },
async () => ({
contents: [{
uri: u, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml(uri),
_meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } },
}],
})
);
}
// Published tool definitions and old conversations can retain any previous
// content hash. Keep those addresses readable after deploying new HTML.
// Match only this registered widget and the version format we publish.
registerAppResource(
server, name + ' (previous versions)',
new ResourceTemplate(uri + '{?v}', { list: undefined }),
{ mimeType: RESOURCE_MIME_TYPE, _meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } } },
async (requestedUri) => {
const requested = requestedUri.href;
if (!new RegExp('^' + uri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\?v=[a-f0-9]{10}$').test(requested)) {
throw new McpError(ErrorCode.InvalidParams, 'Unknown widget version URI');
}
return {
contents: [{
uri: requested, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml(uri),
_meta: { csp: WIDGET_CSP, ui: { csp: WIDGET_CSP } },
}],
};
}
);
}
}
/** `_meta` for a tool RESULT (and optionally for tool registration). */
function uiMeta(uri) {
const u = versionedUri(uri);
return { [RESOURCE_URI_META_KEY]: u, ui: { resourceUri: u } };
}
/**
* Should this server instance produce widget results?
* - `opts.apps === true` — set by the kolbo-api remote connector (claude.ai),
* where the stateless transport makes client capabilities unavailable per-call.
* - stdio hosts (Claude Desktop) — detected from the initialize handshake.
* - `KOLBO_MCP_APPS=1|0` env — manual override for local testing.
*/
function appsEnabled(server, opts = {}) {
if (process.env.KOLBO_MCP_APPS === '0') return false;
if (opts.apps === true || process.env.KOLBO_MCP_APPS === '1') return true;
try {
const caps = server?.server?.getClientCapabilities?.();
if (getUiCapability(caps) !== undefined) return true;
// Codex Desktop currently mounts MCP App resources from tool `_meta`, but
// its initialize handshake identifies as `codex-mcp-client` with an empty
// capabilities object. Without this compatibility path the host mounts a
// "Preparing" card while the tool takes the blocking/text fallback, so the
// completed media never reaches the iframe as structuredContent.
//
// Keep the desktop-origin check: Codex CLI uses the same client name but is
// a text surface, where returning immediately would remove the final URLs.
const info = server?.server?.getClientVersion?.();
const origin = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || '';
return info?.name === 'codex-mcp-client' && /codex desktop/i.test(origin);
} catch (_) {
return false;
}
}
/**
* Build a widget-carrying tool result. `text` stays the LLM-facing source of
* truth; `structured` goes to the widget only.
*/
function uiResult(uri, text, structured) {
return {
content: [{ type: 'text', text }],
structuredContent: structured,
// 'kolbo/tool' reaches ChatGPT widgets as toolResponseMetadata — that host
// never tells the iframe which tool ran, and the card title depends on it.
_meta: { ...uiMeta(uri), ...(structured && structured.tool ? { 'kolbo/tool': structured.tool } : {}) },
};
}
/** List tools always ship structuredContent — Kolbo Code does not advertise
* MCP Apps, so gating on appsEnabled() left list.html / the generation
* fallback stuck on Loading with only `{ sessions }` text. */
function listResult(text, structured) {
return uiResult(UI.list, text, structured);
}
/* ------------------------------------------------------------------ */
/* Model icon lookup (name/identifier → absolute avatar URL) */
/* ------------------------------------------------------------------ */
const ICON_TTL_MS = 10 * 60 * 1000;
const infoCache = new Map(); // apiBase → { at, byKey: Map<lowername, info>, all: info[] }
/**
* Resolve a Model.avatar value to an absolute URL. Avatars are bare filenames
* (sometimes with spaces). They're mirrored from kolbo-api/assets to the
* public DO Spaces CDN by kolbo-api scripts/infra/upload-model-icons-cdn.js —
* the CDN is the ONLY host that reliably loads inside claude.ai's sandboxed
* widget iframes (api.kolbo.ai sits behind Cloudflare bot rules that block
* sandbox image requests; app.kolbo.ai is the SPA whose catch-all returns
* 200 text/html for missing files).
*/
const ICON_CDN_BASE = 'https://kolbo-general-media.fra1.cdn.digitaloceanspaces.com/models_icons';
function resolveAvatarUrl(avatar) {
if (!avatar) return null;
if (/^https?:\/\//i.test(avatar)) {
try {
const parsed = new URL(avatar);
const file = parsed.pathname.match(/\/(?:models_icons|assets)\/([^/]+)$/);
// api.kolbo.ai / app.kolbo.ai avatars 404 or get bot-blocked inside
// sandboxed widget iframes. The CDN copy is the same file.
if (file && /(?:^|\.)kolbo\.ai$|digitaloceanspaces\.com$/i.test(parsed.hostname)) {
return `${ICON_CDN_BASE}/${file[1]}`;
}
} catch (_) { /* keep the original absolute URL */ }
return avatar;
}
return `${ICON_CDN_BASE}/${encodeURIComponent(String(avatar).replace(/^\/+/, ''))}`;
}
async function modelCatalog(client) {
const cacheKey = client.apiBase || 'default';
const hit = infoCache.get(cacheKey);
if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit;
const byKey = new Map();
const all = [];
try {
const res = await client.request('GET', '/v1/models');
const models = res?.models || res?.data?.models || [];
for (const m of models) {
if (!m) continue;
const icon = resolveAvatarUrl(m.avatar, client.apiBase);
// Real p75 wall-clock estimate mined from production creditUsages —
// the same source the in-app countdowns use. No estimate → no ETA shown.
const eta = Number(m.estimatedDurationSeconds || m.estimated_duration_seconds) || null;
// `name` is the CLEAN display name ("Google TTS"); it is what widgets show.
// Without it the model chip fell back to whatever raw string the caller or
// the status endpoint supplied ("google_tts", "fal-ai/bytedance/omnihuman/v1.5").
// `types` is the catalog `type` array ("text_to_video", "img_to_video", …) —
// the ONLY thing that tells two same-named variants apart. See canonicalModelId.
const raw = m.types !== undefined ? m.types : m.type;
const types = (Array.isArray(raw) ? raw : [raw]).filter(Boolean).map(String);
const aspects = Array.isArray(m.supported_aspect_ratios)
? m.supported_aspect_ratios
: (Array.isArray(m.supportedAspectRatios) ? m.supportedAspectRatios : []);
const aspectsByType = m.supported_aspect_ratios_by_type || m.supportedAspectRatiosByType || null;
const info = { icon, eta, id: m.identifier || null, name: m.name || null, types, aspects, aspectsByType };
all.push(info);
// Display names collide across variants ("Nano Banana 2" names both the
// t2i model and its editing sibling) — on collision keep the model with
// the SHORTEST identifier (the base model), deterministically. This map is
// for ICONS/ETAs, where the variant doesn't matter; identifier resolution
// must NOT use it (that is what made "Kling 2.6 Pro" always mean the t2v one).
const setName = (k) => {
const prev = byKey.get(k);
if (!prev || !prev.id || (info.id && info.id.length < prev.id.length)) byKey.set(k, info);
};
if (m.name) setName(String(m.name).toLowerCase());
if (m.identifier) byKey.set(String(m.identifier).toLowerCase(), info);
}
} catch (_) {
/* fail open — widgets fall back to monogram chips, no ETA */
}
const entry = { at: Date.now(), byKey, all };
// Never cache an empty map: the first request in a fresh worker (typical
// right after a deploy restart) can fail transiently, and caching that
// failure blanks every model icon for the TTL window.
if (byKey.size > 0) infoCache.set(cacheKey, entry);
return entry;
}
async function modelInfoMap(client) {
return (await modelCatalog(client)).byKey;
}
/** Resolve one model's { icon, eta, name }; missing → all null. */
async function modelInfo(client, modelName) {
if (!modelName) return { icon: null, eta: null, name: null };
const catalog = await modelCatalog(client);
const exact = catalog.byKey.get(String(modelName).toLowerCase());
if (exact) return exact;
// Same separator-insensitive / prefix leniency as canonicalModelId — the
// chip used to miss "gpt-image-2" when the catalog only keyed the editor
// sibling, and the widget fell back to a first-letter monogram.
const want = normId(modelName);
if (!want) return { icon: null, eta: null, name: null };
const rows = catalog.all || [];
return rows.find((row) => normId(row.id) === want || normId(row.name) === want)
|| rows.find((row) => normId(row.id).startsWith(want) || normId(row.name).startsWith(want))
|| variantBase(rows, modelName)
|| { icon: null, eta: null, name: null };
}
// A finished generation can report an internal variant of a catalog model —
// "seedance-2-5-draft-enhance" (draft tier + prompt enhance) is not itself in
// /v1/models, so the chip showed a letter monogram and the raw id. Resolve it
// to the LONGEST catalog identifier it extends at a "-" boundary
// ("seedance-2-5", not "seedance-2").
function variantBase(rows, modelName) {
const raw = String(modelName).toLowerCase();
let best = null;
for (const row of rows) {
const id = row.id && String(row.id).toLowerCase();
if (id && raw.startsWith(id + '-') && (!best || id.length > best.id.length)) best = row;
}
return best;
}
/**
* Snap a requested aspect ratio onto the catalog enum for that model.
* Every image/video generate_* tool must call this after canonicalModelId —
* list_models already prints supported_aspect_ratios, but the LLM still
* invents 21:9 / "widescreen" / "21/9". Fail open only when the catalog is
* empty or the model is unpublished (hidden ids still reach the API).
*/
const ASPECT_ALIASES = {
square: '1:1',
landscape: '16:9',
widescreen: '16:9',
horizontal: '16:9',
portrait: '9:16',
vertical: '9:16',
story: '9:16',
stories: '9:16',
reel: '9:16',
reels: '9:16',
ultrawide: '21:9',
cinema: '21:9',
cinematic: '21:9',
};
function normalizeAspectRatio(requested) {
if (requested == null || requested === '') return requested;
const raw = String(requested).trim();
const lower = raw.toLowerCase();
if (lower === 'auto' || lower === 'adaptive') return lower;
if (ASPECT_ALIASES[lower]) return ASPECT_ALIASES[lower];
const pair = lower.match(/^(\d+(?:\.\d+)?)\s*[:x×/\-]\s*(\d+(?:\.\d+)?)$/);
if (!pair) return raw;
const a = Number(pair[1]);
const b = Number(pair[2]);
if (!Number.isFinite(a) || !Number.isFinite(b) || a <= 0 || b <= 0) return raw;
return Number.isInteger(a) && Number.isInteger(b) ? `${a}:${b}` : `${a}:${b}`;
}
function parseAspectDecimal(ratio) {
const normalized = normalizeAspectRatio(ratio);
const parts = String(normalized || '').split(':').map(Number);
if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n) || n <= 0)) return 1;
return parts[0] / parts[1];
}
function aspectKey(ratio) {
return String(normalizeAspectRatio(ratio) || '').toLowerCase();
}
function closestAspectRatio(requested, supported) {
const normalized = normalizeAspectRatio(requested);
if (!normalized) return requested;
if (normalized === 'auto' || normalized === 'adaptive') return normalized;
if (!Array.isArray(supported) || supported.length === 0) return normalized;
const exact = supported.find((ratio) => aspectKey(ratio) === aspectKey(normalized));
if (exact) return exact;
const concrete = supported.filter((ratio) => {
const key = aspectKey(ratio);
return key && key !== 'auto' && key !== 'adaptive';
});
if (!concrete.length) return normalized;
const target = parseAspectDecimal(normalized);
let best = concrete[0];
let bestDist = Infinity;
for (const ratio of concrete) {
const dist = Math.abs(parseAspectDecimal(ratio) - target);
if (dist < bestDist) {
bestDist = dist;
best = ratio;
}
}
return best;
}
function findCatalogModel(catalog, modelId, type) {
if (!catalog || !modelId) return null;
const key = String(modelId).toLowerCase().trim();
const want = normId(key);
if (!want) return null;
const byKey = catalog.byKey && catalog.byKey.get(key);
if (byKey) return byKey;
const all = catalog.all || [];
const types = (Array.isArray(type) ? type : [type]).filter(Boolean);
const candidates = all.filter((info) =>
(info.id && (info.id.toLowerCase() === key || normId(info.id) === want))
|| (info.name && (info.name.toLowerCase() === key || normId(info.name) === want))
);
if (!candidates.length) return null;
if (types.length) {
const typed = candidates.filter((info) => Array.isArray(info.types) && info.types.some((t) => types.includes(t)));
if (typed.length) return typed[0];
}
return candidates[0];
}
function supportedAspectsFor(info, type) {
if (!info) return [];
const byType = type && info.aspectsByType;
if (byType && typeof byType === 'object') {
const typed = Array.isArray(type)
? type.map((t) => byType[t]).find((arr) => Array.isArray(arr) && arr.length)
: byType[type];
if (Array.isArray(typed) && typed.length) return typed;
}
return Array.isArray(info.aspects) ? info.aspects : [];
}
async function resolveCatalogAspectRatio(client, modelId, requested, type) {
if (!requested) return requested;
const normalized = normalizeAspectRatio(requested);
if (!modelId) return normalized;
try {
const catalog = await modelCatalog(client);
const info = findCatalogModel(catalog, modelId, type);
if (!info) return normalized;
return closestAspectRatio(normalized, supportedAspectsFor(info, type));
} catch (_) {
return normalized;
}
}
/* ------------------------------------------------------------------ */
/* Voice lookup (voice_id / display name → { id, name, thumbnail }) */
/* ------------------------------------------------------------------ */
// Same shape and TTL as the model catalog above: the voice catalog is stable,
// and a per-generation /v1/voices round trip would be paid on every speech card.
const voiceCache = new Map(); // apiBase → { at, byKey }
async function voiceInfoMap(client) {
const cacheKey = client.apiBase || 'default';
const hit = voiceCache.get(cacheKey);
if (hit && Date.now() - hit.at < ICON_TTL_MS) return hit.byKey;
const byKey = new Map();
try {
const res = await client.request('GET', '/v1/voices');
for (const v of res?.voices || []) {
if (!v || !v.voice_id) continue;
// thumbnail/preview come from the catalog record — NEVER templated from
// the id, so a change to the CDN path scheme cannot silently 404 the card.
const info = { id: v.voice_id, name: v.name || v.voice_id, thumbnail: v.thumbnail || null };
byKey.set(String(v.voice_id).toLowerCase(), info);
// Display names are not unique across locales (the same Gemini voice is
// catalogued per language). First one wins; the id lookup above is exact.
const nameKey = String(info.name).toLowerCase();
if (v.name && !byKey.has(nameKey)) byKey.set(nameKey, info);
}
} catch (_) {
/* fail open — cards fall back to the raw voice string */
}
if (byKey.size > 0) voiceCache.set(cacheKey, { at: Date.now(), byKey });
return byKey;
}
/** Resolve a voice id OR display name to { id, name, thumbnail }; null if unknown. */
async function voiceInfo(client, voice) {
if (!voice) return null;
const map = await voiceInfoMap(client);
return map.get(String(voice).toLowerCase().trim()) || null;
}
/** Back-compat shim (used by uiCompleted and older call sites). */
async function modelIcon(client, modelName) {
return (await modelInfo(client, modelName)).icon;
}
// Separator-insensitive key. Catalog keys carry their own punctuation — the
// NAME is keyed "minimax h3", the IDENTIFIER "flux-2/flash" — so both sides
// must be flattened before comparing. Normalising only the input (the old
// `key.replace(/\s+/g, '-')`) is why "flux-2-flash" never found "flux-2/flash".
const normId = (s) => String(s || '').toLowerCase().replace(/[\s._/-]+/g, '');
// The API maps these to Smart Select itself. They are never typos, so they must
// never be "corrected" or reported as unknown.
const AUTO_ALIASES = new Set([
'auto', 'autoselect', 'smartselect', 'kolbosmartselectrouter', 'default', 'none',
]);
/**
* Narrow several models that answer to the same string down to one identifier.
* The CALLING TOOL's catalog type decides: a display name like "Kling 2.6 Pro"
* names one model PER MODALITY (…/text-to-video and …/image-to-video), and only
* the caller knows which it wants. No type match (or no type given) → shortest
* identifier, the same deterministic tiebreak the icon map uses.
*/
function pickForType(candidates, types) {
if (!candidates.length) return null;
const typed = types.length
? candidates.filter((i) => i.types.some((t) => types.includes(t)))
: [];
const pool = typed.length ? typed : candidates;
return pool.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
}
// Strip modality tokens so a t2v id and its i2v sibling share one family key
// (grok-imagine-text-to-video ↔ grok-imagine-image-to-video; kling …/text-to-video
// ↔ …/image-to-video). Version tokens stay (1.5 ≠ 1.0).
function fam(s) {
return normId(s).replace(
/texttovideo|imagetovideo|imgtovideo|texttoimage|imagetoimage|imageediting|imageedit|referencetovideo|videotovideo|videoedit|editvideo|firstlastframe|firstlast/g,
'',
);
}
function sibling(models, hit, types) {
if (!hit || !types.length) return hit;
const row = models.find((i) => i.id === hit);
if (row && row.types.some((t) => types.includes(t))) return hit;
const key = fam(hit);
const sibs = models.filter((i) => fam(i.id) === key && i.types.some((t) => types.includes(t)));
if (!sibs.length) return hit;
return sibs.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
}
/**
* Lenient model-identifier resolution for LLM-supplied model args.
* Users say "z-image"; the real identifier is "z-image/turbo" — the backend
* has no fuzzy matching on generation routes and fails deep in credit
* reservation. Resolve here: exact name/identifier hit → its identifier;
* else a separator-insensitive hit ("flux-2-flash" → "flux-2/flash"); else a
* UNIQUE prefix match ("z-image" → "z-image/turbo").
*
* `type` is the calling tool's catalog type (a string, or an array when the
* tool spans several — lipsync, 3D). It is what makes resolution MODALITY-AWARE:
* without it, "Kling 2.6 Pro" from generate_video_from_image resolved to
* kling-video/v2.6/pro/text-to-video (2026-08-10), so the image-to-video
* pipeline submitted the TEXT-to-video endpoint and billed against it.
* An explicit t2v identifier on an i2v tool remaps to the unique same-family
* sibling (grok-imagine-text-to-video → grok-imagine-image-to-video). No
* sibling → the id is passed through unchanged (MiniMax H3).
*
* Still unresolved: throw with the near misses named. The API answers a bad
* identifier with a bare INVALID_*_MODEL and no hint, which on 2026-08-09 sent
* an agent guessing "minimax-hailuo-3" (real id: "minimax-h3") and then
* substituting a far more expensive model. Only throws when the catalog is
* healthy AND actually offers candidates — otherwise it passes through
* unchanged, so identifiers the catalog does not publish (hidden models) still
* reach the API and it stays the source of truth.
*/
const NATIVE_WORKFLOW_MODEL_IDS = new Set([
'kolbo-morphious-motion', 'kolbo-morphious-swap', 'kolbo-morphious-reframe',
'kolbo-morphious-lite-motion', 'kolbo-morphious-lite-swap',
'higgsfield-genjutsu-motion-transfer', 'higgsfield-genjutsu-object-swap',
]);
async function canonicalModelId(client, input, type) {
if (!input || typeof input !== 'string') return input;
const key = input.toLowerCase().trim();
const want = normId(key);
if (!want || AUTO_ALIASES.has(want)) return input;
// Exact native-workflow ids pass through: the near-miss check below would reject an
// unpublished or cached-out 'kolbo-*' id as unknown.
if (NATIVE_WORKFLOW_MODEL_IDS.has(key)) return input;
let all;
try {
all = (await modelCatalog(client)).all;
} catch (_) {
return input; // fail open — never block a generation on a catalog hiccup
}
const models = (all || []).filter((i) => i.id);
if (!models.length) return input;
const types = (Array.isArray(type) ? type : [type]).filter(Boolean);
const dashed = key.replace(/\s+/g, '-');
// 1. exact name / identifier hit
const exact = sibling(models, pickForType(models.filter((i) => [i.id, i.name].some(
(k) => k && (k.toLowerCase() === key || k.toLowerCase() === dashed)
)), types), types);
if (exact) return exact;
// 2. separator-insensitive exact ("flux-2-flash" → "flux-2/flash")
const loose = sibling(models, pickForType(models.filter((i) => normId(i.id) === want || normId(i.name) === want), types), types);
if (loose) return loose;
// 3. unique prefix ("z-image" → "z-image/turbo") — the modality filter runs
// FIRST, so a stem shared by a t2v/i2v pair is no longer ambiguous.
const prefixed = models.filter((i) => normId(i.id).startsWith(want) || normId(i.name).startsWith(want));
const narrowed = types.length ? prefixed.filter((i) => i.types.some((t) => types.includes(t))) : [];
const ids = new Set((narrowed.length ? narrowed : prefixed).map((i) => i.id));
if (ids.size === 1) return [...ids][0];
// The general catalog is cached for widget performance, while list_models
// is intentionally live. On a just-published model, refresh only the typed
// family before reporting an unknown identifier so a model discovered one
// moment ago is immediately usable.
if (types.length && typeof client.get === 'function') {
try {
const freshRows = [];
for (const expectedType of types) {
const response = await client.get(`/v1/models?type=${encodeURIComponent(expectedType)}`);
freshRows.push(...(response?.models || response?.data?.models || []));
}
const freshMatches = freshRows.filter((row) => {
const id = row?.identifier;
const name = row?.name;
return (id && (id.toLowerCase() === key || normId(id) === want))
|| (name && (name.toLowerCase() === key || normId(name) === want));
});
const freshIds = [...new Set(freshMatches.map((row) => row.identifier).filter(Boolean))];
if (freshIds.length === 1) return freshIds[0];
} catch (_) {
// Keep the existing actionable near-miss error when the refresh fails.
}
}
// 4. unknown — name the near misses instead of dead-ending at the API.
const stem = normId(key.split(/[\s._/-]+/).filter(Boolean)[0] || key);
const near = [...new Set(
models
.filter((i) => stem && (normId(i.id).startsWith(stem) || normId(i.name).startsWith(stem)))
.map((i) => (i.name ? `${i.id} (${i.name})` : i.id))
)].sort().slice(0, 12);
if (!near.length) return input;
throw new Error(
`Unknown model identifier "${input}". Did you mean: ${near.join(', ')}? `
+ 'Never guess an identifier — call list_models with the matching `type` and `format: "json"` '
+ 'to get the exact identifiers and caps.'
);
}
/**
* Reject a published model that belongs to a different operation family.
* Hidden/unpublished identifiers still fail open so existing pinned engines
* remain usable; the API remains authoritative for those.
*/
async function assertModelSupportsType(client, modelId, type) {
if (!modelId || !type) return modelId;
let all;
try {
all = (await modelCatalog(client)).all;
} catch (_) {
return modelId;
}
const row = (all || []).find((item) => item.id === modelId);
if (!row) return modelId;
const expected = (Array.isArray(type) ? type : [type]).filter(Boolean);
if (!expected.length || row.types.some((value) => expected.includes(value))) return modelId;
throw new Error(
`Model "${modelId}" cannot be used for this operation (expected type: ${expected.join(' or ')}). `
+ `Call list_models with type="${expected[0]}" and pass a concrete identifier it returns.`
);
}
/* ------------------------------------------------------------------ */
/* Declaration-level widget metadata */
/* ------------------------------------------------------------------ */
// Hosts (claude.ai) decide whether to prepare a widget iframe from the TOOL
// DECLARATION in tools/list — result-level `_meta` alone is not enough. The
// legacy server.tool() registration API has no _meta parameter, so we attach
// it post-registration via the SDK's registered-tool objects (tools/list
// serves `tool._meta` verbatim; verified against SDK 1.29.0).
const TOOL_WIDGETS = {
// generation card
generate_image: UI.generation,
generate_image_edit: UI.generation,
generate_creative_director: UI.generation,
generate_video: UI.generation,
generate_video_from_image: UI.generation,
generate_video_from_video: UI.generation,
generate_elements: UI.generation,
generate_first_last_frame: UI.generation,
generate_lipsync: UI.generation,
generate_music: UI.generation,
generate_speech: UI.generation,
generate_sound: UI.generation,
generate_3d: UI.generation,
// Declared explicitly so Apps hosts prepare the card from tools/list rather
// than inferring it from the `generate_*` name; result-level _meta alone is
// not enough for hosts that read the declaration (see the note below).
generate_character_sheet: UI.generation,
edit_image: UI.generation,
edit_video: UI.generation,
// transcript viewer
transcribe_audio: UI.transcript,
// model catalog
list_models: UI.catalog,
// media grid
list_media: UI.mediaGrid,
search_stock_media: UI.mediaGrid,
search_music_library: UI.mediaGrid,
browse_music_library: UI.mediaGrid,
get_stock_collections: UI.mediaGrid,
list_presets: UI.mediaGrid,
list_voices: UI.mediaGrid,
list_visual_dnas: UI.mediaGrid,
list_moodboards: UI.mediaGrid,
list_morphious_styles: UI.mediaGrid,
// NOTE: list_color_palettes' handler has always called uiResult(UI.mediaGrid, ...)
// (see color_palettes.js) but was missing here — hosts that prepare the widget
// iframe from the tool DECLARATION (claude.ai reads tools/list, not the result)
// never saw it as widget-carrying. Result-level _meta alone isn't enough.
list_color_palettes: UI.mediaGrid,
// upload widget
media_upload_widget: UI.upload,
font_upload_widget: UI.fontUpload,
// generic list widget — flat record lists with no natural thumbnail
list_projects: UI.list,
// Must stay list.html — mapping this to generation.html mounts "Kolbo Generation /
// Preparing" empty cards for every session row.
list_sessions: UI.list,
list_session_generations: UI.list,
list_project_context: UI.list,
list_agents: UI.list,
list_docs: UI.list,
list_media_folders: UI.list,
list_visual_dna_folders: UI.list,
show_plans: UI.plans,
check_credits: UI.credits,
list_project_assets: UI.list,
};
function attachToolWidgetMeta(server) {
const registered = server && server._registeredTools;
if (!registered) return;
for (const [name, uri] of Object.entries(TOOL_WIDGETS)) {
const tool = registered[name];
if (!tool) continue;
tool._meta = { ...(tool._meta || {}), ...uiMeta(uri) };
}
}
module.exports = {
UI,
WIDGET_CSP,
TOOL_WIDGETS,
registerApps,
attachToolWidgetMeta,
versionedUri,
uiMeta,
uiResult,
listResult,
appsEnabled,
modelIcon,
modelInfo,
modelInfoMap,
voiceInfo,
canonicalModelId,
assertModelSupportsType,
normalizeAspectRatio,
closestAspectRatio,
resolveCatalogAspectRatio,
resolveAvatarUrl,
widgetHtml, // exported for smoke tests
};