@kolbo/mcp 1.63.0 → 1.64.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.63.0",
3
+ "version": "1.64.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": {
@@ -293,7 +293,8 @@ function releaseSeen() {
293
293
  function schedulePoll(sc) {
294
294
  if (cancelRequested) return;
295
295
  if (!seen) { whenSeenFns.push(function () { schedulePoll(sc); }); return; }
296
- // The call itself long-waits server-side (normally up to three minutes).
296
+ // The call itself long-waits server-side, for one transport-safe window
297
+ // (~45s over the remote connector — see WAIT_WINDOW_MS in tools/generate.js).
297
298
  // This short pause only separates successive wait windows — the FIRST call
298
299
  // goes out immediately, so a card revealed by scrolling resolves at once.
299
300
  var delay = pollStart ? 1500 : 0;
package/src/polling.js CHANGED
@@ -100,12 +100,71 @@ async function pollUntilDone(client, generationId, options = {}) {
100
100
  }
101
101
 
102
102
  // Still running: put a byte on the wire before going quiet again, so no
103
- // intermediary mistakes a 3-minute wait for a dead connection.
103
+ // intermediary mistakes a long wait for a dead connection.
104
104
  await progress.tick();
105
105
 
106
- // Wait before next poll
107
- await new Promise(resolve => setTimeout(resolve, interval));
106
+ // Wait before next poll — but never past the deadline. The check at the top
107
+ // of the loop only runs BETWEEN sleeps, so an unclamped sleep let the call
108
+ // overshoot `timeout` by up to a full interval (a 45s window with a 15s
109
+ // cadence could return at 60s). That is the difference between landing
110
+ // inside the caller's transport window and blowing straight through it.
111
+ const remaining = timeout - (Date.now() - startTime);
112
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(interval, remaining))));
108
113
  }
109
114
  }
110
115
 
111
- module.exports = { pollUntilDone, PollingTimeoutError, GenerationFailedError };
116
+ // ─── Blocking-wait window for the STATUS tools ──────────────────────────────
117
+ // How long get_generation_status / get_creative_director_status may block
118
+ // inside ONE tool call before handing back a non-terminal result the caller
119
+ // re-issues. This is NOT the generation's lifetime — the job keeps running
120
+ // server-side either way.
121
+ //
122
+ // It used to be a flat 180s, which over the remote HTTP connector no caller
123
+ // could ever reach: there the whole tool call has to fit inside a single
124
+ // POST /mcp response, and every hop in front of us has a shorter fuse.
125
+ //
126
+ // • MCP client request timeout — 60s (SDK DEFAULT_REQUEST_TIMEOUT_MSEC), and
127
+ // it only resets on a progress notification when the client opted into
128
+ // resetTimeoutOnProgress, whose SDK default is false. We cannot make that
129
+ // choice on the host's behalf, so this is the ceiling we must respect.
130
+ // • Cloudflare origin read — 100s. api.kolbo.ai is Cloudflare-proxied.
131
+ // • kolbo-api httpServer.timeout — 120s. Measured against the production
132
+ // settings: a SILENT stream is RST at exactly 120.0s, while a 15s write
133
+ // cadence survives 200s. So progress.tick() does defeat this hop — but no
134
+ // amount of ticking defeats a client timeout that does not reset.
135
+ //
136
+ // Net effect of the old 180s: a 185s music generation made wait=true fail with
137
+ // "the connector's server isn't responding" every single time, on a perfectly
138
+ // healthy paid generation. Returning early with state:"processing" is strictly
139
+ // better than erroring — the caller re-issues and nothing is lost.
140
+ //
141
+ // stdio hosts have no hop in between and do reset on our ticks, so they keep
142
+ // the long window. KOLBO_MCP_WAIT_MS overrides both without a release, if a
143
+ // host ever proves tighter still.
144
+ const TRANSPORT_CEILING_MS = 60000;
145
+ // Headroom for everything that happens AFTER the last poll and before the
146
+ // response is on the wire: the final status read, addDisplayNames' catalog
147
+ // lookups, JSON serialization.
148
+ const RESULT_ASSEMBLY_BUDGET_MS = 10000;
149
+ const REMOTE_WAIT_MS = 45000;
150
+ const STDIO_WAIT_MS = 180000;
151
+
152
+ /**
153
+ * @param {object} [options] tool options; `apps === true` is the remote-HTTP
154
+ * transport signal (set only by kolbo-api's connector).
155
+ */
156
+ function waitWindowMs(options = {}) {
157
+ const override = Number(process.env.KOLBO_MCP_WAIT_MS);
158
+ if (Number.isFinite(override) && override > 0) return override;
159
+ return options.apps === true ? REMOTE_WAIT_MS : STDIO_WAIT_MS;
160
+ }
161
+
162
+ module.exports = {
163
+ pollUntilDone,
164
+ PollingTimeoutError,
165
+ GenerationFailedError,
166
+ waitWindowMs,
167
+ TRANSPORT_CEILING_MS,
168
+ RESULT_ASSEMBLY_BUDGET_MS,
169
+ REMOTE_WAIT_MS,
170
+ };
@@ -5,7 +5,7 @@
5
5
 
6
6
  const { z } = require('zod');
7
7
  const FormData = require('form-data');
8
- const { pollUntilDone } = require('../polling');
8
+ const { pollUntilDone, waitWindowMs } = require('../polling');
9
9
  const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
10
10
  const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
11
11
 
@@ -126,6 +126,18 @@ function registerGenerateTools(server, client, options = {}) {
126
126
  // "submitted" response + a live ui://kolbo/generation.html widget that keeps
127
127
  // one wait=true status call in flight. Text-only hosts never take this branch.
128
128
  const ui = () => appsEnabled(server, options);
129
+
130
+ // How long the STATUS tools may block inside one tool call before handing
131
+ // back a non-terminal result the caller re-issues. Bounded by the transport,
132
+ // not by the generation — full reasoning and the measured numbers live next
133
+ // to the constants in ../polling.js.
134
+ const WAIT_WINDOW_MS = waitWindowMs(options);
135
+ const WAIT_WINDOW_S = Math.round(WAIT_WINDOW_MS / 1000);
136
+ // What to tell a caller holding a still-running generation. Never "don't call
137
+ // again" — for anything longer than the window, calling again IS the protocol.
138
+ const stillRunningHint = (idsPhrase) =>
139
+ `Still running — this is NOT a failure and no credits were lost. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s and then returns whatever the state is, so a long job (music ~3 min, video can be longer) legitimately needs SEVERAL wait=true calls in a row. Call get_generation_status again with wait=true${idsPhrase}. Do not spin with wait=false, and do not re-run the generation tool.`;
140
+
129
141
  // ─── generate_image ────────────────────────────────────────
130
142
  server.tool(
131
143
  'generate_image',
@@ -367,10 +379,10 @@ function registerGenerateTools(server, client, options = {}) {
367
379
  // blocking poll window) needs this tool to be re-checked until done.
368
380
  server.tool(
369
381
  'get_creative_director_status',
370
- 'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block for up to ~3 minutes instead of repeatedly calling this tool. Prefer this over the generic get_generation_status for Creative Director ids — the generic tool now returns the same scene data (it delegates here), but this one is the direct route.',
382
+ 'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block until the batch is terminal or the wait window closes, whichever comes first — a batch longer than one window returns state="processing" and you simply call again with wait=true. Prefer this over the generic get_generation_status for Creative Director ids — the generic tool now returns the same scene data (it delegates here), but this one is the direct route.',
371
383
  {
372
384
  generation_id: z.string().describe('The Creative Director generation_id returned by generate_creative_director.'),
373
- wait: z.boolean().optional().describe('If true, block until the batch is terminal, up to ~3 minutes. Use this instead of repeatedly checking in a loop.')
385
+ wait: z.boolean().optional().describe(`If true, block until the batch is terminal, for at most ~${WAIT_WINDOW_S}s per call. A batch that outlives one window comes back state="processing" (not an error) — call again with wait=true until it is terminal. Always prefer this over polling with wait=false.`)
374
386
  },
375
387
  async ({ generation_id, wait }) => {
376
388
  const statusUrl = `/v1/generate/creative-director/${encodeURIComponent(generation_id)}/status`;
@@ -379,7 +391,7 @@ function registerGenerateTools(server, client, options = {}) {
379
391
  try {
380
392
  status = await pollUntilDone(client, generation_id, {
381
393
  interval: 15000,
382
- timeout: 180000,
394
+ timeout: WAIT_WINDOW_MS,
383
395
  statusUrl
384
396
  });
385
397
  } catch (err) {
@@ -409,7 +421,7 @@ function registerGenerateTools(server, client, options = {}) {
409
421
  completed_scenes: completed,
410
422
  _hint: status.state === 'completed'
411
423
  ? 'All scenes terminal. Every completed scene\'s image_urls/video_urls are final.'
412
- : 'Still running. Call get_creative_director_status once with wait=true; do not poll it in a loop.'
424
+ : `Still running — not a failure. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s, and a video batch routinely outlasts several windows, so call get_creative_director_status again with wait=true and keep going until state is terminal. Scenes that already carry image_urls/video_urls are done; never re-run generate_creative_director.`
413
425
  }, null, 2) }] };
414
426
  }
415
427
  );
@@ -817,11 +829,11 @@ function registerGenerateTools(server, client, options = {}) {
817
829
  // ─── get_generation_status ─────────────────────────────────
818
830
  server.tool(
819
831
  'get_generation_status',
820
- 'Check the status of one or more generations. Use after a generation tool returned "submitted" (widget hosts) or timed out. Tracking SEVERAL concurrent generations? Pass them ALL in generation_ids — one call returns an all_done summary. Need the final result? Set wait=true and the server blocks until every generation finishes (up to ~3 min). NEVER call this tool repeatedly in a loop one wait=true call replaces the whole loop.',
832
+ `Check the status of one or more generations. Use after a generation tool returned "submitted" (widget hosts) or timed out. Tracking SEVERAL concurrent generations? Pass them ALL in generation_ids — one call returns an all_done summary. Need the final result? Set wait=true and the server blocks until every generation finishes, for at most ~${WAIT_WINDOW_S}s per call. A job that outlives one window (music is ~3 min, video longer) comes back state="processing" that is a normal result, not an error: call again with wait=true and keep going until every id is terminal. Never poll with wait=false in a loop.`,
821
833
  {
822
834
  generation_id: z.string().optional().describe('A single generation ID to check'),
823
835
  generation_ids: z.array(z.string()).optional().describe('Multiple generation IDs to check in ONE call. Returns { all_done, pending, generations[] } — always prefer this over checking IDs one by one.'),
824
- wait: z.boolean().optional().describe('If true, block until every generation reaches a terminal state (completed/failed), up to ~3 minutes, then return the final results. Use this instead of re-calling the tool in a loop.')
836
+ wait: z.boolean().optional().describe(`If true, block until every generation reaches a terminal state (completed/failed), for at most ~${WAIT_WINDOW_S}s per call, then return whatever state they are in. Anything still processing is reported, not errored — re-issue with wait=true and only the still-pending ids. This is always better than polling with wait=false.`)
825
837
  },
826
838
  async ({ generation_id, generation_ids, wait }) => {
827
839
  const ids = (generation_ids && generation_ids.length > 0)
@@ -836,15 +848,16 @@ function registerGenerateTools(server, client, options = {}) {
836
848
  if (wait) {
837
849
  // Widgets use this long-wait path. A 15s API check cadence keeps
838
850
  // completion responsive without multiplying backend traffic for
839
- // every card left open in a host conversation.
840
- const result = await pollUntilDone(client, id, { interval: 15000, timeout: 180000 });
851
+ // every card left open in a host conversation. The window itself is
852
+ // bounded by the transport see WAIT_WINDOW_MS above.
853
+ const result = await pollUntilDone(client, id, { interval: 15000, timeout: WAIT_WINDOW_MS });
841
854
  return { generation_id: id, ...result };
842
855
  }
843
856
  const result = await client.get(`/v1/generate/${encodeURIComponent(id)}/status`);
844
857
  return { generation_id: id, ...result };
845
858
  } catch (err) {
846
859
  if (err.timedOut) {
847
- return { generation_id: id, state: 'processing', _timed_out: true, note: 'Still running after 3 min of waiting — call get_generation_status again with wait=true.' };
860
+ return { generation_id: id, state: 'processing', _timed_out: true, note: `Still running after this ~${WAIT_WINDOW_S}s wait window — call get_generation_status again with wait=true.` };
848
861
  }
849
862
  if (err.name === 'GenerationFailedError') {
850
863
  return { generation_id: id, state: 'failed', error: err.message };
@@ -858,9 +871,15 @@ function registerGenerateTools(server, client, options = {}) {
858
871
 
859
872
  const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
860
873
  const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
861
- const pendingHint = wait
862
- ? 'Some generations are still running after the wait window. Call get_generation_status ONCE more with wait=true and the remaining generation_ids — do not spin without wait.'
863
- : 'Some generations are still processing. Do NOT re-call this tool in a loop call it ONCE with wait=true (and all pending generation_ids) to block until they finish.';
874
+ // The old wait=false hint said "call it ONCE with wait=true ... to block
875
+ // until they finish". That is the advice that broke: one wait=true call
876
+ // cannot outlast a 185s music job, and a caller that obeyed it got a
877
+ // transport error instead of a result. Say what actually works.
878
+ const pendingIds = pending.map(r => r.generation_id);
879
+ const idsPhrase = pendingIds.length > 1
880
+ ? ` and ONLY the still-pending ids: ${JSON.stringify(pendingIds)}`
881
+ : '';
882
+ const pendingHint = stillRunningHint(idsPhrase);
864
883
 
865
884
  // Single-id calls keep the original flat shape — the generation widget
866
885
  // waits on this tool with { generation_id, wait:true } and reads