@kolbo/mcp 1.66.0 → 1.67.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.66.0",
3
+ "version": "1.67.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/skill/SKILL.md CHANGED
@@ -226,7 +226,7 @@ You are NOT allowed to:
226
226
 
227
227
  Existing video → modify → **single `generate_video_from_video` call** with source video URL + edit prompt.
228
228
 
229
- **Use a TRUE video-to-video model.** Image-to-video models reject with `WRONG_MODEL_TYPE`. Valid: `wan/2-7-videoedit`, `happyhorse/video-edit`, `kling-video/o3-video-to-video`, or any model whose DB `type` includes `video_to_video` (use `list_models({ type: "video_to_video" })`).
229
+ **Use a TRUE video-to-video model.** Image-to-video models reject with `WRONG_MODEL_TYPE`. Valid: `wan/2-7-videoedit`, `happyhorse/video-edit`, `kling-video/o3-video-to-video`, `pika/pikadditions/video-to-video`, `pika/pikaswaps/video-to-video`, `pika/pikaffects/video-to-video`, or any model whose DB `type` includes `video_to_video` (use `list_models({ type: "video_to_video" })`).
230
230
 
231
231
  **Motion-control / animate-move models invert the inputs**: `reference_images[0]` = the CHARACTER IMAGE to animate, `source_video` = the driving/reference video whose motion is transferred. Omitting the character image returns a `MOTION_CONTROL_INPUTS` error.
232
232
 
@@ -42,7 +42,7 @@ client share a filesystem. Choose by transport:
42
42
  |---|---|
43
43
  | **Local (stdio) install** — `npx @kolbo/mcp` on the same machine | `upload_media` with the absolute path |
44
44
  | **Remote connector + you can run shell commands** (Claude Code, Codex, Cursor, CI) | `create_upload_ticket`, then POST each file to `upload_url` |
45
- | **Remote connector, no filesystem** (claude.ai web/mobile) | `media_upload_widget` — the user picks the file |
45
+ | **Remote connector, no filesystem** (claude.ai web/mobile) | `media_upload_widget` — the user picks the file. On Claude iOS/Android the card opens a full-screen uploader (in-chat file pickers are dropped by WebKit); after upload the user pastes the copied CDN URLs back into chat. |
46
46
 
47
47
  `create_upload_ticket` returns `upload_url` + a short-lived `token`. Upload with
48
48
  multipart field `file` and `Authorization: Bearer <token>`; the stable CDN URL comes
@@ -50,9 +50,14 @@ back at `media.url`. One POST per file, ticket reusable for a batch. Then pass t
50
50
  URLs to any generation tool.
51
51
 
52
52
  ```bash
53
- curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3"
53
+ curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/abs/path/clip.mp3;type=audio/mpeg"
54
54
  ```
55
55
 
56
+ **Declare the type.** `curl` labels the part from its own mime table and falls back to
57
+ `application/octet-stream` for anything missing from it — `.mp3` included — which the
58
+ endpoint can reject as an unsupported type. Append `;type=<mime>` (`audio/mpeg`,
59
+ `audio/wav`, `video/mp4`, `image/png`, `application/pdf`, …) and it never comes up.
60
+
56
61
  **Pace a batch.** The upload endpoint is rate limited — the ticket response says by
57
62
  how much in `rate_limit` (currently 40 uploads per 60s). Firing 55 files back to
58
63
  back stalls at file 41. Sleep ~2s between files, or read the 429: it carries a
package/src/apps/theme.js CHANGED
@@ -291,6 +291,18 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
291
291
  .k-generated-audio .k-audio-art { width: 36px; height: 36px; }
292
292
  .k-generated-audio .k-btn { grid-column: 2; justify-self: start; }
293
293
  .k-generated-audio .k-audio-player { grid-column: 1 / -1; }
294
+ /* Touch-friendly MCP App layout (Claude iOS/Android iframe). */
295
+ .k-head { padding: 10px 12px; gap: 8px; }
296
+ .k-body { padding: 10px 12px 12px; }
297
+ .k-footer { padding: 8px 12px 12px; }
298
+ .k-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
299
+ .k-actions { flex-direction: column; align-items: stretch; }
300
+ .k-actions .k-btn { width: 100%; min-height: 44px; justify-content: center; }
301
+ .k-btn { min-height: 40px; padding: 10px 14px; }
302
+ .k-title { font-size: 13.5px; }
303
+ .k-prompt-row { flex-wrap: wrap; }
304
+ .k-prompt-row .k-input { min-width: 0; flex: 1 1 100%; }
305
+ .k-prompt-row .k-btn { flex: 1 1 auto; }
294
306
  }
295
307
  .k-play {
296
308
  width: 32px; height: 32px; border-radius: 50%; flex: none; border: 1px solid rgba(255,255,255,0.18);
@@ -11,16 +11,21 @@ const { widgetPage } = require('../html');
11
11
  * POSTed straight from the iframe to api.kolbo.ai/mcp/upload with a
12
12
  * short-lived, upload-only ticket (never the user's API key).
13
13
  *
14
+ * Mobile (Claude iOS/Android): WebKit drops <input type=file> selections inside
15
+ * cross-origin MCP App iframes, so the primary CTA openLinks a TOP-LEVEL
16
+ * upload page (/mcp/upload-ui) where the native picker works. Desktop keeps
17
+ * the in-chat picker; empty-selection after pick also offers the external page.
18
+ *
14
19
  * structuredContent: {
15
- * widget: 'upload', title, upload_url, token, expires_at (epoch ms),
20
+ * widget: 'upload', title, upload_url, upload_ui_url, token, expires_at (epoch ms),
16
21
  * accept (input accept attr), max_files, max_mb: {image,video,audio,document},
17
22
  * project_id?
18
23
  * }
19
24
  *
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.
25
+ * Flow (desktop): pick/drop files -> client-side type+size validation -> XHR
26
+ * upload (2 concurrent, per-file progress + thumbnails) -> per-file CDN URL.
27
+ * Every completed file is pushed into the model context silently; the
28
+ * "Use these files" button sends one chat message with all URLs.
24
29
  */
25
30
 
26
31
  const BODY = `
@@ -32,15 +37,15 @@ const BODY = `
32
37
  <span class="k-chip" id="count-chip" style="display:none"></span>
33
38
  </div>
34
39
  <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">
40
+ <div id="drop" style="border:1.5px dashed var(--border);border-radius:12px;padding:22px 14px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;-webkit-tap-highlight-color:transparent">
36
41
  <div id="drop-icon" style="font-size:26px;line-height:1;margin-bottom:8px;color:var(--text-muted)"></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>
42
+ <div id="drop-title" style="font-size:13px;font-weight:600">Click or drop files here</div>
43
+ <div id="accept-hint" style="font-size:11.5px;color:var(--text-muted);margin-top:4px;line-height:1.35"></div>
39
44
  </div>
40
- <input type="file" id="picker" multiple style="display:none">
45
+ <input type="file" id="picker" multiple accept="*/*" style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0)">
41
46
  <div id="rows" style="margin-top:10px"></div>
42
47
  <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>
48
+ <div id="notice" style="display:none;margin-top:8px;font-size:12px;color:var(--text-muted);line-height:1.4"></div>
44
49
  </div>
45
50
  <div class="k-footer">
46
51
  <span><a href="#" id="kolbo-link">Kolbo.AI</a> Media Library</span>
@@ -55,11 +60,12 @@ el('drop-icon').innerHTML = ICONS.upload;
55
60
  el('kolbo-link').onclick = function (e) { e.preventDefault(); window.kolbo.openLink('https://app.kolbo.ai/media-library'); };
56
61
 
57
62
  var state = null;
58
- var items = []; // {file, kind, status, pct, url, err, id}
63
+ var items = []; // {file, kind, status, pct, url, err, id, thumb}
59
64
  var nextItemId = 1;
60
65
  var CONCURRENCY = 2;
61
66
  var active = 0;
62
67
  var sent = false;
68
+ var pickerArmed = false;
63
69
 
64
70
  var KINDS = {
65
71
  image: { exts: ['jpg','jpeg','png','webp','gif','heic','heif','avif','bmp','tif','tiff'], icon: ICONS.image },
@@ -68,12 +74,36 @@ var KINDS = {
68
74
  document: { exts: ['pdf','txt','md','csv','json','docx','xlsx','pptx','doc','xls'], icon: ICONS.document }
69
75
  };
70
76
 
71
- function classify(name) {
72
- var ext = String(name || '').split('.').pop().toLowerCase();
73
- for (var k in KINDS) { if (KINDS[k].exts.indexOf(ext) !== -1) return k; }
77
+ function isMobileHost() {
78
+ var ua = navigator.userAgent || '';
79
+ if (/iPhone|iPad|iPod|Android/i.test(ua)) return true;
80
+ // iPadOS desktop-UA spoof
81
+ if (navigator.maxTouchPoints > 1 && /MacIntel/.test(navigator.platform || '')) return true;
82
+ return false;
83
+ }
84
+
85
+ function classify(file) {
86
+ var name = (file && file.name) || '';
87
+ var ext = name.indexOf('.') >= 0 ? String(name).split('.').pop().toLowerCase() : '';
88
+ if (ext) {
89
+ for (var k in KINDS) { if (KINDS[k].exts.indexOf(ext) !== -1) return k; }
90
+ }
91
+ // Mobile Photos often yields a MIME with a weak/missing filename.
92
+ var mime = String((file && file.type) || '').toLowerCase();
93
+ if (mime.indexOf('image/') === 0) return 'image';
94
+ if (mime.indexOf('video/') === 0) return 'video';
95
+ if (mime.indexOf('audio/') === 0) return 'audio';
96
+ if (mime === 'application/pdf' || mime.indexOf('text/') === 0 || mime.indexOf('application/vnd.') === 0 || mime === 'application/msword' || mime === 'application/json') return 'document';
74
97
  return null;
75
98
  }
76
99
 
100
+ function filenameFor(it) {
101
+ var n = (it.file && it.file.name) || '';
102
+ if (n && n.indexOf('.') !== -1) return n;
103
+ var ext = { image: 'jpg', video: 'mp4', audio: 'mp3', document: 'pdf' }[it.kind] || 'bin';
104
+ return 'upload-' + it.id + '.' + ext;
105
+ }
106
+
77
107
  function fmtSize(b) {
78
108
  if (b == null) return '';
79
109
  if (b > 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + 'MB';
@@ -82,6 +112,38 @@ function fmtSize(b) {
82
112
 
83
113
  function expired() { return state && state.expires_at && Date.now() > state.expires_at; }
84
114
 
115
+ function uploadUiUrl() {
116
+ if (!state) return '';
117
+ var base = state.upload_ui_url || String(state.upload_url || '').replace(/\\/upload\\/?$/, '/upload-ui');
118
+ if (!base) return '';
119
+ var cfg = {
120
+ token: state.token,
121
+ upload_url: state.upload_url,
122
+ title: state.title || 'Upload media',
123
+ kinds: state.kinds,
124
+ max_files: state.max_files,
125
+ max_mb: state.max_mb,
126
+ project_id: state.project_id,
127
+ expires_at: state.expires_at
128
+ };
129
+ try {
130
+ var json = JSON.stringify(cfg);
131
+ var b64 = btoa(unescape(encodeURIComponent(json))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
132
+ return base + '#' + b64;
133
+ } catch (e) {
134
+ return base + '#' + encodeURIComponent(JSON.stringify(cfg));
135
+ }
136
+ }
137
+
138
+ function openExternalUploader() {
139
+ var url = uploadUiUrl();
140
+ if (!url) return;
141
+ window.kolbo.openLink(url);
142
+ el('notice').style.display = '';
143
+ el('notice').innerHTML = ICONS.open + ' Uploader opened. After files finish, return here and paste the copied message into Claude.';
144
+ window.kolbo.notifySize();
145
+ }
146
+
85
147
  function boot(sc) {
86
148
  if (!sc || sc.widget !== 'upload') return;
87
149
  state = sc;
@@ -89,10 +151,27 @@ function boot(sc) {
89
151
  var kinds = sc.kinds && sc.kinds.length ? sc.kinds : ['image','video','audio','document'];
90
152
  var exts = [];
91
153
  kinds.forEach(function (k) { if (KINDS[k]) exts = exts.concat(KINDS[k].exts); });
92
- el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).join(','));
154
+ el('picker').setAttribute('accept', exts.map(function (e) { return '.' + e; }).concat(kinds.map(function (k) { return k + '/*'; })).join(','));
93
155
  var maxN = sc.max_files || 10;
94
- el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files');
156
+ var mobile = isMobileHost();
157
+ if (mobile) {
158
+ el('drop-title').textContent = 'Tap to open uploader';
159
+ el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files') + '. Opens a full-screen picker (required on iPhone & Android).';
160
+ } else {
161
+ el('drop-title').textContent = 'Click or drop files here';
162
+ el('accept-hint').textContent = kinds.join(' · ') + (maxN === 1 ? ' — one file' : ' — up to ' + maxN + ' files');
163
+ }
95
164
  if (expired()) return showExpired();
165
+ // Always offer the external path — desktop users may be inside a host that
166
+ // also sandboxes file inputs; mobile uses it as the primary CTA.
167
+ el('actions').style.display = '';
168
+ el('actions').innerHTML = mobile
169
+ ? '<button class="k-btn primary" id="btn-external" style="width:100%;min-height:44px">' + ICONS.upload + ' Open uploader</button>' +
170
+ '<button class="k-btn ghost" id="btn-inline">Try in-chat picker</button>'
171
+ : '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Open full-screen uploader</button>';
172
+ el('btn-external').onclick = function (e) { e.preventDefault(); e.stopPropagation(); openExternalUploader(); };
173
+ var inline = el('btn-inline');
174
+ if (inline) inline.onclick = function (e) { e.preventDefault(); e.stopPropagation(); armInlinePicker(); };
96
175
  window.kolbo.notifySize();
97
176
  }
98
177
 
@@ -104,8 +183,18 @@ function showExpired() {
104
183
  window.kolbo.notifySize();
105
184
  }
106
185
 
186
+ function armInlinePicker() {
187
+ pickerArmed = true;
188
+ el('picker').click();
189
+ }
190
+
107
191
  // ---- picking ----
108
- el('drop').onclick = function () { el('picker').click(); };
192
+ el('drop').onclick = function () {
193
+ if (!state) return;
194
+ if (expired()) return showExpired();
195
+ if (isMobileHost()) return openExternalUploader();
196
+ armInlinePicker();
197
+ };
109
198
  el('drop').ondragover = function (e) { e.preventDefault(); el('drop').style.borderColor = 'var(--accent, #7c6cff)'; };
110
199
  el('drop').ondragleave = function () { el('drop').style.borderColor = 'var(--border)'; };
111
200
  el('drop').ondrop = function (e) {
@@ -113,7 +202,54 @@ el('drop').ondrop = function (e) {
113
202
  el('drop').style.borderColor = 'var(--border)';
114
203
  addFiles(e.dataTransfer && e.dataTransfer.files);
115
204
  };
116
- el('picker').onchange = function () { addFiles(el('picker').files); el('picker').value = ''; };
205
+ el('picker').onchange = function () {
206
+ var files = el('picker').files;
207
+ // iOS/WebKit cross-origin iframe: picker UI runs, selection is dropped → empty FileList.
208
+ if (pickerArmed && (!files || !files.length)) {
209
+ pickerArmed = false;
210
+ el('notice').style.display = '';
211
+ el('notice').innerHTML = ICONS.warn + ' In-chat picker could not receive the file on this device. Use the full-screen uploader instead.';
212
+ el('actions').style.display = '';
213
+ el('actions').innerHTML = '<button class="k-btn primary" id="btn-external" style="width:100%;min-height:44px">' + ICONS.upload + ' Open uploader</button>';
214
+ el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
215
+ window.kolbo.notifySize();
216
+ return;
217
+ }
218
+ pickerArmed = false;
219
+ addFiles(files);
220
+ el('picker').value = '';
221
+ };
222
+
223
+ function makeThumb(it) {
224
+ if (it.kind === 'image') {
225
+ try { it.thumb = URL.createObjectURL(it.file); } catch (e) {}
226
+ return;
227
+ }
228
+ if (it.kind !== 'video') return;
229
+ try {
230
+ var url = URL.createObjectURL(it.file);
231
+ var v = document.createElement('video');
232
+ v.muted = true; v.playsInline = true; v.preload = 'metadata'; v.src = url;
233
+ var done = false;
234
+ function snap() {
235
+ if (done) return;
236
+ try {
237
+ var c = document.createElement('canvas');
238
+ c.width = 68; c.height = 68;
239
+ c.getContext('2d').drawImage(v, 0, 0, 68, 68);
240
+ it.thumb = c.toDataURL('image/jpeg', 0.7);
241
+ done = true;
242
+ render();
243
+ } catch (e) {}
244
+ try { URL.revokeObjectURL(url); } catch (e2) {}
245
+ }
246
+ v.addEventListener('loadeddata', function () {
247
+ try { v.currentTime = Math.min(0.25, (v.duration || 1) * 0.1); } catch (e) { snap(); }
248
+ });
249
+ v.addEventListener('seeked', snap);
250
+ setTimeout(snap, 1500);
251
+ } catch (e) {}
252
+ }
117
253
 
118
254
  function addFiles(list) {
119
255
  if (!list || !state) return;
@@ -122,15 +258,15 @@ function addFiles(list) {
122
258
  for (var i = 0; i < list.length; i++) {
123
259
  if (items.length >= maxFiles) break;
124
260
  var f = list[i];
125
- var kind = classify(f.name);
261
+ var kind = classify(f);
126
262
  var it = { file: f, kind: kind, status: 'queued', pct: 0, url: null, err: null, id: nextItemId++, thumb: null };
127
- if (kind === 'image') { try { it.thumb = URL.createObjectURL(f); } catch (e) {} }
128
263
  var allowedKinds = state.kinds && state.kinds.length ? state.kinds : ['image','video','audio','document'];
129
264
  if (!kind || allowedKinds.indexOf(kind) === -1) {
130
265
  it.status = 'error'; it.err = 'Unsupported file type';
131
266
  } else {
132
267
  var capMb = (state.max_mb && state.max_mb[kind]) || 50;
133
268
  if (f.size > capMb * 1024 * 1024) { it.status = 'error'; it.err = kind + ' files are limited to ' + capMb + 'MB'; }
269
+ else makeThumb(it);
134
270
  }
135
271
  items.push(it);
136
272
  }
@@ -154,7 +290,7 @@ function upload(it) {
154
290
  active++;
155
291
  render();
156
292
  var fd = new FormData();
157
- fd.append('file', it.file, it.file.name);
293
+ fd.append('file', it.file, filenameFor(it));
158
294
  if (state.project_id) fd.append('project_id', state.project_id);
159
295
  var xhr = new XMLHttpRequest();
160
296
  xhr.open('POST', state.upload_url, true);
@@ -169,10 +305,9 @@ function upload(it) {
169
305
  if (xhr.status >= 200 && xhr.status < 300 && res && res.success && res.media && res.media.url) {
170
306
  it.status = 'done';
171
307
  it.url = res.media.url;
172
- // Silent context update the model learns the URL even before the
173
- // user clicks "Use these files".
308
+ if (!it.thumb && res.media.thumbnail_url) it.thumb = res.media.thumbnail_url;
174
309
  try {
175
- window.kolbo.updateModelContext('Upload widget: "' + it.file.name + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
310
+ window.kolbo.updateModelContext('Upload widget: "' + filenameFor(it) + '" (' + it.kind + ') uploaded to the Kolbo media library. URL: ' + it.url);
176
311
  } catch (e) {}
177
312
  } else {
178
313
  it.status = 'error';
@@ -184,7 +319,7 @@ function upload(it) {
184
319
  xhr.onerror = function () {
185
320
  active--;
186
321
  it.status = 'error';
187
- it.err = 'Network error — try again';
322
+ it.err = 'Network error — try the full-screen uploader';
188
323
  render();
189
324
  pump();
190
325
  };
@@ -204,11 +339,11 @@ function rowHtml(it) {
204
339
  : '';
205
340
  var left = it.thumb
206
341
  ? '<img data-thumb="' + it.id + '" src="' + it.thumb + '" alt="" style="width:34px;height:34px;object-fit:cover;border-radius:7px;flex:none;border:1px solid var(--border);background:var(--surface)">'
207
- : '<span>' + icon + '</span>';
342
+ : '<span style="width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:var(--surface);border:1px solid var(--border);color:var(--text-muted)">' + icon + '</span>';
208
343
  return '<div id="row-' + it.id + '" style="padding:8px 10px;border:1px solid var(--border);border-radius:10px;margin-bottom:6px;background:var(--surface)">' +
209
344
  '<div style="display:flex;align-items:center;gap:8px;font-size:12.5px">' +
210
345
  left +
211
- '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(it.file.name) + '">' + esc(it.file.name) + '</span>' +
346
+ '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(filenameFor(it)) + '">' + esc(filenameFor(it)) + '</span>' +
212
347
  '<span style="color:var(--text-muted);font-size:11px">' + fmtSize(it.file.size) + '</span>' +
213
348
  '<span id="status-' + it.id + '" style="font-size:11.5px">' + right + '</span>' +
214
349
  '</div>' + bar + '</div>';
@@ -223,8 +358,6 @@ function renderRow(it) {
223
358
 
224
359
  function render() {
225
360
  el('rows').innerHTML = items.map(rowHtml).join('');
226
- // Thumbnail fallback chain: local blob preview -> CDN URL (allowlisted in the
227
- // host CSP) once uploaded -> plain type icon.
228
361
  Array.prototype.forEach.call(el('rows').querySelectorAll('[data-thumb]'), function (img) {
229
362
  img.onerror = function () {
230
363
  var id = Number(img.getAttribute('data-thumb'));
@@ -253,19 +386,29 @@ function render() {
253
386
  el('count-chip').textContent = done.length + '/' + items.length + ' uploaded';
254
387
  if (done.length && !busy && !sent) {
255
388
  el('actions').style.display = '';
256
- el('actions').innerHTML = '<button class="k-btn primary" id="btn-use">Use ' + (done.length === 1 ? 'this file' : 'these ' + done.length + ' files') + '</button>' +
257
- '<button class="k-btn ghost" id="btn-more">Add more</button>';
389
+ el('actions').innerHTML = '<button class="k-btn primary" id="btn-use" style="min-height:44px">Use ' + (done.length === 1 ? 'this file' : 'these ' + done.length + ' files') + '</button>' +
390
+ '<button class="k-btn ghost" id="btn-more">Add more</button>' +
391
+ '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen</button>';
258
392
  el('btn-use').onclick = function () {
259
393
  if (sent) return;
260
394
  sent = true;
261
- var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + i.file.name + ' (' + i.kind + '): ' + i.url; });
395
+ var lines = done.map(function (i, idx) { return (idx + 1) + '. ' + filenameFor(i) + ' (' + i.kind + '): ' + i.url; });
262
396
  window.kolbo.sendMessage('I uploaded ' + done.length + ' file(s) to my Kolbo media library:\\n' + lines.join('\\n') + '\\nContinue with these files.');
263
397
  el('actions').innerHTML = '<span style="font-size:12px;color:var(--text-muted)">' + ICONS.check + ' Sent to Claude — continuing…</span>';
264
398
  window.kolbo.notifySize();
265
399
  };
266
- el('btn-more').onclick = function () { el('picker').click(); };
400
+ el('btn-more').onclick = function () {
401
+ if (isMobileHost()) openExternalUploader();
402
+ else armInlinePicker();
403
+ };
404
+ el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
405
+ } else if (!items.length) {
406
+ // keep boot() actions (external / inline)
267
407
  } else if (!done.length || busy) {
268
- el('actions').style.display = 'none';
408
+ el('actions').style.display = '';
409
+ el('actions').innerHTML = (busy ? '<span style="font-size:12px;color:var(--text-muted)">Uploading…</span>' : '') +
410
+ '<button class="k-btn ghost" id="btn-external">' + ICONS.open + ' Full-screen uploader</button>';
411
+ el('btn-external').onclick = function (e) { e.preventDefault(); openExternalUploader(); };
269
412
  }
270
413
  window.kolbo.notifySize();
271
414
  }
@@ -43,7 +43,14 @@ function uploadTicketPayload(ticket) {
43
43
  accepted: ticket.accepted,
44
44
  rate_limit: rate,
45
45
  how_to_upload: {
46
- example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3"',
46
+ example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3;type=audio/mpeg"',
47
+ // curl types the part from ITS mime table and falls back to
48
+ // application/octet-stream for anything missing from it (.mp3 included).
49
+ // Newer servers resolve that from the extension, older ones answer
50
+ // "File type not supported: application/octet-stream" — so the example
51
+ // above declares the type and this says why, rather than leaving the
52
+ // caller to rediscover it from a 415.
53
+ mime_note: 'Append `;type=<mime>` to the file part (audio/mpeg, audio/wav, video/mp4, image/png, application/pdf …). Without it curl declares application/octet-stream and the upload can be rejected as an unsupported type.',
47
54
  optional_fields: ['project_id', 'description'],
48
55
  response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
49
56
  pacing: `RATE LIMIT: ${rate.max_uploads} uploads per ${rate.per_seconds}s. For a batch larger than that, pace it (e.g. sleep ${Math.max(1, Math.ceil(rate.per_seconds / rate.max_uploads))}s between files) instead of firing them back to back. Over the limit you get HTTP 429 with a Retry-After header and retry_after_seconds in the body — wait that long, then continue; do not guess a backoff and do not treat it as a failed upload.`,
@@ -95,10 +102,15 @@ function registerMediaTools(server, client, options = {}) {
95
102
  };
96
103
 
97
104
  if (ui()) {
105
+ // upload_ui_url: top-level page for Claude iOS/Android — in-iframe
106
+ // <input type=file> selections are dropped by WebKit (see upload widget).
107
+ const uploadUiUrl = ticket.upload_ui_url
108
+ || String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
98
109
  return uiResult(UI.upload, JSON.stringify(info, null, 2), {
99
110
  widget: 'upload',
100
111
  title: purpose || 'Upload media',
101
112
  upload_url: ticket.upload_url,
113
+ upload_ui_url: uploadUiUrl,
102
114
  token: ticket.token,
103
115
  expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
104
116
  kinds: media_types && media_types.length ? media_types : undefined,
@@ -149,8 +149,8 @@ function registerReviewTools(server, client) {
149
149
  'List review collection folders in a project.',
150
150
  { project_id: projectIdField },
151
151
  async ({ project_id }) => {
152
- const params = project_id ? `?project_id=${encodeURIComponent(project_id)}` : '';
153
- const result = await client.get(`/v1/review/collections${params}`);
152
+ const qs = project_id ? `project_id=${encodeURIComponent(project_id)}` : '';
153
+ const result = await client.get(`/v1/review/collections${qs ? '?' + qs : ''}`);
154
154
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
155
155
  }
156
156
  );
@@ -200,8 +200,8 @@ function registerReviewTools(server, client) {
200
200
  version_media_id: z.string().optional(),
201
201
  },
202
202
  async ({ asset_id, version_media_id }) => {
203
- const params = version_media_id ? `?version_media_id=${encodeURIComponent(version_media_id)}` : '';
204
- const result = await client.get(`/v1/review/assets/${encodeURIComponent(asset_id)}/comments${params}`);
203
+ const qs = version_media_id ? `version_media_id=${encodeURIComponent(version_media_id)}` : '';
204
+ const result = await client.get(`/v1/review/assets/${encodeURIComponent(asset_id)}/comments${qs ? '?' + qs : ''}`);
205
205
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
206
206
  }
207
207
  );
@@ -11,13 +11,15 @@ function registerVoiceTools(server, client, options = {}) {
11
11
  // ─── list_voices ──────────────────────────────────────────────
12
12
  server.tool(
13
13
  'list_voices',
14
- 'List available TTS voices for speech generation. Filter by language, gender, or provider to find the right voice. Returns voice_id, name, provider, language, gender, accent, description, styles, and preview_url for each voice.',
14
+ 'List available TTS voices for speech generation. Filter by language, gender, or provider to find the right voice. Returns voice_id, name, provider, language, gender, accent, description, styles, and preview_url for each voice. The catalogue runs to hundreds of voices, so results are paginated: filter first, then use `page` to walk the rest.',
15
15
  {
16
- language: z.string().optional().describe('Filter by language name (e.g. "english", "hebrew", "spanish", "french"). Case-insensitive partial match.'),
16
+ language: z.string().optional().describe('Filter by language name (e.g. "english", "hebrew", "spanish", "french") or locale code ("he", "he-IL"). Case-insensitive partial match.'),
17
17
  gender: z.enum(['male', 'female']).optional().describe('Filter by gender.'),
18
- provider: z.string().optional().describe('Filter by provider (e.g. "elevenlabs", "google"). Omit for all providers.')
18
+ provider: z.string().optional().describe('Filter by provider (e.g. "elevenlabs", "google"). Omit for all providers.'),
19
+ page: z.number().optional().describe('Page number, 1-indexed. Default: 1'),
20
+ limit: z.number().optional().describe('Results per page, max 200. Default: 60')
19
21
  },
20
- async ({ language, gender, provider }) => {
22
+ async ({ language, gender, provider, page, limit }) => {
21
23
  const params = new URLSearchParams();
22
24
  if (gender) params.set('gender', gender);
23
25
  if (provider) params.set('provider', provider);
@@ -28,10 +30,9 @@ function registerVoiceTools(server, client, options = {}) {
28
30
  const result = await client.get(`/v1/voices${qs.toString() ? '?' + qs.toString() : ''}`);
29
31
 
30
32
  let voices = result.voices || [];
31
- // The API matches `language` EXACTLY against the voice's language name or
32
- // its locale code "hebrew" and "he-IL" both hit, but "he" / "heb" /
33
- // "Hebrew (Israel)" return nothing. This arg is documented as a partial
34
- // match, and an empty list reads to the model as "Kolbo has no Google
33
+ // Safety net for a server whose `language` matching is narrower than this
34
+ // arg promises (it was once an exact match, so "he" / "heb" returned
35
+ // nothing). An empty list reads to the model as "Kolbo has no Google
35
36
  // Hebrew voices at all", which is how a whole provider goes missing.
36
37
  // Retry ONCE, unfiltered, and match locally — only on the empty path, so
37
38
  // the normal call still costs one request.
@@ -48,29 +49,42 @@ function registerVoiceTools(server, client, options = {}) {
48
49
  };
49
50
  }
50
51
 
51
- const lines = voices.map(v => {
52
+ // The unfiltered catalog measured 190,286 chars — past what hosts accept — so
53
+ // only a slice can ever come back. Page it instead of truncating it: the API
54
+ // returns the whole filtered set, the window is picked here. Without `page`
55
+ // every voice past the first 60 was simply unreachable.
56
+ const VOICE_CAP = 60;
57
+ const perPage = Math.min(Math.max(Math.trunc(limit) || VOICE_CAP, 1), 200);
58
+ const pageNum = Math.max(Math.trunc(page) || 1, 1);
59
+ const pageCount = Math.ceil(voices.length / perPage);
60
+ const start = (pageNum - 1) * perPage;
61
+ const shownVoices = voices.slice(start, start + perPage);
62
+
63
+ if (shownVoices.length === 0) {
64
+ return {
65
+ content: [{ type: 'text', text: `Page ${pageNum} is past the end — ${voices.length} voice(s) match those filters (${pageCount} page(s) at ${perPage} per page).` }]
66
+ };
67
+ }
68
+
69
+ const lines = shownVoices.map(v => {
52
70
  const tags = [v.language, v.gender, v.accent].filter(Boolean).join(' · ');
53
71
  const styles = Array.isArray(v.styles) && v.styles.length ? ` | styles: ${v.styles.join(', ')}` : '';
54
72
  const v3 = v.v3_optimized ? ' [v3]' : '';
55
73
  return `${v.voice_id} — ${v.name} (${v.provider})${v3}\n ${tags}${styles}${v.description ? `\n ${v.description}` : ''}`;
56
74
  });
57
75
 
58
- // The unfiltered catalog measured 190,286 chars — past what hosts accept.
59
- // Cap the listing and tell the model how to narrow, rather than handing
60
- // back a blob the host truncates at an arbitrary byte.
61
- const VOICE_CAP = 60;
62
- const shown = lines.slice(0, VOICE_CAP);
63
- const more = voices.length - shown.length;
76
+ const more = voices.length - (start + shownVoices.length);
64
77
  const narrowHint = more > 0
65
- ? `\n\n…and ${more} more. Filter by \`language\`, \`gender\`, or \`provider\` to narrow.`
78
+ ? `\n\n…and ${more} more. Pass \`page: ${pageNum + 1}\` for the next ${perPage}, or narrow with \`language\` / \`gender\` / \`provider\`.`
66
79
  : '';
67
- const text = `Available voices (showing ${shown.length} of ${voices.length}):\n\n${shown.join('\n\n')}${narrowHint}\n\nUse the "voice_id" value in generate_speech calls.`;
80
+ const range = `${start + 1}–${start + shownVoices.length}`;
81
+ const text = `Available voices (showing ${range} of ${voices.length}, page ${pageNum}/${pageCount}):\n\n${lines.join('\n\n')}${narrowHint}\n\nUse the "voice_id" value in generate_speech calls.`;
68
82
 
69
83
  if (ui()) {
70
84
  return uiResult(UI.mediaGrid, text, {
71
85
  widget: 'media-grid',
72
86
  title: 'Voices',
73
- items: voices.slice(0, 24).map(v => ({
87
+ items: shownVoices.map(v => ({
74
88
  id: v.voice_id,
75
89
  title: v.name,
76
90
  subtitle: [v.provider, v.language, v.gender, v.accent].filter(Boolean).join(' · '),
@@ -80,7 +94,7 @@ function registerVoiceTools(server, client, options = {}) {
80
94
  use_hint: 'Use voice "{TITLE}" (voice_id: {ID}) for text-to-speech — ask me what text to speak.'
81
95
  })),
82
96
  total: voices.length,
83
- has_more: voices.length > 24
97
+ has_more: more > 0
84
98
  });
85
99
  }
86
100