@kolbo/mcp 1.0.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Use [Kolbo AI](https://kolbo.ai) as native tools in Claude Code and Claude Desktop via MCP (Model Context Protocol).
4
4
 
5
- Generate images, videos, music, speech, and sound effects — all from natural language in your coding environment.
5
+ Generate images, videos, music, speech, sound effects, multi-scene campaigns, and conversational chat — all from natural language in your coding environment. 100+ AI models behind Smart Select routing, with reusable Visual DNA profiles for character/style consistency.
6
6
 
7
7
  ## Quick Setup
8
8
 
@@ -34,22 +34,70 @@ Just ask Claude naturally:
34
34
 
35
35
  - *"Generate an image of a sunset over mountains"*
36
36
  - *"Create a 5-second video of waves crashing"*
37
+ - *"Build a 4-scene storyboard for a coffee shop ad"*
38
+ - *"Remove the background from this image"*
37
39
  - *"Make a lo-fi hip hop beat"*
38
- - *"Convert this text to speech: Hello world"*
40
+ - *"Read this out loud with a British female voice"*
41
+ - *"Ask Claude about the latest AI news with web search on"*
42
+ - *"Create a Visual DNA profile called 'Alex' from these images"*
39
43
 
40
- ## Available Tools
44
+ ## Available Tools (30)
41
45
 
46
+ **Generation**
42
47
  | Tool | Description |
43
48
  |------|-------------|
44
- | `generate_image` | Generate images from text prompts |
45
- | `generate_video` | Generate videos from text |
46
- | `generate_video_from_image` | Animate an image into video |
47
- | `generate_music` | Generate music from descriptions |
48
- | `generate_speech` | Convert text to speech |
49
- | `generate_sound` | Generate sound effects |
50
- | `list_models` | Browse available AI models |
49
+ | `generate_image` | Text image |
50
+ | `generate_image_edit` | Existing image(s) + prompt → edited image |
51
+ | `generate_video` | Text video |
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 |
57
+ | `generate_creative_director` | One brief → N coordinated scenes (image or video) |
58
+ | `generate_music` | Text (+ optional lyrics) → song |
59
+ | `generate_speech` | Text + voice → spoken audio |
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 |
63
+
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.
65
+
66
+ **Chat**
67
+ | Tool | Description |
68
+ |------|-------------|
69
+ | `chat_send_message` | Multi-turn chat with any Kolbo model; supports web search and deep think |
70
+ | `chat_list_conversations` | List past chat threads |
71
+ | `chat_get_messages` | Fetch messages in a conversation |
72
+
73
+ **Visual DNA** (reusable character/style/product profiles)
74
+ | Tool | Description |
75
+ |------|-------------|
76
+ | `create_visual_dna` | Create a profile from URLs or local files |
77
+ | `list_visual_dnas` | List your profiles |
78
+ | `get_visual_dna` | Fetch one profile |
79
+ | `delete_visual_dna` | Delete a profile |
80
+
81
+ **Moodboards**
82
+ | Tool | Description |
83
+ |------|-------------|
84
+ | `list_moodboards` | Browse presets + your moodboards |
85
+ | `get_moodboard` | Fetch one moodboard with all image URLs |
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
+
93
+ **Discovery & Account**
94
+ | Tool | Description |
95
+ |------|-------------|
96
+ | `list_models` | Current model catalog with costs and capabilities |
97
+ | `list_voices` | TTS voices (presets + cloned) |
98
+ | `list_presets` | Generation presets across image/video/music/text-to-video catalogs |
51
99
  | `check_credits` | Check credit balance |
52
- | `get_generation_status` | Check a generation's status |
100
+ | `get_generation_status` | Poll a generation by ID (fallback if a tool times out) |
53
101
 
54
102
  ## Environment Variables
55
103
 
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.0.0",
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": {
7
7
  "kolbo-mcp": "./bin/kolbo-mcp.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node src/index.js"
10
+ "start": "node src/index.js",
11
+ "smoke": "node scripts/smoke.js",
12
+ "check-parity": "node scripts/check-parity.js",
13
+ "prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js"
11
14
  },
12
15
  "keywords": [
13
16
  "kolbo",
@@ -24,9 +27,9 @@
24
27
  "license": "MIT",
25
28
  "repository": {
26
29
  "type": "git",
27
- "url": "https://github.com/nicenathapong/kolbo-mcp"
30
+ "url": "https://github.com/Zoharvan12/kolbo-mcp"
28
31
  },
29
- "homepage": "https://docs.kolbo.ai/developer-api/claude-code-mcp",
32
+ "homepage": "https://docs.kolbo.ai/developer-api/claude-code-skill",
30
33
  "author": "Kolbo AI <support@kolbo.ai>",
31
34
  "publishConfig": {
32
35
  "access": "public"
@@ -37,7 +40,13 @@
37
40
  "README.md"
38
41
  ],
39
42
  "dependencies": {
40
- "@modelcontextprotocol/sdk": "^1.12.1"
43
+ "@modelcontextprotocol/sdk": "^1.26.0",
44
+ "form-data": "^4.0.5"
45
+ },
46
+ "overrides": {
47
+ "hono": "^4.12.12",
48
+ "@hono/node-server": "^1.19.13",
49
+ "path-to-regexp": "^8.4.2"
41
50
  },
42
51
  "engines": {
43
52
  "node": ">=18.0.0"
package/src/client.js CHANGED
@@ -1,6 +1,23 @@
1
1
  /**
2
2
  * Kolbo API HTTP client wrapper
3
3
  */
4
+
5
+ /**
6
+ * Structured error thrown when the Kolbo API returns a non-OK response.
7
+ * Preserves the SDK's error code, HTTP status, and full response data so
8
+ * MCP tools (and the LLM consuming them) can distinguish NOT_FOUND from
9
+ * INSUFFICIENT_CREDITS from VALIDATION_ERROR etc.
10
+ */
11
+ class KolboApiError extends Error {
12
+ constructor(message, { code, status, data } = {}) {
13
+ super(message);
14
+ this.name = 'KolboApiError';
15
+ this.code = code || null;
16
+ this.status = status || null;
17
+ this.data = data || null;
18
+ }
19
+ }
20
+
4
21
  class KolboClient {
5
22
  constructor() {
6
23
  this.apiKey = process.env.KOLBO_API_KEY;
@@ -26,10 +43,27 @@ class KolboClient {
26
43
  }
27
44
 
28
45
  const response = await fetch(url, options);
29
- const data = await response.json();
46
+ let data;
47
+ try {
48
+ data = await response.json();
49
+ } catch (_) {
50
+ // Non-JSON body (gateway error, HTML etc.)
51
+ throw new KolboApiError(`API error: ${response.status} ${response.statusText}`, {
52
+ status: response.status,
53
+ data: null
54
+ });
55
+ }
30
56
 
31
57
  if (!response.ok || data.success === false) {
32
- throw new Error(data.error || data.message || `API error: ${response.status}`);
58
+ const message = data.error || data.message || `API error: ${response.status}`;
59
+ const code = data.code || null;
60
+ // Surface the code in the message so the LLM sees it even if it ignores the .code property
61
+ const fullMessage = code ? `${message} [${code}]` : message;
62
+ throw new KolboApiError(fullMessage, {
63
+ code,
64
+ status: response.status,
65
+ data
66
+ });
33
67
  }
34
68
 
35
69
  return data;
@@ -42,6 +76,55 @@ class KolboClient {
42
76
  async get(path) {
43
77
  return this.request('GET', path);
44
78
  }
79
+
80
+ async delete(path) {
81
+ return this.request('DELETE', path);
82
+ }
83
+
84
+ async postMultipart(path, formData) {
85
+ const url = `${this.baseUrl}${path}`;
86
+ const headers = {
87
+ 'X-API-Key': this.apiKey,
88
+ ...formData.getHeaders()
89
+ };
90
+
91
+ // form-data exposes getLengthSync for known-size parts; set Content-Length when available.
92
+ try {
93
+ const len = formData.getLengthSync();
94
+ if (len) headers['Content-Length'] = String(len);
95
+ } catch (_) { /* streaming length unavailable — let fetch handle it */ }
96
+
97
+ const response = await fetch(url, {
98
+ method: 'POST',
99
+ headers,
100
+ body: formData,
101
+ duplex: 'half'
102
+ });
103
+
104
+ let data;
105
+ try {
106
+ data = await response.json();
107
+ } catch (_) {
108
+ throw new KolboApiError(`API error: ${response.status} ${response.statusText}`, {
109
+ status: response.status,
110
+ data: null
111
+ });
112
+ }
113
+
114
+ if (!response.ok || data.success === false) {
115
+ const message = data.error || data.message || `API error: ${response.status}`;
116
+ const code = data.code || null;
117
+ const fullMessage = code ? `${message} [${code}]` : message;
118
+ throw new KolboApiError(fullMessage, {
119
+ code,
120
+ status: response.status,
121
+ data
122
+ });
123
+ }
124
+
125
+ return data;
126
+ }
45
127
  }
46
128
 
47
129
  module.exports = KolboClient;
130
+ module.exports.KolboApiError = KolboApiError;
package/src/index.js CHANGED
@@ -1,8 +1,71 @@
1
+ /* ============================================================================
2
+ * @kolbo/mcp — Kolbo AI MCP Server
3
+ *
4
+ * ⛔ STOP. READ THIS BEFORE TOUCHING ANY TOOL REGISTRATION. ⛔
5
+ *
6
+ * This package is published to npm and installed via `npx -y @kolbo/mcp`.
7
+ * Thousands of users have it CACHED on their machines, pinned to old versions
8
+ * by npx's cache. Every tool name, every arg name, every response shape
9
+ * registered below is a PUBLIC CONTRACT. Breaking it silently strands users
10
+ * whose LLM will keep calling tool names their cached server no longer
11
+ * registers — or worse, calls new-style args that the old server can't parse.
12
+ *
13
+ * THE THREE COMMANDMENTS
14
+ *
15
+ * 1. NEVER RENAME AN EXISTING TOOL.
16
+ * Not `generate_image` → `create_image`. Not `list_models` → `get_models`.
17
+ * Not "just cleaning up the name." Old cached clients break the instant
18
+ * you rename. If you must rename, keep the OLD name as an alias that
19
+ * forwards to the new implementation for at least one full major version.
20
+ *
21
+ * 2. NEVER REMOVE AN EXISTING TOOL.
22
+ * Deprecate it in the description ("[DEPRECATED: use X]") and keep it
23
+ * working. Only remove in a major version bump with release notes.
24
+ *
25
+ * 3. NEVER CHANGE AN EXISTING TOOL'S ARG NAMES, TYPES, OR REQUIRED STATUS
26
+ * IN A BACKWARD-INCOMPATIBLE WAY.
27
+ * Adding a new OPTIONAL arg with a sensible default is fine. Everything
28
+ * else below is forbidden in a minor release:
29
+ * - renaming `prompt` to `text`
30
+ * - making a previously-optional arg required
31
+ * - changing `aspect_ratio: string` to `aspect_ratio: { w, h }`
32
+ * - removing an arg (even one you think nobody uses)
33
+ *
34
+ * VERSION BUMPS
35
+ *
36
+ * - minor (1.1.0 → 1.2.0): new tool, new optional arg, description tweak
37
+ * - patch (1.1.0 → 1.1.1): internal refactor, bug fix with no user impact
38
+ * - major (1.1.0 → 2.0.0): ANY breaking change from commandments 1–3 above,
39
+ * AND only after going through the deprecation path in CLAUDE.md.
40
+ *
41
+ * WHY THIS MATTERS
42
+ *
43
+ * Users install via `npx -y @kolbo/mcp` — npx CACHES packages. A user who
44
+ * installed 3 months ago may still be running v1.0 until their cache
45
+ * invalidates. When their Claude Desktop starts the MCP server, it
46
+ * registers whatever tools ITS VERSION knows about. Their LLM sees that
47
+ * list and calls those names. You cannot force-update them.
48
+ *
49
+ * The matching backend SDK routes in
50
+ * `kolbo-api/src/modules/sdk/index.js` are the same kind of public
51
+ * contract and follow the same rules — never rename, never remove.
52
+ *
53
+ * Full rules, deprecation path, and parity-audit instructions: CLAUDE.md
54
+ *
55
+ * If you are a coding agent about to rename/remove a tool or arg: STOP and
56
+ * ask the human first. This is not optional.
57
+ * ==========================================================================*/
58
+
1
59
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
2
60
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
3
61
  const KolboClient = require('./client');
4
62
  const { registerGenerateTools } = require('./tools/generate');
5
63
  const { registerModelTools } = require('./tools/models');
64
+ const { registerChatTools } = require('./tools/chat');
65
+ const { registerVisualDnaTools } = require('./tools/visual_dna');
66
+ const { registerMoodboardTools } = require('./tools/moodboards');
67
+ const { registerMediaTools } = require('./tools/media');
68
+ const { registerPresetTools } = require('./tools/presets');
6
69
 
7
70
  async function main() {
8
71
  const client = new KolboClient();
@@ -15,6 +78,11 @@ async function main() {
15
78
  // Register all tools
16
79
  registerGenerateTools(server, client);
17
80
  registerModelTools(server, client);
81
+ registerChatTools(server, client);
82
+ registerVisualDnaTools(server, client);
83
+ registerMoodboardTools(server, client);
84
+ registerMediaTools(server, client);
85
+ registerPresetTools(server, client);
18
86
 
19
87
  // Start the server with stdio transport
20
88
  const transport = new StdioServerTransport();
package/src/polling.js CHANGED
@@ -1,31 +1,57 @@
1
1
  /**
2
2
  * Poll a generation until it reaches a terminal state
3
3
  */
4
+
5
+ class PollingTimeoutError extends Error {
6
+ constructor(generationId, timeoutMs) {
7
+ const seconds = Math.round(timeoutMs / 1000);
8
+ super(
9
+ `Generation timed out after ${seconds}s of polling. The generation may STILL be running on the server — ` +
10
+ `call get_generation_status with generation_id="${generationId}" to check its current state. ` +
11
+ `Videos, deep-think chat, and large batches can take longer than the default polling window.`
12
+ );
13
+ this.name = 'PollingTimeoutError';
14
+ this.generationId = generationId;
15
+ this.timeoutMs = timeoutMs;
16
+ this.timedOut = true;
17
+ }
18
+ }
19
+
20
+ class GenerationFailedError extends Error {
21
+ constructor(generationId, reason) {
22
+ super(`Generation failed: ${reason || 'unknown error'} (generation_id="${generationId}")`);
23
+ this.name = 'GenerationFailedError';
24
+ this.generationId = generationId;
25
+ }
26
+ }
27
+
4
28
  async function pollUntilDone(client, generationId, options = {}) {
5
29
  const {
6
30
  interval = 5000,
7
- timeout = 300000 // 5 minutes default
31
+ timeout = 300000, // 5 minutes default
32
+ statusUrl
8
33
  } = options;
9
34
 
10
35
  const startTime = Date.now();
36
+ const url = statusUrl || `/v1/generate/${encodeURIComponent(generationId)}/status`;
11
37
 
12
38
  while (true) {
13
39
  if (Date.now() - startTime > timeout) {
14
- throw new Error(`Generation ${generationId} timed out after ${timeout / 1000}s`);
40
+ throw new PollingTimeoutError(generationId, timeout);
15
41
  }
16
42
 
17
- const result = await client.get(`/v1/generate/${generationId}/status`);
43
+ const result = await client.get(url);
18
44
 
19
45
  if (result.state === 'completed') {
20
46
  return result;
21
47
  }
22
48
 
23
49
  if (result.state === 'failed') {
24
- throw new Error(result.error || 'Generation failed');
50
+ throw new GenerationFailedError(generationId, result.error);
25
51
  }
26
52
 
27
53
  if (result.state === 'cancelled') {
28
- throw new Error('Generation was cancelled');
54
+ throw new GenerationFailedError(generationId, 'generation was cancelled');
29
55
  }
30
56
 
31
57
  // Wait before next poll
@@ -33,4 +59,4 @@ async function pollUntilDone(client, generationId, options = {}) {
33
59
  }
34
60
  }
35
61
 
36
- module.exports = { pollUntilDone };
62
+ module.exports = { pollUntilDone, PollingTimeoutError, GenerationFailedError };
@@ -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
+ };