UNPKG

@kolbo/mcp

Version:

Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code

595 lines (539 loc) • 22.4 kB
const fs = require('fs'); const path = require('path'); const os = require('os'); const progress = require('./progress'); const { rewriteTree } = require('./cdn'); const UPSTREAM_NAME = /\b(?:fal(?:\.ai)?|kie(?:\.ai)?|toapis?|kinovi|piapi|yike|goapi|wavespeed|openrouter|cometapi|dashscope|volcengine|laozhang)\b/i; const UPSTREAM_LABEL = /(^|\|\s*)\s*(?:fal(?:\.ai)?|kie(?:\.ai)?|toapis?|kinovi|piapi|yike|goapi|wavespeed|openrouter|cometapi|dashscope|volcengine|laozhang|bytedance|replicate|pika)\s*:\s*/gi; const UPSTREAM_TAG = /\[\s*(?:fal(?:\.ai)?|kie(?:\.ai)?|toapis?|kinovi|piapi|yike|goapi|wavespeed|openrouter|cometapi|dashscope|volcengine|laozhang|bytedance|replicate|pika)\s*\]\s*/gi; function publicApiErrorMessage(value, fallback) { if (typeof value !== 'string' || !value.trim()) return fallback; const stripped = value.replace(UPSTREAM_LABEL, '$1').replace(UPSTREAM_TAG, '').replace(/^\s*\|\s*/, '').trim(); return (!stripped || UPSTREAM_NAME.test(stripped) || /\bpika\b(?!\s*\d)/i.test(stripped)) ? fallback : stripped; } function publicApiErrorData(data) { if (!data || typeof data !== 'object') return null; return { ...(data.code && { code: data.code }), ...(typeof data.retryable === 'boolean' && { retryable: data.retryable }), ...(data.category && { category: data.category }), ...(data.failure && typeof data.failure === 'object' && { failure: { ...(data.failure.code && { code: data.failure.code }), ...(data.failure.category && { category: data.failure.category }), ...(typeof data.failure.retryable === 'boolean' && { retryable: data.failure.retryable }), } }), }; } /** * Kolbo API HTTP client wrapper * * Auth resolution (first match wins): * 1. KOLBO_API_KEY env var — explicit key, always honored * 2. CLI auth store (auth.json) — auto-shared with `kolbo auth login` * * API base resolution (mirrors CLI partner.ts): * 1. KOLBO_API_URL env var — explicit override * 2. KOLBO_API_BASE env var — same as CLI * 3. partner.json on disk — whitelabel config * 4. https://api.kolbo.ai/api — default */ class KolboApiError extends Error { constructor(message, { code, status, data } = {}) { super(message); this.name = 'KolboApiError'; this.code = code || null; this.status = status || null; this.data = data || null; } } // Per-REQUEST ceilings. These are not the generation timeouts — a long job is a // poll LOOP of many short requests, and pollUntilDone only checks its own // deadline BETWEEN polls. So an individual request that never settles hangs the // whole tool straight past its declared window, with no error, forever. Bound // each request instead; the loop keeps its own budget. const REQUEST_TIMEOUT_MS = Number(process.env.KOLBO_HTTP_TIMEOUT_MS) || 120000; // Uploads legitimately run long — up to 25MB/file over a slow uplink. const UPLOAD_TIMEOUT_MS = Number(process.env.KOLBO_UPLOAD_TIMEOUT_MS) || 600000; // AbortSignal.timeout is Node 18+; package engines already require >=18. function timeoutSignal(ms) { return typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(ms) : undefined; } function isAbortError(err) { return err && (err.name === 'TimeoutError' || err.name === 'AbortError'); } function composeSignals(signals) { const active = signals.filter(Boolean); if (active.length === 0) return { signal: undefined, cleanup: () => {} }; if (active.length === 1) return { signal: active[0], cleanup: () => {} }; const controller = new AbortController(); const onAbort = () => controller.abort(); for (const item of active) { if (item.aborted) { controller.abort(); break; } item.addEventListener('abort', onAbort, { once: true }); } return { signal: controller.signal, cleanup: () => active.forEach(item => item.removeEventListener('abort', onAbort)) }; } // --------------------------------------------------------------------------- // 429 handling // --------------------------------------------------------------------------- // A 429 is rejected by the rate-limit middleware BEFORE the handler runs: no // credits spent, no generation started, nothing half-done. That makes it the // one status it is safe to replay — but only ONCE, and only when the server // told us how long to wait. An unbounded backoff loop would hide a real // capacity problem and stall the tool call past the host's own timeout. const RETRY_429_MAX_WAIT_S = 65; function retryAfterSeconds(response) { const raw = response.headers.get('retry-after'); if (!raw) return null; const n = Number(raw); return Number.isFinite(n) && n >= 0 ? n : null; } async function retryOnce429(attempt) { try { return await attempt(); } catch (err) { const wait = err && err.status === 429 ? err.retryAfterSeconds : null; if (wait === null || wait === undefined || wait > RETRY_429_MAX_WAIT_S) throw err; await progress.tick(); // keepalive — the wait can be up to a minute await new Promise((resolve) => setTimeout(resolve, (wait + 1) * 1000)); return attempt(); } } // --------------------------------------------------------------------------- // Partner / whitelabel resolution (mirrors CLI's brand/partner.ts) // --------------------------------------------------------------------------- function readJsonSync(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; } } /** * Resolve the API base URL, checking the same sources as the CLI: * 1. KOLBO_API_URL / KOLBO_API_BASE env vars * 2. partner.json files (KOLBO_PARTNER_PROFILE, XDG_CONFIG_HOME, ~/.config) * 3. Default: https://api.kolbo.ai/api */ function resolveApiBase() { // Env vars take priority const fromEnv = process.env.KOLBO_API_URL || process.env.KOLBO_API_BASE; if (fromEnv) return fromEnv.replace(/\/$/, ''); // Partner profile files (same order as CLI) const candidates = []; if (process.env.KOLBO_PARTNER_PROFILE) { candidates.push(process.env.KOLBO_PARTNER_PROFILE); } const xdgConfig = process.env.XDG_CONFIG_HOME || ( process.platform === 'win32' ? path.join(os.homedir(), '.config') : path.join(os.homedir(), '.config') ); candidates.push(path.join(xdgConfig, 'kolbo', 'partner.json')); for (const file of candidates) { const data = readJsonSync(file); if (data && data.apiBase) return data.apiBase.replace(/\/$/, ''); } return 'https://api.kolbo.ai/api'; } // --------------------------------------------------------------------------- // CLI auth store reader // --------------------------------------------------------------------------- /** * XDG data dir — same logic as the `xdg-basedir` npm package the CLI uses. * On Windows with Git Bash / MSYS2 this resolves to ~/.local/share (matching * what the CLI actually writes to). */ function xdgDataDir() { if (process.env.XDG_DATA_HOME) return process.env.XDG_DATA_HOME; if (process.platform === 'win32') { // xdg-basedir on Windows: LOCALAPPDATA → ~/.local/share fallback return process.env.LOCALAPPDATA || path.join(os.homedir(), '.local', 'share'); } if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support'); } return path.join(os.homedir(), '.local', 'share'); } /** * Read the Kolbo Code auth store. Kolbo Code writes credentials to * <xdg-data>/kolbo/auth.json after device-code login. * * On Windows (Git Bash / MSYS2) xdg-basedir resolves to ~/.local/share, * so we check multiple candidates to be safe. */ function readCliAuthKey() { const dataDir = xdgDataDir(); const candidates = [ path.join(dataDir, 'kolbo', 'auth.json'), ]; // Windows fallback: also check ~/.local/share if LOCALAPPDATA was primary if (process.platform === 'win32' && dataDir !== path.join(os.homedir(), '.local', 'share')) { candidates.push(path.join(os.homedir(), '.local', 'share', 'kolbo', 'auth.json')); } // Determine the API host for namespaced auth lookup const apiBase = process.env.KOLBO_API_URL || process.env.KOLBO_API_BASE || ''; let apiHost = null; try { apiHost = new URL(apiBase).host; } catch (_) {} if (!apiHost) { // Check partner.json for the API host const partnerCandidates = []; if (process.env.KOLBO_PARTNER_PROFILE) partnerCandidates.push(process.env.KOLBO_PARTNER_PROFILE); const xdgCfg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); partnerCandidates.push(path.join(xdgCfg, 'kolbo', 'partner.json')); for (const f of partnerCandidates) { const p = readJsonSync(f); if (p && p.apiBase) { try { apiHost = new URL(p.apiBase).host; } catch (_) {} break; } } } if (!apiHost) apiHost = 'api.kolbo.ai'; for (const file of candidates) { try { const auth = JSON.parse(fs.readFileSync(file, 'utf8')); // Try namespaced key first (e.g. "kolbo@api.kolbo.ai"), then bare "kolbo" const entry = auth[`kolbo@${apiHost}`] || auth.kolbo; if (!entry) continue; if (entry.type === 'oauth' && entry.refresh) return entry.refresh; if (entry.type === 'api' && entry.key) return entry.key; } catch (_) { // File doesn't exist or isn't valid JSON — try next } } return null; } // --------------------------------------------------------------------------- // HTTP client // --------------------------------------------------------------------------- class KolboClient { /** * @param {object} [opts] * @param {string} [opts.apiKey] Explicit key. Takes precedence over env + * auth store. Used by a remote HTTP host that injects the caller's key per * request (one KolboClient per request) instead of reading a process-wide * env var. When set, the auth-store 401 refresh path is disabled — the host * owns the key lifecycle. * @param {string} [opts.apiBase] Explicit API base URL override. */ constructor(opts = {}) { this.baseUrl = opts.apiBase ? String(opts.apiBase).replace(/\/$/, '') : resolveApiBase(); this._explicitKey = opts.apiKey || null; this._envKey = process.env.KOLBO_API_KEY || null; this._authStoreKey = null; // lazy-loaded // Local stdio servers (raw `npx @kolbo/mcp` in Claude Desktop / Code / Cursor) // may start with no key and log in via the browser on first use. Disabled when: // - the host opts out (remote HTTP connector passes allowBrowserLogin:false — // it always injects the caller's key, and opening a browser on a server is // nonsensical), or // - we're spawned by Kolbo Code (it sets KOLBO_CALLER_SESSION_ID and runs its // OWN in-app sign-in off the [KOLBO_AUTH_MISSING] error — don't double up). this._allowBrowserLogin = opts.allowBrowserLogin !== undefined ? opts.allowBrowserLogin : !process.env.KOLBO_CALLER_SESSION_ID; this._loginPromise = null; this.apiKey = this._explicitKey || this._envKey || this._readAuthStore(); if (!this.apiKey && !this._allowBrowserLogin) { throw new Error( 'Kolbo API key not found. Sign in to Kolbo to continue. [KOLBO_AUTH_MISSING]' ); } // When allowBrowserLogin is on and there's no key yet, we DON'T throw — // the first request triggers an interactive browser login (see _ensureLogin). } /** * Ensure we have a key before a request. If none, run the one-time browser * login (single-flight so concurrent first calls share one login window). */ async _ensureLogin() { if (this.apiKey) return; if (!this._allowBrowserLogin) { throw new Error('Kolbo API key not found. Sign in to Kolbo to continue. [KOLBO_AUTH_MISSING]'); } if (!this._loginPromise) { const { browserLogin } = require('./auth'); this._loginPromise = browserLogin({ apiBase: this.baseUrl }) .then((key) => { this.apiKey = key; this._explicitKey = key; return key; }) .catch((err) => { this._loginPromise = null; throw err; }); } await this._loginPromise; } _readAuthStore() { this._authStoreKey = readCliAuthKey(); return this._authStoreKey; } /** * On 401, re-read the CLI auth store in case the user re-authenticated * since the MCP server started. Returns true if a new key was found. */ _tryRefreshKey() { // Host-injected per-request key is authoritative — never override it from // the local CLI auth store (which may not even exist in a server context). if (this._explicitKey) return false; if (this._envKey) { // Env var is set but invalid — can't override it, but try auth store const fresh = readCliAuthKey(); if (fresh && fresh !== this._envKey) { this.apiKey = fresh; return true; } return false; } const fresh = readCliAuthKey(); if (fresh && fresh !== this.apiKey) { this.apiKey = fresh; return true; } return false; } async request(method, reqPath, body = null, requestOptions = {}) { if (!this.apiKey) await this._ensureLogin(); const result = await this._doRequest(method, reqPath, body, requestOptions); // On 401, try re-reading auth store and retry once if (result._status === 401 && this._tryRefreshKey()) { return this._doRequest(method, reqPath, body, requestOptions); } return result; } async _doRequest(method, reqPath, body = null, requestOptions = {}) { const url = `${this.baseUrl}${reqPath}`; const headers = { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }; // Stable per-app-launch identifier from the parent process (Kolbo Code // sets this in the MCP env when spawning us). kolbo-api tags every // CreditUsage record with it so the desktop UI and the get_session_usage // tool can aggregate spend without enumerating individual generations. const callerSessionId = process.env.KOLBO_CALLER_SESSION_ID; if (callerSessionId) { headers['X-Kolbo-Caller-Session-Id'] = callerSessionId; } const options = { method, headers, }; if (body) { options.body = JSON.stringify(body); } const callerSignal = requestOptions.ignoreCallerSignal ? null : progress.signal(); const requestTimeoutMs = requestOptions.timeoutMs || REQUEST_TIMEOUT_MS; const composed = composeSignals([callerSignal, timeoutSignal(requestTimeoutMs)]); options.signal = composed.signal; let response; try { response = await fetch(url, options); } catch (err) { if (isAbortError(err)) { if (callerSignal?.aborted) { throw new KolboApiError( `Request cancelled by caller: ${method} ${reqPath}`, { code: 'REQUEST_CANCELLED', status: 499 } ); } throw new KolboApiError( `Request timed out after ${requestTimeoutMs / 1000}s: ${method} ${reqPath}. ` + 'The job may still be running server-side — poll get_generation_status before retrying. ' + 'Raise KOLBO_HTTP_TIMEOUT_MS if this is a legitimately slow endpoint.', { code: 'REQUEST_TIMEOUT', status: 504 } ); } throw err; } finally { composed.cleanup(); } let data; try { data = await response.json(); } catch (_) { throw new KolboApiError(`API error: ${response.status} ${response.statusText}`, { status: response.status, data: null }); } if (!response.ok || data.success === false) { const message = publicApiErrorMessage( data.error || data.message, `The Kolbo request could not be completed (HTTP ${response.status}).` ); const code = data.code || null; let fullMessage = code ? `${message} [${code}]` : message; if (response.status === 401) { // Tag the response so the retry logic in request() can see it AND so // the Kolbo Code parent process can intercept this error before the // agent sees it — trigger the in-app reconnect flow, refresh the key, // and transparently retry the tool call. Never instruct the user to // open a terminal: most users run Kolbo Code as a desktop / web app // and have no terminal context. data._status = 401; data._kolbo_auth_expired = true; fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`; } const apiError = new KolboApiError(fullMessage, { code, status: response.status, data: publicApiErrorData(data) }); if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response); throw apiError; } const generationId = data?.generation_id || data?.generationId; if (method === 'POST' && generationId) { await progress.generation(generationId); if (!requestOptions.suppressGenerationTracking) { progress.trackGeneration(generationId, () => this._cancelAfterCallerAbort(generationId)); } } return rewriteTree(data); } async _cancelAfterCallerAbort(generationId) { try { await this._doRequest( 'POST', `/v1/generate/${encodeURIComponent(generationId)}/cancel`, {}, { ignoreCallerSignal: true, suppressGenerationTracking: true, timeoutMs: 30000 } ); } catch (_) { // The host has already cancelled the visible tool call. This is a // best-effort cleanup and must never become an unhandled rejection. } } async post(reqPath, body, requestOptions) { return this.request('POST', reqPath, body, requestOptions); } async get(reqPath, requestOptions) { return this.request('GET', reqPath, null, requestOptions); } async put(reqPath, body = null) { return this.request('PUT', reqPath, body); } async patch(reqPath, body = null) { return this.request('PATCH', reqPath, body); } async delete(reqPath, body = null) { return this.request('DELETE', reqPath, body); } // Uploads are the one thing agents genuinely do in a tight loop (`upload_media` // per file), and /v1/media/upload shares the per-minute SDK generation bucket — // so a batch trips 429 long before the user's patience does. Absorb exactly one // of those, honouring the server's Retry-After. Everything else (including the // poll loop, which does its own capped backoff in polling.js) still surfaces // the 429 straight to the caller, now with the wait spelled out in the message. async postMultipart(reqPath, formData) { return this._multipart('POST', reqPath, formData); } async putMultipart(reqPath, formData) { return this._multipart('PUT', reqPath, formData); } async _multipart(method, reqPath, formData) { if (!this.apiKey) await this._ensureLogin(); return retryOnce429(async () => { const result = await this._doMultipart(method, reqPath, formData); if (result._status === 401 && this._tryRefreshKey()) { return this._doMultipart(method, reqPath, formData); } return result; }); } async _doMultipart(method, reqPath, formData) { const url = `${this.baseUrl}${reqPath}`; const headers = { 'X-API-Key': this.apiKey, ...formData.getHeaders() }; // Same caller-session header as JSON requests — see _doRequest. const callerSessionId = process.env.KOLBO_CALLER_SESSION_ID; if (callerSessionId) { headers['X-Kolbo-Caller-Session-Id'] = callerSessionId; } // Serialize form-data to a Buffer before passing to fetch(). Node's // built-in fetch (undici) can't consume legacy Node.js streams from // the `form-data` package, causing "fetch failed" on local file uploads. const body = formData.getBuffer(); headers['Content-Length'] = String(body.length); const callerSignal = progress.signal(); const composed = composeSignals([callerSignal, timeoutSignal(UPLOAD_TIMEOUT_MS)]); let response; try { response = await fetch(url, { method, headers, body, signal: composed.signal }); } catch (err) { if (isAbortError(err)) { if (callerSignal?.aborted) { throw new KolboApiError( `Upload cancelled by caller: ${method} ${reqPath}`, { code: 'REQUEST_CANCELLED', status: 499 } ); } throw new KolboApiError( `Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s: ${method} ${reqPath} ` + `(${Math.round(body.length / 1024)}KB). Raise KOLBO_UPLOAD_TIMEOUT_MS for slow links.`, { code: 'UPLOAD_TIMEOUT', status: 504 } ); } throw err; } finally { composed.cleanup(); } let data; try { data = await response.json(); } catch (_) { throw new KolboApiError(`API error: ${response.status} ${response.statusText}`, { status: response.status, data: null }); } if (!response.ok || data.success === false) { const message = publicApiErrorMessage( data.error || data.message, `The Kolbo request could not be completed (HTTP ${response.status}).` ); const code = data.code || null; let fullMessage = code ? `${message} [${code}]` : message; if (response.status === 401) { // Multipart uploads: same auth-expired contract as _doRequest. The // Kolbo Code parent process intercepts [KOLBO_AUTH_EXPIRED] and runs // the in-app reconnect flow — no terminal command needed. data._status = 401; data._kolbo_auth_expired = true; fullMessage = `${fullMessage} [KOLBO_AUTH_EXPIRED]`; } const apiError = new KolboApiError(fullMessage, { code, status: response.status, data: publicApiErrorData(data) }); if (response.status === 429) apiError.retryAfterSeconds = retryAfterSeconds(response); throw apiError; } const generationId = data?.generation_id || data?.generationId; if (generationId) { await progress.generation(generationId); progress.trackGeneration(generationId, () => this._cancelAfterCallerAbort(generationId)); } return rewriteTree(data); } } module.exports = KolboClient; module.exports.KolboApiError = KolboApiError;