@kolbo/mcp 1.37.1 → 1.38.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
@@ -163,7 +163,8 @@ Every generation tool also accepts an optional `project_id` arg that routes the
163
163
  **Media Library**
164
164
  | Tool | Description |
165
165
  |------|-------------|
166
- | `upload_media` | Upload a local file (or remote URL) stable Kolbo CDN URL for reuse |
166
+ | `media_upload_widget` | Open an in-chat upload card so claude.ai users can upload LOCAL files (image / video / audio / document) chat attachments are unreachable from remote MCP, so this is the way to bring them in. Returns stable CDN URLs |
167
+ | `upload_media` | Upload a local file (path or URL), or inline `source_base64` + `filename`, → stable Kolbo CDN URL for reuse |
167
168
  | `list_media` | Browse media library — filter by `project_id`, `folder_id`, `type`, `category` (ai / uploaded / edited / favorites / training-lab), `source_type`, `sort`, `search`, pagination |
168
169
  | `list_media_folders` | List the user's media folders (owned + shared) — discover `folder_id` values to pass to `list_media` |
169
170
  | `create_media_folder` | Create a new folder (name, optional description / color / icon) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.37.1",
3
+ "version": "1.38.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": {
package/src/apps/index.js CHANGED
@@ -20,12 +20,14 @@ const { generationWidgetHtml } = require('./widgets/generation');
20
20
  const { mediaGridWidgetHtml } = require('./widgets/mediaGrid');
21
21
  const { catalogWidgetHtml } = require('./widgets/catalog');
22
22
  const { transcriptWidgetHtml } = require('./widgets/transcript');
23
+ const { uploadWidgetHtml } = require('./widgets/upload');
23
24
 
24
25
  const UI = {
25
26
  generation: 'ui://kolbo/generation.html',
26
27
  mediaGrid: 'ui://kolbo/media-grid.html',
27
28
  catalog: 'ui://kolbo/catalog.html',
28
29
  transcript: 'ui://kolbo/transcript.html',
30
+ upload: 'ui://kolbo/upload.html',
29
31
  };
30
32
 
31
33
  const WIDGET_BUILDERS = {
@@ -33,6 +35,7 @@ const WIDGET_BUILDERS = {
33
35
  [UI.mediaGrid]: mediaGridWidgetHtml,
34
36
  [UI.catalog]: catalogWidgetHtml,
35
37
  [UI.transcript]: transcriptWidgetHtml,
38
+ [UI.upload]: uploadWidgetHtml,
36
39
  };
37
40
 
38
41
  // Widgets are pure functions of source — build once per process.
@@ -72,7 +75,14 @@ const WIDGET_CSP = {
72
75
  'https://*.sketchfab.com',
73
76
  'https://*.cloudfront.net',
74
77
  ],
75
- connectDomains: [],
78
+ // connect-src — XHR/fetch FROM widget iframes. Used by the upload widget to
79
+ // POST files to /mcp/upload with its short-lived ticket.
80
+ connectDomains: [
81
+ 'https://api.kolbo.ai',
82
+ 'https://api-staging.kolbo.ai',
83
+ 'https://api-dev.kolbo.ai',
84
+ 'https://*.kolbo.ai',
85
+ ],
76
86
  };
77
87
 
78
88
  /** Register all Kolbo widget resources on an McpServer. */
@@ -82,6 +92,7 @@ function registerApps(server) {
82
92
  [UI.mediaGrid, 'Kolbo Library Widget'],
83
93
  [UI.catalog, 'Kolbo Model Catalog Widget'],
84
94
  [UI.transcript, 'Kolbo Transcription Widget'],
95
+ [UI.upload, 'Kolbo Upload Widget'],
85
96
  ]) {
86
97
  registerAppResource(
87
98
  server, name, uri,
@@ -269,6 +280,8 @@ const TOOL_WIDGETS = {
269
280
  list_visual_dnas: UI.mediaGrid,
270
281
  list_moodboards: UI.mediaGrid,
271
282
  shorts_analyze: UI.mediaGrid,
283
+ // upload widget
284
+ media_upload_widget: UI.upload,
272
285
  };
273
286
 
274
287
  function attachToolWidgetMeta(server) {
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ const { widgetPage } = require('../html');
4
+
5
+ /**
6
+ * Upload widget — media_upload_widget tool.
7
+ *
8
+ * Lets claude.ai users upload LOCAL files (images / video / audio / documents)
9
+ * into their Kolbo media library from inside the chat. Chat attachments are
10
+ * unreachable from remote MCP servers — this widget is the bridge: the file is
11
+ * POSTed straight from the iframe to api.kolbo.ai/mcp/upload with a
12
+ * short-lived, upload-only ticket (never the user's API key).
13
+ *
14
+ * structuredContent: {
15
+ * widget: 'upload', title, upload_url, token, expires_at (epoch ms),
16
+ * accept (input accept attr), max_files, max_mb: {image,video,audio,document},
17
+ * project_id?
18
+ * }
19
+ *
20
+ * Flow: pick/drop files -> client-side type+size validation -> XHR upload
21
+ * (2 concurrent, per-file progress) -> per-file CDN URL. Every completed file
22
+ * is pushed into the model context silently; the "Use these files" button
23
+ * sends one chat message with all URLs so the model continues the task.
24
+ */
25
+
26
+ const BODY = `
27
+ <div class="k-card">
28
+ <div class="k-head">
29
+ <span class="k-logo" id="logo"></span>
30
+ <span class="k-title" id="title">Upload media</span>
31
+ <span class="k-spacer"></span>
32
+ <span class="k-chip" id="count-chip" style="display:none"></span>
33
+ </div>
34
+ <div class="k-body">
35
+ <div id="drop" style="border:1.5px dashed var(--border);border-radius:12px;padding:26px 16px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s">
36
+ <div style="font-size:22px;margin-bottom:6px">⬆</div>
37
+ <div style="font-size:13px;font-weight:600">Click or drop files here</div>
38
+ <div id="accept-hint" style="font-size:11.5px;color:var(--text-muted);margin-top:4px"></div>
39
+ </div>
40
+ <input type="file" id="picker" multiple style="display:none">
41
+ <div id="rows" style="margin-top:10px"></div>
42
+ <div class="k-actions" id="actions" style="display:none"></div>
43
+ <div id="notice" style="display:none;margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
44
+ </div>
45
+ <div class="k-footer">
46
+ <span><a href="#" id="kolbo-link">Kolbo.AI</a> Media Library</span>
47
+ <span class="k-credits">free</span>
48
+ </div>
49
+ </div>
50
+ `;
51
+
52
+ const SCRIPT = `
53
+ el('logo').innerHTML = KOLBO_LOGO + '<span>Kolbo</span>';
54
+ el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai/media-library'); };
55
+
56
+ var state = null;
57
+ var items = []; // {file, kind, status, pct, url, err, id}
58
+ var nextItemId = 1;
59
+ var CONCURRENCY = 2;
60
+ var active = 0;
61
+ var sent = false;
62
+
63
+ var KINDS = {
64
+ image: { exts: ['jpg','jpeg','png','webp','gif','heic','heif','avif','bmp','tif','tiff'], icon: '🖼' },
65
+ video: { exts: ['mp4','mov','webm','m4v','mkv','avi'], icon: '🎬' },
66
+ audio: { exts: ['mp3','wav','m4a','aac','ogg','flac'], icon: '🎵' },
67
+ document: { exts: ['pdf','txt','md','csv','json','docx','xlsx','pptx','doc','xls'], icon: '📄' }
68
+ };
69
+
70
+ function classify(name) {
71
+ var ext = String(name || '').split('.').pop().toLowerCase();
72
+ for (var k in KINDS) { if (KINDS[k].exts.indexOf(ext) !== -1) return k; }
73
+ return null;
74
+ }
75
+
76
+ function fmtSize(b) {
77
+ if (b == null) return '';
78
+ if (b > 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + 'MB';
79
+ return Math.max(1, Math.round(b / 1024)) + 'KB';
80
+ }
81
+
82
+ function expired() { return state && state.expires_at && Date.now() > state.expires_at; }
83
+
84
+ function boot(sc) {
85
+ if (!sc || sc.widget !== 'upload') return;
86
+ state = sc;
87
+ if (sc.title) el('title').textContent = sc.title;
88
+ var kinds = sc.kinds && sc.kinds.length ? sc.kinds : ['image','video','audio','document'];
89
+ var exts = [];
90
+ kinds.forEach(function (k) { if (KINDS[k]) exts = exts.concat(KINDS[k].exts); });
91
+ el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).join(','));
92
+ el('accept-hint').textContent = kinds.join(' · ') + ' — up to ' + (sc.max_files || 10) + ' files';
93
+ if (expired()) return showExpired();
94
+ window.kolbo.notifySize();
95
+ }
96
+
97
+ function showExpired() {
98
+ el('drop').style.pointerEvents = 'none';
99
+ el('drop').style.opacity = '0.5';
100
+ el('notice').style.display = '';
101
+ el('notice').innerHTML = '⏱ This upload window expired. Ask Claude to open a new upload widget.';
102
+ window.kolbo.notifySize();
103
+ }
104
+
105
+ // ---- picking ----
106
+ el('drop').onclick = function () { el('picker').click(); };
107
+ el('drop').ondragover = function (e) { e.preventDefault(); el('drop').style.borderColor = 'var(--accent, #7c6cff)'; };
108
+ el('drop').ondragleave = function () { el('drop').style.borderColor = 'var(--border)'; };
109
+ el('drop').ondrop = function (e) {
110
+ e.preventDefault();
111
+ el('drop').style.borderColor = 'var(--border)';
112
+ addFiles(e.dataTransfer && e.dataTransfer.files);
113
+ };
114
+ el('picker').onchange = function () { addFiles(el('picker').files); el('picker').value = ''; };
115
+
116
+ function addFiles(list) {
117
+ if (!list || !state) return;
118
+ if (expired()) return showExpired();
119
+ var maxFiles = state.max_files || 10;
120
+ for (var i = 0; i < list.length; i++) {
121
+ if (items.length >= maxFiles) break;
122
+ var f = list[i];
123
+ var kind = classify(f.name);
124
+ var it = { file: f, kind: kind, status: 'queued', pct: 0, url: null, err: null, id: nextItemId++ };
125
+ var allowedKinds = state.kinds && state.kinds.length ? state.kinds : ['image','video','audio','document'];
126
+ if (!kind || allowedKinds.indexOf(kind) === -1) {
127
+ it.status = 'error'; it.err = 'Unsupported file type';
128
+ } else {
129
+ var capMb = (state.max_mb && state.max_mb[kind]) || 50;
130
+ if (f.size > capMb * 1024 * 1024) { it.status = 'error'; it.err = kind + ' files are limited to ' + capMb + 'MB'; }
131
+ }
132
+ items.push(it);
133
+ }
134
+ render();
135
+ pump();
136
+ }
137
+
138
+ // ---- upload queue ----
139
+ function pump() {
140
+ if (expired()) return showExpired();
141
+ while (active < CONCURRENCY) {
142
+ var next = null;
143
+ for (var i = 0; i < items.length; i++) { if (items[i].status === 'queued') { next = items[i]; break; } }
144
+ if (!next) break;
145
+ upload(next);
146
+ }
147
+ }
148
+
149
+ function upload(it) {
150
+ it.status = 'uploading';
151
+ active++;
152
+ render();
153
+ var fd = new FormData();
154
+ fd.append('file', it.file, it.file.name);
155
+ if (state.project_id) fd.append('project_id', state.project_id);
156
+ var xhr = new XMLHttpRequest();
157
+ xhr.open('POST', state.upload_url, true);
158
+ xhr.setRequestHeader('Authorization', 'Bearer ' + state.token);
159
+ xhr.upload.onprogress = function (e) {
160
+ if (e.lengthComputable) { it.pct = Math.round((e.loaded / e.total) * 100); renderRow(it); }
161
+ };
162
+ xhr.onload = function () {
163
+ active--;
164
+ var res = null;
165
+ try { res = JSON.parse(xhr.responseText); } catch (e) {}
166
+ if (xhr.status >= 200 && xhr.status < 300 && res && res.success && res.media && res.media.url) {
167
+ it.status = 'done';
168
+ it.url = res.media.url;
169
+ // Silent context update — the model learns the URL even before the
170
+ // user clicks "Use these files".
171
+ try {
172
+ window.kolbo.updateModelContext('Upload widget: "' + it.file.name + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
173
+ } catch (e) {}
174
+ } else {
175
+ it.status = 'error';
176
+ it.err = (res && res.error) || ('Upload failed (' + xhr.status + ')');
177
+ }
178
+ render();
179
+ pump();
180
+ };
181
+ xhr.onerror = function () {
182
+ active--;
183
+ it.status = 'error';
184
+ it.err = 'Network error — try again';
185
+ render();
186
+ pump();
187
+ };
188
+ xhr.send(fd);
189
+ }
190
+
191
+ // ---- rendering ----
192
+ function rowHtml(it) {
193
+ var icon = it.kind && KINDS[it.kind] ? KINDS[it.kind].icon : '📎';
194
+ var right = '';
195
+ if (it.status === 'queued') right = '<span style="color:var(--text-muted)">queued</span>';
196
+ else if (it.status === 'uploading') right = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
197
+ else if (it.status === 'done') right = '<span style="color:#4ade80">✓ uploaded</span>';
198
+ else right = '<span class="k-error" style="padding:0;border:0;background:none">✕ ' + esc(it.err || 'failed') + '</span> <a href="#" data-retry="' + it.id + '" style="font-size:11px">retry</a>';
199
+ var bar = it.status === 'uploading'
200
+ ? '<div style="height:3px;border-radius:2px;background:var(--surface);margin-top:5px;overflow:hidden"><div id="bar-' + it.id + '" style="height:100%;width:' + it.pct + '%;background:var(--accent,#7c6cff);transition:width .2s"></div></div>'
201
+ : '';
202
+ return '<div id="row-' + it.id + '" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;margin-bottom:6px;background:var(--surface)">' +
203
+ '<div style="display:flex;align-items:center;gap:8px;font-size:12.5px">' +
204
+ '<span>' + icon + '</span>' +
205
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(it.file.name) + '">' + esc(it.file.name) + '</span>' +
206
+ '<span style="color:var(--text-muted);font-size:11px">' + fmtSize(it.file.size) + '</span>' +
207
+ '<span id="status-' + it.id + '" style="font-size:11.5px">' + right + '</span>' +
208
+ '</div>' + bar + '</div>';
209
+ }
210
+
211
+ function renderRow(it) {
212
+ var s = el('status-' + it.id);
213
+ if (s && it.status === 'uploading') s.innerHTML = '<span style="color:var(--text-muted)">' + it.pct + '%</span>';
214
+ var b = el('bar-' + it.id);
215
+ if (b) b.style.width = it.pct + '%';
216
+ }
217
+
218
+ function render() {
219
+ el('rows').innerHTML = items.map(rowHtml).join('');
220
+ Array.prototype.forEach.call(el('rows').querySelectorAll('[data-retry]'), function (a) {
221
+ a.onclick = function (e) {
222
+ e.preventDefault();
223
+ var id = Number(a.getAttribute('data-retry'));
224
+ for (var i = 0; i < items.length; i++) {
225
+ if (items[i].id === id) { items[i].status = 'queued'; items[i].err = null; items[i].pct = 0; }
226
+ }
227
+ render();
228
+ pump();
229
+ };
230
+ });
231
+ var done = items.filter(function (i) { return i.status === 'done'; });
232
+ var busy = items.some(function (i) { return i.status === 'uploading' || i.status === 'queued'; });
233
+ el('count-chip').style.display = items.length ? '' : 'none';
234
+ el('count-chip').textContent = done.length + '/' + items.length + ' uploaded';
235
+ if (done.length && !busy && !sent) {
236
+ el('actions').style.display = '';
237
+ el('actions').innerHTML = '<button class="k-btn primary" id="btn-use">Use ' + (done.length === 1 ? 'this file' : 'these ' + done.length + ' files') + '</button>' +
238
+ '<button class="k-btn ghost" id="btn-more">Add more</button>';
239
+ el('btn-use').onclick = function () {
240
+ if (sent) return;
241
+ sent = true;
242
+ var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + i.file.name + ' (' + i.kind + '): ' + i.url; });
243
+ window.kolbo.sendMessage('I uploaded ' + done.length + ' file(s) to my Kolbo media library:\\n' + lines.join('\\n') + '\\nContinue with these files.');
244
+ el('actions').innerHTML = '<span style="font-size:12px;color:var(--text-muted)">✓ Sent to Claude — continuing…</span>';
245
+ window.kolbo.notifySize();
246
+ };
247
+ el('btn-more').onclick = function () { el('picker').click(); };
248
+ } else if (!done.length || busy) {
249
+ el('actions').style.display = 'none';
250
+ }
251
+ window.kolbo.notifySize();
252
+ }
253
+
254
+ window.kolbo.onToolResult(function (result) {
255
+ var sc = result.structuredContent || structured(result);
256
+ if (sc && sc.widget === 'upload') return boot(sc);
257
+ var card = document.querySelector('.k-card');
258
+ if (card && !state) card.style.display = 'none';
259
+ window.kolbo.notifySize();
260
+ });
261
+ `;
262
+
263
+ function uploadWidgetHtml() {
264
+ return widgetPage({ title: 'Kolbo Upload', body: BODY, script: SCRIPT });
265
+ }
266
+
267
+ module.exports = { uploadWidgetHtml };
package/src/index.js CHANGED
@@ -118,7 +118,8 @@ function createServer(opts = {}) {
118
118
  '4. If the user has not mentioned any project, omit `project_id` — the default bucket is correct in that case. Do not ask which project to use unless the user\'s intent is ambiguous.',
119
119
  '5. Written deliverables (plans, briefs, scripts, research summaries) can live in Kolbo too: author them as AI Docs with `create_doc` (project-scoped, editable in the app, shareable via `share_doc`).',
120
120
  '6. DIRECTOR / BATCH JOBS: generate_creative_director runs its scenes (image OR video) in parallel and only reports state="completed" once EVERY scene is terminal. Video batches can take many minutes. If the tool returns `_timed_out:true`, the batch is STILL RUNNING on the server — call `get_creative_director_status` with the returned generation_id and keep checking until state="completed" to collect all scene outputs. NEVER conclude a Director run failed and fall back to plain generate_image/generate_video without first checking status — doing so wastes the user\'s credits by paying twice. If scenes already carry image_urls/video_urls, they are done; do not regenerate.',
121
- '7. SESSION CONTINUITY: keep one workflow in ONE session. chat_send_message and the generation tools return a `session_id` — for follow-ups, refinements, retries, or additional steps on the SAME task/theme, pass that same `session_id` back on the next call instead of starting fresh. Only OMIT session_id (start a new session) when the user genuinely switches to an unrelated task. Do not open a new conversation/session for every message of the same workflow — it fragments the user\'s history and loses context.'
121
+ '7. SESSION CONTINUITY: keep one workflow in ONE session. chat_send_message and the generation tools return a `session_id` — for follow-ups, refinements, retries, or additional steps on the SAME task/theme, pass that same `session_id` back on the next call instead of starting fresh. Only OMIT session_id (start a new session) when the user genuinely switches to an unrelated task. Do not open a new conversation/session for every message of the same workflow — it fragments the user\'s history and loses context.',
122
+ '8. LOCAL FILES / CHAT ATTACHMENTS: remote MCP tools CANNOT read files the user attached to the chat. When a claude.ai (browser/mobile) user has a local image/video/audio/document to use as a generation input, IMMEDIATELY call `media_upload_widget` — an upload card appears in the chat, they upload, and stable Kolbo CDN URLs come back in a follow-up message. Never ask them to re-attach the file in chat and never invent a URL. On Claude Desktop/Code with filesystem access, use `upload_media` with the absolute local path instead.'
122
123
  ].join('\n')
123
124
  });
124
125
 
@@ -10,17 +10,94 @@ const { UI, uiResult, appsEnabled } = require('../apps');
10
10
 
11
11
  function registerMediaTools(server, client, options = {}) {
12
12
  const ui = () => appsEnabled(server, options);
13
+
14
+ // ─── media_upload_widget ───────────────────────────────────
15
+ server.tool(
16
+ 'media_upload_widget',
17
+ 'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). On Claude Desktop / Claude Code with filesystem access, prefer `upload_media` with the local path instead.',
18
+ {
19
+ purpose: z.string().optional().describe('Short title shown on the card, e.g. "Upload the photo to animate". Helps the user know what to drop.'),
20
+ media_types: z.array(z.enum(['image', 'video', 'audio', 'document'])).optional().describe('Restrict which file kinds the widget accepts. Omit to accept all types.'),
21
+ max_files: z.number().optional().describe('Maximum number of files (default 10, max 20).'),
22
+ project_id: z.string().optional().describe('Project ObjectId to file the uploads into (resolve names via `list_projects`).')
23
+ },
24
+ async ({ purpose, media_types, max_files, project_id }) => {
25
+ const ticket = await client.post('/v1/media/upload-ticket', {});
26
+ if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
27
+
28
+ const info = {
29
+ status: 'upload_widget_opened',
30
+ instructions: 'An upload card is now shown to the user. WAIT for them to upload — the uploaded file URLs will arrive in a follow-up message (or in the model context). Do not guess URLs.',
31
+ accepted: ticket.accepted,
32
+ expires_in_seconds: ticket.expires_in,
33
+ };
34
+
35
+ if (ui()) {
36
+ return uiResult(UI.upload, JSON.stringify(info, null, 2), {
37
+ widget: 'upload',
38
+ title: purpose || 'Upload media',
39
+ upload_url: ticket.upload_url,
40
+ token: ticket.token,
41
+ expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
42
+ kinds: media_types && media_types.length ? media_types : undefined,
43
+ max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
44
+ max_mb: ticket.max_file_mb || { image: 50, video: 500, audio: 200, document: 50 },
45
+ ...(project_id ? { project_id } : {}),
46
+ });
47
+ }
48
+
49
+ // Text-only host: no iframe to render — steer to upload_media.
50
+ return {
51
+ content: [{
52
+ type: 'text',
53
+ text: JSON.stringify({
54
+ status: 'widget_unavailable',
55
+ hint: 'This host cannot render the upload widget. Use the upload_media tool with a URL or absolute local file path instead.'
56
+ }, null, 2)
57
+ }]
58
+ };
59
+ }
60
+ );
61
+
13
62
  // ─── upload_media ──────────────────────────────────────────
14
63
  server.tool(
15
64
  'upload_media',
16
65
  '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.',
17
66
  {
18
- source: z.string().describe('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.'),
67
+ source: z.string().optional().describe('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. Provide this OR source_base64.'),
68
+ source_base64: z.string().optional().describe('Raw file content as base64 (no data: prefix) — fallback for hosts with no filesystem or public URL (e.g. small images on claude.ai when the upload widget is unavailable). Requires `filename`. Keep under ~10MB; for larger files use media_upload_widget.'),
69
+ filename: z.string().optional().describe('Original filename WITH extension (e.g. photo.png) — required with source_base64; the extension determines the media type.'),
19
70
  description: z.string().optional().describe('Optional description / caption for the uploaded media'),
20
71
  project_id: z.string().optional().describe('Project ObjectId to file the upload into. Call `list_projects` to resolve a name → id. When the user is working in a named project, pass it here too — omitting it files the upload outside that project.')
21
72
  },
22
- async ({ source, description, project_id }) => {
23
- if (!source) throw new Error('source is required (URL or absolute local path)');
73
+ async ({ source, source_base64, filename, description, project_id }) => {
74
+ if (!source && !source_base64) throw new Error('Provide source (URL or absolute local path) OR source_base64 (+ filename)');
75
+
76
+ if (source_base64) {
77
+ if (!filename || !/\.[a-z0-9]{2,5}$/i.test(filename)) {
78
+ throw new Error('source_base64 requires a `filename` with an extension (e.g. photo.png)');
79
+ }
80
+ const buffer = Buffer.from(source_base64, 'base64');
81
+ if (!buffer.length) throw new Error('source_base64 decoded to an empty file');
82
+ // Backend routes by mimetype (video/audio must NOT hit the image
83
+ // optimizer) — derive it from the extension, never octet-stream.
84
+ const ext = filename.split('.').pop().toLowerCase();
85
+ const MIME = {
86
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', heic: 'image/heic', avif: 'image/avif',
87
+ mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/x-m4v', mkv: 'video/x-matroska',
88
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', flac: 'audio/flac',
89
+ pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json',
90
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
91
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
92
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
93
+ };
94
+ const form = new FormData();
95
+ form.append('file', buffer, { filename, contentType: MIME[ext] || 'application/octet-stream' });
96
+ if (description) form.append('description', description);
97
+ if (project_id) form.append('project_id', project_id);
98
+ const result = await client.postMultipart('/v1/media/upload', form);
99
+ return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
100
+ }
24
101
 
25
102
  // Even for URL input we download-and-reupload — that's the whole point
26
103
  // of upload_media (getting a stable Kolbo-owned URL). For ephemeral