@kolbo/mcp 1.57.1 → 1.58.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 +3 -2
- package/src/apps/theme.js +1 -1
- package/src/apps/widgets/generation.js +91 -14
- package/src/auth.js +155 -156
- package/src/tools/generate.js +24 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolbo/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.58.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": {
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"start": "node src/index.js",
|
|
11
11
|
"smoke": "node scripts/smoke.js",
|
|
12
12
|
"check-parity": "node scripts/check-parity.js",
|
|
13
|
-
"prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
|
|
13
|
+
"prepublishOnly": "node scripts/smoke.js && node scripts/check-parity.js && node scripts/check-widget-fields.js && node scripts/check-widget-render.js && node scripts/check-skill-tools.js && node scripts/check-install.js",
|
|
14
14
|
"check-widget-fields": "node scripts/check-widget-fields.js",
|
|
15
|
+
"check-widget-render": "node scripts/check-widget-render.js",
|
|
15
16
|
"check-skill-tools": "node scripts/check-skill-tools.js",
|
|
16
17
|
"check-install": "node scripts/check-install.js"
|
|
17
18
|
},
|
package/src/apps/theme.js
CHANGED
|
@@ -180,7 +180,7 @@ body {
|
|
|
180
180
|
display: inline-flex; align-items: center; justify-content: center;
|
|
181
181
|
cursor: pointer; opacity: 0; transition: opacity 150ms var(--smooth), background 150ms var(--smooth);
|
|
182
182
|
}
|
|
183
|
-
.k-media:hover .k-dl, .k-viewer:hover .k-dl { opacity: 1; }
|
|
183
|
+
.k-media:hover .k-dl, .k-viewer:hover .k-dl, .k-skel:hover .k-dl { opacity: 1; }
|
|
184
184
|
.k-dl:hover { background: var(--brand); border-color: var(--brand); }
|
|
185
185
|
.k-viewer { position: relative; }
|
|
186
186
|
|
|
@@ -14,7 +14,8 @@ const { widgetPage } = require('../html');
|
|
|
14
14
|
* status_args, // extra args for the poll tool (optional)
|
|
15
15
|
|
|
16
16
|
* model, model_icon, prompt, count,
|
|
17
|
-
* settings: { duration, resolution, aspect_ratio, audio, voice, mode
|
|
17
|
+
* settings: { duration, resolution, aspect_ratio, quality, audio, voice, mode,
|
|
18
|
+
* enhance_prompt, web_search, visual_dna, moodboard, preset, cinematic },
|
|
18
19
|
* reference_image, // thumbnail URL (optional)
|
|
19
20
|
* urls, thumbnail_url, title, duration, credits_used,
|
|
20
21
|
* tracks: [{ title, duration, thumbnail_url, model }], // optional audio metadata by URL index
|
|
@@ -111,11 +112,18 @@ function renderChips(sc) {
|
|
|
111
112
|
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
|
|
112
113
|
if (s.resolution) h += chip(esc(s.resolution));
|
|
113
114
|
if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
|
|
115
|
+
if (s.quality) h += chip(esc(s.quality) + ' quality');
|
|
116
|
+
if (s.enhance_prompt) h += chip(ICONS.sparkle + ' enhanced');
|
|
117
|
+
if (s.web_search) h += chip('web search');
|
|
118
|
+
if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
|
|
119
|
+
if (s.moodboard) h += chip('moodboard');
|
|
120
|
+
if (s.preset) h += chip('preset');
|
|
121
|
+
if (s.cinematic) h += chip('cinematic');
|
|
114
122
|
if (s.audio) h += chip(ICONS.sound + ' audio');
|
|
115
123
|
if (s.voice) h += chip(ICONS.mic + ' ' + esc(s.voice));
|
|
116
124
|
if (s.mode) h += chip(esc(s.mode));
|
|
117
125
|
if (sc.count > 1) h += chip('×' + sc.count);
|
|
118
|
-
if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" title="Reference image" onerror="this.style.display=\\'none\\'">';
|
|
126
|
+
if (sc.reference_image) h += '<img class="k-ref-thumb" src="' + esc(sc.reference_image) + '" alt="" loading="lazy" title="Reference image" onerror="this.style.display=\\'none\\'">';
|
|
119
127
|
el('chips').innerHTML = h;
|
|
120
128
|
}
|
|
121
129
|
function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
|
|
@@ -237,13 +245,48 @@ var MAX_POLL_ERRORS = 30;
|
|
|
237
245
|
var pollStart = 0, pollErrors = 0;
|
|
238
246
|
var cancelRequested = false; // set by the Stop button; freezes the poll loop
|
|
239
247
|
|
|
248
|
+
/* ---------- offscreen gate ----------
|
|
249
|
+
The host mounts one of these iframes per generation, and re-delivers the
|
|
250
|
+
ORIGINAL "submitted" (phase:generating) result on every conversation open —
|
|
251
|
+
so a 50-generation session used to fire 50 status tools/call round trips plus
|
|
252
|
+
50+ full-resolution media downloads before the user had scrolled to any of
|
|
253
|
+
them. Hold the FIRST poll (and therefore every media request the result
|
|
254
|
+
produces) until the card is actually on screen. Once a card has been seen it
|
|
255
|
+
polls normally forever — a live generation the user scrolls away from still
|
|
256
|
+
finishes and still reports back. */
|
|
257
|
+
var seen = false, whenSeenFns = [];
|
|
258
|
+
function releaseSeen() {
|
|
259
|
+
if (seen) return;
|
|
260
|
+
seen = true;
|
|
261
|
+
var fns = whenSeenFns; whenSeenFns = [];
|
|
262
|
+
fns.forEach(function (f) { try { f(); } catch (e) {} });
|
|
263
|
+
}
|
|
264
|
+
(function () {
|
|
265
|
+
var card = document.querySelector('.k-card');
|
|
266
|
+
if (!window.IntersectionObserver || !card) return releaseSeen();
|
|
267
|
+
var fired = false;
|
|
268
|
+
var io = new IntersectionObserver(function (entries) {
|
|
269
|
+
fired = true;
|
|
270
|
+
if (!entries.some(function (e) { return e.isIntersecting; })) return;
|
|
271
|
+
io.disconnect();
|
|
272
|
+
releaseSeen();
|
|
273
|
+
// IO clips against ancestor frames, so this is true parent-viewport
|
|
274
|
+
// visibility. rootMargin starts the work just before the card scrolls in.
|
|
275
|
+
}, { rootMargin: '400px' });
|
|
276
|
+
io.observe(card);
|
|
277
|
+
// A host where IO never reports at all must not strand the card forever.
|
|
278
|
+
setTimeout(function () { if (!fired) releaseSeen(); }, 8000);
|
|
279
|
+
})();
|
|
280
|
+
|
|
240
281
|
function schedulePoll(sc) {
|
|
241
282
|
if (cancelRequested) return;
|
|
283
|
+
if (!seen) { whenSeenFns.push(function () { schedulePoll(sc); }); return; }
|
|
284
|
+
// The call itself long-waits server-side (normally up to three minutes).
|
|
285
|
+
// This short pause only separates successive wait windows — the FIRST call
|
|
286
|
+
// goes out immediately, so a card revealed by scrolling resolves at once.
|
|
287
|
+
var delay = pollStart ? 1500 : 0;
|
|
242
288
|
if (!pollStart) pollStart = Date.now();
|
|
243
289
|
clearTimeout(pollTimer);
|
|
244
|
-
// The call itself long-waits server-side (normally up to three minutes).
|
|
245
|
-
// This short pause only separates successive wait windows.
|
|
246
|
-
var delay = 1500;
|
|
247
290
|
pollTimer = setTimeout(function () { poll(sc); }, delay);
|
|
248
291
|
}
|
|
249
292
|
function poll(sc) {
|
|
@@ -304,7 +347,10 @@ function poll(sc) {
|
|
|
304
347
|
/* ---------- batch (prompts[] fan-out) ---------- */
|
|
305
348
|
// Each poll round resolves when every id has completed or its wait window
|
|
306
349
|
// closed (~3 min), so finished cells fill in per round while the rest keep
|
|
307
|
-
// their skeleton. When all_done
|
|
350
|
+
// their skeleton. When all_done the SAME grid is re-rendered from the resolved
|
|
351
|
+
// set — a batch is one grouped card end to end. It must NOT fall through to the
|
|
352
|
+
// scenes carousel: that collapses eight tiles into one big image plus a thumb
|
|
353
|
+
// strip, which is where the grouping (and the per-tile prompt caption) was lost.
|
|
308
354
|
function handleBatchStatus(sc, st) {
|
|
309
355
|
pollErrors = 0;
|
|
310
356
|
var gens = st.generations || [];
|
|
@@ -336,8 +382,9 @@ function handleBatchStatus(sc, st) {
|
|
|
336
382
|
});
|
|
337
383
|
state = done;
|
|
338
384
|
el('credits').textContent = done.credits_used != null ? fmtCredits(done.credits_used) : '';
|
|
339
|
-
if (failedCount) setPhaseChip(failedCount + ' failed', false);
|
|
340
385
|
renderResult(done);
|
|
386
|
+
// After renderResult — it resets the chip, so setting this first erased it.
|
|
387
|
+
if (failedCount) setPhaseChip(failedCount + ' failed', false);
|
|
341
388
|
try {
|
|
342
389
|
window.kolbo.updateModelContext(
|
|
343
390
|
'Batch generation completed (' + (sc.tool || '') + '): ' + scenes.length + ' of ' + gens.length + ' succeeded.' +
|
|
@@ -368,6 +415,7 @@ function renderResult(sc) {
|
|
|
368
415
|
clearTimeout(pollTimer);
|
|
369
416
|
|
|
370
417
|
setPhaseChip('', false);
|
|
418
|
+
if (sc.batch && sc.scenes && sc.scenes.length) return renderBatchGrid(sc);
|
|
371
419
|
if (sc.kind === 'scenes' && sc.scenes && sc.scenes.length) return renderScenes(sc);
|
|
372
420
|
var urls = sc.urls || [];
|
|
373
421
|
if (!urls.length) return renderError('No output received');
|
|
@@ -396,7 +444,7 @@ function renderImages(sc, urls) {
|
|
|
396
444
|
var thumbs = '';
|
|
397
445
|
if (urls.length > 1) {
|
|
398
446
|
thumbs = '<div class="k-thumbs">' + urls.map(function (u, i) {
|
|
399
|
-
return '<div class="k-thumb' + (i === selected ? ' active' : '') + '" data-i="' + i + '"><img src="' + esc(u) + '" alt=""></div>';
|
|
447
|
+
return '<div class="k-thumb' + (i === selected ? ' active' : '') + '" data-i="' + i + '"><img src="' + esc(u) + '" alt="" loading="lazy"></div>';
|
|
400
448
|
}).join('') + '</div>';
|
|
401
449
|
}
|
|
402
450
|
el('stage').innerHTML = viewer + thumbs;
|
|
@@ -415,8 +463,10 @@ function renderImages(sc, urls) {
|
|
|
415
463
|
}
|
|
416
464
|
|
|
417
465
|
function renderVideo(sc, urls) {
|
|
466
|
+
// preload="none" behind a poster: the card shows the still until the user
|
|
467
|
+
// hits play, instead of pulling the video header on mount.
|
|
418
468
|
el('stage').innerHTML = '<div class="k-viewer"><video id="main-video" src="' + esc(urls[0]) + '"' +
|
|
419
|
-
(sc.thumbnail_url ? ' poster="' + esc(sc.thumbnail_url) + '"' : '') + ' controls playsinline></video>' +
|
|
469
|
+
(sc.thumbnail_url ? ' poster="' + esc(sc.thumbnail_url) + '" preload="none"' : ' preload="metadata"') + ' controls playsinline></video>' +
|
|
420
470
|
dlBtnHTML(urls[0]) + '</div>';
|
|
421
471
|
wireDlButtons(el('stage'));
|
|
422
472
|
}
|
|
@@ -429,14 +479,14 @@ function renderAudio(sc, urls) {
|
|
|
429
479
|
var duration = track.duration != null ? track.duration : sc.duration;
|
|
430
480
|
var artwork = track.thumbnail_url || sc.thumbnail_url;
|
|
431
481
|
return '<div class="k-audio-row k-generated-audio">' +
|
|
432
|
-
(artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="">' :
|
|
482
|
+
(artwork ? '<img class="k-audio-art" src="' + esc(artwork) + '" alt="" loading="lazy">' :
|
|
433
483
|
'<div class="k-audio-art k-audio-placeholder">' + ICONS.audio + '</div>') +
|
|
434
484
|
'<div class="k-audio-meta"><div class="k-audio-title">' + esc(title) + '</div>' +
|
|
435
485
|
'<div class="k-audio-sub">' + esc(track.model || sc.model || '') +
|
|
436
486
|
(duration ? ' · ' + fmtDur(duration) : '') + '</div></div>' +
|
|
437
487
|
'<button class="k-btn k-audio-download" data-audio-download="' + esc(u) +
|
|
438
488
|
'" aria-label="Download ' + esc(title) + '">' + ICONS.download + ' Download</button>' +
|
|
439
|
-
'<audio class="k-audio-player" src="' + esc(u) + '" controls preload="
|
|
489
|
+
'<audio class="k-audio-player" src="' + esc(u) + '" controls preload="none" aria-label="Play ' +
|
|
440
490
|
esc(title) + '"></audio></div>';
|
|
441
491
|
}).join('');
|
|
442
492
|
Array.prototype.forEach.call(el('stage').querySelectorAll('[data-audio-download]'), function (b) {
|
|
@@ -456,7 +506,7 @@ function renderAudio(sc, urls) {
|
|
|
456
506
|
|
|
457
507
|
function render3d(sc, urls) {
|
|
458
508
|
el('stage').innerHTML = (sc.thumbnail_url
|
|
459
|
-
? '<div class="k-viewer"><img src="' + esc(sc.thumbnail_url) + '" alt=""></div>' : '') +
|
|
509
|
+
? '<div class="k-viewer"><img src="' + esc(sc.thumbnail_url) + '" alt="" loading="lazy"></div>' : '') +
|
|
460
510
|
urls.map(function (u) {
|
|
461
511
|
var extMatch = u.split('?')[0].match(/\\.(\\w+)$/);
|
|
462
512
|
var ext = extMatch ? extMatch[1].toUpperCase() : 'FILE';
|
|
@@ -508,6 +558,32 @@ function sceneItems(sc) {
|
|
|
508
558
|
return items;
|
|
509
559
|
}
|
|
510
560
|
|
|
561
|
+
// Batch (prompts[] fan-out) result: the SAME tile grid the generating phase
|
|
562
|
+
// showed, each tile still captioned with the prompt that produced it. Downloads
|
|
563
|
+
// are per-tile (a batch has no single "current" url); click a tile to focus it.
|
|
564
|
+
function renderBatchGrid(sc) {
|
|
565
|
+
var items = sceneItems(sc);
|
|
566
|
+
if (!items.length) return renderError('No completed results received');
|
|
567
|
+
var shape = items[0].type === 'video' ? 'video' : 'square';
|
|
568
|
+
el('stage').innerHTML = '<div class="k-gen-grid n' + Math.min(items.length, 4) + '">' +
|
|
569
|
+
items.map(function (it, i) {
|
|
570
|
+
return '<div class="k-skel done ' + shape + '" data-focus="' + i + '">' +
|
|
571
|
+
(it.type === 'video'
|
|
572
|
+
? '<video class="k-cell-fill" src="' + esc(it.url) + '" controls playsinline preload="metadata"></video>'
|
|
573
|
+
: '<img class="k-cell-fill" src="' + esc(it.url) + '" alt="" loading="lazy" style="cursor:zoom-in">') +
|
|
574
|
+
(it.label ? '<span class="k-skel-cap" title="' + esc(it.label) + '">' + esc(it.label) + '</span>' : '') +
|
|
575
|
+
dlBtnHTML(it.url) + '</div>';
|
|
576
|
+
}).join('') + '</div>';
|
|
577
|
+
wireDlButtons(el('stage'));
|
|
578
|
+
Array.prototype.forEach.call(el('stage').querySelectorAll('[data-focus]'), function (cell) {
|
|
579
|
+
var it = items[+cell.getAttribute('data-focus')];
|
|
580
|
+
if (it.type !== 'image') return; // <video controls> owns its own clicks
|
|
581
|
+
cell.onclick = function () { focusMedia(it.url); };
|
|
582
|
+
});
|
|
583
|
+
renderActions(sc);
|
|
584
|
+
window.kolbo.notifySize();
|
|
585
|
+
}
|
|
586
|
+
|
|
511
587
|
function renderScenes(sc) {
|
|
512
588
|
var items = sceneItems(sc);
|
|
513
589
|
if (!items.length) return renderError('No completed scenes received');
|
|
@@ -555,7 +631,7 @@ function exitFocus() {
|
|
|
555
631
|
window.kolbo.requestDisplayMode('inline').catch(function () {});
|
|
556
632
|
isFullscreen = false;
|
|
557
633
|
applyFullscreen(false);
|
|
558
|
-
|
|
634
|
+
renderResult(state); // restore whichever multi-item view we came from
|
|
559
635
|
window.kolbo.notifySize();
|
|
560
636
|
}
|
|
561
637
|
|
|
@@ -740,7 +816,8 @@ function completedFromPlain(sc) {
|
|
|
740
816
|
settings: {
|
|
741
817
|
duration: sc.duration || originArgs.duration,
|
|
742
818
|
resolution: originArgs.resolution,
|
|
743
|
-
aspect_ratio: originArgs.aspect_ratio
|
|
819
|
+
aspect_ratio: originArgs.aspect_ratio,
|
|
820
|
+
quality: originArgs.quality
|
|
744
821
|
},
|
|
745
822
|
urls: sc.urls || []
|
|
746
823
|
});
|
package/src/auth.js
CHANGED
|
@@ -1,156 +1,155 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keyless browser login for the LOCAL (stdio) Kolbo MCP server.
|
|
3
|
-
*
|
|
4
|
-
* When the server runs on the user's machine with no KOLBO_API_KEY and no
|
|
5
|
-
* stored credential, the first tool call triggers this: we open the browser to
|
|
6
|
-
* Kolbo's OAuth login (the same server that powers the remote connector), the
|
|
7
|
-
* user clicks Allow, and we capture a token via a loopback redirect — no API
|
|
8
|
-
* key to create or paste. The token is cached so every later run is silent.
|
|
9
|
-
*
|
|
10
|
-
* Standard "native app" OAuth: authorization-code + PKCE with a
|
|
11
|
-
* http://localhost:<port>/callback redirect (already allow-listed by the Kolbo
|
|
12
|
-
* OAuth server). This path is NOT used by the remote connector (it always
|
|
13
|
-
* injects the caller's key, and passes allowBrowserLogin:false).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const http = require('http');
|
|
17
|
-
const crypto = require('crypto');
|
|
18
|
-
const { exec } = require('child_process');
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const os = require('os');
|
|
22
|
-
|
|
23
|
-
function b64url(buf) {
|
|
24
|
-
return Buffer.from(buf).toString('base64url');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function openBrowser(url) {
|
|
28
|
-
const cmd =
|
|
29
|
-
process.platform === 'win32' ? `start "" "${url}"`
|
|
30
|
-
: process.platform === 'darwin' ? `open "${url}"`
|
|
31
|
-
: `xdg-open "${url}"`;
|
|
32
|
-
try { exec(cmd, () => {}); } catch (_) { /* best effort */ }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Where we cache the token — same location + shape that client.js reads back
|
|
36
|
-
// (`<xdg-data>/kolbo/auth.json` → { "kolbo@<host>": { type: 'api', key } }).
|
|
37
|
-
function authStorePath() {
|
|
38
|
-
const dataDir =
|
|
39
|
-
process.env.XDG_DATA_HOME ||
|
|
40
|
-
(process.platform === 'win32'
|
|
41
|
-
? (process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share'))
|
|
42
|
-
: process.platform === 'darwin'
|
|
43
|
-
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
44
|
-
: path.join(os.homedir(), '.local', 'share'));
|
|
45
|
-
return path.join(dataDir, 'kolbo', 'auth.json');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function storeKey(apiHost, key) {
|
|
49
|
-
try {
|
|
50
|
-
const file = authStorePath();
|
|
51
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
-
let store = {};
|
|
53
|
-
try { store = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) {}
|
|
54
|
-
store[`kolbo@${apiHost}`] = { type: 'api', key, savedAt: new Date().toISOString() };
|
|
55
|
-
fs.writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
56
|
-
} catch (_) { /* non-fatal — the key still works for this process */ }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function donePage(ok) {
|
|
60
|
-
const title = ok ? 'Connected to Kolbo' : 'Connection cancelled';
|
|
61
|
-
const sub = ok ? 'You can close this tab and return to your app.' : 'You can close this tab.';
|
|
62
|
-
const mark = ok ? '✓' : '✕';
|
|
63
|
-
return `<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
|
64
|
-
`<body style="margin:0;font-family:Inter,system-ui,sans-serif;background:#05050f;color:#fff;` +
|
|
65
|
-
`display:flex;align-items:center;justify-content:center;height:100vh">` +
|
|
66
|
-
`<div style="text-align:center"><div style="font-size:42px;color:#8B5CF6;margin-bottom:8px">${mark}</div>` +
|
|
67
|
-
`<h2 style="margin:0 0 6px">${title}</h2><p style="opacity:.55;font-size:14px">${sub}</p></div></body>`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Run the interactive browser login. Resolves with the kolbo_live_ key.
|
|
72
|
-
* @param {object} opts
|
|
73
|
-
* @param {string} opts.apiBase e.g. https://api.kolbo.ai/api
|
|
74
|
-
*/
|
|
75
|
-
async function browserLogin({ apiBase }) {
|
|
76
|
-
// The OAuth endpoints live at the host root, not under /api.
|
|
77
|
-
const oauthBase = apiBase.replace(/\/api\/?$/, '');
|
|
78
|
-
let apiHost = 'api.kolbo.ai';
|
|
79
|
-
try { apiHost = new URL(apiBase).host; } catch (_) {}
|
|
80
|
-
|
|
81
|
-
const verifier = b64url(crypto.randomBytes(32));
|
|
82
|
-
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|
83
|
-
const state = b64url(crypto.randomBytes(16));
|
|
84
|
-
|
|
85
|
-
// Loopback callback server on a random free port.
|
|
86
|
-
const server = http.createServer();
|
|
87
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
88
|
-
const port = server.address().port;
|
|
89
|
-
const redirectUri = `http://localhost:${port}/callback`;
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
// 1. Dynamic client registration (public + PKCE).
|
|
93
|
-
const regRes = await fetch(`${oauthBase}/oauth/register`, {
|
|
94
|
-
method: 'POST',
|
|
95
|
-
headers: { 'Content-Type': 'application/json' },
|
|
96
|
-
body: JSON.stringify({ client_name: 'Kolbo MCP (local)', redirect_uris: [redirectUri] }),
|
|
97
|
-
});
|
|
98
|
-
if (!regRes.ok) throw new Error(`client registration failed (${regRes.status})`);
|
|
99
|
-
const { client_id } = await regRes.json();
|
|
100
|
-
|
|
101
|
-
// 2. Wait for the browser redirect to hit our loopback server.
|
|
102
|
-
const codePromise = new Promise((resolve, reject) => {
|
|
103
|
-
const timer = setTimeout(() => reject(new Error('login timed out (5 min)')), 5 * 60 * 1000);
|
|
104
|
-
server.on('request', (req, resp) => {
|
|
105
|
-
let u;
|
|
106
|
-
try { u = new URL(req.url, redirectUri); } catch (_) { resp.writeHead(400); resp.end(); return; }
|
|
107
|
-
if (u.pathname !== '/callback') { resp.writeHead(404); resp.end(); return; }
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
const code = u.searchParams.get('code');
|
|
110
|
-
const st = u.searchParams.get('state');
|
|
111
|
-
const err = u.searchParams.get('error');
|
|
112
|
-
resp.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
113
|
-
resp.end(donePage(!err && !!code));
|
|
114
|
-
if (err) return reject(new Error(`login denied: ${err}`));
|
|
115
|
-
if (!code || st !== state) return reject(new Error('login: invalid callback'));
|
|
116
|
-
resolve(code);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// 3. Open the consent/login page.
|
|
121
|
-
const authUrl =
|
|
122
|
-
`${oauthBase}/oauth/authorize?response_type=code&client_id=${encodeURIComponent(client_id)}` +
|
|
123
|
-
`&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}` +
|
|
124
|
-
`&code_challenge_method=S256&state=${state}&scope=kolbo`;
|
|
125
|
-
openBrowser(authUrl);
|
|
126
|
-
process.stderr.write(
|
|
127
|
-
`\n[kolbo] Connect your Kolbo account in the browser. If it didn't open, visit:\n${authUrl}\n\n`
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const code = await codePromise;
|
|
131
|
-
|
|
132
|
-
// 4. Exchange the code (with the PKCE verifier) for the token.
|
|
133
|
-
const tokRes = await fetch(`${oauthBase}/oauth/token`, {
|
|
134
|
-
method: 'POST',
|
|
135
|
-
headers: { 'Content-Type': 'application/json' },
|
|
136
|
-
body: JSON.stringify({
|
|
137
|
-
grant_type: 'authorization_code',
|
|
138
|
-
code,
|
|
139
|
-
code_verifier: verifier,
|
|
140
|
-
redirect_uri: redirectUri,
|
|
141
|
-
client_id,
|
|
142
|
-
}),
|
|
143
|
-
});
|
|
144
|
-
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
|
|
145
|
-
const tok = await tokRes.json();
|
|
146
|
-
if (!tok.access_token) throw new Error('login: no access_token returned');
|
|
147
|
-
|
|
148
|
-
storeKey(apiHost, tok.access_token);
|
|
149
|
-
return tok.access_token;
|
|
150
|
-
} finally {
|
|
151
|
-
try { server.close(); } catch (_) {}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
module.exports = { browserLogin };
|
|
156
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Keyless browser login for the LOCAL (stdio) Kolbo MCP server.
|
|
3
|
+
*
|
|
4
|
+
* When the server runs on the user's machine with no KOLBO_API_KEY and no
|
|
5
|
+
* stored credential, the first tool call triggers this: we open the browser to
|
|
6
|
+
* Kolbo's OAuth login (the same server that powers the remote connector), the
|
|
7
|
+
* user clicks Allow, and we capture a token via a loopback redirect — no API
|
|
8
|
+
* key to create or paste. The token is cached so every later run is silent.
|
|
9
|
+
*
|
|
10
|
+
* Standard "native app" OAuth: authorization-code + PKCE with a
|
|
11
|
+
* http://localhost:<port>/callback redirect (already allow-listed by the Kolbo
|
|
12
|
+
* OAuth server). This path is NOT used by the remote connector (it always
|
|
13
|
+
* injects the caller's key, and passes allowBrowserLogin:false).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { exec } = require('child_process');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
|
|
23
|
+
function b64url(buf) {
|
|
24
|
+
return Buffer.from(buf).toString('base64url');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function openBrowser(url) {
|
|
28
|
+
const cmd =
|
|
29
|
+
process.platform === 'win32' ? `start "" "${url}"`
|
|
30
|
+
: process.platform === 'darwin' ? `open "${url}"`
|
|
31
|
+
: `xdg-open "${url}"`;
|
|
32
|
+
try { exec(cmd, () => {}); } catch (_) { /* best effort */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Where we cache the token — same location + shape that client.js reads back
|
|
36
|
+
// (`<xdg-data>/kolbo/auth.json` → { "kolbo@<host>": { type: 'api', key } }).
|
|
37
|
+
function authStorePath() {
|
|
38
|
+
const dataDir =
|
|
39
|
+
process.env.XDG_DATA_HOME ||
|
|
40
|
+
(process.platform === 'win32'
|
|
41
|
+
? (process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share'))
|
|
42
|
+
: process.platform === 'darwin'
|
|
43
|
+
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
44
|
+
: path.join(os.homedir(), '.local', 'share'));
|
|
45
|
+
return path.join(dataDir, 'kolbo', 'auth.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function storeKey(apiHost, key) {
|
|
49
|
+
try {
|
|
50
|
+
const file = authStorePath();
|
|
51
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
+
let store = {};
|
|
53
|
+
try { store = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) {}
|
|
54
|
+
store[`kolbo@${apiHost}`] = { type: 'api', key, savedAt: new Date().toISOString() };
|
|
55
|
+
fs.writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
56
|
+
} catch (_) { /* non-fatal — the key still works for this process */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function donePage(ok) {
|
|
60
|
+
const title = ok ? 'Connected to Kolbo' : 'Connection cancelled';
|
|
61
|
+
const sub = ok ? 'You can close this tab and return to your app.' : 'You can close this tab.';
|
|
62
|
+
const mark = ok ? '✓' : '✕';
|
|
63
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
|
64
|
+
`<body style="margin:0;font-family:Inter,system-ui,sans-serif;background:#05050f;color:#fff;` +
|
|
65
|
+
`display:flex;align-items:center;justify-content:center;height:100vh">` +
|
|
66
|
+
`<div style="text-align:center"><div style="font-size:42px;color:#8B5CF6;margin-bottom:8px">${mark}</div>` +
|
|
67
|
+
`<h2 style="margin:0 0 6px">${title}</h2><p style="opacity:.55;font-size:14px">${sub}</p></div></body>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the interactive browser login. Resolves with the kolbo_live_ key.
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {string} opts.apiBase e.g. https://api.kolbo.ai/api
|
|
74
|
+
*/
|
|
75
|
+
async function browserLogin({ apiBase }) {
|
|
76
|
+
// The OAuth endpoints live at the host root, not under /api.
|
|
77
|
+
const oauthBase = apiBase.replace(/\/api\/?$/, '');
|
|
78
|
+
let apiHost = 'api.kolbo.ai';
|
|
79
|
+
try { apiHost = new URL(apiBase).host; } catch (_) {}
|
|
80
|
+
|
|
81
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
82
|
+
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
|
|
83
|
+
const state = b64url(crypto.randomBytes(16));
|
|
84
|
+
|
|
85
|
+
// Loopback callback server on a random free port.
|
|
86
|
+
const server = http.createServer();
|
|
87
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
88
|
+
const port = server.address().port;
|
|
89
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// 1. Dynamic client registration (public + PKCE).
|
|
93
|
+
const regRes = await fetch(`${oauthBase}/oauth/register`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({ client_name: 'Kolbo MCP (local)', redirect_uris: [redirectUri] }),
|
|
97
|
+
});
|
|
98
|
+
if (!regRes.ok) throw new Error(`client registration failed (${regRes.status})`);
|
|
99
|
+
const { client_id } = await regRes.json();
|
|
100
|
+
|
|
101
|
+
// 2. Wait for the browser redirect to hit our loopback server.
|
|
102
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
103
|
+
const timer = setTimeout(() => reject(new Error('login timed out (5 min)')), 5 * 60 * 1000);
|
|
104
|
+
server.on('request', (req, resp) => {
|
|
105
|
+
let u;
|
|
106
|
+
try { u = new URL(req.url, redirectUri); } catch (_) { resp.writeHead(400); resp.end(); return; }
|
|
107
|
+
if (u.pathname !== '/callback') { resp.writeHead(404); resp.end(); return; }
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
const code = u.searchParams.get('code');
|
|
110
|
+
const st = u.searchParams.get('state');
|
|
111
|
+
const err = u.searchParams.get('error');
|
|
112
|
+
resp.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
113
|
+
resp.end(donePage(!err && !!code));
|
|
114
|
+
if (err) return reject(new Error(`login denied: ${err}`));
|
|
115
|
+
if (!code || st !== state) return reject(new Error('login: invalid callback'));
|
|
116
|
+
resolve(code);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 3. Open the consent/login page.
|
|
121
|
+
const authUrl =
|
|
122
|
+
`${oauthBase}/oauth/authorize?response_type=code&client_id=${encodeURIComponent(client_id)}` +
|
|
123
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}` +
|
|
124
|
+
`&code_challenge_method=S256&state=${state}&scope=kolbo`;
|
|
125
|
+
openBrowser(authUrl);
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
`\n[kolbo] Connect your Kolbo account in the browser. If it didn't open, visit:\n${authUrl}\n\n`
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const code = await codePromise;
|
|
131
|
+
|
|
132
|
+
// 4. Exchange the code (with the PKCE verifier) for the token.
|
|
133
|
+
const tokRes = await fetch(`${oauthBase}/oauth/token`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
grant_type: 'authorization_code',
|
|
138
|
+
code,
|
|
139
|
+
code_verifier: verifier,
|
|
140
|
+
redirect_uri: redirectUri,
|
|
141
|
+
client_id,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
if (!tokRes.ok) throw new Error(`token exchange failed (${tokRes.status})`);
|
|
145
|
+
const tok = await tokRes.json();
|
|
146
|
+
if (!tok.access_token) throw new Error('login: no access_token returned');
|
|
147
|
+
|
|
148
|
+
storeKey(apiHost, tok.access_token);
|
|
149
|
+
return tok.access_token;
|
|
150
|
+
} finally {
|
|
151
|
+
try { server.close(); } catch (_) {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { browserLogin };
|
package/src/tools/generate.js
CHANGED
|
@@ -73,6 +73,24 @@ async function pollBatch(client, batch, { interval, timeout }) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// ─── Widget settings block ──────────────────────────────────────────────────
|
|
77
|
+
// What the CALLER actually asked for, for the generation card AND for the model
|
|
78
|
+
// reading the tool result. Undefined/false keys are dropped by JSON.stringify, so
|
|
79
|
+
// only values that were really supplied ever surface. This used to be
|
|
80
|
+
// `{ resolution, aspect_ratio }` only — `quality` (and every knob below it) was
|
|
81
|
+
// silently dropped, so three calls at low/medium/high rendered identical cards.
|
|
82
|
+
const imageSettings = (a = {}) => ({
|
|
83
|
+
resolution: a.resolution,
|
|
84
|
+
aspect_ratio: a.aspect_ratio,
|
|
85
|
+
quality: a.quality,
|
|
86
|
+
enhance_prompt: a.enhance_prompt || undefined,
|
|
87
|
+
web_search: a.enable_web_search || undefined,
|
|
88
|
+
visual_dna: (a.visual_dna_ids && a.visual_dna_ids.length) || undefined,
|
|
89
|
+
moodboard: a.moodboard_id ? true : undefined,
|
|
90
|
+
preset: a.preset_id ? true : undefined,
|
|
91
|
+
cinematic: a.cinematic ? true : undefined,
|
|
92
|
+
});
|
|
93
|
+
|
|
76
94
|
const promptsField = (what) => z.array(z.string()).optional().describe(
|
|
77
95
|
`BATCH MODE — several DIFFERENT prompts (2–${MAX_BATCH_PROMPTS}) generated concurrently in ONE call and rendered together in ONE combined widget. Whenever the user wants multiple distinct ${what} with their own prompts, ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All prompts share the same model/settings. When set, \`prompt\` is ignored. For N variations of a SINGLE prompt use num_images (image tools); for an AI-planned coherent scene set use generate_creative_director.`
|
|
78
96
|
);
|
|
@@ -121,7 +139,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
121
139
|
const batch = await submitBatch(prompts, (p) => client.post('/v1/generate/image', { ...shared, prompt: p }));
|
|
122
140
|
if (ui()) return uiGenerating({
|
|
123
141
|
tool: 'generate_image', kind: 'image', gen: batch.ok[0].gen, client, model,
|
|
124
|
-
count: batch.ids.length, settings:
|
|
142
|
+
count: batch.ids.length, settings: imageSettings(shared),
|
|
125
143
|
generation_ids: batch.ids, prompts: batch.ok.map((o) => o.prompt),
|
|
126
144
|
failed_submissions: batch.failed,
|
|
127
145
|
status_args: { generation_ids: batch.ids, wait: true },
|
|
@@ -134,7 +152,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
134
152
|
|
|
135
153
|
if (ui()) return uiGenerating({
|
|
136
154
|
tool: 'generate_image', kind: 'image', gen, client, model, prompt,
|
|
137
|
-
count: num_images, settings:
|
|
155
|
+
count: num_images, settings: imageSettings(shared),
|
|
138
156
|
reference_image: reference_images?.[0]
|
|
139
157
|
});
|
|
140
158
|
|
|
@@ -189,7 +207,8 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
189
207
|
|
|
190
208
|
if (ui()) return uiGenerating({
|
|
191
209
|
tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
|
|
192
|
-
count: num_images,
|
|
210
|
+
count: num_images,
|
|
211
|
+
settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, cinematic }),
|
|
193
212
|
reference_image: source_images?.[0]
|
|
194
213
|
});
|
|
195
214
|
|
|
@@ -1434,13 +1453,13 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1434
1453
|
.describe('Output quality preset (e.g. "high", "standard"). Applies where the underlying model supports quality tiers.'),
|
|
1435
1454
|
|
|
1436
1455
|
ai_optimize: z.boolean().optional()
|
|
1437
|
-
.describe('Whether to let Kolbo AI enhance your prompt before sending to the model. Default:
|
|
1456
|
+
.describe('Whether to let Kolbo AI enhance your prompt before sending to the model. Default: false — your prompt reaches the model exactly as written. Only pass true if the user explicitly asks to enhance/improve the prompt.'),
|
|
1438
1457
|
|
|
1439
1458
|
project_id: projectIdField
|
|
1440
1459
|
},
|
|
1441
1460
|
async ({
|
|
1442
1461
|
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
|
|
1443
|
-
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
|
|
1462
|
+
mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
|
|
1444
1463
|
zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
|
|
1445
1464
|
project_id
|
|
1446
1465
|
}) => {
|