@kolbo/mcp 1.86.0 → 1.86.4

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/src/index.js CHANGED
@@ -163,7 +163,12 @@ function createServer(opts = {}) {
163
163
  // remote HTTP host enables it, so stdio clients (Kolbo Code / Desktop / Cursor)
164
164
  // keep identical text-URL output. `apps` gates interactive widget results
165
165
  // (MCP Apps) the same way — see src/apps/index.js.
166
- const toolOptions = { inlineImages: !!opts.inlineImages, apps: !!opts.apps };
166
+ const toolOptions = {
167
+ inlineImages: !!opts.inlineImages,
168
+ remote: !!opts.remote || !!opts.apps,
169
+ apps: !!opts.apps,
170
+ asyncGenerations: !!opts.asyncGenerations,
171
+ };
167
172
  registerGenerateTools(server, client, toolOptions);
168
173
  registerModelTools(server, client, toolOptions);
169
174
  registerVoiceTools(server, client, toolOptions);
@@ -51,7 +51,7 @@ const PRIVATE_WRITE = [
51
51
  'move_session', 'bulk_move_sessions', 'move_generations_to_session',
52
52
  'split_session', 'undo_session_organization',
53
53
  'rename_session', 'restore_session',
54
- 'create_project', 'update_project',
54
+ 'create_project', 'duplicate_project', 'update_project',
55
55
  'archive_project', 'unarchive_project', 'add_project_context',
56
56
  'link_project_asset', 'unlink_project_asset',
57
57
  'create_agent',
@@ -23,11 +23,14 @@
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
25
  const net = require('net');
26
+ const dns = require('dns').promises;
27
+ const { Agent, fetch: undiciFetch } = require('undici');
26
28
 
27
29
  const MAX_FILE_BYTES = 500 * 1024 * 1024; // 500 MB — larger than visual_dna because
28
30
  // lipsync/v2v/transcription accept full
29
31
  // videos and long audio tracks.
30
32
  const VISUAL_DNA_MAX_BYTES = 25 * 1024 * 1024; // kept for visual_dna backward-compat
33
+ const REMOTE_FETCH_MAX_BYTES = 100 * 1024 * 1024;
31
34
  const MAX_REDIRECTS = 5;
32
35
 
33
36
  // THE single statement of how a local file gets into Kolbo. It is repeated to
@@ -67,16 +70,21 @@ const REMOTE_FILE_HINT =
67
70
  const LOCAL_FILE_HINT =
68
71
  ' LOCAL FILE? Absolute local paths work here (server and client share a filesystem). ' +
69
72
  'Never reply that you cannot upload files — for a file you will reference more than once, call `upload_media` first and reuse the returned https:// URL.';
73
+ const REMOTE_TEXT_FILE_HINT =
74
+ ' REMOTE FILE INPUT: This client cannot send local filesystem paths or render Kolbo\'s upload widget. ' +
75
+ 'Use an existing public https:// URL. If the attachment has no public URL, ask the user to upload it in the Kolbo Media Library and paste the resulting URL; never invent a URL or claim a local path is usable here.';
70
76
 
71
77
  /**
72
78
  * Append the transport-correct local-file route to every media-input tool's
73
79
  * description, post-registration (same pattern as attachToolWidgetMeta).
74
- * `options.apps === true` is set ONLY by kolbo-api's remote per-request server,
75
- * so it is a transport signal — not `appsEnabled()`, which is also true for
76
- * stdio hosts that render widgets but CAN still read local paths.
80
+ * `options.remote === true` is set only by kolbo-api's remote per-request
81
+ * server. Do not use `appsEnabled()` as the transport signal: stdio hosts can
82
+ * render widgets while still sharing a filesystem with this process.
77
83
  */
78
84
  function attachFileInputHints(server, options = {}) {
79
- const hint = options.apps === true ? REMOTE_FILE_HINT : LOCAL_FILE_HINT;
85
+ const hint = options.asyncGenerations
86
+ ? REMOTE_TEXT_FILE_HINT
87
+ : (options.remote === true || options.apps === true ? REMOTE_FILE_HINT : LOCAL_FILE_HINT);
80
88
  const registered = server._registeredTools || {};
81
89
  for (const name of FILE_INPUT_TOOLS) {
82
90
  const t = registered[name];
@@ -99,6 +107,7 @@ function isPrivateIPv4(ip) {
99
107
  if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) return true;
100
108
  const [a, b] = parts;
101
109
  if (a === 10) return true;
110
+ if (a === 100 && b >= 64 && b <= 127) return true;
102
111
  if (a === 127) return true;
103
112
  if (a === 0) return true;
104
113
  if (a === 169 && b === 254) return true; // includes 169.254.169.254 cloud metadata
@@ -166,21 +175,100 @@ function assertSafeUrl(rawUrl) {
166
175
  return u;
167
176
  }
168
177
 
178
+ async function resolvePublicAddresses(hostname) {
179
+ let host = hostname.toLowerCase();
180
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
181
+ const literalFamily = net.isIP(host);
182
+ if (literalFamily) return [{ address: host, family: literalFamily }];
183
+
184
+ let timer;
185
+ const timeout = new Promise((_, reject) => {
186
+ timer = setTimeout(() => reject(new Error(`DNS lookup timed out for ${host}`)), 3000);
187
+ });
188
+ let rows;
189
+ try {
190
+ rows = await Promise.race([dns.lookup(host, { all: true, verbatim: true }), timeout]);
191
+ } finally {
192
+ clearTimeout(timer);
193
+ }
194
+ if (!rows.length) throw new Error(`DNS lookup returned no addresses for ${host}`);
195
+ for (const row of rows) {
196
+ const blocked = row.family === 4 ? isPrivateIPv4(row.address) : isPrivateIPv6(row.address);
197
+ if (blocked) throw new Error(`Refusing private / loopback / metadata DNS target for ${host}`);
198
+ }
199
+ return rows;
200
+ }
201
+
202
+ function pinnedDispatcher(addresses) {
203
+ let cursor = 0;
204
+ return new Agent({
205
+ connect: {
206
+ lookup(_hostname, options, callback) {
207
+ if (options?.all) return callback(null, addresses);
208
+ const row = addresses[cursor++ % addresses.length];
209
+ return callback(null, row.address, row.family);
210
+ },
211
+ },
212
+ });
213
+ }
214
+
169
215
  async function safeFetch(rawUrl, opts = {}) {
170
216
  let current = rawUrl;
171
217
  for (let i = 0; i <= MAX_REDIRECTS; i++) {
172
- assertSafeUrl(current);
173
- const res = await fetch(current, { redirect: 'manual', signal: opts.signal });
218
+ const url = assertSafeUrl(current);
219
+ const addresses = await resolvePublicAddresses(url.hostname);
220
+ const dispatcher = pinnedDispatcher(addresses);
221
+ let res;
222
+ try {
223
+ res = await undiciFetch(current, { redirect: 'manual', signal: opts.signal, dispatcher });
224
+ } catch (err) {
225
+ await dispatcher.close().catch(() => {});
226
+ throw err;
227
+ }
174
228
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
175
229
  const next = new URL(res.headers.get('location'), current).toString();
230
+ await res.body?.cancel().catch(() => {});
231
+ await dispatcher.close().catch(() => {});
176
232
  current = next;
177
233
  continue;
178
234
  }
235
+ // close() waits for this response body to be consumed, so schedule it but
236
+ // do not await it before returning the Response to the caller.
237
+ dispatcher.close().catch(() => {});
179
238
  return res;
180
239
  }
181
240
  throw new Error(`Too many redirects fetching ${rawUrl}`);
182
241
  }
183
242
 
243
+ async function discardResponse(res) {
244
+ try { await res?.body?.cancel(); } catch (_) {}
245
+ }
246
+
247
+ async function readResponseBuffer(res, maxBytes) {
248
+ if (!res?.body || typeof res.body.getReader !== 'function') {
249
+ throw new Error('Remote response has no readable body');
250
+ }
251
+ const reader = res.body.getReader();
252
+ const chunks = [];
253
+ let total = 0;
254
+ try {
255
+ while (true) {
256
+ const { done, value } = await reader.read();
257
+ if (done) break;
258
+ const chunk = Buffer.from(value);
259
+ total += chunk.length;
260
+ if (total > maxBytes) {
261
+ await reader.cancel().catch(() => {});
262
+ throw new Error(`Remote response exceeds ${maxBytes}-byte limit`);
263
+ }
264
+ chunks.push(chunk);
265
+ }
266
+ } finally {
267
+ try { reader.releaseLock(); } catch (_) {}
268
+ }
269
+ return Buffer.concat(chunks, total);
270
+ }
271
+
184
272
  function guessFilename(source, fallbackExt) {
185
273
  if (isHttpUrl(source)) {
186
274
  try {
@@ -219,21 +307,24 @@ function guessContentType(filename) {
219
307
  * @returns {Promise<{buffer: Buffer, filename: string, contentType: string, size: number}>}
220
308
  */
221
309
  async function resolveToBuffer(source, kind, opts = {}) {
222
- const maxBytes = opts.maxBytes || MAX_FILE_BYTES;
310
+ const requestedMaxBytes = opts.maxBytes || MAX_FILE_BYTES;
311
+ const maxBytes = opts.allowLocalFiles === false
312
+ ? Math.min(requestedMaxBytes, REMOTE_FETCH_MAX_BYTES)
313
+ : requestedMaxBytes;
223
314
  const defaultExt = kind === 'image' ? '.png' : kind === 'video' ? '.mp4' : '.mp3';
224
315
 
225
316
  if (isHttpUrl(source)) {
226
317
  const res = await safeFetch(source);
227
- if (!res.ok) throw new Error(`Failed to fetch ${source}: ${res.status} ${res.statusText}`);
318
+ if (!res.ok) {
319
+ await discardResponse(res);
320
+ throw new Error(`Failed to fetch ${source}: ${res.status} ${res.statusText}`);
321
+ }
228
322
  const contentLen = parseInt(res.headers.get('content-length') || '0', 10);
229
323
  if (contentLen && contentLen > maxBytes) {
324
+ await discardResponse(res);
230
325
  throw new Error(`File at ${source} (${contentLen} bytes) exceeds ${maxBytes}-byte limit`);
231
326
  }
232
- const arrayBuf = await res.arrayBuffer();
233
- const buffer = Buffer.from(arrayBuf);
234
- if (buffer.length > maxBytes) {
235
- throw new Error(`File at ${source} (${buffer.length} bytes) exceeds ${maxBytes}-byte limit`);
236
- }
327
+ const buffer = await readResponseBuffer(res, maxBytes);
237
328
  const filename = guessFilename(source, defaultExt);
238
329
  return {
239
330
  buffer,
@@ -243,6 +334,12 @@ async function resolveToBuffer(source, kind, opts = {}) {
243
334
  };
244
335
  }
245
336
 
337
+ if (opts.allowLocalFiles === false) {
338
+ throw new Error(
339
+ 'This remote connector accepts public https:// URLs only. Upload the file to the Kolbo Media Library and pass its public URL.'
340
+ );
341
+ }
342
+
246
343
  if (!path.isAbsolute(source)) {
247
344
  // `path.isAbsolute` is platform-specific: on a POSIX server (every remote
248
345
  // connector deployment) a valid Windows path like `C:\Users\...` or
@@ -410,21 +507,32 @@ async function inlineImageBlocks(urls, opts = {}) {
410
507
  // bounds concurrency, and this sits on the connector response path right
411
508
  // after generation. Order is preserved by map-then-filter; any failure (size,
412
509
  // type, timeout, network) returns null and falls back to URL-only.
510
+ const maxCount = Math.min(INLINE_IMG_MAX_COUNT, Math.max(1, Number(opts.maxCount) || INLINE_IMG_MAX_COUNT));
511
+ const maxBytes = Math.min(INLINE_IMG_MAX_BYTES, Math.max(1, Number(opts.maxBytes) || INLINE_IMG_MAX_BYTES));
512
+ const fetchTimeoutMs = Math.min(INLINE_IMG_FETCH_TIMEOUT_MS, Math.max(1, Number(opts.fetchTimeoutMs) || INLINE_IMG_FETCH_TIMEOUT_MS));
413
513
  const blocks = await Promise.all(
414
- urls.slice(0, INLINE_IMG_MAX_COUNT).map(async (url) => {
514
+ urls.slice(0, maxCount).map(async (url) => {
415
515
  const controller = new AbortController();
416
- const timer = setTimeout(() => controller.abort(), INLINE_IMG_FETCH_TIMEOUT_MS);
516
+ const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
417
517
  try {
418
518
  if (typeof url !== 'string' || !isHttpUrl(url)) return null;
419
519
  const res = await safeFetch(url, { signal: controller.signal });
420
- if (!res.ok) return null;
520
+ if (!res.ok) {
521
+ await discardResponse(res);
522
+ return null;
523
+ }
421
524
  const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
422
- if (!contentType.startsWith('image/')) return null; // never embed non-images
525
+ if (!contentType.startsWith('image/')) {
526
+ await discardResponse(res);
527
+ return null; // never embed non-images
528
+ }
423
529
  const declaredLen = Number(res.headers.get('content-length') || 0);
424
- if (declaredLen && declaredLen > INLINE_IMG_MAX_BYTES) return null;
425
- const ab = await res.arrayBuffer();
426
- if (ab.byteLength > INLINE_IMG_MAX_BYTES) return null;
427
- return { type: 'image', data: Buffer.from(ab).toString('base64'), mimeType: contentType };
530
+ if (declaredLen && declaredLen > maxBytes) {
531
+ await discardResponse(res);
532
+ return null;
533
+ }
534
+ const buffer = await readResponseBuffer(res, maxBytes);
535
+ return { type: 'image', data: buffer.toString('base64'), mimeType: contentType };
428
536
  } catch (_) {
429
537
  return null;
430
538
  } finally {
@@ -440,27 +548,32 @@ async function inlineImageBlocks(urls, opts = {}) {
440
548
  // tool to the frontend page + tool slug whose session view can RESUME that
441
549
  // session (mirrors kolbo-map src/constants/sessionTypes.js resumeUrl map — the
442
550
  // route must match the SESSION MODEL the SDK created, per sdkSessionManager):
443
- // ImageSession → /image-tools?tool=text-to-image
444
- // imgEditSession (image_edit AND edit_image/global_image_edit) → /image-tools?tool=image-editing
445
- // textToVideoSession /video-tools?tool=text-to-video
446
- // imgToVideoSession (video_from_image, elements, first_last_frame) → /video-tools?tool=image-to-video
551
+ // ImageSession (image AND image_edit) → /image-tools?tool=create-image
552
+ // imgEditSession (edit_image / global_image_edit — the Canvas) → /image-tools?tool=canvas
553
+ // imgToVideoSession (video, video_from_image, elements, first_last_frame)
554
+ // → /video-tools?tool=create-video
447
555
  // videoToVideoSession → /video-tools?tool=video-to-video
448
556
  // lipsyncSession → /video-tools?tool=lipsync
449
557
  // MusicGeneratorSession / TextToSpeechSession / textToSoundSession /
450
558
  // speechToTextSession → /audio-tools with the matching slug
451
559
  // CreativeDirectorSession → /creative-director?session=... (no tool param)
560
+ // RETIRED — do NOT reintroduce as separate destinations. "Image Editing" folded
561
+ // into Create Image and "Text to Video" folded into Create Video (a mode inside
562
+ // it); sdkSessionManager already routes image_edit → ImageSession and video →
563
+ // imgToVideoSession. The old ?tool=image-editing / ?tool=text-to-video slugs
564
+ // still redirect, but nothing new should emit them.
452
565
  // Intentionally ABSENT (no deep-linkable session page — widget falls back to
453
566
  // plain https://app.kolbo.ai): edit_video (GlobalVideoEditSession has no
454
567
  // session deep-link), generate_3d (project-scoped, no session), shorts render.
455
568
  const APP_BASE_URL = 'https://app.kolbo.ai';
456
569
  const OPEN_URL_ROUTES = {
457
- generate_image: { path: '/image-tools', tool: 'text-to-image' },
458
- generate_image_edit: { path: '/image-tools', tool: 'image-editing' },
459
- edit_image: { path: '/image-tools', tool: 'image-editing' },
460
- generate_video: { path: '/video-tools', tool: 'text-to-video' },
461
- generate_video_from_image: { path: '/video-tools', tool: 'image-to-video' },
462
- generate_elements: { path: '/video-tools', tool: 'image-to-video', mode: 'elements' },
463
- generate_first_last_frame: { path: '/video-tools', tool: 'image-to-video', mode: 'first-last' },
570
+ generate_image: { path: '/image-tools', tool: 'create-image' },
571
+ generate_image_edit: { path: '/image-tools', tool: 'create-image' },
572
+ edit_image: { path: '/image-tools', tool: 'canvas' },
573
+ generate_video: { path: '/video-tools', tool: 'create-video' },
574
+ generate_video_from_image: { path: '/video-tools', tool: 'create-video' },
575
+ generate_elements: { path: '/video-tools', tool: 'create-video', mode: 'elements' },
576
+ generate_first_last_frame: { path: '/video-tools', tool: 'create-video', mode: 'first-last' },
464
577
  generate_video_from_video: { path: '/video-tools', tool: 'video-to-video' },
465
578
  generate_lipsync: { path: '/video-tools', tool: 'lipsync' },
466
579
  generate_music: { path: '/audio-tools', tool: 'music-generator' },
@@ -796,6 +909,42 @@ async function uiGenerating(p) {
796
909
  return uiResult(UI.generation, text, structured);
797
910
  }
798
911
 
912
+ /**
913
+ * Return a paid generation immediately for hosts that cannot render MCP Apps
914
+ * and enforce short tool-call timeouts (for example Manus custom MCP). This is
915
+ * deliberately plain MCP content: no ui:// resource and no promise of a card.
916
+ * The existing get_generation_status tool is the durable status endpoint.
917
+ */
918
+ function asyncGenerating(p) {
919
+ const ids = Array.isArray(p.generation_ids) && p.generation_ids.length
920
+ ? p.generation_ids
921
+ : [p.gen.generation_id].filter(Boolean);
922
+ const defaultStatusArgs = ids.length > 1
923
+ ? { generation_ids: ids, wait: false }
924
+ : { generation_id: ids[0], wait: false };
925
+ const pollTool = p.poll_tool || 'get_generation_status';
926
+ const statusArgs = { ...(p.status_args || defaultStatusArgs), wait: false };
927
+ const structured = {
928
+ status: 'submitted',
929
+ generation_id: p.gen.generation_id,
930
+ session_id: p.gen.session_id,
931
+ ...(ids.length > 1 ? { batch: true, generation_ids: ids } : {}),
932
+ ...(p.failed_submissions && p.failed_submissions.length
933
+ ? { failed_submissions: p.failed_submissions } : {}),
934
+ ...(p.warning ? { warning: p.warning } : {}),
935
+ poll_tool: pollTool,
936
+ status_args: statusArgs,
937
+ next_action: `The job is running. Do not submit it again. When the result is needed, call ${pollTool} with the supplied status_args. If it is still processing, tell the user and check again later; do not poll in a tight loop.`,
938
+ paid_job_notice: ids.length > 1
939
+ ? `This is a paid batch. Only if the user asks to stop or approves a replacement, cancel every running generation first: ${ids.join(', ')}.`
940
+ : 'This is a paid generation. Only if the user asks to stop or approves a replacement, cancel this generation before starting the replacement.',
941
+ };
942
+ return {
943
+ content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
944
+ structuredContent: structured,
945
+ };
946
+ }
947
+
799
948
  /**
800
949
  * Wrap an already-completed generation result with the widget.
801
950
  *
@@ -1019,6 +1168,7 @@ module.exports = {
1019
1168
  isHttpUrl,
1020
1169
  assertSafeUrl,
1021
1170
  safeFetch,
1171
+ readResponseBuffer,
1022
1172
  guessFilename,
1023
1173
  guessContentType,
1024
1174
  resolveToBuffer,
@@ -1032,6 +1182,7 @@ module.exports = {
1032
1182
  linkFields,
1033
1183
  buildProjectUrl,
1034
1184
  uiGenerating,
1185
+ asyncGenerating,
1035
1186
  uiCompleted,
1036
1187
  insufficientCreditsResult,
1037
1188
  appsEnabled,
@@ -9,7 +9,7 @@ const { resolveToBuffer } = require('./_shared');
9
9
  // An HTML/SVG/Mermaid document is text — cap well below the media limit.
10
10
  const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
11
11
 
12
- function registerArtifactTools(server, client) {
12
+ function registerArtifactTools(server, client, options = {}) {
13
13
  // ─── publish_html_artifact ─────────────────────────────────────
14
14
  server.tool(
15
15
  'publish_html_artifact',
@@ -34,7 +34,10 @@ function registerArtifactTools(server, client) {
34
34
  if (hasPath) {
35
35
  // resolveToBuffer gives us the absolute-path check, the SSRF guard for
36
36
  // https:// sources, and the remote-connector error message for free.
37
- const { buffer } = await resolveToBuffer(file_path.trim(), 'html', { maxBytes: MAX_ARTIFACT_BYTES });
37
+ const { buffer } = await resolveToBuffer(file_path.trim(), 'html', {
38
+ maxBytes: MAX_ARTIFACT_BYTES,
39
+ allowLocalFiles: !options.remote,
40
+ });
38
41
  body_content = buffer.toString('utf8');
39
42
  if (!body_content.trim()) throw new Error(`File is empty: ${file_path}`);
40
43
  }