@kolbo/mcp 1.19.1 → 1.20.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.19.1",
3
+ "version": "1.20.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/auth.js ADDED
@@ -0,0 +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 };
package/src/client.js CHANGED
@@ -158,17 +158,45 @@ class KolboClient {
158
158
  this._explicitKey = opts.apiKey || null;
159
159
  this._envKey = process.env.KOLBO_API_KEY || null;
160
160
  this._authStoreKey = null; // lazy-loaded
161
+ // Local stdio servers (raw `npx @kolbo/mcp` in Claude Desktop / Code / Cursor)
162
+ // may start with no key and log in via the browser on first use. Disabled when:
163
+ // - the host opts out (remote HTTP connector passes allowBrowserLogin:false —
164
+ // it always injects the caller's key, and opening a browser on a server is
165
+ // nonsensical), or
166
+ // - we're spawned by Kolbo Code (it sets KOLBO_CALLER_SESSION_ID and runs its
167
+ // OWN in-app sign-in off the [KOLBO_AUTH_MISSING] error — don't double up).
168
+ this._allowBrowserLogin =
169
+ opts.allowBrowserLogin !== undefined
170
+ ? opts.allowBrowserLogin
171
+ : !process.env.KOLBO_CALLER_SESSION_ID;
172
+ this._loginPromise = null;
161
173
  this.apiKey = this._explicitKey || this._envKey || this._readAuthStore();
162
174
 
163
- if (!this.apiKey) {
164
- // No key in env OR auth store. The Kolbo Code parent process should
165
- // never spawn us in this state (it injects the key into env after the
166
- // user signs in). If this fires, the parent will catch it via the
167
- // [KOLBO_AUTH_MISSING] tag and surface the in-app sign-in flow.
175
+ if (!this.apiKey && !this._allowBrowserLogin) {
168
176
  throw new Error(
169
177
  'Kolbo API key not found. Sign in to Kolbo to continue. [KOLBO_AUTH_MISSING]'
170
178
  );
171
179
  }
180
+ // When allowBrowserLogin is on and there's no key yet, we DON'T throw —
181
+ // the first request triggers an interactive browser login (see _ensureLogin).
182
+ }
183
+
184
+ /**
185
+ * Ensure we have a key before a request. If none, run the one-time browser
186
+ * login (single-flight so concurrent first calls share one login window).
187
+ */
188
+ async _ensureLogin() {
189
+ if (this.apiKey) return;
190
+ if (!this._allowBrowserLogin) {
191
+ throw new Error('Kolbo API key not found. Sign in to Kolbo to continue. [KOLBO_AUTH_MISSING]');
192
+ }
193
+ if (!this._loginPromise) {
194
+ const { browserLogin } = require('./auth');
195
+ this._loginPromise = browserLogin({ apiBase: this.baseUrl })
196
+ .then((key) => { this.apiKey = key; this._explicitKey = key; return key; })
197
+ .catch((err) => { this._loginPromise = null; throw err; });
198
+ }
199
+ await this._loginPromise;
172
200
  }
173
201
 
174
202
  _readAuthStore() {
@@ -202,6 +230,7 @@ class KolboClient {
202
230
  }
203
231
 
204
232
  async request(method, reqPath, body = null) {
233
+ if (!this.apiKey) await this._ensureLogin();
205
234
  const result = await this._doRequest(method, reqPath, body);
206
235
 
207
236
  // On 401, try re-reading auth store and retry once
@@ -291,6 +320,7 @@ class KolboClient {
291
320
  }
292
321
 
293
322
  async postMultipart(reqPath, formData) {
323
+ if (!this.apiKey) await this._ensureLogin();
294
324
  const result = await this._doMultipart(reqPath, formData);
295
325
  if (result._status === 401 && this._tryRefreshKey()) {
296
326
  return this._doMultipart(reqPath, formData);
@@ -106,11 +106,11 @@ function assertSafeUrl(rawUrl) {
106
106
  return u;
107
107
  }
108
108
 
109
- async function safeFetch(rawUrl) {
109
+ async function safeFetch(rawUrl, opts = {}) {
110
110
  let current = rawUrl;
111
111
  for (let i = 0; i <= MAX_REDIRECTS; i++) {
112
112
  assertSafeUrl(current);
113
- const res = await fetch(current, { redirect: 'manual' });
113
+ const res = await fetch(current, { redirect: 'manual', signal: opts.signal });
114
114
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
115
115
  const next = new URL(res.headers.get('location'), current).toString();
116
116
  current = next;
@@ -251,20 +251,28 @@ const projectIdField = z.string().optional().describe(
251
251
  // can never be base64-embedded even if mistakenly passed in;
252
252
  // - any fetch/decoding failure silently falls back to URL-only.
253
253
  const INLINE_IMG_MAX_COUNT = 4;
254
- const INLINE_IMG_MAX_BYTES = 8 * 1024 * 1024; // 8 MB per image
254
+ // Cap kept conservative on purpose: a base64 image rides inside the JSON-RPC
255
+ // tool result, and chat clients (claude.ai etc.) drop the WHOLE result if it's
256
+ // too large — which looks like "no image at all". Anything over the cap is left
257
+ // to the URL in the text payload (clients render a "Show Image" affordance from
258
+ // it), so a big image degrades to a click instead of vanishing.
259
+ const INLINE_IMG_MAX_BYTES = 1.5 * 1024 * 1024; // 1.5 MB per image
260
+ const INLINE_IMG_FETCH_TIMEOUT_MS = 8000; // never hang the tool response on a slow CDN
255
261
 
256
262
  async function inlineImageBlocks(urls, opts = {}) {
257
263
  if (!opts || !opts.enabled) return [];
258
264
  if (!Array.isArray(urls) || urls.length === 0) return [];
259
265
  // Fetch the (≤4) images in parallel — they're independent, the cap already
260
266
  // bounds concurrency, and this sits on the connector response path right
261
- // after generation. Order is preserved by map-then-filter; any failure falls
262
- // back to URL-only for that image.
267
+ // after generation. Order is preserved by map-then-filter; any failure (size,
268
+ // type, timeout, network) returns null and falls back to URL-only.
263
269
  const blocks = await Promise.all(
264
270
  urls.slice(0, INLINE_IMG_MAX_COUNT).map(async (url) => {
271
+ const controller = new AbortController();
272
+ const timer = setTimeout(() => controller.abort(), INLINE_IMG_FETCH_TIMEOUT_MS);
265
273
  try {
266
274
  if (typeof url !== 'string' || !isHttpUrl(url)) return null;
267
- const res = await safeFetch(url);
275
+ const res = await safeFetch(url, { signal: controller.signal });
268
276
  if (!res.ok) return null;
269
277
  const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
270
278
  if (!contentType.startsWith('image/')) return null; // never embed non-images
@@ -275,6 +283,8 @@ async function inlineImageBlocks(urls, opts = {}) {
275
283
  return { type: 'image', data: Buffer.from(ab).toString('base64'), mimeType: contentType };
276
284
  } catch (_) {
277
285
  return null;
286
+ } finally {
287
+ clearTimeout(timer);
278
288
  }
279
289
  })
280
290
  );