@kolbo/mcp 1.1.0 → 1.3.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 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 (21)
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.1.0",
3
+ "version": "1.3.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,8 +40,14 @@
40
40
  "README.md"
41
41
  ],
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.12.1",
44
- "form-data": "^4.0.5"
43
+ "@modelcontextprotocol/sdk": "1.29.0",
44
+ "form-data": "^4.0.5",
45
+ "zod": "^3.25.0"
46
+ },
47
+ "overrides": {
48
+ "hono": "^4.12.12",
49
+ "@hono/node-server": "^1.19.13",
50
+ "path-to-regexp": "^8.4.2"
45
51
  },
46
52
  "engines": {
47
53
  "node": ">=18.0.0"
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/chat.js CHANGED
@@ -3,6 +3,7 @@
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 { z } = require('zod');
6
7
  const { pollUntilDone } = require('../polling');
7
8
 
8
9
  function registerChatTools(server, client) {
@@ -11,13 +12,13 @@ function registerChatTools(server, client) {
11
12
  'chat_send_message',
12
13
  'Send a chat message to Kolbo AI. Starts a new conversation (omit session_id) or continues an existing one. Returns the assistant response when complete. Supports web search and deep think modes.',
13
14
  {
14
- message: { type: 'string', description: 'The user message to send' },
15
- model: { type: 'string', description: 'Model identifier (e.g. "gpt-4o", "claude-sonnet-4-5"). Omit for Smart Select (auto).' },
16
- session_id: { type: 'string', description: 'Existing chat session ID to continue. Omit to start a new conversation.' },
17
- system_prompt: { type: 'string', description: 'System prompt for the conversation. Only applied when creating a new session.' },
18
- web_search: { type: 'boolean', description: 'Enable web search for this message. Default: false' },
19
- deep_think: { type: 'boolean', description: 'Enable deep think (extended reasoning). Default: false' },
20
- enhance_prompt: { type: 'boolean', description: 'Enhance the prompt. Default: true' }
15
+ message: z.string().describe('The user message to send'),
16
+ model: z.string().optional().describe('Model identifier (e.g. "gpt-4o", "claude-sonnet-4-5"). Omit for Smart Select (auto).'),
17
+ session_id: z.string().optional().describe('Existing chat session ID to continue. Omit to start a new conversation.'),
18
+ system_prompt: z.string().optional().describe('System prompt for the conversation. Only applied when creating a new session.'),
19
+ web_search: z.boolean().optional().describe('Enable web search for this message. Default: false'),
20
+ deep_think: z.boolean().optional().describe('Enable deep think (extended reasoning). Default: false'),
21
+ enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true')
21
22
  },
22
23
  async ({ message, model, session_id, system_prompt, web_search, deep_think, enhance_prompt }) => {
23
24
  const gen = await client.post('/v1/chat', {
@@ -65,8 +66,8 @@ function registerChatTools(server, client) {
65
66
  'chat_list_conversations',
66
67
  'List your SDK chat conversations, most-recent first. Returns session_id, name, and activity timestamps.',
67
68
  {
68
- page: { type: 'number', description: 'Page number, 1-indexed. Default: 1' },
69
- limit: { type: 'number', description: 'Results per page, max 50. Default: 20' }
69
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1'),
70
+ limit: z.number().optional().describe('Results per page, max 50. Default: 20')
70
71
  },
71
72
  async ({ page, limit }) => {
72
73
  const params = new URLSearchParams();
@@ -93,9 +94,9 @@ function registerChatTools(server, client) {
93
94
  'chat_get_messages',
94
95
  'Fetch messages in a chat conversation. Returns role, content, model, and any media URLs attached to each message.',
95
96
  {
96
- session_id: { type: 'string', description: 'The chat session ID' },
97
- page: { type: 'number', description: 'Page number, 1-indexed. Default: 1' },
98
- limit: { type: 'number', description: 'Messages per page, max 100. Default: 50' }
97
+ session_id: z.string().describe('The chat session ID'),
98
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1'),
99
+ limit: z.number().optional().describe('Messages per page, max 100. Default: 50')
99
100
  },
100
101
  async ({ session_id, page, limit }) => {
101
102
  const params = new URLSearchParams();