@kolbo/mcp 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/package.json +7 -2
- package/src/index.js +4 -0
- package/src/polling.js +1 -1
- package/src/tools/_shared.js +212 -0
- package/src/tools/generate.js +353 -1
- package/src/tools/media.js +77 -0
- package/src/tools/presets.js +34 -0
- package/src/tools/visual_dna.js +6 -70
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Just ask Claude naturally:
|
|
|
41
41
|
- *"Ask Claude about the latest AI news with web search on"*
|
|
42
42
|
- *"Create a Visual DNA profile called 'Alex' from these images"*
|
|
43
43
|
|
|
44
|
-
## Available Tools (
|
|
44
|
+
## Available Tools (30)
|
|
45
45
|
|
|
46
46
|
**Generation**
|
|
47
47
|
| Tool | Description |
|
|
@@ -50,10 +50,16 @@ Just ask Claude naturally:
|
|
|
50
50
|
| `generate_image_edit` | Existing image(s) + prompt → edited image |
|
|
51
51
|
| `generate_video` | Text → video |
|
|
52
52
|
| `generate_video_from_image` | Still image + motion prompt → video |
|
|
53
|
+
| `generate_video_from_video` | Input video + prompt → restyled video (video-to-video) |
|
|
54
|
+
| `generate_elements` | Reference images/videos + prompt → animated video |
|
|
55
|
+
| `generate_first_last_frame` | First frame + last frame → interpolated video |
|
|
56
|
+
| `generate_lipsync` | Source image/video + audio → lipsynced video |
|
|
53
57
|
| `generate_creative_director` | One brief → N coordinated scenes (image or video) |
|
|
54
58
|
| `generate_music` | Text (+ optional lyrics) → song |
|
|
55
59
|
| `generate_speech` | Text + voice → spoken audio |
|
|
56
60
|
| `generate_sound` | Text → sound effect |
|
|
61
|
+
| `generate_3d` | Text or reference images → 3D model (GLB/FBX/OBJ/USDZ) |
|
|
62
|
+
| `transcribe_audio` | Audio/video URL or file → text + SRT subtitles |
|
|
57
63
|
|
|
58
64
|
Every image/video/creative-director tool accepts `visual_dna_ids` and `moodboard_id` for character/style consistency across outputs — you can compose `create_visual_dna` → `generate_image` (with the DNA applied server-side) in a single agent turn. `generate_creative_director` also accepts `moodboard_ids` plural for blending.
|
|
59
65
|
|
|
@@ -78,11 +84,18 @@ Every image/video/creative-director tool accepts `visual_dna_ids` and `moodboard
|
|
|
78
84
|
| `list_moodboards` | Browse presets + your moodboards |
|
|
79
85
|
| `get_moodboard` | Fetch one moodboard with all image URLs |
|
|
80
86
|
|
|
87
|
+
**Media Library**
|
|
88
|
+
| Tool | Description |
|
|
89
|
+
|------|-------------|
|
|
90
|
+
| `upload_media` | Upload a local file (or remote URL) → stable Kolbo CDN URL for reuse |
|
|
91
|
+
| `list_media` | Browse your uploaded media with type filter and pagination |
|
|
92
|
+
|
|
81
93
|
**Discovery & Account**
|
|
82
94
|
| Tool | Description |
|
|
83
95
|
|------|-------------|
|
|
84
96
|
| `list_models` | Current model catalog with costs and capabilities |
|
|
85
97
|
| `list_voices` | TTS voices (presets + cloned) |
|
|
98
|
+
| `list_presets` | Generation presets across image/video/music/text-to-video catalogs |
|
|
86
99
|
| `check_credits` | Check credit balance |
|
|
87
100
|
| `get_generation_status` | Poll a generation by ID (fallback if a tool times out) |
|
|
88
101
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolbo/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,9 +40,14 @@
|
|
|
40
40
|
"README.md"
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
44
44
|
"form-data": "^4.0.5"
|
|
45
45
|
},
|
|
46
|
+
"overrides": {
|
|
47
|
+
"hono": "^4.12.12",
|
|
48
|
+
"@hono/node-server": "^1.19.13",
|
|
49
|
+
"path-to-regexp": "^8.4.2"
|
|
50
|
+
},
|
|
46
51
|
"engines": {
|
|
47
52
|
"node": ">=18.0.0"
|
|
48
53
|
}
|
package/src/index.js
CHANGED
|
@@ -64,6 +64,8 @@ const { registerModelTools } = require('./tools/models');
|
|
|
64
64
|
const { registerChatTools } = require('./tools/chat');
|
|
65
65
|
const { registerVisualDnaTools } = require('./tools/visual_dna');
|
|
66
66
|
const { registerMoodboardTools } = require('./tools/moodboards');
|
|
67
|
+
const { registerMediaTools } = require('./tools/media');
|
|
68
|
+
const { registerPresetTools } = require('./tools/presets');
|
|
67
69
|
|
|
68
70
|
async function main() {
|
|
69
71
|
const client = new KolboClient();
|
|
@@ -79,6 +81,8 @@ async function main() {
|
|
|
79
81
|
registerChatTools(server, client);
|
|
80
82
|
registerVisualDnaTools(server, client);
|
|
81
83
|
registerMoodboardTools(server, client);
|
|
84
|
+
registerMediaTools(server, client);
|
|
85
|
+
registerPresetTools(server, client);
|
|
82
86
|
|
|
83
87
|
// Start the server with stdio transport
|
|
84
88
|
const transport = new StdioServerTransport();
|
package/src/polling.js
CHANGED
|
@@ -33,7 +33,7 @@ async function pollUntilDone(client, generationId, options = {}) {
|
|
|
33
33
|
} = options;
|
|
34
34
|
|
|
35
35
|
const startTime = Date.now();
|
|
36
|
-
const url = statusUrl || `/v1/generate/${generationId}/status`;
|
|
36
|
+
const url = statusUrl || `/v1/generate/${encodeURIComponent(generationId)}/status`;
|
|
37
37
|
|
|
38
38
|
while (true) {
|
|
39
39
|
if (Date.now() - startTime > timeout) {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/* Shared helpers for MCP tools. No server.tool() registrations here.
|
|
2
|
+
*
|
|
3
|
+
* This file centralizes the URL-or-local-path → Buffer resolver used by
|
|
4
|
+
* every tool that accepts file-ish arguments (visual_dna, elements,
|
|
5
|
+
* first_last_frame, lipsync, video_from_video, transcription, media upload,
|
|
6
|
+
* future additions). It also owns the SSRF guard applied to any URL we
|
|
7
|
+
* fetch on the user's local machine.
|
|
8
|
+
*
|
|
9
|
+
* SSRF defense in depth:
|
|
10
|
+
* 1. Only http: / https: protocols.
|
|
11
|
+
* 2. Block IP literals in private / loopback / link-local / multicast /
|
|
12
|
+
* reserved ranges (IPv4 and IPv6).
|
|
13
|
+
* 3. Block common internal hostnames (localhost, *.local, *.internal,
|
|
14
|
+
* metadata.google.internal, metadata.goog).
|
|
15
|
+
* 4. Manual redirect following so every hop is re-validated (a crafted
|
|
16
|
+
* public URL could 302 to 169.254.169.254 — global fetch would follow
|
|
17
|
+
* silently).
|
|
18
|
+
*
|
|
19
|
+
* If you add a new tool that fetches URLs, import resolveToBuffer from here
|
|
20
|
+
* rather than reinventing the guard.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const net = require('net');
|
|
26
|
+
|
|
27
|
+
const MAX_FILE_BYTES = 500 * 1024 * 1024; // 500 MB — larger than visual_dna because
|
|
28
|
+
// lipsync/v2v/transcription accept full
|
|
29
|
+
// videos and long audio tracks.
|
|
30
|
+
const VISUAL_DNA_MAX_BYTES = 25 * 1024 * 1024; // kept for visual_dna backward-compat
|
|
31
|
+
const MAX_REDIRECTS = 5;
|
|
32
|
+
|
|
33
|
+
function isHttpUrl(s) {
|
|
34
|
+
return typeof s === 'string' && /^https?:\/\//i.test(s);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isPrivateIPv4(ip) {
|
|
38
|
+
const parts = ip.split('.').map(Number);
|
|
39
|
+
if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) return true;
|
|
40
|
+
const [a, b] = parts;
|
|
41
|
+
if (a === 10) return true;
|
|
42
|
+
if (a === 127) return true;
|
|
43
|
+
if (a === 0) return true;
|
|
44
|
+
if (a === 169 && b === 254) return true; // includes 169.254.169.254 cloud metadata
|
|
45
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
46
|
+
if (a === 192 && b === 168) return true;
|
|
47
|
+
if (a === 192 && b === 0 && parts[2] === 0) return true;
|
|
48
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
49
|
+
if (a >= 224) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPrivateIPv6(ip) {
|
|
54
|
+
const lower = ip.toLowerCase();
|
|
55
|
+
if (lower === '::' || lower === '::1') return true;
|
|
56
|
+
if (lower.startsWith('fe80:') || lower.startsWith('fe8') ||
|
|
57
|
+
lower.startsWith('fe9') || lower.startsWith('fea') ||
|
|
58
|
+
lower.startsWith('feb')) return true;
|
|
59
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
|
60
|
+
if (lower.startsWith('ff')) return true;
|
|
61
|
+
// IPv4-mapped / compat in dotted form: ::ffff:1.2.3.4 or ::1.2.3.4
|
|
62
|
+
const mappedDot = lower.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
|
|
63
|
+
if (mappedDot) return isPrivateIPv4(mappedDot[1]);
|
|
64
|
+
// IPv4-mapped in pure hex form: ::ffff:7f00:1 (Node normalizes
|
|
65
|
+
// ::ffff:127.0.0.1 → ::ffff:7f00:1). Extract last 2 hextets → 4 bytes.
|
|
66
|
+
const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
67
|
+
if (mappedHex) {
|
|
68
|
+
const hi = parseInt(mappedHex[1], 16);
|
|
69
|
+
const lo = parseInt(mappedHex[2], 16);
|
|
70
|
+
const dotted = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
71
|
+
return isPrivateIPv4(dotted);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isBlockedHostname(hostname) {
|
|
77
|
+
// new URL('http://[::1]/').hostname returns "[::1]" (brackets kept).
|
|
78
|
+
// Strip them so net.isIP and our private-range checks see the bare address.
|
|
79
|
+
let host = hostname.toLowerCase();
|
|
80
|
+
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
|
81
|
+
const blockedNames = new Set([
|
|
82
|
+
'localhost',
|
|
83
|
+
'ip6-localhost',
|
|
84
|
+
'ip6-loopback',
|
|
85
|
+
'metadata.google.internal',
|
|
86
|
+
'metadata.goog'
|
|
87
|
+
]);
|
|
88
|
+
if (blockedNames.has(host)) return true;
|
|
89
|
+
if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) return true;
|
|
90
|
+
const ipFamily = net.isIP(host);
|
|
91
|
+
if (ipFamily === 4 && isPrivateIPv4(host)) return true;
|
|
92
|
+
if (ipFamily === 6 && isPrivateIPv6(host)) return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertSafeUrl(rawUrl) {
|
|
97
|
+
let u;
|
|
98
|
+
try { u = new URL(rawUrl); }
|
|
99
|
+
catch (_) { throw new Error(`Invalid URL: ${rawUrl}`); }
|
|
100
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
101
|
+
throw new Error(`Unsupported URL protocol "${u.protocol}" — only http/https allowed`);
|
|
102
|
+
}
|
|
103
|
+
if (isBlockedHostname(u.hostname)) {
|
|
104
|
+
throw new Error(`Refusing to fetch from private / loopback / metadata host: ${u.hostname}`);
|
|
105
|
+
}
|
|
106
|
+
return u;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function safeFetch(rawUrl) {
|
|
110
|
+
let current = rawUrl;
|
|
111
|
+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
112
|
+
assertSafeUrl(current);
|
|
113
|
+
const res = await fetch(current, { redirect: 'manual' });
|
|
114
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
115
|
+
const next = new URL(res.headers.get('location'), current).toString();
|
|
116
|
+
current = next;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
return res;
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`Too many redirects fetching ${rawUrl}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function guessFilename(source, fallbackExt) {
|
|
125
|
+
if (isHttpUrl(source)) {
|
|
126
|
+
try {
|
|
127
|
+
const u = new URL(source);
|
|
128
|
+
const base = path.basename(u.pathname) || `upload${fallbackExt}`;
|
|
129
|
+
return base.includes('.') ? base : `${base}${fallbackExt}`;
|
|
130
|
+
} catch (_) {
|
|
131
|
+
return `upload${fallbackExt}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return path.basename(source);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function guessContentType(filename) {
|
|
138
|
+
const ext = path.extname(filename).toLowerCase();
|
|
139
|
+
const map = {
|
|
140
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
141
|
+
'.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
142
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
143
|
+
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
|
|
144
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
145
|
+
'.m4a': 'audio/mp4', '.flac': 'audio/flac', '.aac': 'audio/aac'
|
|
146
|
+
};
|
|
147
|
+
return map[ext] || 'application/octet-stream';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a URL or absolute local path into an in-memory Buffer.
|
|
152
|
+
* - URLs: fetched via safeFetch (SSRF-guarded, manual redirect handling)
|
|
153
|
+
* - Local paths: read via fs.readFileSync (must be absolute)
|
|
154
|
+
*
|
|
155
|
+
* @param {string} source - URL or absolute local path
|
|
156
|
+
* @param {'image'|'video'|'audio'} kind - hint for default filename extension
|
|
157
|
+
* @param {Object} [opts]
|
|
158
|
+
* @param {number} [opts.maxBytes] - override the default size cap
|
|
159
|
+
* @returns {Promise<{buffer: Buffer, filename: string, contentType: string, size: number}>}
|
|
160
|
+
*/
|
|
161
|
+
async function resolveToBuffer(source, kind, opts = {}) {
|
|
162
|
+
const maxBytes = opts.maxBytes || MAX_FILE_BYTES;
|
|
163
|
+
const defaultExt = kind === 'image' ? '.png' : kind === 'video' ? '.mp4' : '.mp3';
|
|
164
|
+
|
|
165
|
+
if (isHttpUrl(source)) {
|
|
166
|
+
const res = await safeFetch(source);
|
|
167
|
+
if (!res.ok) throw new Error(`Failed to fetch ${source}: ${res.status} ${res.statusText}`);
|
|
168
|
+
const contentLen = parseInt(res.headers.get('content-length') || '0', 10);
|
|
169
|
+
if (contentLen && contentLen > maxBytes) {
|
|
170
|
+
throw new Error(`File at ${source} (${contentLen} bytes) exceeds ${maxBytes}-byte limit`);
|
|
171
|
+
}
|
|
172
|
+
const arrayBuf = await res.arrayBuffer();
|
|
173
|
+
const buffer = Buffer.from(arrayBuf);
|
|
174
|
+
if (buffer.length > maxBytes) {
|
|
175
|
+
throw new Error(`File at ${source} (${buffer.length} bytes) exceeds ${maxBytes}-byte limit`);
|
|
176
|
+
}
|
|
177
|
+
const filename = guessFilename(source, defaultExt);
|
|
178
|
+
return {
|
|
179
|
+
buffer,
|
|
180
|
+
filename,
|
|
181
|
+
contentType: res.headers.get('content-type') || guessContentType(filename),
|
|
182
|
+
size: buffer.length
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!path.isAbsolute(source)) {
|
|
187
|
+
throw new Error(`Local file paths must be absolute: ${source}`);
|
|
188
|
+
}
|
|
189
|
+
const stat = fs.statSync(source);
|
|
190
|
+
if (stat.size > maxBytes) {
|
|
191
|
+
throw new Error(`File ${source} (${stat.size} bytes) exceeds ${maxBytes}-byte limit`);
|
|
192
|
+
}
|
|
193
|
+
const buffer = fs.readFileSync(source);
|
|
194
|
+
const filename = path.basename(source);
|
|
195
|
+
return {
|
|
196
|
+
buffer,
|
|
197
|
+
filename,
|
|
198
|
+
contentType: guessContentType(filename),
|
|
199
|
+
size: buffer.length
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
MAX_FILE_BYTES,
|
|
205
|
+
VISUAL_DNA_MAX_BYTES,
|
|
206
|
+
isHttpUrl,
|
|
207
|
+
assertSafeUrl,
|
|
208
|
+
safeFetch,
|
|
209
|
+
guessFilename,
|
|
210
|
+
guessContentType,
|
|
211
|
+
resolveToBuffer
|
|
212
|
+
};
|
package/src/tools/generate.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
4
|
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
5
|
|
|
6
|
+
const FormData = require('form-data');
|
|
6
7
|
const { pollUntilDone } = require('../polling');
|
|
8
|
+
const { resolveToBuffer } = require('./_shared');
|
|
7
9
|
|
|
8
10
|
function registerGenerateTools(server, client) {
|
|
9
11
|
// ─── generate_image ────────────────────────────────────────
|
|
@@ -356,7 +358,7 @@ function registerGenerateTools(server, client) {
|
|
|
356
358
|
generation_id: { type: 'string', description: 'The generation ID to check' }
|
|
357
359
|
},
|
|
358
360
|
async ({ generation_id }) => {
|
|
359
|
-
const result = await client.get(`/v1/generate/${generation_id}/status`);
|
|
361
|
+
const result = await client.get(`/v1/generate/${encodeURIComponent(generation_id)}/status`);
|
|
360
362
|
|
|
361
363
|
return {
|
|
362
364
|
content: [{
|
|
@@ -366,6 +368,356 @@ function registerGenerateTools(server, client) {
|
|
|
366
368
|
};
|
|
367
369
|
}
|
|
368
370
|
);
|
|
371
|
+
|
|
372
|
+
// ═════════════════════════════════════════════════════════════
|
|
373
|
+
// ─── 2026-04 SDK Expansion Batch ─────────────────────────────
|
|
374
|
+
// ═════════════════════════════════════════════════════════════
|
|
375
|
+
|
|
376
|
+
// ─── generate_elements ─────────────────────────────────────
|
|
377
|
+
server.tool(
|
|
378
|
+
'generate_elements',
|
|
379
|
+
'Generate a video from reference elements (images and/or videos) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". Supports Visual DNA for character consistency. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
|
|
380
|
+
{
|
|
381
|
+
prompt: { type: 'string', description: 'Text description of the desired video / animation' },
|
|
382
|
+
model: { type: 'string', description: 'Model identifier. Use list_models type="video" to see options. Omit for Smart Select.' },
|
|
383
|
+
reference_images: { type: 'array', description: 'Array of public image URLs used as reference elements (product shots, character references, etc.). URL mode.' },
|
|
384
|
+
files: { type: 'array', description: 'Array of URLs or absolute local paths — alternative to reference_images. Use this when you have local files to upload. Each item can be a URL OR a local path.' },
|
|
385
|
+
duration: { type: 'number', description: 'Duration in seconds. Default: 5' },
|
|
386
|
+
aspect_ratio: { type: 'string', description: 'Aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"' },
|
|
387
|
+
motion: { type: 'string', description: 'Motion style / intensity hint (optional)' },
|
|
388
|
+
preset_id: { type: 'string', description: 'Preset ID from list_presets type="video" (optional)' },
|
|
389
|
+
enhance_prompt: { type: 'boolean', description: 'Enhance the prompt. Default: true' },
|
|
390
|
+
visual_dna_ids: { type: 'array', description: 'Array of Visual DNA profile IDs to apply for character/style consistency across outputs.' }
|
|
391
|
+
},
|
|
392
|
+
async ({ prompt, model, reference_images, files, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids }) => {
|
|
393
|
+
if (!prompt) throw new Error('prompt is required');
|
|
394
|
+
|
|
395
|
+
let startResponse;
|
|
396
|
+
if (files && files.length > 0) {
|
|
397
|
+
// Multipart mode: resolve each file source to a buffer and upload.
|
|
398
|
+
const resolved = await Promise.all(files.map(src => resolveToBuffer(src, 'image')));
|
|
399
|
+
const form = new FormData();
|
|
400
|
+
form.append('prompt', prompt);
|
|
401
|
+
if (model) form.append('model', model);
|
|
402
|
+
if (duration !== undefined) form.append('duration', String(duration));
|
|
403
|
+
if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
|
|
404
|
+
if (motion) form.append('motion', motion);
|
|
405
|
+
if (preset_id) form.append('preset_id', preset_id);
|
|
406
|
+
if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
|
|
407
|
+
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
408
|
+
if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
|
|
409
|
+
for (const f of resolved) {
|
|
410
|
+
form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
|
|
411
|
+
}
|
|
412
|
+
startResponse = await client.postMultipart('/v1/generate/elements', form);
|
|
413
|
+
} else {
|
|
414
|
+
// URL-only mode: plain JSON.
|
|
415
|
+
startResponse = await client.post('/v1/generate/elements', {
|
|
416
|
+
prompt, model, reference_images, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
421
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
422
|
+
timeout: 600000
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
content: [{
|
|
427
|
+
type: 'text',
|
|
428
|
+
text: JSON.stringify({
|
|
429
|
+
urls: result.result?.urls || [],
|
|
430
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
431
|
+
duration: result.result?.duration || null,
|
|
432
|
+
model: result.result?.model || null
|
|
433
|
+
}, null, 2)
|
|
434
|
+
}]
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// ─── generate_first_last_frame ─────────────────────────────
|
|
440
|
+
server.tool(
|
|
441
|
+
'generate_first_last_frame',
|
|
442
|
+
'Generate a video that morphs / interpolates from a FIRST frame to a LAST frame. Provide the two frames as URLs (first_frame_url + last_frame_url) OR as local file paths (first_frame + last_frame). Optional prompt describes the desired motion/transition. Do NOT mix URL and file inputs. Returns the final video URL when complete.',
|
|
443
|
+
{
|
|
444
|
+
first_frame_url: { type: 'string', description: 'Public URL of the first frame image (URL mode)' },
|
|
445
|
+
last_frame_url: { type: 'string', description: 'Public URL of the last frame image (URL mode)' },
|
|
446
|
+
first_frame: { type: 'string', description: 'URL or absolute local path to the first frame (file mode — alternative to first_frame_url)' },
|
|
447
|
+
last_frame: { type: 'string', description: 'URL or absolute local path to the last frame (file mode — alternative to last_frame_url)' },
|
|
448
|
+
prompt: { type: 'string', description: 'Optional description of the desired motion between the two frames (e.g. "smooth camera dolly in")' },
|
|
449
|
+
model: { type: 'string', description: 'Model identifier. Use list_models type="video_from_image" to see options. Omit for Smart Select.' },
|
|
450
|
+
duration: { type: 'number', description: 'Duration in seconds. Default: 5' },
|
|
451
|
+
aspect_ratio: { type: 'string', description: 'Aspect ratio (auto-detected from first frame if not provided). Default: "16:9"' },
|
|
452
|
+
enhance_prompt: { type: 'boolean', description: 'Enhance the prompt. Default: true' },
|
|
453
|
+
visual_dna_ids: { type: 'array', description: 'Array of Visual DNA profile IDs to apply.' }
|
|
454
|
+
},
|
|
455
|
+
async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids }) => {
|
|
456
|
+
const urlMode = first_frame_url && last_frame_url;
|
|
457
|
+
const fileMode = first_frame && last_frame;
|
|
458
|
+
if (!urlMode && !fileMode) {
|
|
459
|
+
throw new Error('Provide either both first_frame_url + last_frame_url OR both first_frame + last_frame (URL/local path).');
|
|
460
|
+
}
|
|
461
|
+
if (urlMode && fileMode) {
|
|
462
|
+
throw new Error('Do not mix URL and file inputs. Provide either URLs OR file sources, not both.');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let startResponse;
|
|
466
|
+
if (fileMode) {
|
|
467
|
+
const [firstResolved, lastResolved] = await Promise.all([
|
|
468
|
+
resolveToBuffer(first_frame, 'image'),
|
|
469
|
+
resolveToBuffer(last_frame, 'image')
|
|
470
|
+
]);
|
|
471
|
+
const form = new FormData();
|
|
472
|
+
form.append('files', firstResolved.buffer, { filename: firstResolved.filename, contentType: firstResolved.contentType });
|
|
473
|
+
form.append('files', lastResolved.buffer, { filename: lastResolved.filename, contentType: lastResolved.contentType });
|
|
474
|
+
if (prompt) form.append('prompt', prompt);
|
|
475
|
+
if (model) form.append('model', model);
|
|
476
|
+
if (duration !== undefined) form.append('duration', String(duration));
|
|
477
|
+
if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
|
|
478
|
+
if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
|
|
479
|
+
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
480
|
+
startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
|
|
481
|
+
} else {
|
|
482
|
+
startResponse = await client.post('/v1/generate/first-last-frame', {
|
|
483
|
+
first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
488
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
489
|
+
timeout: 300000
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
content: [{
|
|
494
|
+
type: 'text',
|
|
495
|
+
text: JSON.stringify({
|
|
496
|
+
urls: result.result?.urls || [],
|
|
497
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
498
|
+
duration: result.result?.duration || null,
|
|
499
|
+
model: result.result?.model || null
|
|
500
|
+
}, null, 2)
|
|
501
|
+
}]
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// ─── generate_lipsync ──────────────────────────────────────
|
|
507
|
+
server.tool(
|
|
508
|
+
'generate_lipsync',
|
|
509
|
+
'Lipsync an audio track to a source image or video. Both `source` (image or video) and `audio` can be provided as URLs or as absolute local file paths. Pass a text_prompt only if the model supports it (some lipsync models do character performance from a prompt). Returns a lipsynced video URL.',
|
|
510
|
+
{
|
|
511
|
+
source: { type: 'string', description: 'URL or absolute local path to the source image or video (the face to animate)' },
|
|
512
|
+
audio: { type: 'string', description: 'URL or absolute local path to the audio track (the voice to sync to)' },
|
|
513
|
+
text_prompt: { type: 'string', description: 'Optional text prompt (for performance-capable models)' },
|
|
514
|
+
model: { type: 'string', description: 'Model identifier. Use list_models type="lipsync" to see options. Omit for Smart Select.' },
|
|
515
|
+
bounding_box_target: { type: 'array', description: 'Optional bounding box [x, y, w, h] for multi-face inputs (Hedra Character3 style). Leave empty for single-face.' }
|
|
516
|
+
},
|
|
517
|
+
async ({ source, audio, text_prompt, model, bounding_box_target }) => {
|
|
518
|
+
if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
|
|
519
|
+
if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
|
|
520
|
+
|
|
521
|
+
const sourceIsUrl = typeof source === 'string' && /^https?:\/\//i.test(source);
|
|
522
|
+
const audioIsUrl = typeof audio === 'string' && /^https?:\/\//i.test(audio);
|
|
523
|
+
|
|
524
|
+
let startResponse;
|
|
525
|
+
if (sourceIsUrl && audioIsUrl) {
|
|
526
|
+
// URL mode
|
|
527
|
+
startResponse = await client.post('/v1/generate/lipsync', {
|
|
528
|
+
source_url: source,
|
|
529
|
+
audio_url: audio,
|
|
530
|
+
prompt: text_prompt,
|
|
531
|
+
model,
|
|
532
|
+
bounding_box_target
|
|
533
|
+
});
|
|
534
|
+
} else {
|
|
535
|
+
// File mode (or mixed — resolve any local paths, pass URLs through as body fields)
|
|
536
|
+
const form = new FormData();
|
|
537
|
+
if (!sourceIsUrl) {
|
|
538
|
+
const resolved = await resolveToBuffer(source, /\.(mp4|mov|webm|mkv)$/i.test(source) ? 'video' : 'image');
|
|
539
|
+
// Decide field name by kind — lipsync controller uses .fields() with image/video/audio.
|
|
540
|
+
const isVideo = /\.(mp4|mov|webm|mkv|avi|m4v)$/i.test(resolved.filename);
|
|
541
|
+
form.append(isVideo ? 'video' : 'image', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
542
|
+
} else {
|
|
543
|
+
form.append('source_url', source);
|
|
544
|
+
}
|
|
545
|
+
if (!audioIsUrl) {
|
|
546
|
+
const resolved = await resolveToBuffer(audio, 'audio');
|
|
547
|
+
form.append('audio', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
548
|
+
} else {
|
|
549
|
+
form.append('audio_url', audio);
|
|
550
|
+
}
|
|
551
|
+
if (text_prompt) form.append('prompt', text_prompt);
|
|
552
|
+
if (model) form.append('model', model);
|
|
553
|
+
if (bounding_box_target) form.append('bounding_box_target', JSON.stringify(bounding_box_target));
|
|
554
|
+
startResponse = await client.postMultipart('/v1/generate/lipsync', form);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
558
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
559
|
+
timeout: 600000
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
content: [{
|
|
564
|
+
type: 'text',
|
|
565
|
+
text: JSON.stringify({
|
|
566
|
+
urls: result.result?.urls || [],
|
|
567
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
568
|
+
duration: result.result?.duration || null,
|
|
569
|
+
model: result.result?.model || null
|
|
570
|
+
}, null, 2)
|
|
571
|
+
}]
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// ─── generate_video_from_video ─────────────────────────────
|
|
577
|
+
server.tool(
|
|
578
|
+
'generate_video_from_video',
|
|
579
|
+
'Restyle / transform an existing video using a text prompt (video-to-video). Use for style transfer, scene restyling, subject swap — anything where you want to keep the motion from the input video but change the look. Source video can be a URL or absolute local path. For animating a still image use generate_video_from_image instead. For text-only → video use generate_video.',
|
|
580
|
+
{
|
|
581
|
+
source_video: { type: 'string', description: 'URL or absolute local path to the source video to restyle' },
|
|
582
|
+
prompt: { type: 'string', description: 'Text description of the desired restyle / transformation' },
|
|
583
|
+
model: { type: 'string', description: 'Model identifier. Omit for Smart Select.' },
|
|
584
|
+
aspect_ratio: { type: 'string', description: 'Output aspect ratio. Default: matches source' },
|
|
585
|
+
duration: { type: 'number', description: 'Duration in seconds (default: matches source)' },
|
|
586
|
+
enhance_prompt: { type: 'boolean', description: 'Enhance the prompt. Default: true' },
|
|
587
|
+
visual_dna_ids: { type: 'array', description: 'Array of Visual DNA profile IDs to apply for character/style consistency.' }
|
|
588
|
+
},
|
|
589
|
+
async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids }) => {
|
|
590
|
+
if (!source_video) throw new Error('source_video is required');
|
|
591
|
+
if (!prompt) throw new Error('prompt is required');
|
|
592
|
+
|
|
593
|
+
const isUrl = /^https?:\/\//i.test(source_video);
|
|
594
|
+
let startResponse;
|
|
595
|
+
if (isUrl) {
|
|
596
|
+
startResponse = await client.post('/v1/generate/video-from-video', {
|
|
597
|
+
video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids
|
|
598
|
+
});
|
|
599
|
+
} else {
|
|
600
|
+
const resolved = await resolveToBuffer(source_video, 'video');
|
|
601
|
+
const form = new FormData();
|
|
602
|
+
form.append('files', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
603
|
+
form.append('prompt', prompt);
|
|
604
|
+
if (model) form.append('model', model);
|
|
605
|
+
if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
|
|
606
|
+
if (duration !== undefined) form.append('duration', String(duration));
|
|
607
|
+
if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
|
|
608
|
+
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
609
|
+
startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
613
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
614
|
+
timeout: 600000
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
content: [{
|
|
619
|
+
type: 'text',
|
|
620
|
+
text: JSON.stringify({
|
|
621
|
+
urls: result.result?.urls || [],
|
|
622
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
623
|
+
duration: result.result?.duration || null,
|
|
624
|
+
model: result.result?.model || null
|
|
625
|
+
}, null, 2)
|
|
626
|
+
}]
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// ─── transcribe_audio ──────────────────────────────────────
|
|
632
|
+
server.tool(
|
|
633
|
+
'transcribe_audio',
|
|
634
|
+
'Transcribe audio or video into text + SRT subtitles. Source can be a URL or an absolute local file path. Returns the full text, SRT content, duration, and download URLs for .srt/.txt files. Works on both audio-only files (mp3, wav, m4a) and videos with audio tracks (mp4, mov, webm).',
|
|
635
|
+
{
|
|
636
|
+
source: { type: 'string', description: 'URL or absolute local path to the audio / video file to transcribe' }
|
|
637
|
+
},
|
|
638
|
+
async ({ source }) => {
|
|
639
|
+
if (!source) throw new Error('source is required (URL or absolute local path)');
|
|
640
|
+
|
|
641
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
642
|
+
let startResponse;
|
|
643
|
+
if (isUrl) {
|
|
644
|
+
startResponse = await client.post('/v1/transcribe', { audio_url: source });
|
|
645
|
+
} else {
|
|
646
|
+
const resolved = await resolveToBuffer(source, 'audio');
|
|
647
|
+
const form = new FormData();
|
|
648
|
+
form.append('file', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
649
|
+
startResponse = await client.postMultipart('/v1/transcribe', form);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
653
|
+
interval: (startResponse.poll_interval_hint || 5) * 1000,
|
|
654
|
+
timeout: 1800000 // 30 minutes — long podcasts are a thing
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
content: [{
|
|
659
|
+
type: 'text',
|
|
660
|
+
text: JSON.stringify({
|
|
661
|
+
text: result.result?.text || '',
|
|
662
|
+
srt_url: result.result?.srt_url || null,
|
|
663
|
+
txt_url: result.result?.txt_url || null,
|
|
664
|
+
duration: result.result?.duration || null
|
|
665
|
+
}, null, 2)
|
|
666
|
+
}]
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
// ─── generate_3d ───────────────────────────────────────────
|
|
672
|
+
server.tool(
|
|
673
|
+
'generate_3d',
|
|
674
|
+
'Generate a 3D model from a text prompt, a single reference image, or multiple reference images (for multi-view reconstruction). Returns model URLs in multiple formats (GLB, FBX, OBJ, USDZ). Modes: "text" (prompt-only), "single" (one image), "multi" (multiple images for better quality). The mode is auto-detected from the inputs if not specified.',
|
|
675
|
+
{
|
|
676
|
+
prompt: { type: 'string', description: 'Text description of the 3D object to generate (used in text mode and also as a hint in image modes)' },
|
|
677
|
+
reference_images: { type: 'array', description: 'Array of public image URLs. 1 image → single mode, 2+ → multi mode.' },
|
|
678
|
+
mode: { type: 'string', description: 'Explicitly set mode: "text" | "single" | "multi". Auto-detected from reference_images if omitted.' },
|
|
679
|
+
texture_prompt: { type: 'string', description: 'Optional prompt to guide texture generation' },
|
|
680
|
+
model: { type: 'string', description: 'Model identifier. Use list_models type="three_d" to see options.' },
|
|
681
|
+
topology: { type: 'string', description: 'Topology preset (optional, model-specific)' },
|
|
682
|
+
target_polycount: { type: 'number', description: 'Target polygon count (optional, model-specific)' },
|
|
683
|
+
enable_tpose: { type: 'boolean', description: 'Force T-pose for character models (optional)' },
|
|
684
|
+
enable_pbr: { type: 'boolean', description: 'Enable PBR textures (optional)' }
|
|
685
|
+
},
|
|
686
|
+
async ({ prompt, reference_images, mode, texture_prompt, model, topology, target_polycount, enable_tpose, enable_pbr }) => {
|
|
687
|
+
if (!prompt && !(reference_images && reference_images.length > 0)) {
|
|
688
|
+
throw new Error('Provide prompt (text mode) or reference_images (single/multi mode)');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const startResponse = await client.post('/v1/generate/3d', {
|
|
692
|
+
mode,
|
|
693
|
+
prompt,
|
|
694
|
+
reference_images,
|
|
695
|
+
texture_prompt,
|
|
696
|
+
model,
|
|
697
|
+
topology,
|
|
698
|
+
target_polycount,
|
|
699
|
+
enable_tpose,
|
|
700
|
+
enable_pbr
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
704
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
705
|
+
timeout: 900000 // 15 minutes — 3D generation is slow
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
content: [{
|
|
710
|
+
type: 'text',
|
|
711
|
+
text: JSON.stringify({
|
|
712
|
+
urls: result.result?.urls || [],
|
|
713
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
714
|
+
mode: result.result?.mode || null,
|
|
715
|
+
prompt_used: result.result?.prompt_used || null
|
|
716
|
+
}, null, 2)
|
|
717
|
+
}]
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
);
|
|
369
721
|
}
|
|
370
722
|
|
|
371
723
|
module.exports = { registerGenerateTools };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
+
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
+
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
+
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
+
|
|
6
|
+
const FormData = require('form-data');
|
|
7
|
+
const { resolveToBuffer } = require('./_shared');
|
|
8
|
+
|
|
9
|
+
function registerMediaTools(server, client) {
|
|
10
|
+
// ─── upload_media ──────────────────────────────────────────
|
|
11
|
+
server.tool(
|
|
12
|
+
'upload_media',
|
|
13
|
+
'Upload a local file (or remote URL) to the user\'s Kolbo media library and get back a stable Kolbo CDN URL. Use this when the user wants to reference a local file in multiple subsequent generation calls — upload once, then pass the returned URL to generate_image / generate_video / visual_dna / etc. Auto-detects media type (image / video / audio) from the file extension. For a single-use reference where you already have a public URL, you can skip this and pass the URL directly to the generation tool.',
|
|
14
|
+
{
|
|
15
|
+
source: { type: 'string', description: 'URL or absolute local path to the file to upload. For local files this is the primary mode; for URLs, this re-hosts the file on Kolbo CDN for stability.' },
|
|
16
|
+
description: { type: 'string', description: 'Optional description / caption for the uploaded media' }
|
|
17
|
+
},
|
|
18
|
+
async ({ source, description }) => {
|
|
19
|
+
if (!source) throw new Error('source is required (URL or absolute local path)');
|
|
20
|
+
|
|
21
|
+
// Even for URL input we download-and-reupload — that's the whole point
|
|
22
|
+
// of upload_media (getting a stable Kolbo-owned URL). For ephemeral
|
|
23
|
+
// pass-through, the generation tools accept URLs directly.
|
|
24
|
+
const kind = /\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? 'video'
|
|
25
|
+
: /\.(mp3|wav|ogg|m4a|flac|aac)(\?|$)/i.test(source) ? 'audio'
|
|
26
|
+
: 'image';
|
|
27
|
+
const resolved = await resolveToBuffer(source, kind);
|
|
28
|
+
|
|
29
|
+
const form = new FormData();
|
|
30
|
+
form.append('file', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
31
|
+
if (description) form.append('description', description);
|
|
32
|
+
|
|
33
|
+
const result = await client.postMultipart('/v1/media/upload', form);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: JSON.stringify(result.media || result, null, 2)
|
|
39
|
+
}]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// ─── list_media ────────────────────────────────────────────
|
|
45
|
+
server.tool(
|
|
46
|
+
'list_media',
|
|
47
|
+
'List the user\'s uploaded media from their Kolbo media library. Supports filtering by type (image / video / audio) and pagination. Returns items with stable URLs, names, sizes, and upload timestamps. Use this to discover what the user has previously uploaded before deciding whether to create new content.',
|
|
48
|
+
{
|
|
49
|
+
type: { type: 'string', description: 'Filter by type: "image" | "video" | "audio". Omit for all types.' },
|
|
50
|
+
page: { type: 'number', description: 'Page number (1-indexed). Default: 1' },
|
|
51
|
+
page_size: { type: 'number', description: 'Items per page. Default: 20, max 100' },
|
|
52
|
+
search: { type: 'string', description: 'Optional full-text search term matched against media names and descriptions' }
|
|
53
|
+
},
|
|
54
|
+
async ({ type, page, page_size, search }) => {
|
|
55
|
+
const params = new URLSearchParams();
|
|
56
|
+
if (type) params.set('type', type);
|
|
57
|
+
if (page) params.set('page', String(page));
|
|
58
|
+
if (page_size) params.set('pageSize', String(page_size));
|
|
59
|
+
if (search) params.set('searchTerm', search);
|
|
60
|
+
|
|
61
|
+
const qs = params.toString();
|
|
62
|
+
const result = await client.get(`/v1/media${qs ? '?' + qs : ''}`);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
content: [{
|
|
66
|
+
type: 'text',
|
|
67
|
+
text: JSON.stringify({
|
|
68
|
+
media: result.media || [],
|
|
69
|
+
pagination: result.pagination || null
|
|
70
|
+
}, null, 2)
|
|
71
|
+
}]
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { registerMediaTools };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
+
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
+
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
+
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
+
|
|
6
|
+
function registerPresetTools(server, client) {
|
|
7
|
+
// ─── list_presets ──────────────────────────────────────────
|
|
8
|
+
server.tool(
|
|
9
|
+
'list_presets',
|
|
10
|
+
'List generation presets across image, video, music, and text-to-video catalogs. Presets bundle a specific prompt template + style direction that can be passed to a generation tool via its `preset_id` arg for a one-shot creative direction. Filter by `type` to narrow to a specific catalog. Returns id, name, description, thumbnail, category, and (for music) audio preview URL.',
|
|
11
|
+
{
|
|
12
|
+
type: { type: 'string', description: 'Filter by catalog: "image" | "video" | "music" | "text_to_video". Omit for all.' }
|
|
13
|
+
},
|
|
14
|
+
async ({ type }) => {
|
|
15
|
+
const params = new URLSearchParams();
|
|
16
|
+
if (type) params.set('type', type);
|
|
17
|
+
const qs = params.toString();
|
|
18
|
+
const result = await client.get(`/v1/presets${qs ? '?' + qs : ''}`);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
content: [{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: JSON.stringify({
|
|
24
|
+
presets: result.presets || [],
|
|
25
|
+
count: result.count || 0,
|
|
26
|
+
...(result.warning ? { warning: result.warning } : {})
|
|
27
|
+
}, null, 2)
|
|
28
|
+
}]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { registerPresetTools };
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -3,78 +3,14 @@
|
|
|
3
3
|
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
4
|
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
5
|
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
6
|
const FormData = require('form-data');
|
|
7
|
+
const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES } = require('./_shared');
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function guessFilename(source, fallbackExt) {
|
|
17
|
-
if (isHttpUrl(source)) {
|
|
18
|
-
try {
|
|
19
|
-
const u = new URL(source);
|
|
20
|
-
const base = path.basename(u.pathname) || `upload${fallbackExt}`;
|
|
21
|
-
return base.includes('.') ? base : `${base}${fallbackExt}`;
|
|
22
|
-
} catch (_) {
|
|
23
|
-
return `upload${fallbackExt}`;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return path.basename(source);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function guessContentType(filename) {
|
|
30
|
-
const ext = path.extname(filename).toLowerCase();
|
|
31
|
-
const map = {
|
|
32
|
-
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
33
|
-
'.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
34
|
-
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
35
|
-
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4'
|
|
36
|
-
};
|
|
37
|
-
return map[ext] || 'application/octet-stream';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function resolveToBuffer(source, kind) {
|
|
41
|
-
// kind: 'image' | 'video' | 'audio' — used for default filename extension only.
|
|
42
|
-
const defaultExt = kind === 'image' ? '.png' : kind === 'video' ? '.mp4' : '.mp3';
|
|
43
|
-
|
|
44
|
-
if (isHttpUrl(source)) {
|
|
45
|
-
const res = await fetch(source);
|
|
46
|
-
if (!res.ok) throw new Error(`Failed to fetch ${source}: ${res.status}`);
|
|
47
|
-
const contentLen = parseInt(res.headers.get('content-length') || '0', 10);
|
|
48
|
-
if (contentLen && contentLen > MAX_FILE_BYTES) {
|
|
49
|
-
throw new Error(`File at ${source} exceeds 25MB limit`);
|
|
50
|
-
}
|
|
51
|
-
const arrayBuf = await res.arrayBuffer();
|
|
52
|
-
const buffer = Buffer.from(arrayBuf);
|
|
53
|
-
if (buffer.length > MAX_FILE_BYTES) {
|
|
54
|
-
throw new Error(`File at ${source} exceeds 25MB limit`);
|
|
55
|
-
}
|
|
56
|
-
return {
|
|
57
|
-
buffer,
|
|
58
|
-
filename: guessFilename(source, defaultExt),
|
|
59
|
-
contentType: res.headers.get('content-type') || guessContentType(guessFilename(source, defaultExt))
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Local path
|
|
64
|
-
if (!path.isAbsolute(source)) {
|
|
65
|
-
throw new Error(`Local file paths must be absolute: ${source}`);
|
|
66
|
-
}
|
|
67
|
-
const stat = fs.statSync(source);
|
|
68
|
-
if (stat.size > MAX_FILE_BYTES) {
|
|
69
|
-
throw new Error(`File ${source} (${stat.size} bytes) exceeds 25MB limit`);
|
|
70
|
-
}
|
|
71
|
-
const buffer = fs.readFileSync(source);
|
|
72
|
-
const filename = path.basename(source);
|
|
73
|
-
return {
|
|
74
|
-
buffer,
|
|
75
|
-
filename,
|
|
76
|
-
contentType: guessContentType(filename)
|
|
77
|
-
};
|
|
9
|
+
// Visual DNA caps reference media at 25MB per file (stricter than the
|
|
10
|
+
// default _shared.resolveToBuffer cap — DNA profiles only need enough
|
|
11
|
+
// source signal to extract features, not full-quality media).
|
|
12
|
+
function resolveToBuffer(source, kind) {
|
|
13
|
+
return sharedResolveToBuffer(source, kind, { maxBytes: VISUAL_DNA_MAX_BYTES });
|
|
78
14
|
}
|
|
79
15
|
|
|
80
16
|
function registerVisualDnaTools(server, client) {
|