@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 +59 -11
- package/package.json +14 -5
- package/src/client.js +85 -2
- package/src/index.js +68 -0
- package/src/polling.js +32 -6
- package/src/tools/_shared.js +212 -0
- package/src/tools/chat.js +135 -0
- package/src/tools/generate.js +723 -224
- package/src/tools/media.js +77 -0
- package/src/tools/models.js +5 -0
- package/src/tools/moodboards.js +45 -0
- package/src/tools/presets.js +34 -0
- package/src/tools/visual_dna.js +134 -0
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,
|
|
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
|
-
- *"
|
|
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` |
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
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` |
|
|
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.
|
|
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/
|
|
30
|
+
"url": "https://github.com/Zoharvan12/kolbo-mcp"
|
|
28
31
|
},
|
|
29
|
-
"homepage": "https://docs.kolbo.ai/developer-api/claude-code-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
40
|
+
throw new PollingTimeoutError(generationId, timeout);
|
|
15
41
|
}
|
|
16
42
|
|
|
17
|
-
const result = await client.get(
|
|
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
|
|
50
|
+
throw new GenerationFailedError(generationId, result.error);
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
if (result.state === 'cancelled') {
|
|
28
|
-
throw new
|
|
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
|
+
};
|