@kolbo/mcp 1.68.0 → 1.69.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/README.md CHANGED
@@ -113,13 +113,13 @@ Just ask your agent naturally:
113
113
 
114
114
  Without the optional skill, the config block alone already exposes every tool — you just describe what you want. With the skill installed, each of these is also routed to the right MCP tool with the right defaults — UGC mode picks 9:16 + sound-off + no-captions, marketplace mode enforces compliance (pure white bg, no text, no props), product photoshoot mode uses the right aspect for the platform (2:3 Pinterest, 16:9 hero banner, 1:1 IG feed), etc. The routing logic is shared with [Kolbo Code](https://github.com/Zoharvan12/kolbo-code), so the behavior is identical however you connect.
115
115
 
116
- ## Available Tools (117)
116
+ ## Available Tools
117
117
 
118
118
  **Generation**
119
119
  | Tool | Description |
120
120
  |------|-------------|
121
- | `generate_image` | Text → image |
122
- | `generate_image_edit` | Existing image(s) + prompt → edited image |
121
+ | `generate_image` | Text → image. Supports `preset_id` from `list_presets type="image"`. |
122
+ | `generate_image_edit` | Existing image(s) + prompt → edited image. Supports `preset_id` from `list_presets type="image_edit"`. |
123
123
  | `generate_video` | Text → video |
124
124
  | `generate_video_from_image` | Still image + motion prompt → video |
125
125
  | `generate_video_from_video` | Input video → restyled video, or burn in subtitles (video-to-video). `prompt` optional — prompt-less models (VEED Subtitles, Act Two, Wan Animate) use `preset` / `source_language` / `translation_language`, plus `srt_content` / `srt_file_url` / `vocabulary` / `customization` for VEED |
@@ -233,7 +233,7 @@ Every generation tool also accepts an optional `project_id` arg that routes the
233
233
  |------|-------------|
234
234
  | `list_models` | Current model catalog with costs and capabilities |
235
235
  | `list_voices` | TTS voices (presets + cloned) |
236
- | `list_presets` | Generation presets across image/video/music/text-to-video catalogs |
236
+ | `list_presets` | Generation presets across image/image-edit/video/music/text-to-video catalogs. Pass the selected exact id as `preset_id`; never claim a preset was applied without it. |
237
237
  | `list_cinematic_presets` | "Cinema mode" presets grouped by dimension (camera, lens, focal_length, aperture, angle, shot_type, color_palette, lighting) — pass ids via the `cinematic` arg on `generate_image` / `generate_image_edit`. Only when the user wants a specific cinematic look |
238
238
  | `list_projects` | List owned + shared projects (id, name, role, is_default) — call first to resolve a project name into the `project_id` you pass to generation tools |
239
239
  | `move_session` | Move a session (generation, chat, transcription…) and ALL its media to another project |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.68.0",
3
+ "version": "1.69.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,11 +10,13 @@
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-widget-render.js && node scripts/check-model-catalog.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-model-catalog.js && node scripts/check-skill-tools.js && node scripts/check-submission-contract.js && node scripts/check-install.js",
14
14
  "check-model-catalog": "node scripts/check-model-catalog.js",
15
15
  "check-widget-fields": "node scripts/check-widget-fields.js",
16
16
  "check-widget-render": "node scripts/check-widget-render.js",
17
17
  "check-skill-tools": "node scripts/check-skill-tools.js",
18
+ "check-submission-contract": "node scripts/check-submission-contract.js",
19
+ "generate-chatgpt-submission": "node scripts/generate-chatgpt-submission.js",
18
20
  "check-install": "node scripts/check-install.js"
19
21
  },
20
22
  "keywords": [
package/skill/SKILL.md CHANGED
@@ -93,8 +93,8 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
93
93
  ### Generation
94
94
  | Tool | Description |
95
95
  |------|-------------|
96
- | `generate_image` | Single image from a text prompt. Supports Visual DNA, moodboards, reference images, web-search grounding. |
97
- | `generate_image_edit` | Edit/transform an existing image. Pass `source_images` + edit prompt. |
96
+ | `generate_image` | Single image from a text prompt. Supports Visual DNA, moodboards, image presets, reference images, web-search grounding. When a preset is requested, resolve it with `list_presets({ type: "image" })` and pass its exact id as `preset_id`. |
97
+ | `generate_image_edit` | Edit/transform an existing image. Pass `source_images` + edit prompt. Image-editing presets are supported through `preset_id` from `list_presets({ type: "image_edit" })`. |
98
98
  | `generate_creative_director` | **2–8 related images or videos as one coherent set.** Use INSTEAD of multiple `generate_image` calls for any related multi-output. |
99
99
  | `generate_video` | Text-to-video. Does **not** support Visual DNA — use `generate_elements` for character-consistent video. |
100
100
  | `generate_video_from_image` | Animate a still. Prompt describes motion, not subject. |
@@ -113,7 +113,7 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
113
113
  | `list_models` / `list_voices` / `check_credits` / `get_generation_status` / `cancel_generation` / `get_session_usage` | Discovery + status. `list_models` with no args returns the recommended shortlist out of ~428 — pass `type` for a full category with per-model caps. `cancel_generation` stops an in-flight job and refunds what it can: use it when the user changes their mind mid-generation instead of letting it run. |
114
114
  | `upload_media` / `create_upload_ticket` / `list_media` / `get_media` / `get_media_stats` / `favorite_media` / `unfavorite_media` / `delete_media` / `restore_media` / `permanently_delete_media` / `move_media` / `bulk_*_media` / `*_media_folder` | Media library — see `workflows/media-library.md`. Getting a LOCAL file in depends on where the server runs: `upload_media` with a path only works on a local (stdio) install; over a remote connector use `create_upload_ticket` and POST the file yourself. |
115
115
  | `create_visual_dna` / `generate_character_sheet` / `list_visual_dnas` / `get_visual_dna` / `delete_visual_dna` / `*_visual_dna_folder` (5 folder tools) | Visual DNA (+ character sheet, character folders) — see `workflows/visual-dna.md` |
116
- | `list_moodboards` / `get_moodboard` / `list_presets` | Style overlays |
116
+ | `list_moodboards` / `get_moodboard` / `list_presets` | Style overlays. A preset request is binding: resolve the requested or closest matching preset in the correct catalog, then pass its exact returned `id` as `preset_id`. Never say a preset was used if the generation call omitted it. |
117
117
  | `list_color_palettes` / `analyze_color_palette` / `create_color_palette` / `update_color_palette` / `delete_color_palette` / `activate_color_palette` / `deactivate_color_palette` | **Color DNA — sticky and account-wide.** At most one palette is active at a time; while it is, it strict-grades **every** image and video generation automatically, with no per-call argument. `analyze_color_palette` pulls colors out of 1-5 image URLs for free and does NOT save. `create_color_palette` defaults `is_active: true`, which activates it and deactivates any other. Per-generation opt-out: `skip_color_palette: true` on `generate_image` / `generate_image_edit` / `generate_video` / `generate_video_from_image`. |
118
118
  | `list_agents` / `create_agent` / `update_agent` / `delete_agent` | Custom chat agents — reusable named personas for `chat_send_message`. The agent's `description` IS the system instruction. Resolve a name the user mentions ("use my SEO agent") to an id with `list_agents`, then pass `agent_id`. Global/preset agents are read-only; only the user's own can be updated or deleted. |
119
119
  | `search_stock_media` / `get_stock_sources` / `get_stock_categories` / `get_stock_collections` / `get_stock_asset` / `analyze_script_for_stock` / `import_stock_asset` | Stock library (free, no credits) — EXISTING photos / videos / 3D / SFX / music. For stock **music** use `search_stock_media` with `mediaType: "music"` (semantic vibe query, e.g. "uplifting corporate background") → `get_stock_asset` for downloads. The older `*_music_library` tools are deprecated adapters over this — prefer the stock tools, except for the licensed-catalog tools in the next row. |
@@ -158,6 +158,8 @@ A user-named tool — in any language — overrides every other rule. Recognized
158
158
 
159
159
  ## Core Workflow
160
160
 
161
+ **Preset contract:** if the user asks for a preset, names one, or says to use one of their/Kolbo presets, call `list_presets` with the matching type before generation and pass the selected exact `id` as `preset_id`. Use `image` for `generate_image` and `image_edit` for `generate_image_edit`. Never invent an id or silently continue without the requested preset.
162
+
161
163
  1. **Check credits** ONCE per conversation (Step 0). Skip if already checked.
162
164
  2. **Discover models** with `list_models` using a `type` filter — but **skip when the user names a specific model**.
163
165
  3. **Pick the model**:
package/src/apps/index.js CHANGED
@@ -54,44 +54,45 @@ function widgetHtml(uri) {
54
54
  // img/script/style/font/media-src; connectDomains to connect-src.
55
55
  const WIDGET_CSP = {
56
56
  resourceDomains: [
57
- // Exact hosts FIRST not every host CSP implementation honors wildcards,
58
- // and a declared-but-unmatched allowlist blocks harder than no declaration.
59
- 'https://api.kolbo.ai', // model icons (/assets)
57
+ // Public production hosts owned by Kolbo.
58
+ 'https://api.kolbo.ai',
60
59
  'https://app.kolbo.ai',
61
- 'https://media.kolbo.ai', // media CDN (prod)
62
- 'https://media-staging.kolbo.ai',
63
- 'https://media-dev.kolbo.ai',
60
+ 'https://media.kolbo.ai',
61
+ 'https://cdn.kolbo.ai',
64
62
  'https://kolboai-production.ams3.digitaloceanspaces.com',
65
63
  'https://kolboai-production.ams3.cdn.digitaloceanspaces.com',
66
- 'https://kolboai-development.ams3.digitaloceanspaces.com',
67
- 'https://kolboai-development.ams3.cdn.digitaloceanspaces.com',
64
+ 'https://kolbo-general-media.fra1.digitaloceanspaces.com',
68
65
  'https://kolbo-general-media.fra1.cdn.digitaloceanspaces.com',
69
- 'https://fonts.googleapis.com', // Inter / JetBrains Mono stylesheet
70
- 'https://fonts.gstatic.com', // font files
71
- 'https://images.pexels.com', // stock thumbnails
72
- 'https://images.unsplash.com', // stock thumbnails (Unsplash)
73
- 'https://plus.unsplash.com', // Unsplash+ premium images
74
- 'https://cdn.coverr.co', // Coverr video thumbnails
75
- 'https://cdn.freesound.org', // Freesound waveform previews
76
- // Wildcards as a second layer for hosts that do support them.
77
- 'https://*.kolbo.ai',
78
- 'https://*.digitaloceanspaces.com',
79
- 'https://*.cdn.digitaloceanspaces.com',
80
- 'https://*.pexels.com',
81
- 'https://*.pixabay.com',
82
- 'https://*.unsplash.com',
83
- 'https://*.coverr.co',
84
- 'https://*.freesound.org',
85
- 'https://*.sketchfab.com',
86
- 'https://*.cloudfront.net',
66
+
67
+ // Fonts used by the shared widget shell.
68
+ 'https://fonts.googleapis.com',
69
+ 'https://fonts.gstatic.com',
70
+
71
+ // Exact preview hosts returned by the production stock integrations.
72
+ 'https://images.pexels.com',
73
+ 'https://videos.pexels.com',
74
+ 'https://images.unsplash.com',
75
+ 'https://plus.unsplash.com',
76
+ 'https://pixabay.com',
77
+ 'https://cdn.pixabay.com',
78
+ 'https://coverr.co',
79
+ 'https://cdn.coverr.co',
80
+ 'https://freesound.org',
81
+ 'https://cdn.freesound.org',
82
+ 'https://sketchfab.com',
83
+ 'https://media.sketchfab.com',
84
+ 'https://cdn.sketchfab.com',
85
+ 'https://assets.sketchfab.com',
86
+ 'https://sketchfab-prod-media.s3.amazonaws.com',
87
+
88
+ // Default SYNCI catalog project. Any production override must be reviewed
89
+ // and added here as an exact hostname before deployment.
90
+ 'https://gfbpxdkripkbbrcvoyeh.supabase.co',
87
91
  ],
88
92
  // connect-src — XHR/fetch FROM widget iframes. Used by the upload widget to
89
93
  // POST files to /mcp/upload with its short-lived ticket.
90
94
  connectDomains: [
91
95
  'https://api.kolbo.ai',
92
- 'https://api-staging.kolbo.ai',
93
- 'https://api-dev.kolbo.ai',
94
- 'https://*.kolbo.ai',
95
96
  ],
96
97
  };
97
98
 
@@ -418,7 +419,6 @@ const TOOL_WIDGETS = {
418
419
  generate_3d: UI.generation,
419
420
  edit_image: UI.generation,
420
421
  edit_video: UI.generation,
421
- shorts_render: UI.generation,
422
422
  // transcript viewer
423
423
  transcribe_audio: UI.transcript,
424
424
  // model catalog
@@ -433,7 +433,6 @@ const TOOL_WIDGETS = {
433
433
  list_voices: UI.mediaGrid,
434
434
  list_visual_dnas: UI.mediaGrid,
435
435
  list_moodboards: UI.mediaGrid,
436
- shorts_analyze: UI.mediaGrid,
437
436
  // NOTE: list_color_palettes' handler has always called uiResult(UI.mediaGrid, ...)
438
437
  // (see color_palettes.js) but was missing here — hosts that prepare the widget
439
438
  // iframe from the tool DECLARATION (claude.ai reads tools/list, not the result)
@@ -463,6 +462,7 @@ function attachToolWidgetMeta(server) {
463
462
 
464
463
  module.exports = {
465
464
  UI,
465
+ WIDGET_CSP,
466
466
  TOOL_WIDGETS,
467
467
  registerApps,
468
468
  attachToolWidgetMeta,
package/src/apps/theme.js CHANGED
@@ -140,7 +140,7 @@ body {
140
140
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
141
141
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
142
142
  .k-skel.done::after { animation: none; background: none; }
143
- .k-cell-fill { width: 100%; height: 100%; object-fit: cover; display: block; }
143
+ .k-cell-fill { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
144
144
  .k-gen-badge {
145
145
  position: absolute; top: 10px; left: 10px; z-index: 2;
146
146
  display: inline-flex; align-items: center; gap: 6px;
@@ -170,7 +170,7 @@ body {
170
170
  .k-media { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid var(--border);
171
171
  background: #000; cursor: pointer; transition: transform 300ms var(--spring), box-shadow 300ms var(--smooth); }
172
172
  .k-media:hover { transform: scale(1.015); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); }
173
- .k-media img, .k-media video { display: block; width: 100%; height: 100%; object-fit: cover; }
173
+ .k-media img, .k-media video { display: block; width: 100%; height: 100%; object-fit: contain; }
174
174
  .k-media.selected { outline: 2px solid var(--brand); outline-offset: 1px; }
175
175
 
176
176
  /* ---- Per-item hover download button (multi-image grids, CD scenes, viewer) ---- */
@@ -257,7 +257,7 @@ html.k-fullscreen .k-actions { flex: none; padding-top: 8px; }
257
257
  background: var(--surface); cursor: pointer; transition: transform 250ms var(--spring); }
258
258
  .k-cell:hover { transform: translateY(-2px) scale(1.01); }
259
259
  .k-cell .k-cell-media { position: relative; aspect-ratio: 1; background: #000; }
260
- .k-cell .k-cell-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
260
+ .k-cell .k-cell-media img { width: 100%; height: 100%; object-fit: contain; display: block; }
261
261
  .k-cell-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
262
262
  .k-cell .k-cell-label { padding: 6px 8px; font-size: 11px; color: var(--text-muted);
263
263
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -112,7 +112,7 @@ function wire() {
112
112
  e.stopPropagation();
113
113
  var url = b.getAttribute('data-video-play');
114
114
  var holder = b.parentNode;
115
- holder.innerHTML = '<video src="' + esc(url) + '" controls autoplay playsinline style="width:100%;height:100%;object-fit:cover"></video>';
115
+ holder.innerHTML = '<video src="' + esc(url) + '" controls autoplay playsinline style="width:100%;height:100%;object-fit:contain;background:#000"></video>';
116
116
  holder.querySelector('video').onclick = function (ev) { ev.stopPropagation(); };
117
117
  };
118
118
  });
package/src/index.js CHANGED
@@ -76,7 +76,8 @@ const { registerReviewTools } = require('./tools/review');
76
76
  const { registerVoiceTools } = require('./tools/voices');
77
77
  const { registerMusicLibraryTools } = require('./tools/music_library');
78
78
  const { registerStockLibraryTools } = require('./tools/stock_library');
79
- const { registerApps, attachToolWidgetMeta } = require('./apps');
79
+ const { registerApps, attachToolWidgetMeta } = require('./apps');
80
+ const { attachToolAnnotations } = require('./toolAnnotations');
80
81
 
81
82
  /**
82
83
  * Build a fully-configured Kolbo MCP server (all tool groups registered)
@@ -126,7 +127,8 @@ function createServer(opts = {}) {
126
127
  '7. SESSION CONTINUITY — one task, one session, always: every generation tool returns a `session_id`. For ANY follow-up, refinement, retry, or next step on the SAME task, pass that session_id back — never start fresh. BATCH RULE (critical): when a single user request produces multiple parallel generations (e.g. "animate these 5 images", "generate 3 variants"), do NOT launch them all at once without a session_id. Instead: (1) run the FIRST generation without session_id to create the session, (2) capture the session_id from its response, (3) pass that session_id to ALL remaining generations in the batch. This keeps the entire batch in one session. Exception: only omit session_id and start fresh when the user explicitly starts an unrelated new task.',
127
128
  '8. LOCAL FILES / REFERENCE MEDIA — HOW TO HANDLE EVERY CASE. (A) User has a LOCAL file (audio, video, image, document) on their machine. What matters is WHERE THIS SERVER RUNS, not what your client can do — your own filesystem access is irrelevant if the server is somewhere else. On a LOCAL stdio install (server and client share a machine) → call `upload_media` with the absolute path, or pass the path straight to tools like `transcribe_audio` that accept local paths. Over a REMOTE connector the server cannot see that path no matter how capable you are, so a local path will always fail: if you can run shell commands or issue HTTP requests → call `create_upload_ticket` and POST the file to the returned upload_url yourself (fastest, no user interaction); if you cannot → call `media_upload_widget` IMMEDIATELY, the user uploads, and a `media.kolbo.ai` CDN URL comes back for any follow-up call. (B) You already have a public URL (media.kolbo.ai, any CDN, any direct link) → pass it directly; all Kolbo tools accept public URLs. NEVER search for DO Spaces keys, DigitalOcean credentials, or server-side upload credentials. NEVER ask the user to put the file on Google Drive, Dropbox, or Loom. NEVER invent or guess a URL. NEVER base64 anything but a tiny file — it costs context in proportion to file size; use the ticket or the widget instead.',
128
129
  '9. MODEL SELECTION — ROUTE BY THE STRENGTHS SUMMARY, NEVER BY THE BADGE OR THE PRICE TAG: ALWAYS pass a specific `model` on every generation tool — do NOT omit it (omitting falls back to "Smart Select" auto-routing, which hides the choice from the user; use it ONLY if the user explicitly asks you to auto-pick). To choose: call `list_models` with the matching `type` and read each model\'s STRENGTHS SUMMARY — the "— …" clause printed after the credit cost. That summary IS the routing instruction: match it against what the user actually asked for (subject, style, motion, length, quality bar, speed), then pick the CHEAPEST model whose summary covers the task. `[NEW]` and `[RECOMMENDED]` badges, a high credit number, and "flagship"/"most intelligent" wording are NOT selection signals — never pick a model because it is newest, biggest or most expensive. Escalate to a premium/frontier model only when the user explicitly asks for maximum quality, or when no cheaper summary covers the requirement. Models printed under "Named-only" (no summary) are opt-in: use them only when the user names them. TEXT/CHAT: `chat_send_message` bills PER TOKEN, so the listed credit number is not the cost — a frontier text model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model and reserve the frontier tier for hard reasoning or long-form code the user asked for.',
129
- '10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit` (it runs on strong dedicated editing models, same as image generation). Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.'
130
+ '10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit` (it runs on strong dedicated editing models, same as image generation). Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.',
131
+ '11. PRESET CONTRACT: if the user asks for a preset, names a preset, or says to use one of their/Kolbo presets, you MUST call `list_presets` with the matching type before generation, resolve the named or closest matching preset, and pass its exact returned `id` as `preset_id`. Use type="image" for generate_image and type="image_edit" for generate_image_edit. Never silently ignore a preset request, never invent an id, and never claim a preset was applied unless `preset_id` was present in the generation call.'
130
132
  ].join('\n')
131
133
  });
132
134
  const progress = require('./progress');
@@ -171,8 +173,12 @@ function createServer(opts = {}) {
171
173
  // transport. Without it, a remote-connector model reads "absolute local path",
172
174
  // sees no filesystem, and tells the user Kolbo cannot accept their file —
173
175
  // the single most-reported failure, despite the upload tools existing.
174
- attachFileInputHints(server, toolOptions);
175
- // Declaration-level `_meta['ui/resourceUri']` on every widget-carrying tool
176
+ attachFileInputHints(server, toolOptions);
177
+ // OpenAI public-app review requires every exposed tool to declare the three
178
+ // safety hints explicitly. The exact contract also fails closed when a tool
179
+ // is added or removed without a classification.
180
+ attachToolAnnotations(server);
181
+ // Declaration-level `_meta['ui/resourceUri']` on every widget-carrying tool —
176
182
  // claude.ai prepares the widget iframe from tools/list, not from the result.
177
183
  attachToolWidgetMeta(server);
178
184
 
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Public MCP safety contract.
5
+ *
6
+ * ChatGPT app review requires every exposed tool to declare all three hints.
7
+ * Keep this map exact: the submission gate rejects missing, duplicate, stale,
8
+ * or non-boolean entries whenever the registered tool surface changes.
9
+ */
10
+
11
+ const READ_ONLY = [
12
+ 'get_creative_director_status', 'get_generation_status', 'list_models',
13
+ 'check_credits', 'get_session_usage', 'list_voices',
14
+ 'chat_list_conversations', 'chat_get_messages',
15
+ 'list_visual_dnas', 'get_visual_dna', 'list_visual_dna_folders',
16
+ 'list_moodboards', 'get_moodboard',
17
+ 'list_color_palettes', 'analyze_color_palette',
18
+ 'list_media', 'list_media_folders', 'get_media', 'get_media_stats',
19
+ 'list_presets', 'list_cinematic_presets',
20
+ 'list_projects', 'list_sessions', 'list_project_context', 'get_project_profile',
21
+ 'list_session_generations',
22
+ 'list_agents', 'list_docs', 'get_doc',
23
+ 'get_review_storage_usage', 'list_review_assets', 'get_review_asset',
24
+ 'list_review_collections', 'list_review_comments', 'list_review_share_links',
25
+ 'search_music_library', 'analyze_script_for_music', 'browse_music_library',
26
+ 'get_music_library_facets', 'get_music_track_audio',
27
+ 'get_music_track_related', 'get_music_track_lyrics',
28
+ 'search_stock_media', 'get_stock_sources', 'get_stock_categories',
29
+ 'get_stock_collections', 'get_stock_asset', 'analyze_script_for_stock',
30
+ ];
31
+
32
+ const PRIVATE_WRITE = [
33
+ 'media_upload_widget', 'create_upload_ticket', 'upload_media',
34
+ 'favorite_media', 'unfavorite_media',
35
+ 'create_media_folder', 'update_media_folder',
36
+ 'add_media_to_folder', 'remove_media_from_folder',
37
+ 'share_media_folder',
38
+ 'restore_media', 'move_media', 'bulk_restore_media', 'bulk_move_media',
39
+ 'move_folder_contents',
40
+ 'import_elevenlabs_voice',
41
+ 'create_visual_dna', 'create_visual_dna_folder', 'update_visual_dna_folder',
42
+ 'move_visual_dna_to_folder',
43
+ 'create_moodboard',
44
+ 'create_color_palette', 'activate_color_palette', 'deactivate_color_palette',
45
+ 'move_session', 'bulk_move_sessions', 'move_generations_to_session',
46
+ 'split_session', 'undo_session_organization',
47
+ 'create_project', 'update_project',
48
+ 'archive_project', 'unarchive_project', 'add_project_context',
49
+ 'create_agent',
50
+ 'create_doc',
51
+ 'create_review_asset', 'update_review_asset', 'add_review_version',
52
+ 'set_review_status', 'create_review_collection', 'update_review_collection',
53
+ 'create_review_comment', 'reply_review_comment',
54
+ 'resolve_review_comment', 'unresolve_review_comment',
55
+ 'import_stock_asset',
56
+ ];
57
+
58
+ const DESTRUCTIVE_WRITE = [
59
+ // These actions spend credits, enqueue irreversible work, or cancel it.
60
+ 'generate_image', 'generate_image_edit', 'generate_creative_director',
61
+ 'generate_video', 'generate_video_from_image', 'generate_music',
62
+ 'generate_speech', 'generate_sound', 'cancel_generation',
63
+ 'generate_elements', 'generate_first_last_frame', 'generate_lipsync',
64
+ 'generate_video_from_video', 'transcribe_audio', 'generate_3d',
65
+ 'edit_image', 'edit_video', 'trim_video', 'clone_voice',
66
+ 'chat_send_message', 'generate_character_sheet',
67
+ 'acquire_clean_music_track', 'import_music_track_to_library',
68
+
69
+ // Deletes and whole-value replacement updates are conservatively destructive.
70
+ 'delete_voice', 'delete_visual_dna', 'delete_visual_dna_folder',
71
+ 'update_moodboard', 'delete_moodboard',
72
+ 'update_color_palette', 'delete_color_palette',
73
+ 'delete_media_folder', 'delete_media', 'permanently_delete_media',
74
+ 'bulk_delete_media', 'bulk_permanently_delete_media',
75
+ 'unshare_media_folder',
76
+ 'delete_project_context', 'regenerate_project_profile',
77
+ 'update_agent', 'delete_agent', 'update_doc', 'delete_doc',
78
+ 'delete_review_asset', 'delete_review_collection',
79
+ 'edit_review_comment', 'delete_review_comment',
80
+ ];
81
+
82
+ const OPEN_WORLD_WRITE = [
83
+ 'publish_html_artifact', 'create_review_share_link',
84
+ ];
85
+
86
+ const OPEN_WORLD_DESTRUCTIVE = [
87
+ 'share_doc', 'revoke_review_share_link',
88
+ ];
89
+
90
+ const CONTRACT_GROUPS = [
91
+ [READ_ONLY, { readOnlyHint: true, openWorldHint: false, destructiveHint: false }],
92
+ [PRIVATE_WRITE, { readOnlyHint: false, openWorldHint: false, destructiveHint: false }],
93
+ [DESTRUCTIVE_WRITE, { readOnlyHint: false, openWorldHint: false, destructiveHint: true }],
94
+ [OPEN_WORLD_WRITE, { readOnlyHint: false, openWorldHint: true, destructiveHint: false }],
95
+ [OPEN_WORLD_DESTRUCTIVE, { readOnlyHint: false, openWorldHint: true, destructiveHint: true }],
96
+ ];
97
+
98
+ function buildToolAnnotations() {
99
+ const annotations = Object.create(null);
100
+ for (const [names, hints] of CONTRACT_GROUPS) {
101
+ for (const name of names) {
102
+ if (annotations[name]) throw new Error(`Duplicate safety annotation contract for tool: ${name}`);
103
+ annotations[name] = Object.freeze({ ...hints });
104
+ }
105
+ }
106
+ return Object.freeze(annotations);
107
+ }
108
+
109
+ const TOOL_ANNOTATIONS = buildToolAnnotations();
110
+
111
+ function attachToolAnnotations(server) {
112
+ const registered = server?._registeredTools || {};
113
+ const registeredNames = Object.keys(registered).sort();
114
+ const contractNames = Object.keys(TOOL_ANNOTATIONS).sort();
115
+ const missing = registeredNames.filter((name) => !TOOL_ANNOTATIONS[name]);
116
+ const stale = contractNames.filter((name) => !registered[name]);
117
+ if (missing.length || stale.length) {
118
+ throw new Error(
119
+ `Tool annotation contract mismatch. Missing: ${missing.join(', ') || 'none'}. `
120
+ + `Stale: ${stale.join(', ') || 'none'}.`
121
+ );
122
+ }
123
+ for (const name of registeredNames) registered[name].annotations = TOOL_ANNOTATIONS[name];
124
+ }
125
+
126
+ module.exports = {
127
+ TOOL_ANNOTATIONS,
128
+ CONTRACT_GROUPS,
129
+ attachToolAnnotations,
130
+ };
@@ -141,7 +141,7 @@ function registerGenerateTools(server, client, options = {}) {
141
141
  // ─── generate_image ────────────────────────────────────────
142
142
  server.tool(
143
143
  'generate_image',
144
- 'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), reference images (for composition guidance), batch generation (num_images for variations of ONE prompt, `prompts` for SEVERAL different prompts in one combined widget), and web-search grounding. When the user wants multiple distinct images, pass all their prompts in `prompts` in ONE call — never a series of separate generate_image calls. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set planned by AI from a single brief (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
144
+ 'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), Kolbo image presets, reference images (for composition guidance), batch generation (num_images for variations of ONE prompt, `prompts` for SEVERAL different prompts in one combined widget), and web-search grounding. PRESET CONTRACT: when the user asks to use a preset, a named preset, or "one of my/Kolbo image presets", call list_presets type="image" first, resolve the requested or best-matching preset, and pass its exact id as `preset_id`; never silently generate without it. When the user wants multiple distinct images, pass all their prompts in `prompts` in ONE call — never a series of separate generate_image calls. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set planned by AI from a single brief (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
145
145
  {
146
146
  prompt: z.string().optional().describe('Text description of the image to generate. Required unless `prompts` is provided.'),
147
147
  prompts: promptsField('images'),
@@ -155,7 +155,7 @@ function registerGenerateTools(server, client, options = {}) {
155
155
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding for the prompt (useful for current events, brand references, real-world accuracy). Default: false'),
156
156
  resolution: z.string().optional().describe('Image resolution tier: "1K" (~1024px), "2K" (Full HD), "3K" (QHD), or "4K" (UHD). Model-dependent — call list_models and read supported_resolutions on the chosen model. Read resolution_multipliers on the same model to predict credit cost. Omit to use the model default.'),
157
157
  quality: z.string().optional().describe('Quality tier for models that support it (e.g. "low", "medium", "high", "auto"). Check list_models → supported_qualities on the chosen model. "auto" is normalised to "medium" on gpt-image-2. Omit to use the model default.'),
158
- preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.'),
158
+ preset_id: z.string().optional().describe('Exact preset ID from list_presets type="image". If the user requests any image preset, resolve it with list_presets and pass it here; do not omit it.'),
159
159
  cinematic: CINEMATIC_SCHEMA,
160
160
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one image ungraded.'),
161
161
  project_id: projectIdField,
@@ -218,7 +218,7 @@ function registerGenerateTools(server, client, options = {}) {
218
218
  // ─── generate_image_edit ──────────────────────────────────
219
219
  server.tool(
220
220
  'generate_image_edit',
221
- 'THE tool for ANY prompt-driven / content edit of an existing image — changing the scene ("make it night", "change the sky to sunset"), adding/removing/replacing objects, restyling, recoloring, compositing, or any "edit this image to…" request. This is the image-editing equivalent of generate_image and runs on strong dedicated editing models (nano-banana-2, gpt-image-2). Provide the source image URL(s) in `source_images` and the instruction in `prompt`. Supports Visual DNA profiles and moodboards for style-consistent edits. Do NOT use `edit_image` for these — that tool is only for mechanical enhancements (upscale/reframe/remove-background/skin). For a brand-new image from scratch, use generate_image. Returns the edited image URL(s) when complete.',
221
+ 'THE tool for ANY prompt-driven / content edit of an existing image — changing the scene ("make it night", "change the sky to sunset"), adding/removing/replacing objects, restyling, recoloring, compositing, or any "edit this image to…" request. This is the image-editing equivalent of generate_image and runs on strong dedicated editing models (nano-banana-2, gpt-image-2). Provide the source image URL(s) in `source_images` and the instruction in `prompt`. Supports Visual DNA profiles, moodboards, and Kolbo image-editing presets. PRESET CONTRACT: if the user requests a preset, call list_presets type="image_edit" and pass its exact id as `preset_id`; never silently omit it. Do NOT use `edit_image` for these — that tool is only for mechanical enhancements (upscale/reframe/remove-background/skin). For a brand-new image from scratch, use generate_image. Returns the edited image URL(s) when complete.',
222
222
  {
223
223
  prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
224
224
  model: z.string().optional().describe('Model identifier — REQUIRED in practice: pick a specific model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). Many text-to-image ids double as editors: the server auto-routes a base id to its editing variant when source_images is present (e.g. "gpt-image-2" → gpt-image-2/edit, "nano-banana-2" → nano-banana-2/edit) — passing the bare id is fine, no need to hunt for the "/edit" suffix yourself. BUT this only works for models that actually have a registered edit variant (most flagship models do: gpt-image, nano-banana, flux-2, seedream, qwen, wan, grok-imagine, kling-image families). Models with none (Midjourney, Flux Pro/Ultra, Imagen4, Ideogram, Recraft, Higgsfield Soul, Krea, Dreamina, and others) silently ignore source_images if passed here instead of erroring — if unsure, confirm the model appears in `list_models type="image_editing"` before trusting a bare id, or just use a known-safe default: "nano-banana-pro/edit" (best general prompt editor), "gpt-image-2" (photoreal, strong text), or "flux-2/edit".'),
@@ -230,22 +230,23 @@ function registerGenerateTools(server, client, options = {}) {
230
230
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
231
231
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
232
232
  resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.'),
233
+ preset_id: z.string().optional().describe('Exact preset ID from list_presets type="image_edit" to apply an image-editing preset. If the user requests a preset, resolve and pass it; do not silently omit it.'),
233
234
  cinematic: CINEMATIC_SCHEMA,
234
235
  skip_color_palette: z.boolean().optional().describe('Opt this single call OUT of the account\'s active Color DNA palette (see list_color_palettes / activate_color_palette). By default, if the user has an active palette it strict-grades every generation automatically — pass true only when the user explicitly wants this one edit ungraded.'),
235
236
  project_id: projectIdField,
236
237
  session_id: sessionIdField
237
238
  },
238
- async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id }) => {
239
+ async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id }) => {
239
240
  model = await canonicalModelId(client, model, 'image_editing'); // lenient id resolution ("z-image" → "z-image/turbo")
240
241
  const gen = await client.post('/v1/generate/image-edit', {
241
242
  prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
242
- visual_dna_ids, moodboard_id, enable_web_search, resolution, cinematic, skip_color_palette, project_id, session_id
243
+ visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id, cinematic, skip_color_palette, project_id, session_id
243
244
  });
244
245
 
245
246
  if (ui()) return uiGenerating({
246
247
  tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
247
248
  count: num_images,
248
- settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, cinematic }),
249
+ settings: imageSettings({ resolution, aspect_ratio, enhance_prompt, enable_web_search, visual_dna_ids, moodboard_id, preset_id, cinematic }),
249
250
  reference_image: source_images?.[0]
250
251
  });
251
252
 
@@ -12,9 +12,9 @@ function registerPresetTools(server, client, options = {}) {
12
12
  // ─── list_presets ──────────────────────────────────────────
13
13
  server.tool(
14
14
  'list_presets',
15
- 'List generation presets across image, video, music, and text-to-video catalogs. Presets bundle a specific prompt template + style direction that can be passed to a generation tool via its `preset_id` arg for a one-shot creative direction. Filter by `type` to narrow to a specific catalog. Returns id, name, description, thumbnail, category, and (for music) audio preview URL.',
15
+ 'List generation presets across image, image-editing, video, music, and text-to-video catalogs. Use this BEFORE generating whenever the user requests a preset, names a preset, or asks to use one of their/Kolbo presets. Resolve the requested name or choose the closest match from the correct `type`, then pass the returned exact `id` as `preset_id` on the generation tool. Never claim a preset was used unless that id is passed. Returns id, name, description, thumbnail, category, and (for music) audio preview URL.',
16
16
  {
17
- type: z.string().optional().describe('Filter by catalog: "image" | "video" | "music" | "text_to_video". Omit for all.')
17
+ type: z.string().optional().describe('Filter by catalog: "image" | "image_edit" | "video" | "music" | "text_to_video". Omit for all.')
18
18
  },
19
19
  async ({ type }) => {
20
20
  const params = new URLSearchParams();
@@ -30,7 +30,7 @@ function registerPresetTools(server, client, options = {}) {
30
30
  cap: 60,
31
31
  total: result.count || presets.length,
32
32
  extra: result.warning ? { warning: result.warning } : undefined,
33
- note: 'Filter with `type` (image | video | music | text_to_video) to see a focused set.',
33
+ note: 'Filter with `type` (image | image_edit | video | music | text_to_video) to see a focused set. Pass the chosen exact id as `preset_id` on the next generation call.',
34
34
  });
35
35
 
36
36
  if (ui()) {
@@ -64,7 +64,7 @@ function registerProjectTools(server, client, options = {}) {
64
64
  // ─── move_session ──────────────────────────────────────────
65
65
  server.tool(
66
66
  'move_session',
67
- 'Move a session — and ALL of its media library items — to another project. Works for any session type: generation sessions (the `session_id` returned by generate_* tools), chat conversations, transcription sessions, etc. Use this when work landed in the wrong project (e.g. the default "API Generations" bucket) and the user wants it in a named project — moving is always better than regenerating. Caller must own the session and have edit/full/owner permission on the target project. Resolve the target project id with `list_projects` first.',
67
+ 'Move ONE session — and ALL of its generations and media library items — to another project. Works for any session type: generation sessions (the `session_id` returned by generate_* tools), chat conversations, transcription sessions, etc. Use this when work landed in the wrong project (e.g. the default "API Generations" bucket) and the user wants it in a named project — moving is always better than regenerating. For SEVERAL sessions use `bulk_move_sessions` instead: one call, up to 100 sessions, and it reports per-session failures. Caller needs edit/full/owner permission on BOTH the source and target projects (a shared-project member can move a teammate\'s session). Resolve the target project id with `list_projects` first.',
68
68
  {
69
69
  session_id: z.string().describe('The session ObjectId to move (from a generation submit response, chat_list_conversations, or an "Open in Kolbo" link).'),
70
70
  project_id: z.string().describe('Target project ObjectId. Call `list_projects` to resolve a project name to its id.'),
@@ -85,6 +85,141 @@ function registerProjectTools(server, client, options = {}) {
85
85
  };
86
86
  }
87
87
  );
88
+
89
+ // ─── bulk_move_sessions ────────────────────────────────────
90
+ server.tool(
91
+ 'bulk_move_sessions',
92
+ 'Move MANY sessions into one project in a single call — the tool to use when reorganizing a user\'s library ("file all my Acme work into the Acme project", "clean up the API Generations bucket"). Each session carries ALL of its generations and media with it. Prefer this over looping `move_session`: one call handles up to 100 sessions, while `move_session` is rate limited per call. Sessions of mixed types (chat + image + video) can go in the SAME call. Each session moves independently, so one that cannot move — a generation still running, a Creative Director session, or one you lack edit access to — does NOT block the rest; check `failed[]` in the result and report those to the user. Resolve session ids with `list_sessions` and the target project id with `list_projects` first.',
93
+ {
94
+ session_ids: z.array(z.string()).describe('Session ObjectIds to move (from `list_sessions`, a generation submit response, or an "Open in Kolbo" link). Up to 100 per call; types may be mixed.'),
95
+ project_id: z.string().describe('Target project ObjectId. Call `list_projects` to resolve a project name to its id.'),
96
+ type: z.string().optional().describe('Optional session type hint that speeds up the lookup when EVERY id in the batch is the same type: image, video, video_from_image, music, speech, sound, image_edit, chat, elements, first_last_frame, lipsync. Omit for mixed batches — the server probes all types.')
97
+ },
98
+ async ({ session_ids, project_id, type }) => {
99
+ const body = { session_ids, project_id };
100
+ if (type) body.type = type;
101
+ const result = await client.post('/v1/sessions/move', body);
102
+ return {
103
+ content: [{
104
+ type: 'text',
105
+ text: JSON.stringify({
106
+ project_id: result.project_id,
107
+ moved_sessions_count: result.moved_sessions_count,
108
+ moved_generations_count: result.moved_generations_count,
109
+ moved_media_count: result.moved_media_count,
110
+ skipped: result.skipped,
111
+ failed: result.failed,
112
+ operation_ids: result.operation_ids,
113
+ _hint: (result.failed && result.failed.length)
114
+ ? 'Some sessions did not move — tell the user which ones and why (see failed[]). The rest are already in the new project.'
115
+ : 'Every session, its generations and its media now live in the new project. Pass any operation_id to `undo_session_organization` within 15 minutes to reverse one.'
116
+ }, null, 2)
117
+ }]
118
+ };
119
+ }
120
+ );
121
+
122
+ // ─── list_session_generations ──────────────────────────────
123
+ server.tool(
124
+ 'list_session_generations',
125
+ 'List a session\'s generations as complete GROUPS — each entry is one generation with its prompt and ALL the outputs it produced. Call this FIRST whenever the user wants to reorganize WITHIN or BETWEEN sessions ("move these three shots into their own session", "split the good takes out"), because `move_generations_to_session` and `split_session` take the ids this returns. Also the cheapest way to see what is actually inside a session before moving it. A generation is never separable from its own outputs, so you always move whole entries. Only image, image-to-video, lipsync and video-to-video sessions support this level of organization; other types return SESSION_TYPE_NOT_MOVABLE and should be moved whole with `move_session`.',
126
+ {
127
+ session_id: z.string().describe('The session ObjectId to inspect.'),
128
+ type: z.string().optional().describe('Optional session type hint to speed up the lookup. Omit if unsure.')
129
+ },
130
+ async ({ session_id, type }) => {
131
+ const path = `/v1/sessions/${encodeURIComponent(session_id)}/generations`;
132
+ const result = await client.get(path + (type ? `?type=${encodeURIComponent(type)}` : ''));
133
+ return {
134
+ content: [{
135
+ type: 'text',
136
+ text: JSON.stringify({
137
+ session: result.session,
138
+ generations: result.generations,
139
+ _hint: 'Pass the `id` values to `move_generations_to_session` (into an existing session) or `split_session` (into a new one). `in_flight: true` means it is still running and cannot be moved yet.'
140
+ }, null, 2)
141
+ }]
142
+ };
143
+ }
144
+ );
145
+
146
+ // ─── move_generations_to_session ───────────────────────────
147
+ server.tool(
148
+ 'move_generations_to_session',
149
+ 'Move SELECTED generations out of one session and into another EXISTING session — the way to merge scattered work ("put these shots into my Hero Sequence session", "these three belong with the earlier batch"). Only the chosen generations and THEIR OWN output media move; shared uploads and reference images stay with the source session, so nothing another generation still depends on is dragged away. The destination must be a session of the SAME kind, and may live in a different project as long as you can edit both. Get the generation ids from `list_session_generations` and the destination id from `list_sessions`. Running generations cannot be moved — wait for them to finish.',
150
+ {
151
+ session_id: z.string().describe('Source session ObjectId — the session the generations are in now.'),
152
+ generation_ids: z.array(z.string()).describe('Generation ids to move, from `list_session_generations`. Whole entries only.'),
153
+ target_session_id: z.string().describe('Destination session ObjectId. Must be the same session kind as the source.'),
154
+ type: z.string().optional().describe('Optional session type hint to speed up the lookup. Omit if unsure.')
155
+ },
156
+ async ({ session_id, generation_ids, target_session_id, type }) => {
157
+ const body = { generation_ids, target_session_id };
158
+ if (type) body.type = type;
159
+ const result = await client.post(
160
+ `/v1/sessions/${encodeURIComponent(session_id)}/generations/move`, body
161
+ );
162
+ return {
163
+ content: [{
164
+ type: 'text',
165
+ text: JSON.stringify({
166
+ moved_generations_count: result.moved_generations_count,
167
+ moved_media_count: result.moved_media_count,
168
+ target_session_id: result.target_session_id,
169
+ project_id: result.project_id,
170
+ operation_id: result.operation_id,
171
+ _hint: 'Reversible for 15 minutes — pass operation_id to `undo_session_organization`.'
172
+ }, null, 2)
173
+ }]
174
+ };
175
+ }
176
+ );
177
+
178
+ // ─── split_session ─────────────────────────────────────────
179
+ server.tool(
180
+ 'split_session',
181
+ 'Carve selected generations out of a session into a BRAND NEW named session, atomically. Use when one session has grown into several distinct pieces of work ("separate the product shots from the lifestyle ones", "give the approved takes their own session"). Creates the new session and moves the chosen generations plus their output media into it in one transaction — nothing half-lands. The new session goes in the same project unless you pass `project_id`. Get the generation ids from `list_session_generations` first.',
182
+ {
183
+ session_id: z.string().describe('Source session ObjectId to split.'),
184
+ generation_ids: z.array(z.string()).describe('Generation ids to move into the new session, from `list_session_generations`.'),
185
+ name: z.string().describe('Name for the new session — make it descriptive, the user sees it in the sidebar.'),
186
+ project_id: z.string().optional().describe('Put the new session in a DIFFERENT project. Omit to keep it in the source session\'s project. You need edit access on both.'),
187
+ type: z.string().optional().describe('Optional session type hint to speed up the lookup. Omit if unsure.')
188
+ },
189
+ async ({ session_id, generation_ids, name, project_id, type }) => {
190
+ const body = { generation_ids, name };
191
+ if (project_id) body.project_id = project_id;
192
+ if (type) body.type = type;
193
+ const result = await client.post(`/v1/sessions/${encodeURIComponent(session_id)}/split`, body);
194
+ return {
195
+ content: [{
196
+ type: 'text',
197
+ text: JSON.stringify({
198
+ session: result.session,
199
+ moved_generations_count: result.moved_generations_count,
200
+ moved_media_count: result.moved_media_count,
201
+ operation_id: result.operation_id,
202
+ _hint: 'Reversible for 15 minutes — `undo_session_organization` removes the new session and returns its generations.'
203
+ }, null, 2)
204
+ }]
205
+ };
206
+ }
207
+ );
208
+
209
+ // ─── undo_session_organization ─────────────────────────────
210
+ server.tool(
211
+ 'undo_session_organization',
212
+ 'Reverse a session move, generation move, or split within 15 minutes of making it. Use immediately when the user says the reorganization was wrong ("no, put that back", "undo that move"). Takes the `operation_id` returned by `move_session`, `bulk_move_sessions`, `move_generations_to_session` or `split_session` — a batch move returns one id PER session, so call this once per id you want to reverse. Refuses safely if the work has moved again since, rather than yanking records out of wherever they now live.',
213
+ {
214
+ operation_id: z.string().describe('The operation_id from the move/split result you want to reverse.')
215
+ },
216
+ async ({ operation_id }) => {
217
+ const result = await client.post(
218
+ `/v1/sessions/organize/undo/${encodeURIComponent(operation_id)}`, {}
219
+ );
220
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
221
+ }
222
+ );
88
223
  // ─── create_project ────────────────────────────────────────
89
224
  server.tool(
90
225
  'create_project',
@@ -297,7 +297,7 @@ function registerReviewTools(server, client) {
297
297
  canSwitchVersions: z.boolean().optional(),
298
298
  canSetStatus: z.boolean().optional(),
299
299
  }).optional(),
300
- password: z.string().optional(),
300
+ password: z.string().optional().describe('Optional NEW password to set on this guest review link. This is not the user\'s Kolbo password and must never contain an account credential.'),
301
301
  allowed_emails: z.array(z.string()).optional(),
302
302
  expires_at: z.string().optional().describe('ISO8601 expiry datetime.'),
303
303
  },