@kolbo/mcp
Version:
Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code
137 lines (125 loc) • 7.32 kB
JavaScript
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
const { z } = require('zod');
const { UI, uiResult, appsEnabled } = require('../apps');
const { compactList } = require('./_shared');
function registerPresetTools(server, client, options = {}) {
const ui = () => appsEnabled(server, options);
// ─── list_presets ──────────────────────────────────────────
server.tool(
'list_presets',
'Resolve a generation preset and pass its exact id as preset_id. ALWAYS pass `search` when you already know the name (headless, bible, character sheet, a user preset) — that is a silent id lookup, not a catalog to show the user. Omit search only when they asked to browse. Custom instructions live on the preset, so prefer generate_image + preset_id over generate_character_sheet. Never invent an id.',
{
type: z.string().optional().describe('Filter by catalog: "image" | "image_edit" | "video" | "music" | "text_to_video". Omit for all.'),
search: z.string().optional().describe('Name lookup, e.g. "headless", "bible", "character sheet". Required when resolving a named sheet/style — do not list the whole catalog.')
},
async ({ type, search }) => {
const params = new URLSearchParams();
if (type) params.set('type', type);
if (search) params.set('search', search);
const qs = params.toString();
const result = await client.get(`/v1/presets${qs ? '?' + qs : ''}`);
let presets = result.presets || [];
if (search && !result.search) {
const q = search.toLowerCase();
presets = presets.filter((p) => `${p.name || ''} ${p.description || ''} ${p.category || ''}`.toLowerCase().includes(q));
}
const lookup = Boolean(search);
// Full catalog measured 632,919 chars. A named lookup should return a handful of rows.
const text = compactList(presets, {
fields: ['id', 'name', 'category', 'type', 'description'],
cap: lookup ? 8 : 12,
total: result.count || presets.length,
extra: {
...(result.warning ? { warning: result.warning } : {}),
...(lookup ? { _lookup: true } : {}),
},
note: lookup
? 'Pick the closest id and pass it as preset_id. Do not show this list to the user.'
: 'Pass search next time if you already know the name. Pass the chosen exact id as preset_id.',
});
if (lookup) return { content: [{ type: 'text', text }] };
// TOOL_WIDGETS declares mediaGrid for this tool, so hosts that mount from
// the declaration (Claude desktop) rendered a media grid fed a list-shaped
// 8-item page with no page_tool: a "Load more" that could never load. Ship
// the whole catalog (a few hundred small rows) to the grid it actually mounts.
return uiResult(UI.mediaGrid, text, {
widget: 'mediaGrid',
title: 'Presets' + (type ? ' — ' + type : ''),
items: presets.slice(0, 300).map(p => ({
id: p.id,
title: p.name,
subtitle: p.category,
thumbnail: p.thumbnail_url || p.thumbnail,
media_type: p.audio_url ? 'audio' : 'image',
preview_audio: p.audio_url,
url: p.thumbnail_url || p.thumbnail,
use_hint: 'Use preset "{TITLE}" (preset_id: {ID}) for my next generation — ask me for the prompt.'
})),
total: result.count || presets.length,
has_more: presets.length > 300
});
}
);
// ─── list_cinematic_presets ────────────────────────────────
server.tool(
'list_cinematic_presets',
'List Kolbo "Cinema mode" presets for image generation/editing — a deliberate photographic ' +
'treatment layered onto the prompt. Returns presets grouped by DIMENSION (data-driven from the ' +
'live catalog; today: camera, lens, focal_length, aperture, angle, shot_type, color_palette, ' +
'lighting). Each preset has id, name, description, thumbnail. ONLY call this when the user wants a ' +
'specific cinematic look; then pass the chosen ids via the `cinematic` arg of generate_image / ' +
'generate_image_edit — at most one id per dimension. "Auto" is the absence of a selection: omit a ' +
'dimension (or the whole `cinematic` object) to let the enhancer decide. For an ordinary generation ' +
'do not call this at all. Never hardcode ids — dimensions and presets change; always fetch here. ' +
'Call with no args for a compact id+name index of every dimension, then pass `dimension` to get the ' +
'full descriptions for just the one you are choosing from.',
{
dimension: z.string().optional().describe(
'Return full detail (incl. descriptions) for ONE dimension only — e.g. "lighting", "camera", ' +
'"looks". Omit for the compact index of all dimensions.'
),
},
async ({ dimension }) => {
const result = await client.get('/v1/cinematic-presets');
// The public route serves the raw grouped map ({ camera:[...], lens:[...] });
// the SDK envelope wraps it as { dimensions:{...} }. Accept either shape.
const dimensions = (result && result.dimensions) || result || {};
const names = Object.keys(dimensions);
// The full catalog pretty-printed is ~63K chars — past what hosts accept, so the
// tool used to fail outright. thumbnail_url is dead weight on a text surface, and
// descriptions are only needed for the dimension actually being chosen from.
const slim = (p) => ({ id: p.id, name: p.name });
const full = (p) => ({
id: p.id,
name: p.name,
description: p.description,
...(p.bundle ? { bundle: p.bundle } : {}),
});
let payload;
if (dimension && dimensions[dimension]) {
payload = {
dimension,
presets: (dimensions[dimension] || []).map(full),
available_dimensions: names,
};
} else {
if (dimension) payload = { _note: `Unknown dimension "${dimension}" — showing the index.` };
payload = {
...payload,
dimensions: Object.fromEntries(names.map((k) => [k, (dimensions[k] || []).map(slim)])),
available_dimensions: names,
_detail_hint: 'Names only. Call again with `dimension: "<name>"` for descriptions.',
};
}
payload._usage_hint = 'Include ONLY the dimensions the user actually wants; pass their ids as the ' +
'`cinematic` arg on generate_image / generate_image_edit, e.g. {"camera":"<id>","lighting":"<id>"}. ' +
'Every omitted/null dimension is Auto — the enhancer completes the look in the spirit of the ones ' +
'you set. Omit the whole object for a non-cinematic generation. Ids are validated per-dimension server-side.';
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
}
);
}
module.exports = { registerPresetTools };