@kolbo/mcp 1.68.0 → 1.69.0-rc.1

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.68.0",
3
+ "version": "1.69.0-rc.1",
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/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/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)
@@ -171,8 +172,12 @@ function createServer(opts = {}) {
171
172
  // transport. Without it, a remote-connector model reads "absolute local path",
172
173
  // sees no filesystem, and tells the user Kolbo cannot accept their file —
173
174
  // 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
175
+ attachFileInputHints(server, toolOptions);
176
+ // OpenAI public-app review requires every exposed tool to declare the three
177
+ // safety hints explicitly. The exact contract also fails closed when a tool
178
+ // is added or removed without a classification.
179
+ attachToolAnnotations(server);
180
+ // Declaration-level `_meta['ui/resourceUri']` on every widget-carrying tool —
176
181
  // claude.ai prepares the widget iframe from tools/list, not from the result.
177
182
  attachToolWidgetMeta(server);
178
183
 
@@ -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
+ };
@@ -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
  },