UNPKG

@kolbo/mcp

Version:

Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code

253 lines (236 loc) • 19.2 kB
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC * CONTRACT. Never rename, remove, or break an existing tool/arg. Full rules: ../index.js top-of-file. */ const { z } = require('zod'); const { UI, uiResult, appsEnabled } = require('../apps'); const { compactList } = require('./_shared'); // Compact one-line render of a normalized stock asset. function assetLine(a) { // What THIS caller would pay, stamped per asset by the API. 0/absent = free // (subscriber, org member, or already bought). Surfaced in the text the agent // reads so it can warn before spending someone's credits. const price = a.meta && a.meta.priceCredits; const cost = (a.meta && a.meta.owned) ? ' [purchased]' : (price ? ` [${price} credits]` : ''); const dims = a.width && a.height ? `${a.width}x${a.height}` : null; const dur = a.durationSeconds != null ? `${Math.round(a.durationSeconds)}s` : null; const meta = [a.mediaType, dims, dur].filter(Boolean).join(' · '); const by = a.author?.name ? ` by ${a.author.name}` : ''; const variants = Array.isArray(a.downloadVariants) ? a.downloadVariants.map((v) => v.label).join('/') : ''; return `[${a.source}:${a.sourceId}] ${a.title || '(untitled)'}${by}${cost}\n ${meta}${variants ? ` variants: ${variants}` : ''}${a.thumbnailUrl ? `\n thumb: ${a.thumbnailUrl}` : ''}`; } // Returns the raw querystring (no leading '?'). Callers inline it as // `${q ? '?' + q : ''}` so the parity checker recognizes it as a querystring. function buildQuery(obj) { const p = new URLSearchParams(); for (const [k, v] of Object.entries(obj)) if (v != null && v !== '') p.set(k, String(v)); return p.toString(); } // Map provider mediaType values onto the widget's 4-value contract. function widgetMediaType(mediaType) { if (mediaType === 'music' || mediaType === 'sfx' || mediaType === 'audio') return 'audio'; if (mediaType === 'video') return 'video'; if (mediaType === '3d') return '3d'; return 'image'; // image / photo / illustration / vector } function registerStockLibraryTools(server, client, options = {}) { const ui = () => appsEnabled(server, options); // ─── search_stock_media ─────────────────────────────────────── server.tool( 'search_stock_media', 'Search the Kolbo unified stock media library and return matching assets. ' + '★ THE DEFAULT TOOL FOR MUSIC: Kolbo\'s own AI music library, free for subscribers and org members and priced per track otherwise (every result states its own cost) — ' + 'use source="kolbo-ai" with mediaType="music" for background/score/bed requests. Prefer it over ' + 'search_music_library (SYNCI), which is the paid licensed catalog and should only be used when the ' + 'user explicitly asks for it. ' + 'Covers external providers (Pexels photos/videos, Pixabay, Sketchfab 3D, licensed Music) AND Kolbo\'s OWN AI-generated library: thousands of SOUND EFFECTS (mediaType="sfx") and MUSIC tracks (source="kolbo-ai"). Use this to FIND ready-made photos, videos, 3D models, music, or sound effects as b-roll/references/project assets — distinct from generate_* tools which create new content.\n\nFor SOUND EFFECTS or MUSIC, Kolbo supports SEMANTIC "VIBE" SEARCH: pass a natural-language description of the feeling/use ("tense ominous build-up for a horror reveal", "uplifting hopeful corporate background", "retro arcade coin pickup") with source="kolbo-ai" and mediaType="sfx" (or "music") — it matches by meaning, not just keywords. For external visual providers, use concrete keywords.\n\nsource="all" interleaves providers for the requested media type; or pick a single source. Returns assets with source, sourceId, mediaType, dimensions, author, attribution, thumbnail, and downloadable variants. To turn a script into queries first, call analyze_script_for_stock.', { query: z.string().max(200).optional().describe('For visual providers: concrete keywords ("city skyline sunset"). For Kolbo SFX/music (source="kolbo-ai"): a natural-language VIBE works great ("eerie suspenseful drone", "emotional sad piano"). Omit to browse.'), source: z.enum(['all', 'kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'music', 'freesound']).optional().describe('Provider. "all" (default) interleaves. "kolbo-ai" = Kolbo\'s own AI SFX + music (best for vibe search). "unsplash" = photos. "coverr" = stock video + music. "freesound" = external CC sound effects. "music" is not available over the API (returns not found) — use "kolbo-ai" or "coverr" for music.'), mediaType: z.enum(['image', 'illustration', 'vector', 'video', '3d', 'music', 'sfx']).optional().describe('Asset type (default "image"). "sfx" = sound effects, "music" = music tracks. Not every source supports every type — call get_stock_sources.'), category: z.string().optional().describe('Category/group chip value (providerParam) from get_stock_categories. For Kolbo SFX these are 77 Soundly-style top-level groups (e.g. Ambience, Animals, Vehicles, Weapons, Water, Designed, Magic, UI) — call get_stock_categories to list them all.'), subcategory: z.string().optional().describe('Kolbo SFX sub-filter within a group (providerParam from get_stock_categories, e.g. Weapons>sword, Water>splash, Footsteps>concrete, Designed>riser). 623 sub-filters across the 77 groups.'), packId: z.string().optional().describe('Filter to one Kolbo themed pack id (from get_stock_collections, kind="pack").'), collectionId: z.string().optional().describe('Filter to one Kolbo collection id (from get_stock_collections).'), orientation: z.enum(['horizontal', 'vertical', 'landscape', 'portrait', 'square']).optional().describe('Orientation filter (provider-dependent).'), color: z.string().optional().describe('Color filter (Pixabay named color, or Pexels named/hex color).'), order: z.enum(['popular', 'latest', 'trending', 'newest', 'title', 'duration_asc', 'duration_desc', 'bpm_asc', 'bpm_desc']).optional().describe('Sort order, provider-dependent. "popular"/"latest": Pixabay, Freesound, Coverr. "trending": Coverr. Music (mediaType="music"): "popular", "newest", "title", "duration_asc", "duration_desc", "bpm_asc", "bpm_desc". A provider ignores values it does not support.'), cursor: z.string().optional().describe('Opaque pagination cursor for Sketchfab single-source browse (from a previous response).'), page: z.number().int().min(1).optional().describe('1-based page number (default 1).'), perPage: z.number().int().min(1).max(80).optional().describe('Results per page (default 24, max 80).'), projectId: z.string().optional().describe('Optional project id. Prices Kolbo music/SFX for that project (e.g. org-project access), so meta.priceCredits matches what importing into that project would cost.') }, async (args) => { const q = buildQuery(args); const result = await client.get(`/v1/stock/search${q ? '?' + q : ''}`); const assets = result.assets || []; if (!assets.length) return { content: [{ type: 'text', text: 'No assets found. Try a broader query, a different source/mediaType, or call get_stock_sources.' }] }; const head = `Found ${assets.length} asset${assets.length === 1 ? '' : 's'}${result.total ? ` (≈${result.total} total)` : ''}${result.hasMore ? ' — more available (increment page)' : ''}:`; // A wide `source=all` search interleaves providers and overran the text // budget at ~27K. Cap the rendered rows; `page` still reaches the rest. const ASSET_CAP = 30; const shownAssets = assets.slice(0, ASSET_CAP); const moreAssets = assets.length - shownAssets.length; const moreHint = moreAssets > 0 ? `\n\n…and ${moreAssets} more on this page — narrow with \`source\` / \`mediaType\`, or use \`page\`.` : ''; const text = `${head}\n\n${shownAssets.map(assetLine).join('\n\n')}${moreHint}\n\nUse [source:sourceId] with get_stock_asset for full variants, or import_stock_asset to copy it into the media library.`; // Ship structuredContent UNCONDITIONALLY. Gating on ui() left every host that // renders widgets without advertising MCP Apps (Kolbo Code) with text-only rows // that carry no thumbnail field at all — and its BY_TOOL map still force-mounts // the media grid on them, so the card rendered one broken-file glyph per cell. // media.js and listResult() have always done it this way; these five lagged. { const items = assets.map((a) => { const mt = widgetMediaType(a.mediaType); return { id: a.source + ':' + a.sourceId, title: a.title, subtitle: (a.author?.name ? a.author.name + ' · ' : '') + a.source, thumbnail: a.thumbnailUrl, media_type: mt, url: (a.downloadVariants?.[0]?.url) || a.thumbnailUrl, preview_audio: mt === 'audio' ? (a.previewUrl || a.downloadVariants?.[0]?.url) : undefined, badge: (a.meta && a.meta.owned) ? 'Purchased' : ((a.meta && a.meta.priceCredits) ? a.meta.priceCredits + ' credits' : undefined), price_credits: (a.meta && a.meta.priceCredits) || undefined, owned: (a.meta && a.meta.owned) || undefined, use_hint: (a.meta && a.meta.priceCredits) ? 'Costs ' + a.meta.priceCredits + ' credits for this account. Confirm with the user, then: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")' : 'Import this stock asset into my media library: import_stock_asset source="' + a.source + '" id="' + a.sourceId + '" ("{TITLE}")' }; }); return uiResult(UI.mediaGrid, text, { widget: 'media-grid', title: 'Stock — "' + (args.query || 'browse') + '"', items, total: result.total != null ? result.total : assets.length, has_more: !!result.hasMore, page: args.page || 1, page_tool: 'search_stock_media', next_args: result.hasMore ? { ...args, page: (args.page || 1) + 1, ...(result.nextCursor ? { cursor: result.nextCursor } : {}) } : undefined }); } return { content: [{ type: 'text', text }] }; } ); // ─── get_stock_sources ──────────────────────────────────────── server.tool( 'get_stock_sources', 'List the enabled stock providers and which media types + filters each supports. Call this to know whether a source supports image/video/illustration/vector/3d/music before searching.', {}, async () => { const result = await client.get('/v1/stock/sources'); return { content: [{ type: 'text', text: JSON.stringify({ mediaTypes: result.mediaTypes, sources: result.sources }, null, 2) }] }; } ); // ─── get_stock_categories ───────────────────────────────────── server.tool( 'get_stock_categories', 'List the dynamic category chips for stock sources. For external providers: Pixabay/Sketchfab categories + curated Pexels topics. For Kolbo SFX (source="kolbo-ai", mediaType="sfx"): the 77 Soundly-style top-level groups (group=null) AND their 623 sub-filters (each has a `group` pointing to its parent). Pass a row\'s `providerParam` as `category` (groups) or `subcategory` (sub-filters) to search_stock_media.', { source: z.enum(['kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'freesound']).optional().describe('Restrict to one source. Use "kolbo-ai" to list the SFX groups + sub-filters.'), mediaType: z.string().optional().describe('Restrict to one media type (e.g. "image", "video", "3d", "sfx").') }, async (args) => { const q = buildQuery(args); const result = await client.get(`/v1/stock/categories${q ? '?' + q : ''}`); // Full category list measured 257,817 chars (Kolbo SFX alone has 77 groups // + 623 sub-filters). The model needs the label and the param to pass back. return { content: [{ type: 'text', text: compactList(result.categories, { fields: ['key', 'label', 'mediaType', 'source', 'group', 'paramType', 'providerParam'], cap: 120, total: result.count, note: 'Narrow with `source` and `mediaType` to see a focused set.', }) }] }; } ); // ─── get_stock_collections ──────────────────────────────────── server.tool( 'get_stock_collections', 'List Kolbo\'s own SFX collections — the category collections AND the curated themed packs (kind="pack": e.g. Trailer Hits, Horror & Tension, Gaming FX, Foley Essentials). Use the returned `id` as `packId` or `collectionId` in search_stock_media to browse one pack/collection. Each has a cover image.', { mediaType: z.string().optional().describe('Media type (default "sfx").'), kind: z.enum(['category', 'pack']).optional().describe('Filter to category collections or themed packs only.') }, async (args) => { const q = buildQuery(args); const result = await client.get(`/v1/stock/collections${q ? '?' + q : ''}`); const text = compactList(result.collections, { fields: ['id', 'name', 'slug', 'mediaType', 'source', 'itemCount'], cap: 60, total: result.count, }); // Ship structuredContent UNCONDITIONALLY. Gating on ui() left every host that // renders widgets without advertising MCP Apps (Kolbo Code) with text-only rows // that carry no thumbnail field at all — and its BY_TOOL map still force-mounts // the media grid on them, so the card rendered one broken-file glyph per cell. // media.js and listResult() have always done it this way; these five lagged. { const collections = result.collections || []; const items = collections.slice(0, 300).map((c) => ({ id: c.id, title: c.title, subtitle: [c.kind, c.mediaType].filter(Boolean).join(' · '), thumbnail: c.coverUrl || null, media_type: 'image', url: c.coverUrl || null, use_hint: 'Browse this stock collection: search_stock_media source="kolbo-ai" ' + (c.kind === 'pack' ? 'packId' : 'collectionId') + '="{ID}" ("{TITLE}")' })); return uiResult(UI.mediaGrid, text, { widget: 'media-grid', title: 'Stock Collections', items, total: result.count != null ? result.count : collections.length }); } return { content: [{ type: 'text', text }] }; } ); // ─── get_stock_asset ────────────────────────────────────────── server.tool( 'get_stock_asset', 'Get a single normalized stock asset with all downloadable variants, author, license, and attribution, by source + id. Call after search_stock_media to resolve the exact download URLs (incl. WAV master + MP3 for Kolbo SFX/music).', { source: z.enum(['kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'music', 'freesound']).describe('The asset source. "music" is not available over the API.'), id: z.string().describe('The provider asset id (sourceId).'), mediaType: z.string().optional().describe('Media type hint (e.g. "video") — needed for sources that share ids across types.'), projectId: z.string().optional().describe('Optional project id used to price Kolbo music/SFX for that project (same as search_stock_media).') }, async ({ source, id, mediaType, projectId }) => { const q = buildQuery({ mediaType, projectId }); const result = await client.get(`/v1/stock/asset/${encodeURIComponent(source)}/${encodeURIComponent(id)}${q ? '?' + q : ''}`); return { content: [{ type: 'text', text: JSON.stringify(result.asset, null, 2) }] }; } ); // ─── analyze_script_for_stock ───────────────────────────────── server.tool( 'analyze_script_for_stock', 'AI helper that turns a video/voiceover script into stock b-roll search terms. Returns { queries[], mediaType, keywords } you can feed into search_stock_media to auto-source matching footage/photos. Use this first when the user gives you a script rather than explicit search keywords.', { script: z.string().min(1).describe('The video/voiceover script or scene description to analyze (up to ~8000 chars).') }, async ({ script }) => { const result = await client.post('/v1/stock/analyze-script', { script }); return { content: [{ type: 'text', text: JSON.stringify({ queries: result.queries, mediaType: result.mediaType, keywords: result.keywords, _followup_hint: 'Run each query through search_stock_media (source="all", mediaType=result.mediaType).' }, null, 2) }] }; } ); // ─── import_stock_asset ─────────────────────────────────────── server.tool( 'import_stock_asset', "Copy a stock asset into the account's Kolbo media library (downloaded to Kolbo's CDN with a stable URL) so it can be used in projects/generations. FREE for images, video and 3D. Kolbo's own MUSIC and SFX are free for subscribers, org members and anyone who already bought the track; for everyone else they COST CREDITS (search results carry the exact price in meta.priceCredits; 0/absent means free for this caller). When a price is shown, tell the user the cost and get agreement BEFORE calling this. Returns the created media library item. Works for Kolbo SFX (source='kolbo-ai', mediaType='sfx') and supported external visual/audio sources. For SYNCI music use import_music_track_to_library; that paid action acquires a clean licensed file.", { source: z.enum(['kolbo-ai', 'pexels', 'unsplash', 'pixabay', 'coverr', 'sketchfab', 'freesound']).describe('The asset source.'), id: z.string().describe('The provider asset id (sourceId).'), mediaType: z.string().optional().describe('Media type hint (e.g. "video", "image", "vector", "3d").'), variant: z.string().optional().describe('Which download variant label to import (from get_stock_asset). Defaults to the best/largest available.'), project_id: z.string().optional().describe('Optional project id to associate the imported item with.') }, async (args) => { const result = await client.post('/v1/stock/import', args); const it = result.libraryItem || {}; return { content: [{ type: 'text', text: `${result.alreadyImported ? 'Already in library' : 'Imported'}: ${it.url || '(no url)'}\n${JSON.stringify({ id: it._id, mediaType: it.mediaType, filename: it.filename, url: it.url }, null, 2)}` }] }; } ); } module.exports = { registerStockLibraryTools };