@kolbo/mcp 1.86.3 → 1.86.4
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 +1 -1
- package/src/apps/widgets/upload.js +456 -457
- package/src/tools/media.js +656 -656
package/src/tools/media.js
CHANGED
|
@@ -1,656 +1,656 @@
|
|
|
1
|
-
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
-
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
-
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
-
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
-
|
|
6
|
-
const { z } = require('zod');
|
|
7
|
-
const FormData = require('form-data');
|
|
8
|
-
const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
|
|
9
|
-
const { ownedUrl } = require('./owned-url');
|
|
10
|
-
const { UI, uiResult, listResult } = require('../apps');
|
|
11
|
-
|
|
12
|
-
// How many tiles the media grid renders. A rendering limit only — the text
|
|
13
|
-
// payload always carries the full page, and `total` reports the real library
|
|
14
|
-
// count, so a capped grid can never be mistaken for "that's everything".
|
|
15
|
-
const GRID_CAP = 24;
|
|
16
|
-
|
|
17
|
-
async function mintUploadTicket(client) {
|
|
18
|
-
const ticket = await client.post('/v1/media/upload-ticket', {});
|
|
19
|
-
if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
|
|
20
|
-
return ticket;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Shape a /v1/media/upload-ticket response for a TEXT consumer (an agent that
|
|
24
|
-
// will POST the file itself). The widget path needs different keys (`expires_at`
|
|
25
|
-
// as an absolute ms timestamp for the countdown), so it builds its own payload.
|
|
26
|
-
//
|
|
27
|
-
// This carries the full recipe, not a pointer to it: both callers are agents
|
|
28
|
-
// holding a token they must use immediately, and telling one of them to go call
|
|
29
|
-
// another tool to learn the POST shape would spend the round trip this whole
|
|
30
|
-
// path exists to remove.
|
|
31
|
-
// The ticket is reusable for a whole batch, but the endpoint is rate limited —
|
|
32
|
-
// and a batch uploader that only learns that from the 41st POST has already
|
|
33
|
-
// stalled halfway through. kolbo-api sends the real numbers in `rate_limit`;
|
|
34
|
-
// this fallback covers an older deployment that predates that field.
|
|
35
|
-
const DEFAULT_UPLOAD_RATE = { max_uploads: 40, per_seconds: 60 };
|
|
36
|
-
|
|
37
|
-
function uploadTicketPayload(ticket) {
|
|
38
|
-
const rate = ticket.rate_limit || DEFAULT_UPLOAD_RATE;
|
|
39
|
-
return {
|
|
40
|
-
upload_url: ticket.upload_url,
|
|
41
|
-
token: ticket.token,
|
|
42
|
-
expires_in_seconds: ticket.expires_in,
|
|
43
|
-
max_file_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
44
|
-
accepted: ticket.accepted,
|
|
45
|
-
rate_limit: rate,
|
|
46
|
-
how_to_upload: {
|
|
47
|
-
example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3;type=audio/mpeg"',
|
|
48
|
-
// curl types the part from ITS mime table and falls back to
|
|
49
|
-
// application/octet-stream for anything missing from it (.mp3 included).
|
|
50
|
-
// Newer servers resolve that from the extension, older ones answer
|
|
51
|
-
// "File type not supported: application/octet-stream" — so the example
|
|
52
|
-
// above declares the type and this says why, rather than leaving the
|
|
53
|
-
// caller to rediscover it from a 415.
|
|
54
|
-
mime_note: 'Append `;type=<mime>` to the file part (audio/mpeg, audio/wav, video/mp4, image/png, application/pdf …). Without it curl declares application/octet-stream and the upload can be rejected as an unsupported type.',
|
|
55
|
-
optional_fields: ['project_id', 'description'],
|
|
56
|
-
response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
|
|
57
|
-
pacing: `RATE LIMIT: ${rate.max_uploads} uploads per ${rate.per_seconds}s. For a batch larger than that, pace it (e.g. sleep ${Math.max(1, Math.ceil(rate.per_seconds / rate.max_uploads))}s between files) instead of firing them back to back. Over the limit you get HTTP 429 with a Retry-After header and retry_after_seconds in the body — wait that long, then continue; do not guess a backoff and do not treat it as a failed upload.`,
|
|
58
|
-
// Git Bash hands curl a POSIX-style /c/Users/... path that Windows curl
|
|
59
|
-
// cannot open (exit 26). Real trap — it cost a round trip to find.
|
|
60
|
-
windows_note: 'Give curl a native path (C:/Users/...) — a Git Bash /c/Users/... path fails to open.',
|
|
61
|
-
},
|
|
62
|
-
// The one thing the server cannot detect: a caller with no shell that got
|
|
63
|
-
// here anyway. Without this it is left holding a token it can never use.
|
|
64
|
-
if_you_cannot_run_shell_or_http: 'Discard this ticket and call `media_upload_widget` instead so the user can pick the file.',
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function registerMediaTools(server, client, options = {}) {
|
|
69
|
-
// `opts.apps` is set only by kolbo-api's per-request server (see createServer
|
|
70
|
-
// in ../index.js), which makes it a TRANSPORT signal — deliberately not
|
|
71
|
-
// `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
|
|
72
|
-
// Transport is what decides whether a local path can resolve at all, so state
|
|
73
|
-
// it in the descriptions rather than making the model infer it. What the
|
|
74
|
-
// server still cannot know is whether the CALLER has a shell (one connector
|
|
75
|
-
// serves both claude.ai and Claude Code), hence the fallback hint in the
|
|
76
|
-
// ticket payload.
|
|
77
|
-
const isRemoteConnector = options.remote === true || options.apps === true;
|
|
78
|
-
|
|
79
|
-
const ticketRouting = isRemoteConnector
|
|
80
|
-
? 'You are reached over a REMOTE connector: this server cannot read the caller\'s disk, so `upload_media` with a local path will always fail here — do not try it. If you can run shell commands or issue HTTP requests yourself, this tool is the right path. If you cannot (claude.ai web/mobile), ignore this tool and call `media_upload_widget` so the user picks the file.'
|
|
81
|
-
: 'You are a LOCAL (stdio) install: server and client share a filesystem, so for an ordinary local file prefer `upload_media` with the absolute path — one call, no ticket needed. Use this tool only when you specifically want to stream files up yourself (large batches, CI, an external uploader).';
|
|
82
|
-
|
|
83
|
-
// ─── media_upload_widget ───────────────────────────────────
|
|
84
|
-
server.tool(
|
|
85
|
-
'media_upload_widget',
|
|
86
|
-
'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). ROUTING depends on where the SERVER runs, not on which client you are: `upload_media` with a local path only works on a LOCAL (stdio) install, where server and client share a filesystem. Over a remote connector a local path is unreachable however capable the client is — there, if you can run shell commands, call `create_upload_ticket` and POST the file yourself (no user interaction needed); use this widget when you cannot reach the filesystem (claude.ai web/mobile) or when the user should choose the file.',
|
|
87
|
-
{
|
|
88
|
-
purpose: z.string().optional().describe('Short title shown on the card, e.g. "Upload the photo to animate". Helps the user know what to drop.'),
|
|
89
|
-
media_types: z.array(z.enum(['image', 'video', 'audio', 'document'])).optional().describe('Restrict which file kinds the widget accepts. Omit to accept all types.'),
|
|
90
|
-
max_files: z.number().optional().describe('Maximum number of files the user may upload (default
|
|
91
|
-
project_id: z.string().optional().describe('Project ObjectId to file the uploads into (resolve names via `list_projects`).')
|
|
92
|
-
},
|
|
93
|
-
async ({ purpose, media_types, max_files, project_id }) => {
|
|
94
|
-
const ticket = await mintUploadTicket(client);
|
|
95
|
-
|
|
96
|
-
const info = {
|
|
97
|
-
status: 'upload_widget_opened',
|
|
98
|
-
instructions: 'An upload card is now shown to the user. WAIT for them to upload — the uploaded file URLs will arrive in a follow-up message (or in the model context). Do not guess URLs.',
|
|
99
|
-
accepted: ticket.accepted,
|
|
100
|
-
expires_in_seconds: ticket.expires_in,
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
104
|
-
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
105
|
-
// rebuilt items from the compactList text, whose field names are
|
|
106
|
-
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
107
|
-
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
108
|
-
// upload_ui_url: top-level page for Claude iOS/Android — in-iframe
|
|
109
|
-
// <input type=file> selections are dropped by WebKit (see upload widget).
|
|
110
|
-
const uploadUiUrl = ticket.upload_ui_url
|
|
111
|
-
|| String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
|
|
112
|
-
return uiResult(UI.upload, JSON.stringify(info, null, 2), {
|
|
113
|
-
widget: 'upload',
|
|
114
|
-
title: purpose || 'Upload media',
|
|
115
|
-
upload_url: ticket.upload_url,
|
|
116
|
-
upload_ui_url: uploadUiUrl,
|
|
117
|
-
token: ticket.token,
|
|
118
|
-
expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
|
|
119
|
-
kinds: media_types && media_types.length ? media_types : undefined,
|
|
120
|
-
max_files: Math.min(Math.max(Number(max_files) ||
|
|
121
|
-
max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
122
|
-
...(project_id ? { project_id } : {}),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
|
|
126
|
-
// but these are exactly the hosts that CAN reach a filesystem, so hand
|
|
127
|
-
// back the ticket already minted instead of dead-ending. Additive fields
|
|
128
|
-
// only; `status` is unchanged for any existing consumer.
|
|
129
|
-
return {
|
|
130
|
-
content: [{
|
|
131
|
-
type: 'text',
|
|
132
|
-
text: JSON.stringify({
|
|
133
|
-
status: 'widget_unavailable',
|
|
134
|
-
hint: 'This host cannot render the upload card, but it can usually reach the filesystem. Upload the file yourself by POSTing it to upload_url with this ticket — the recipe is below. On a LOCAL stdio install you can also just call upload_media with the absolute path.',
|
|
135
|
-
...uploadTicketPayload(ticket),
|
|
136
|
-
}, null, 2)
|
|
137
|
-
}]
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
// ─── create_upload_ticket ──────────────────────────────────
|
|
143
|
-
server.tool(
|
|
144
|
-
'create_upload_ticket',
|
|
145
|
-
'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. The endpoint is RATE LIMITED (the exact cap and window come back in the ticket\'s `rate_limit` field — currently 40 uploads/minute): pace a batch bigger than that rather than firing every file at once, and on HTTP 429 honour the `Retry-After` header / `retry_after_seconds` body field instead of guessing a backoff. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
|
|
146
|
-
{},
|
|
147
|
-
async () => {
|
|
148
|
-
const ticket = await mintUploadTicket(client);
|
|
149
|
-
return {
|
|
150
|
-
content: [{
|
|
151
|
-
type: 'text',
|
|
152
|
-
text: JSON.stringify({
|
|
153
|
-
status: 'upload_ticket_created',
|
|
154
|
-
...uploadTicketPayload(ticket),
|
|
155
|
-
}, null, 2)
|
|
156
|
-
}]
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
// ─── upload_media ──────────────────────────────────────────
|
|
162
|
-
server.tool(
|
|
163
|
-
'upload_media',
|
|
164
|
-
'Upload a LOCAL file (or a NON-Kolbo remote URL) to the user\'s Kolbo media library and get back a stable Kolbo CDN URL. NEVER call this on a URL that is already Kolbo-hosted: generate_* / list_media / prior upload_media results, media.kolbo.ai, *.kolbo.ai, or DigitalOcean Spaces. Those URLs are already usable — pass them as-is to generate_* as reference_images / source_images / image_url. Use this only for a path on disk or an external (non-Kolbo) URL that needs re-hosting. Auto-detects media type from the file extension.',
|
|
165
|
-
{
|
|
166
|
-
source: z.string().optional().describe('Absolute local path, or a NON-Kolbo URL to re-host. Do not pass a media.kolbo.ai / generate_* / list_media URL — those are already hosted and this tool will refuse to duplicate them. Provide this OR source_base64.'),
|
|
167
|
-
source_base64: z.string().optional().describe('Raw file content as base64 (no data: prefix) — fallback for hosts with no filesystem or public URL (e.g. small images on claude.ai when the upload widget is unavailable). Requires `filename`. Keep under ~10MB; for larger files use media_upload_widget.'),
|
|
168
|
-
filename: z.string().optional().describe('Original filename WITH extension (e.g. photo.png) — required with source_base64; the extension determines the media type.'),
|
|
169
|
-
description: z.string().optional().describe('Optional description / caption for the uploaded media'),
|
|
170
|
-
project_id: z.string().optional().describe('Project ObjectId to file the upload into. Call `list_projects` to resolve a name → id. When the user is working in a named project, pass it here too — omitting it files the upload outside that project.')
|
|
171
|
-
},
|
|
172
|
-
async ({ source, source_base64, filename, description, project_id }) => {
|
|
173
|
-
if (!source && !source_base64) throw new Error('Provide source (URL or absolute local path) OR source_base64 (+ filename)');
|
|
174
|
-
|
|
175
|
-
if (source && ownedUrl(source)) {
|
|
176
|
-
return {
|
|
177
|
-
content: [{
|
|
178
|
-
type: 'text',
|
|
179
|
-
text: JSON.stringify({
|
|
180
|
-
reused: true,
|
|
181
|
-
url: source,
|
|
182
|
-
hint: 'This URL is already on Kolbo CDN. Pass it as-is to generate_* (reference_images / source_images / image_url / files). Do not upload again.',
|
|
183
|
-
}, null, 2),
|
|
184
|
-
}],
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (source_base64) {
|
|
189
|
-
if (!filename || !/\.[a-z0-9]{2,5}$/i.test(filename)) {
|
|
190
|
-
throw new Error('source_base64 requires a `filename` with an extension (e.g. photo.png)');
|
|
191
|
-
}
|
|
192
|
-
const buffer = Buffer.from(source_base64, 'base64');
|
|
193
|
-
if (!buffer.length) throw new Error('source_base64 decoded to an empty file');
|
|
194
|
-
// Backend routes by mimetype (video/audio must NOT hit the image
|
|
195
|
-
// optimizer) — derive it from the extension, never octet-stream.
|
|
196
|
-
const ext = filename.split('.').pop().toLowerCase();
|
|
197
|
-
const MIME = {
|
|
198
|
-
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', heic: 'image/heic', avif: 'image/avif',
|
|
199
|
-
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/x-m4v', mkv: 'video/x-matroska',
|
|
200
|
-
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
201
|
-
pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json',
|
|
202
|
-
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
203
|
-
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
204
|
-
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
205
|
-
};
|
|
206
|
-
const form = new FormData();
|
|
207
|
-
form.append('file', buffer, { filename, contentType: MIME[ext] || 'application/octet-stream' });
|
|
208
|
-
if (description) form.append('description', description);
|
|
209
|
-
if (project_id) form.append('project_id', project_id);
|
|
210
|
-
const result = await client.postMultipart('/v1/media/upload', form);
|
|
211
|
-
return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Even for URL input we download-and-reupload — that's the whole point
|
|
215
|
-
// of upload_media (getting a stable Kolbo-owned URL). For ephemeral
|
|
216
|
-
// pass-through, the generation tools accept URLs directly.
|
|
217
|
-
const kind = /\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? 'video'
|
|
218
|
-
: /\.(mp3|wav|ogg|m4a|flac|aac)(\?|$)/i.test(source) ? 'audio'
|
|
219
|
-
: 'image';
|
|
220
|
-
const resolved = await resolveToBuffer(source, kind, { allowLocalFiles: !options.remote });
|
|
221
|
-
|
|
222
|
-
const form = new FormData();
|
|
223
|
-
form.append('file', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
224
|
-
if (description) form.append('description', description);
|
|
225
|
-
if (project_id) form.append('project_id', project_id);
|
|
226
|
-
|
|
227
|
-
const result = await client.postMultipart('/v1/media/upload', form);
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
content: [{
|
|
231
|
-
type: 'text',
|
|
232
|
-
text: JSON.stringify(result.media || result, null, 2)
|
|
233
|
-
}]
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
// ─── list_media ────────────────────────────────────────────
|
|
239
|
-
server.tool(
|
|
240
|
-
'list_media',
|
|
241
|
-
'Browse the user\'s Kolbo media library — both uploaded files AND AI-generated outputs they have saved. Powerful filtering: scope to a single project (`project_id`), a user folder (`folder_id`), a "section" / category (`category`: ai / uploaded / edited / favorites / training-lab), a media type (`type`: image / video / audio), or generation provenance (`source_type`). Combine filters freely. Use this to discover what the user already has before generating something new, to retrieve a specific past creation, or to list everything in a project for downstream batch work.',
|
|
242
|
-
{
|
|
243
|
-
project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `list_projects` to discover IDs. Omit to list across all the user\'s media.'),
|
|
244
|
-
folder_id: z.string().optional().describe('Restrict to a user folder (Mongo ObjectId). Discover folder IDs via `list_media_folders`. Takes precedence over project_id when both are set.'),
|
|
245
|
-
type: z.enum(['image', 'video', 'audio', 'all']).optional().describe('Filter by media type. Default: all types.'),
|
|
246
|
-
category: z.enum(['ai', 'uploaded', 'edited', 'favorites', 'training-lab', 'all']).optional().describe('Filter by "section" (matches the Kolbo desktop app sidebar): `ai` = AI-generated, `uploaded` = files the user uploaded, `edited` = AI-edited variants, `favorites` = items the user starred, `training-lab` = training-lab assets. Default: all sections.'),
|
|
247
|
-
source_type: z.enum(['uploaded', 'generated', 'chat-generated']).optional().describe('Lower-level provenance filter. Use `category` for the common case; use `source_type` for fine-grained distinction (e.g. only chat-generated images).'),
|
|
248
|
-
sort: z.enum(['created_desc', 'created_asc', 'name_asc', 'name_desc']).optional().describe('Sort order. Default: created_desc (newest first).'),
|
|
249
|
-
page: z.number().optional().describe('1-indexed page number. Default: 1'),
|
|
250
|
-
page_size: z.number().optional().describe('Items per page. Default: 50, max 200.'),
|
|
251
|
-
search: z.string().optional().describe('Free-text match against filename + original prompt.')
|
|
252
|
-
},
|
|
253
|
-
async ({ project_id, folder_id, type, category, source_type, sort, page, page_size, search }) => {
|
|
254
|
-
const params = new URLSearchParams();
|
|
255
|
-
if (project_id) params.set('project_id', project_id);
|
|
256
|
-
if (folder_id) params.set('folder_id', folder_id);
|
|
257
|
-
if (type) params.set('type', type);
|
|
258
|
-
if (category) params.set('category', category);
|
|
259
|
-
if (source_type) params.set('source_type', source_type);
|
|
260
|
-
if (sort) params.set('sort', sort);
|
|
261
|
-
if (page) params.set('page', String(page));
|
|
262
|
-
if (page_size) params.set('page_size', String(page_size));
|
|
263
|
-
if (search) params.set('search', search);
|
|
264
|
-
|
|
265
|
-
const qs = params.toString();
|
|
266
|
-
const result = await client.get(`/v1/media${qs ? '?' + qs : ''}`);
|
|
267
|
-
|
|
268
|
-
const media = result.media || [];
|
|
269
|
-
const pagination = result.pagination || null;
|
|
270
|
-
// A default page of 50 items measured 119,847 chars — every row carries a
|
|
271
|
-
// full metadata object and the original prompt. Keep what identifies and
|
|
272
|
-
// locates an item; get_media returns one in full.
|
|
273
|
-
const text = compactList(media, {
|
|
274
|
-
fields: ['id', 'filename', 'media_type', 'url', 'thumbnail_url', 'size', 'project_id', 'created_at'],
|
|
275
|
-
cap: 50,
|
|
276
|
-
total: pagination ? (pagination.total_items != null ? pagination.total_items : pagination.total) : media.length,
|
|
277
|
-
extra: pagination ? { pagination } : undefined,
|
|
278
|
-
note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
282
|
-
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
283
|
-
// rebuilt items from the compactList text, whose field names are
|
|
284
|
-
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
285
|
-
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
286
|
-
// The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
|
|
287
|
-
// reading `total` always came back undefined, so the grid claimed the page
|
|
288
|
-
// size was the whole library. Accept either, then fall back.
|
|
289
|
-
const totalItems = pagination
|
|
290
|
-
? (pagination.total_items != null ? pagination.total_items : pagination.total)
|
|
291
|
-
: null;
|
|
292
|
-
const items = media.slice(0, GRID_CAP).map((m) => ({
|
|
293
|
-
id: m.id,
|
|
294
|
-
title: m.filename,
|
|
295
|
-
subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
|
|
296
|
-
thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
|
|
297
|
-
media_type: m.media_type,
|
|
298
|
-
url: m.url,
|
|
299
|
-
use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
|
|
300
|
-
}));
|
|
301
|
-
return uiResult(UI.mediaGrid, text, {
|
|
302
|
-
widget: 'media-grid',
|
|
303
|
-
title: 'Media Library',
|
|
304
|
-
items,
|
|
305
|
-
total: totalItems != null ? totalItems : media.length,
|
|
306
|
-
shown: Math.min(media.length, GRID_CAP),
|
|
307
|
-
// Everything "Load more" needs to fetch page N+1 ITSELF. The button used
|
|
308
|
-
// to send a chat message asking the model to run the next page, on the
|
|
309
|
-
// belief that a widget cannot invoke a tool — it can
|
|
310
|
-
// (window.kolbo.callTool, the same call every generation card polls
|
|
311
|
-
// with). Worse, the payload carried no page and no filters, so the model
|
|
312
|
-
// could not reconstruct the query either and typically re-ran page 1.
|
|
313
|
-
page_tool: 'list_media',
|
|
314
|
-
page: page || 1,
|
|
315
|
-
page_size: page_size || 50,
|
|
316
|
-
query: { project_id, folder_id, type, category, source_type, sort, search }
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
);
|
|
320
|
-
|
|
321
|
-
// ─── favorite_media ────────────────────────────────────────
|
|
322
|
-
server.tool(
|
|
323
|
-
'favorite_media',
|
|
324
|
-
'Mark a media item as a favorite for the user. Idempotent — calling on an already-favorited item is a no-op. Requires the media `id` from `list_media`. After favoriting, the item shows up in `list_media` with `category=favorites` and in the desktop app sidebar\'s Favorites section. Use this when the user explicitly says "favorite this", "save this to favorites", "star this", or similar.',
|
|
325
|
-
{
|
|
326
|
-
media_id: z.string().describe('The MediaLibraryItem id (returned as `id` from `list_media`).')
|
|
327
|
-
},
|
|
328
|
-
async ({ media_id }) => {
|
|
329
|
-
const result = await client.post(`/v1/media/${encodeURIComponent(media_id)}/favorite`, {});
|
|
330
|
-
return {
|
|
331
|
-
content: [{
|
|
332
|
-
type: 'text',
|
|
333
|
-
text: JSON.stringify(result, null, 2)
|
|
334
|
-
}]
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
// ─── unfavorite_media ──────────────────────────────────────
|
|
340
|
-
server.tool(
|
|
341
|
-
'unfavorite_media',
|
|
342
|
-
'Remove a media item from the user\'s favorites. Idempotent — calling on an item that isn\'t favorited is a no-op. Requires the media `id` from `list_media`. Use this when the user says "unfavorite", "remove from favorites", "unstar", or similar.',
|
|
343
|
-
{
|
|
344
|
-
media_id: z.string().describe('The MediaLibraryItem id (returned as `id` from `list_media`).')
|
|
345
|
-
},
|
|
346
|
-
async ({ media_id }) => {
|
|
347
|
-
const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}/favorite`);
|
|
348
|
-
return {
|
|
349
|
-
content: [{
|
|
350
|
-
type: 'text',
|
|
351
|
-
text: JSON.stringify(result, null, 2)
|
|
352
|
-
}]
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
// ─── list_media_folders ────────────────────────────────────
|
|
358
|
-
server.tool(
|
|
359
|
-
'list_media_folders',
|
|
360
|
-
'List the user\'s media folders (their own + folders shared with them). Folders are user-scoped and can span multiple projects — they\'re a way for the user to group media across the library independent of project structure. Use this to discover folder IDs to pass into `list_media` via `folder_id`, or to show the user what folders exist before suggesting where to look.',
|
|
361
|
-
{},
|
|
362
|
-
async () => {
|
|
363
|
-
const result = await client.get('/v1/media/folders');
|
|
364
|
-
const folders = result.folders || [];
|
|
365
|
-
const text = JSON.stringify({ folders, count: result.count || 0 }, null, 2);
|
|
366
|
-
|
|
367
|
-
return listResult(text, {
|
|
368
|
-
widget: 'list',
|
|
369
|
-
title: 'Media Folders',
|
|
370
|
-
items: folders.map(f => ({
|
|
371
|
-
id: f.id,
|
|
372
|
-
title: f.name,
|
|
373
|
-
subtitle: f.description,
|
|
374
|
-
meta: (f.item_count || 0) + (f.item_count === 1 ? ' item' : ' items'),
|
|
375
|
-
use_hint: 'List media in my "{TITLE}" folder (folder_id: {ID}).'
|
|
376
|
-
})),
|
|
377
|
-
total: folders.length
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
);
|
|
381
|
-
|
|
382
|
-
// ─── create_media_folder ───────────────────────────────────
|
|
383
|
-
server.tool(
|
|
384
|
-
'create_media_folder',
|
|
385
|
-
'Create a new media folder for the user. Folders are user-scoped (span all projects) and useful for grouping related assets. Returns the new folder id — pass it as `folder_id` to `list_media`, `add_media_to_folder`, etc.',
|
|
386
|
-
{
|
|
387
|
-
name: z.string().describe('Folder name (1–100 characters).'),
|
|
388
|
-
description: z.string().optional().describe('Optional description (up to 500 characters).'),
|
|
389
|
-
color: z.string().optional().describe('Optional hex color like "#3B82F6" for UI tinting. Default: Kolbo blue.'),
|
|
390
|
-
icon: z.string().optional().describe('Optional Lucide icon name (e.g. "folder", "star", "image"). Default: "folder".')
|
|
391
|
-
},
|
|
392
|
-
async ({ name, description, color, icon }) => {
|
|
393
|
-
const result = await client.post('/v1/media/folders', { name, description, color, icon });
|
|
394
|
-
return { content: [{ type: 'text', text: JSON.stringify(result.folder || result, null, 2) }] };
|
|
395
|
-
}
|
|
396
|
-
);
|
|
397
|
-
|
|
398
|
-
// ─── update_media_folder ───────────────────────────────────
|
|
399
|
-
server.tool(
|
|
400
|
-
'update_media_folder',
|
|
401
|
-
'Rename a folder or update its color / icon / description. Owner only. Any subset of fields may be provided — fields omitted are left unchanged.',
|
|
402
|
-
{
|
|
403
|
-
folder_id: z.string().describe('Folder id from `list_media_folders` or `create_media_folder`.'),
|
|
404
|
-
name: z.string().optional().describe('New folder name (1–100 characters).'),
|
|
405
|
-
description: z.string().optional().describe('New description (up to 500 characters). Pass "" to clear.'),
|
|
406
|
-
color: z.string().optional().describe('New hex color like "#3B82F6".'),
|
|
407
|
-
icon: z.string().optional().describe('New Lucide icon name.')
|
|
408
|
-
},
|
|
409
|
-
async ({ folder_id, name, description, color, icon }) => {
|
|
410
|
-
const body = {};
|
|
411
|
-
if (name !== undefined) body.name = name;
|
|
412
|
-
if (description !== undefined) body.description = description;
|
|
413
|
-
if (color !== undefined) body.color = color;
|
|
414
|
-
if (icon !== undefined) body.icon = icon;
|
|
415
|
-
const result = await client.put(`/v1/media/folders/${encodeURIComponent(folder_id)}`, body);
|
|
416
|
-
return { content: [{ type: 'text', text: JSON.stringify(result.folder || result, null, 2) }] };
|
|
417
|
-
}
|
|
418
|
-
);
|
|
419
|
-
|
|
420
|
-
// ─── delete_media_folder ───────────────────────────────────
|
|
421
|
-
server.tool(
|
|
422
|
-
'delete_media_folder',
|
|
423
|
-
'Delete a folder (soft delete — items inside are detached but NOT deleted from the user\'s media library). Owner only. ALWAYS ask the user to confirm before calling this — folder deletion is not surfaced in any "undo" flow.',
|
|
424
|
-
{
|
|
425
|
-
folder_id: z.string().describe('Folder id to delete.')
|
|
426
|
-
},
|
|
427
|
-
async ({ folder_id }) => {
|
|
428
|
-
const result = await client.delete(`/v1/media/folders/${encodeURIComponent(folder_id)}`);
|
|
429
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
430
|
-
}
|
|
431
|
-
);
|
|
432
|
-
|
|
433
|
-
// ─── add_media_to_folder ───────────────────────────────────
|
|
434
|
-
server.tool(
|
|
435
|
-
'add_media_to_folder',
|
|
436
|
-
'Add one or more media items to a folder. Caller must own the folder or be a shared member. Idempotent — items already in the folder are skipped silently. Up to 500 items per call.',
|
|
437
|
-
{
|
|
438
|
-
folder_id: z.string().describe('Target folder id.'),
|
|
439
|
-
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids (from `list_media`). Up to 500.')
|
|
440
|
-
},
|
|
441
|
-
async ({ folder_id, media_ids }) => {
|
|
442
|
-
const result = await client.post(
|
|
443
|
-
`/v1/media/folders/${encodeURIComponent(folder_id)}/items`,
|
|
444
|
-
{ media_ids }
|
|
445
|
-
);
|
|
446
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
447
|
-
}
|
|
448
|
-
);
|
|
449
|
-
|
|
450
|
-
// ─── remove_media_from_folder ──────────────────────────────
|
|
451
|
-
server.tool(
|
|
452
|
-
'remove_media_from_folder',
|
|
453
|
-
'Remove one or more media items from a folder. Caller must own the folder or be a shared member. Items themselves remain in the library. Up to 500 items per call.',
|
|
454
|
-
{
|
|
455
|
-
folder_id: z.string().describe('Folder id.'),
|
|
456
|
-
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to remove from the folder.')
|
|
457
|
-
},
|
|
458
|
-
async ({ folder_id, media_ids }) => {
|
|
459
|
-
const result = await client.delete(
|
|
460
|
-
`/v1/media/folders/${encodeURIComponent(folder_id)}/items`,
|
|
461
|
-
{ media_ids }
|
|
462
|
-
);
|
|
463
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
464
|
-
}
|
|
465
|
-
);
|
|
466
|
-
|
|
467
|
-
// ─── share_media_folder ────────────────────────────────────
|
|
468
|
-
server.tool(
|
|
469
|
-
'share_media_folder',
|
|
470
|
-
'Share a folder with one or more users by email. Owner only. Users must already have a Kolbo account; emails not found are returned in `not_found`. Shared members can list folder contents, add and remove items, but cannot delete the folder or reshare it.',
|
|
471
|
-
{
|
|
472
|
-
folder_id: z.string().describe('Folder id to share.'),
|
|
473
|
-
user_emails: z.array(z.string()).describe('Array of email addresses to grant access to. Up to 50 per call.')
|
|
474
|
-
},
|
|
475
|
-
async ({ folder_id, user_emails }) => {
|
|
476
|
-
const result = await client.post(
|
|
477
|
-
`/v1/media/folders/${encodeURIComponent(folder_id)}/share`,
|
|
478
|
-
{ user_emails }
|
|
479
|
-
);
|
|
480
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
481
|
-
}
|
|
482
|
-
);
|
|
483
|
-
|
|
484
|
-
// ─── get_media ─────────────────────────────────────────────
|
|
485
|
-
server.tool(
|
|
486
|
-
'get_media',
|
|
487
|
-
'Fetch one media item\'s full details by id. Returns the same shape as items in `list_media` plus extra metadata. Use this when the user references a specific item ("tell me about this generation", "what prompt did I use for [item]").',
|
|
488
|
-
{
|
|
489
|
-
media_id: z.string().describe('MediaLibraryItem id (from `list_media`). Generation ids are also accepted as a fallback.')
|
|
490
|
-
},
|
|
491
|
-
async ({ media_id }) => {
|
|
492
|
-
const result = await client.get(`/v1/media/${encodeURIComponent(media_id)}`);
|
|
493
|
-
return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
|
|
494
|
-
}
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
// ─── delete_media ──────────────────────────────────────────
|
|
498
|
-
server.tool(
|
|
499
|
-
'delete_media',
|
|
500
|
-
'Soft-delete a media item — moves it to the user\'s trash where it can be restored for 30 days. Owner only. Idempotent. Use this for "delete this image / video / song" — NOT for `permanently_delete_media`, which is irreversible.',
|
|
501
|
-
{
|
|
502
|
-
media_id: z.string().describe('MediaLibraryItem id to soft-delete.')
|
|
503
|
-
},
|
|
504
|
-
async ({ media_id }) => {
|
|
505
|
-
const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}`);
|
|
506
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
507
|
-
}
|
|
508
|
-
);
|
|
509
|
-
|
|
510
|
-
// ─── restore_media ─────────────────────────────────────────
|
|
511
|
-
server.tool(
|
|
512
|
-
'restore_media',
|
|
513
|
-
'Restore a soft-deleted (trashed) media item back to the user\'s active library. Owner only. Use after `delete_media` if the user changes their mind, or when the user explicitly asks "restore [item] from trash".',
|
|
514
|
-
{
|
|
515
|
-
media_id: z.string().describe('MediaLibraryItem id to restore from trash.')
|
|
516
|
-
},
|
|
517
|
-
async ({ media_id }) => {
|
|
518
|
-
const result = await client.post(`/v1/media/${encodeURIComponent(media_id)}/restore`, {});
|
|
519
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
520
|
-
}
|
|
521
|
-
);
|
|
522
|
-
|
|
523
|
-
// ─── permanently_delete_media ──────────────────────────────
|
|
524
|
-
server.tool(
|
|
525
|
-
'permanently_delete_media',
|
|
526
|
-
'PERMANENTLY delete a media item — removes it from MongoDB, deletes the file from S3, removes from all folders, and deletes the source generation record. NOT REVERSIBLE — there is no recovery flow. Owner only. ALWAYS ask the user to explicitly confirm before calling this; use `delete_media` for normal "delete" intent.',
|
|
527
|
-
{
|
|
528
|
-
media_id: z.string().describe('MediaLibraryItem id to permanently delete. Cannot be undone.')
|
|
529
|
-
},
|
|
530
|
-
async ({ media_id }) => {
|
|
531
|
-
const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}/permanent`);
|
|
532
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
533
|
-
}
|
|
534
|
-
);
|
|
535
|
-
|
|
536
|
-
// ─── move_media ────────────────────────────────────────────
|
|
537
|
-
server.tool(
|
|
538
|
-
'move_media',
|
|
539
|
-
'Move a media item to a different project. Caller must own the item AND have access to the target project. Items in shared projects from other members cannot be moved by you. Use this when the user says "move this to project X" or wants to reorganize.',
|
|
540
|
-
{
|
|
541
|
-
media_id: z.string().describe('MediaLibraryItem id to move.'),
|
|
542
|
-
project_id: z.string().describe('Target project id (use `list_projects` to discover ids).')
|
|
543
|
-
},
|
|
544
|
-
async ({ media_id, project_id }) => {
|
|
545
|
-
const result = await client.patch(
|
|
546
|
-
`/v1/media/${encodeURIComponent(media_id)}/project`,
|
|
547
|
-
{ project_id }
|
|
548
|
-
);
|
|
549
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
550
|
-
}
|
|
551
|
-
);
|
|
552
|
-
|
|
553
|
-
// ─── bulk_delete_media ─────────────────────────────────────
|
|
554
|
-
server.tool(
|
|
555
|
-
'bulk_delete_media',
|
|
556
|
-
'Soft-delete up to 1000 media items in one call. Items go to trash (30-day recovery). Owner only — items not owned by the user are silently skipped (count returned in response). Use this for "clean up all my old [type]" or "delete the failed generations from yesterday".',
|
|
557
|
-
{
|
|
558
|
-
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids. Up to 1000 per call.')
|
|
559
|
-
},
|
|
560
|
-
async ({ media_ids }) => {
|
|
561
|
-
const result = await client.post('/v1/media/bulk/delete', { media_ids });
|
|
562
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
563
|
-
}
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
// ─── bulk_restore_media ────────────────────────────────────
|
|
567
|
-
server.tool(
|
|
568
|
-
'bulk_restore_media',
|
|
569
|
-
'Restore up to 1000 trashed media items at once. Owner only. Returns the count restored and how many ids weren\'t in trash (already active or not owned).',
|
|
570
|
-
{
|
|
571
|
-
media_ids: z.array(z.string()).describe('Array of trashed MediaLibraryItem ids to restore. Up to 1000.')
|
|
572
|
-
},
|
|
573
|
-
async ({ media_ids }) => {
|
|
574
|
-
const result = await client.post('/v1/media/bulk/restore', { media_ids });
|
|
575
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
576
|
-
}
|
|
577
|
-
);
|
|
578
|
-
|
|
579
|
-
// ─── bulk_permanently_delete_media ─────────────────────────
|
|
580
|
-
server.tool(
|
|
581
|
-
'bulk_permanently_delete_media',
|
|
582
|
-
'PERMANENTLY delete up to 1000 media items. NOT REVERSIBLE — removes from MongoDB, S3, folders, and source generation records. Owner only. ALWAYS confirm with the user before calling; this is the bulk equivalent of `permanently_delete_media`.',
|
|
583
|
-
{
|
|
584
|
-
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to permanently delete. Up to 1000. Cannot be undone.')
|
|
585
|
-
},
|
|
586
|
-
async ({ media_ids }) => {
|
|
587
|
-
const result = await client.post('/v1/media/bulk/permanent', { media_ids });
|
|
588
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
589
|
-
}
|
|
590
|
-
);
|
|
591
|
-
|
|
592
|
-
// ─── bulk_move_media ───────────────────────────────────────
|
|
593
|
-
server.tool(
|
|
594
|
-
'bulk_move_media',
|
|
595
|
-
'Move up to 1000 media items to a different project in a single call. Caller must own ALL items AND have access to the target project — if any item isn\'t owned by the caller, the entire operation is rejected (atomic).',
|
|
596
|
-
{
|
|
597
|
-
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to move. Up to 1000.'),
|
|
598
|
-
project_id: z.string().describe('Target project id.')
|
|
599
|
-
},
|
|
600
|
-
async ({ media_ids, project_id }) => {
|
|
601
|
-
const result = await client.post('/v1/media/bulk/move', { media_ids, project_id });
|
|
602
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
603
|
-
}
|
|
604
|
-
);
|
|
605
|
-
|
|
606
|
-
// ─── move_folder_contents ──────────────────────────────────
|
|
607
|
-
server.tool(
|
|
608
|
-
'move_folder_contents',
|
|
609
|
-
'Move every media item inside a folder to a different project. Caller must own ALL items in the folder AND have access to the target project. Shared folder members cannot use this — only the item owner can move items between projects.',
|
|
610
|
-
{
|
|
611
|
-
folder_id: z.string().describe('Folder id whose contents will be moved.'),
|
|
612
|
-
project_id: z.string().describe('Target project id.')
|
|
613
|
-
},
|
|
614
|
-
async ({ folder_id, project_id }) => {
|
|
615
|
-
const result = await client.post(
|
|
616
|
-
`/v1/media/folders/${encodeURIComponent(folder_id)}/move-contents`,
|
|
617
|
-
{ project_id }
|
|
618
|
-
);
|
|
619
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
620
|
-
}
|
|
621
|
-
);
|
|
622
|
-
|
|
623
|
-
// ─── get_media_stats ───────────────────────────────────────
|
|
624
|
-
server.tool(
|
|
625
|
-
'get_media_stats',
|
|
626
|
-
'Get counts and total storage size of the user\'s media (or a specific project\'s media). Returns `{ total, images, videos, audio, total_size_bytes }`. Use this for "how many videos do I have", "what\'s my storage usage", or before bulk operations to estimate scope.',
|
|
627
|
-
{
|
|
628
|
-
project_id: z.string().optional().describe('Optional project id to scope stats to one project. Omit for the user\'s personal library across all projects.')
|
|
629
|
-
},
|
|
630
|
-
async ({ project_id }) => {
|
|
631
|
-
const params = new URLSearchParams();
|
|
632
|
-
if (project_id) params.set('project_id', project_id);
|
|
633
|
-
const qs = params.toString();
|
|
634
|
-
const result = await client.get(qs ? `/v1/media/stats?${qs}` : '/v1/media/stats');
|
|
635
|
-
return { content: [{ type: 'text', text: JSON.stringify(result.stats || result, null, 2) }] };
|
|
636
|
-
}
|
|
637
|
-
);
|
|
638
|
-
|
|
639
|
-
// ─── unshare_media_folder ──────────────────────────────────
|
|
640
|
-
server.tool(
|
|
641
|
-
'unshare_media_folder',
|
|
642
|
-
'Revoke a single user\'s access to a folder. Owner only. The user keeps any media they uploaded — only the folder access is removed.',
|
|
643
|
-
{
|
|
644
|
-
folder_id: z.string().describe('Folder id.'),
|
|
645
|
-
user_id: z.string().describe('User id to revoke (from the folder\'s `shared_with` array).')
|
|
646
|
-
},
|
|
647
|
-
async ({ folder_id, user_id }) => {
|
|
648
|
-
const result = await client.delete(
|
|
649
|
-
`/v1/media/folders/${encodeURIComponent(folder_id)}/share/${encodeURIComponent(user_id)}`
|
|
650
|
-
);
|
|
651
|
-
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
652
|
-
}
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
module.exports = { registerMediaTools };
|
|
1
|
+
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
+
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
+
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
+
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const FormData = require('form-data');
|
|
8
|
+
const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
|
|
9
|
+
const { ownedUrl } = require('./owned-url');
|
|
10
|
+
const { UI, uiResult, listResult } = require('../apps');
|
|
11
|
+
|
|
12
|
+
// How many tiles the media grid renders. A rendering limit only — the text
|
|
13
|
+
// payload always carries the full page, and `total` reports the real library
|
|
14
|
+
// count, so a capped grid can never be mistaken for "that's everything".
|
|
15
|
+
const GRID_CAP = 24;
|
|
16
|
+
|
|
17
|
+
async function mintUploadTicket(client) {
|
|
18
|
+
const ticket = await client.post('/v1/media/upload-ticket', {});
|
|
19
|
+
if (!ticket || !ticket.token) throw new Error('Could not create an upload ticket — try again.');
|
|
20
|
+
return ticket;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Shape a /v1/media/upload-ticket response for a TEXT consumer (an agent that
|
|
24
|
+
// will POST the file itself). The widget path needs different keys (`expires_at`
|
|
25
|
+
// as an absolute ms timestamp for the countdown), so it builds its own payload.
|
|
26
|
+
//
|
|
27
|
+
// This carries the full recipe, not a pointer to it: both callers are agents
|
|
28
|
+
// holding a token they must use immediately, and telling one of them to go call
|
|
29
|
+
// another tool to learn the POST shape would spend the round trip this whole
|
|
30
|
+
// path exists to remove.
|
|
31
|
+
// The ticket is reusable for a whole batch, but the endpoint is rate limited —
|
|
32
|
+
// and a batch uploader that only learns that from the 41st POST has already
|
|
33
|
+
// stalled halfway through. kolbo-api sends the real numbers in `rate_limit`;
|
|
34
|
+
// this fallback covers an older deployment that predates that field.
|
|
35
|
+
const DEFAULT_UPLOAD_RATE = { max_uploads: 40, per_seconds: 60 };
|
|
36
|
+
|
|
37
|
+
function uploadTicketPayload(ticket) {
|
|
38
|
+
const rate = ticket.rate_limit || DEFAULT_UPLOAD_RATE;
|
|
39
|
+
return {
|
|
40
|
+
upload_url: ticket.upload_url,
|
|
41
|
+
token: ticket.token,
|
|
42
|
+
expires_in_seconds: ticket.expires_in,
|
|
43
|
+
max_file_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
44
|
+
accepted: ticket.accepted,
|
|
45
|
+
rate_limit: rate,
|
|
46
|
+
how_to_upload: {
|
|
47
|
+
example: 'curl -X POST "<upload_url>" -H "Authorization: Bearer <token>" -F "file=@/absolute/path/to/file.mp3;type=audio/mpeg"',
|
|
48
|
+
// curl types the part from ITS mime table and falls back to
|
|
49
|
+
// application/octet-stream for anything missing from it (.mp3 included).
|
|
50
|
+
// Newer servers resolve that from the extension, older ones answer
|
|
51
|
+
// "File type not supported: application/octet-stream" — so the example
|
|
52
|
+
// above declares the type and this says why, rather than leaving the
|
|
53
|
+
// caller to rediscover it from a 415.
|
|
54
|
+
mime_note: 'Append `;type=<mime>` to the file part (audio/mpeg, audio/wav, video/mp4, image/png, application/pdf …). Without it curl declares application/octet-stream and the upload can be rejected as an unsupported type.',
|
|
55
|
+
optional_fields: ['project_id', 'description'],
|
|
56
|
+
response: 'JSON — the stable CDN URL is at media.url. One POST per file; reuse the ticket for a batch.',
|
|
57
|
+
pacing: `RATE LIMIT: ${rate.max_uploads} uploads per ${rate.per_seconds}s. For a batch larger than that, pace it (e.g. sleep ${Math.max(1, Math.ceil(rate.per_seconds / rate.max_uploads))}s between files) instead of firing them back to back. Over the limit you get HTTP 429 with a Retry-After header and retry_after_seconds in the body — wait that long, then continue; do not guess a backoff and do not treat it as a failed upload.`,
|
|
58
|
+
// Git Bash hands curl a POSIX-style /c/Users/... path that Windows curl
|
|
59
|
+
// cannot open (exit 26). Real trap — it cost a round trip to find.
|
|
60
|
+
windows_note: 'Give curl a native path (C:/Users/...) — a Git Bash /c/Users/... path fails to open.',
|
|
61
|
+
},
|
|
62
|
+
// The one thing the server cannot detect: a caller with no shell that got
|
|
63
|
+
// here anyway. Without this it is left holding a token it can never use.
|
|
64
|
+
if_you_cannot_run_shell_or_http: 'Discard this ticket and call `media_upload_widget` instead so the user can pick the file.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function registerMediaTools(server, client, options = {}) {
|
|
69
|
+
// `opts.apps` is set only by kolbo-api's per-request server (see createServer
|
|
70
|
+
// in ../index.js), which makes it a TRANSPORT signal — deliberately not
|
|
71
|
+
// `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
|
|
72
|
+
// Transport is what decides whether a local path can resolve at all, so state
|
|
73
|
+
// it in the descriptions rather than making the model infer it. What the
|
|
74
|
+
// server still cannot know is whether the CALLER has a shell (one connector
|
|
75
|
+
// serves both claude.ai and Claude Code), hence the fallback hint in the
|
|
76
|
+
// ticket payload.
|
|
77
|
+
const isRemoteConnector = options.remote === true || options.apps === true;
|
|
78
|
+
|
|
79
|
+
const ticketRouting = isRemoteConnector
|
|
80
|
+
? 'You are reached over a REMOTE connector: this server cannot read the caller\'s disk, so `upload_media` with a local path will always fail here — do not try it. If you can run shell commands or issue HTTP requests yourself, this tool is the right path. If you cannot (claude.ai web/mobile), ignore this tool and call `media_upload_widget` so the user picks the file.'
|
|
81
|
+
: 'You are a LOCAL (stdio) install: server and client share a filesystem, so for an ordinary local file prefer `upload_media` with the absolute path — one call, no ticket needed. Use this tool only when you specifically want to stream files up yourself (large batches, CI, an external uploader).';
|
|
82
|
+
|
|
83
|
+
// ─── media_upload_widget ───────────────────────────────────
|
|
84
|
+
server.tool(
|
|
85
|
+
'media_upload_widget',
|
|
86
|
+
'Open an interactive file-upload card in the chat so the user can upload LOCAL files (images, videos, audio, documents) into their Kolbo media library. USE THIS IMMEDIATELY whenever a claude.ai (browser/mobile) user wants to use a local file, or references a file they attached to the chat — remote MCP tools CANNOT read chat attachments, so the user must re-upload through this widget; do not ask them to re-attach the file in chat. Each uploaded file gets a stable Kolbo CDN URL that arrives in a follow-up user message — then pass those URLs to generation tools (generate_image_edit, generate_video_from_image, generate_lipsync, transcribe_audio, visual DNA, etc.). ROUTING depends on where the SERVER runs, not on which client you are: `upload_media` with a local path only works on a LOCAL (stdio) install, where server and client share a filesystem. Over a remote connector a local path is unreachable however capable the client is — there, if you can run shell commands, call `create_upload_ticket` and POST the file yourself (no user interaction needed); use this widget when you cannot reach the filesystem (claude.ai web/mobile) or when the user should choose the file.',
|
|
87
|
+
{
|
|
88
|
+
purpose: z.string().optional().describe('Short title shown on the card, e.g. "Upload the photo to animate". Helps the user know what to drop.'),
|
|
89
|
+
media_types: z.array(z.enum(['image', 'video', 'audio', 'document'])).optional().describe('Restrict which file kinds the widget accepts. Omit to accept all types.'),
|
|
90
|
+
max_files: z.number().optional().describe('Maximum number of files the user may upload (default 20, max 50). LEAVE UNSET in almost all cases so the user can drop multiple files — only set this (e.g. to 1) if the task genuinely requires exactly one file. Do not restrict to 1 just because the current step uses one image; the user may want to upload several.'),
|
|
91
|
+
project_id: z.string().optional().describe('Project ObjectId to file the uploads into (resolve names via `list_projects`).')
|
|
92
|
+
},
|
|
93
|
+
async ({ purpose, media_types, max_files, project_id }) => {
|
|
94
|
+
const ticket = await mintUploadTicket(client);
|
|
95
|
+
|
|
96
|
+
const info = {
|
|
97
|
+
status: 'upload_widget_opened',
|
|
98
|
+
instructions: 'An upload card is now shown to the user. WAIT for them to upload — the uploaded file URLs will arrive in a follow-up message (or in the model context). Do not guess URLs.',
|
|
99
|
+
accepted: ticket.accepted,
|
|
100
|
+
expires_in_seconds: ticket.expires_in,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
104
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
105
|
+
// rebuilt items from the compactList text, whose field names are
|
|
106
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
107
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
108
|
+
// upload_ui_url: top-level page for Claude iOS/Android — in-iframe
|
|
109
|
+
// <input type=file> selections are dropped by WebKit (see upload widget).
|
|
110
|
+
const uploadUiUrl = ticket.upload_ui_url
|
|
111
|
+
|| String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
|
|
112
|
+
return uiResult(UI.upload, JSON.stringify(info, null, 2), {
|
|
113
|
+
widget: 'upload',
|
|
114
|
+
title: purpose || 'Upload media',
|
|
115
|
+
upload_url: ticket.upload_url,
|
|
116
|
+
upload_ui_url: uploadUiUrl,
|
|
117
|
+
token: ticket.token,
|
|
118
|
+
expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
|
|
119
|
+
kinds: media_types && media_types.length ? media_types : undefined,
|
|
120
|
+
max_files: Math.min(Math.max(Number(max_files) || 20, 1), 50),
|
|
121
|
+
max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
122
|
+
...(project_id ? { project_id } : {}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
|
|
126
|
+
// but these are exactly the hosts that CAN reach a filesystem, so hand
|
|
127
|
+
// back the ticket already minted instead of dead-ending. Additive fields
|
|
128
|
+
// only; `status` is unchanged for any existing consumer.
|
|
129
|
+
return {
|
|
130
|
+
content: [{
|
|
131
|
+
type: 'text',
|
|
132
|
+
text: JSON.stringify({
|
|
133
|
+
status: 'widget_unavailable',
|
|
134
|
+
hint: 'This host cannot render the upload card, but it can usually reach the filesystem. Upload the file yourself by POSTing it to upload_url with this ticket — the recipe is below. On a LOCAL stdio install you can also just call upload_media with the absolute path.',
|
|
135
|
+
...uploadTicketPayload(ticket),
|
|
136
|
+
}, null, 2)
|
|
137
|
+
}]
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// ─── create_upload_ticket ──────────────────────────────────
|
|
143
|
+
server.tool(
|
|
144
|
+
'create_upload_ticket',
|
|
145
|
+
'Get a short-lived ticket for uploading LOCAL files straight into the user\'s Kolbo media library, with NO upload card and no user interaction. ' + ticketRouting + ' Why it exists: when the server cannot read the caller\'s disk, the only other ways in are making the user click an upload card (`media_upload_widget`) or inlining the file as base64 via `upload_media` — base64 is slow and burns context in proportion to file size, so do not use it for anything but a tiny file. Returns `upload_url` + `token`; POST each file as multipart field `file` with header `Authorization: Bearer <token>` and read the CDN URL from `media.url` in the response. One POST per file; the ticket is reusable until it expires. The endpoint is RATE LIMITED (the exact cap and window come back in the ticket\'s `rate_limit` field — currently 40 uploads/minute): pace a batch bigger than that rather than firing every file at once, and on HTTP 429 honour the `Retry-After` header / `retry_after_seconds` body field instead of guessing a backoff. Then pass those URLs to any generation tool (transcribe_audio, generate_image_edit, generate_video_from_image, generate_lipsync, visual DNA, …).',
|
|
146
|
+
{},
|
|
147
|
+
async () => {
|
|
148
|
+
const ticket = await mintUploadTicket(client);
|
|
149
|
+
return {
|
|
150
|
+
content: [{
|
|
151
|
+
type: 'text',
|
|
152
|
+
text: JSON.stringify({
|
|
153
|
+
status: 'upload_ticket_created',
|
|
154
|
+
...uploadTicketPayload(ticket),
|
|
155
|
+
}, null, 2)
|
|
156
|
+
}]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ─── upload_media ──────────────────────────────────────────
|
|
162
|
+
server.tool(
|
|
163
|
+
'upload_media',
|
|
164
|
+
'Upload a LOCAL file (or a NON-Kolbo remote URL) to the user\'s Kolbo media library and get back a stable Kolbo CDN URL. NEVER call this on a URL that is already Kolbo-hosted: generate_* / list_media / prior upload_media results, media.kolbo.ai, *.kolbo.ai, or DigitalOcean Spaces. Those URLs are already usable — pass them as-is to generate_* as reference_images / source_images / image_url. Use this only for a path on disk or an external (non-Kolbo) URL that needs re-hosting. Auto-detects media type from the file extension.',
|
|
165
|
+
{
|
|
166
|
+
source: z.string().optional().describe('Absolute local path, or a NON-Kolbo URL to re-host. Do not pass a media.kolbo.ai / generate_* / list_media URL — those are already hosted and this tool will refuse to duplicate them. Provide this OR source_base64.'),
|
|
167
|
+
source_base64: z.string().optional().describe('Raw file content as base64 (no data: prefix) — fallback for hosts with no filesystem or public URL (e.g. small images on claude.ai when the upload widget is unavailable). Requires `filename`. Keep under ~10MB; for larger files use media_upload_widget.'),
|
|
168
|
+
filename: z.string().optional().describe('Original filename WITH extension (e.g. photo.png) — required with source_base64; the extension determines the media type.'),
|
|
169
|
+
description: z.string().optional().describe('Optional description / caption for the uploaded media'),
|
|
170
|
+
project_id: z.string().optional().describe('Project ObjectId to file the upload into. Call `list_projects` to resolve a name → id. When the user is working in a named project, pass it here too — omitting it files the upload outside that project.')
|
|
171
|
+
},
|
|
172
|
+
async ({ source, source_base64, filename, description, project_id }) => {
|
|
173
|
+
if (!source && !source_base64) throw new Error('Provide source (URL or absolute local path) OR source_base64 (+ filename)');
|
|
174
|
+
|
|
175
|
+
if (source && ownedUrl(source)) {
|
|
176
|
+
return {
|
|
177
|
+
content: [{
|
|
178
|
+
type: 'text',
|
|
179
|
+
text: JSON.stringify({
|
|
180
|
+
reused: true,
|
|
181
|
+
url: source,
|
|
182
|
+
hint: 'This URL is already on Kolbo CDN. Pass it as-is to generate_* (reference_images / source_images / image_url / files). Do not upload again.',
|
|
183
|
+
}, null, 2),
|
|
184
|
+
}],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (source_base64) {
|
|
189
|
+
if (!filename || !/\.[a-z0-9]{2,5}$/i.test(filename)) {
|
|
190
|
+
throw new Error('source_base64 requires a `filename` with an extension (e.g. photo.png)');
|
|
191
|
+
}
|
|
192
|
+
const buffer = Buffer.from(source_base64, 'base64');
|
|
193
|
+
if (!buffer.length) throw new Error('source_base64 decoded to an empty file');
|
|
194
|
+
// Backend routes by mimetype (video/audio must NOT hit the image
|
|
195
|
+
// optimizer) — derive it from the extension, never octet-stream.
|
|
196
|
+
const ext = filename.split('.').pop().toLowerCase();
|
|
197
|
+
const MIME = {
|
|
198
|
+
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', heic: 'image/heic', avif: 'image/avif',
|
|
199
|
+
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/x-m4v', mkv: 'video/x-matroska',
|
|
200
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
201
|
+
pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json',
|
|
202
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
203
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
204
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
205
|
+
};
|
|
206
|
+
const form = new FormData();
|
|
207
|
+
form.append('file', buffer, { filename, contentType: MIME[ext] || 'application/octet-stream' });
|
|
208
|
+
if (description) form.append('description', description);
|
|
209
|
+
if (project_id) form.append('project_id', project_id);
|
|
210
|
+
const result = await client.postMultipart('/v1/media/upload', form);
|
|
211
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Even for URL input we download-and-reupload — that's the whole point
|
|
215
|
+
// of upload_media (getting a stable Kolbo-owned URL). For ephemeral
|
|
216
|
+
// pass-through, the generation tools accept URLs directly.
|
|
217
|
+
const kind = /\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? 'video'
|
|
218
|
+
: /\.(mp3|wav|ogg|m4a|flac|aac)(\?|$)/i.test(source) ? 'audio'
|
|
219
|
+
: 'image';
|
|
220
|
+
const resolved = await resolveToBuffer(source, kind, { allowLocalFiles: !options.remote });
|
|
221
|
+
|
|
222
|
+
const form = new FormData();
|
|
223
|
+
form.append('file', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
224
|
+
if (description) form.append('description', description);
|
|
225
|
+
if (project_id) form.append('project_id', project_id);
|
|
226
|
+
|
|
227
|
+
const result = await client.postMultipart('/v1/media/upload', form);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
content: [{
|
|
231
|
+
type: 'text',
|
|
232
|
+
text: JSON.stringify(result.media || result, null, 2)
|
|
233
|
+
}]
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// ─── list_media ────────────────────────────────────────────
|
|
239
|
+
server.tool(
|
|
240
|
+
'list_media',
|
|
241
|
+
'Browse the user\'s Kolbo media library — both uploaded files AND AI-generated outputs they have saved. Powerful filtering: scope to a single project (`project_id`), a user folder (`folder_id`), a "section" / category (`category`: ai / uploaded / edited / favorites / training-lab), a media type (`type`: image / video / audio), or generation provenance (`source_type`). Combine filters freely. Use this to discover what the user already has before generating something new, to retrieve a specific past creation, or to list everything in a project for downstream batch work.',
|
|
242
|
+
{
|
|
243
|
+
project_id: z.string().optional().describe('Restrict to a single project (Mongo ObjectId). Use `list_projects` to discover IDs. Omit to list across all the user\'s media.'),
|
|
244
|
+
folder_id: z.string().optional().describe('Restrict to a user folder (Mongo ObjectId). Discover folder IDs via `list_media_folders`. Takes precedence over project_id when both are set.'),
|
|
245
|
+
type: z.enum(['image', 'video', 'audio', 'all']).optional().describe('Filter by media type. Default: all types.'),
|
|
246
|
+
category: z.enum(['ai', 'uploaded', 'edited', 'favorites', 'training-lab', 'all']).optional().describe('Filter by "section" (matches the Kolbo desktop app sidebar): `ai` = AI-generated, `uploaded` = files the user uploaded, `edited` = AI-edited variants, `favorites` = items the user starred, `training-lab` = training-lab assets. Default: all sections.'),
|
|
247
|
+
source_type: z.enum(['uploaded', 'generated', 'chat-generated']).optional().describe('Lower-level provenance filter. Use `category` for the common case; use `source_type` for fine-grained distinction (e.g. only chat-generated images).'),
|
|
248
|
+
sort: z.enum(['created_desc', 'created_asc', 'name_asc', 'name_desc']).optional().describe('Sort order. Default: created_desc (newest first).'),
|
|
249
|
+
page: z.number().optional().describe('1-indexed page number. Default: 1'),
|
|
250
|
+
page_size: z.number().optional().describe('Items per page. Default: 50, max 200.'),
|
|
251
|
+
search: z.string().optional().describe('Free-text match against filename + original prompt.')
|
|
252
|
+
},
|
|
253
|
+
async ({ project_id, folder_id, type, category, source_type, sort, page, page_size, search }) => {
|
|
254
|
+
const params = new URLSearchParams();
|
|
255
|
+
if (project_id) params.set('project_id', project_id);
|
|
256
|
+
if (folder_id) params.set('folder_id', folder_id);
|
|
257
|
+
if (type) params.set('type', type);
|
|
258
|
+
if (category) params.set('category', category);
|
|
259
|
+
if (source_type) params.set('source_type', source_type);
|
|
260
|
+
if (sort) params.set('sort', sort);
|
|
261
|
+
if (page) params.set('page', String(page));
|
|
262
|
+
if (page_size) params.set('page_size', String(page_size));
|
|
263
|
+
if (search) params.set('search', search);
|
|
264
|
+
|
|
265
|
+
const qs = params.toString();
|
|
266
|
+
const result = await client.get(`/v1/media${qs ? '?' + qs : ''}`);
|
|
267
|
+
|
|
268
|
+
const media = result.media || [];
|
|
269
|
+
const pagination = result.pagination || null;
|
|
270
|
+
// A default page of 50 items measured 119,847 chars — every row carries a
|
|
271
|
+
// full metadata object and the original prompt. Keep what identifies and
|
|
272
|
+
// locates an item; get_media returns one in full.
|
|
273
|
+
const text = compactList(media, {
|
|
274
|
+
fields: ['id', 'filename', 'media_type', 'url', 'thumbnail_url', 'size', 'project_id', 'created_at'],
|
|
275
|
+
cap: 50,
|
|
276
|
+
total: pagination ? (pagination.total_items != null ? pagination.total_items : pagination.total) : media.length,
|
|
277
|
+
extra: pagination ? { pagination } : undefined,
|
|
278
|
+
note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
282
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
283
|
+
// rebuilt items from the compactList text, whose field names are
|
|
284
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
285
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
286
|
+
// The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
|
|
287
|
+
// reading `total` always came back undefined, so the grid claimed the page
|
|
288
|
+
// size was the whole library. Accept either, then fall back.
|
|
289
|
+
const totalItems = pagination
|
|
290
|
+
? (pagination.total_items != null ? pagination.total_items : pagination.total)
|
|
291
|
+
: null;
|
|
292
|
+
const items = media.slice(0, GRID_CAP).map((m) => ({
|
|
293
|
+
id: m.id,
|
|
294
|
+
title: m.filename,
|
|
295
|
+
subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
|
|
296
|
+
thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
|
|
297
|
+
media_type: m.media_type,
|
|
298
|
+
url: m.url,
|
|
299
|
+
use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
|
|
300
|
+
}));
|
|
301
|
+
return uiResult(UI.mediaGrid, text, {
|
|
302
|
+
widget: 'media-grid',
|
|
303
|
+
title: 'Media Library',
|
|
304
|
+
items,
|
|
305
|
+
total: totalItems != null ? totalItems : media.length,
|
|
306
|
+
shown: Math.min(media.length, GRID_CAP),
|
|
307
|
+
// Everything "Load more" needs to fetch page N+1 ITSELF. The button used
|
|
308
|
+
// to send a chat message asking the model to run the next page, on the
|
|
309
|
+
// belief that a widget cannot invoke a tool — it can
|
|
310
|
+
// (window.kolbo.callTool, the same call every generation card polls
|
|
311
|
+
// with). Worse, the payload carried no page and no filters, so the model
|
|
312
|
+
// could not reconstruct the query either and typically re-ran page 1.
|
|
313
|
+
page_tool: 'list_media',
|
|
314
|
+
page: page || 1,
|
|
315
|
+
page_size: page_size || 50,
|
|
316
|
+
query: { project_id, folder_id, type, category, source_type, sort, search }
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// ─── favorite_media ────────────────────────────────────────
|
|
322
|
+
server.tool(
|
|
323
|
+
'favorite_media',
|
|
324
|
+
'Mark a media item as a favorite for the user. Idempotent — calling on an already-favorited item is a no-op. Requires the media `id` from `list_media`. After favoriting, the item shows up in `list_media` with `category=favorites` and in the desktop app sidebar\'s Favorites section. Use this when the user explicitly says "favorite this", "save this to favorites", "star this", or similar.',
|
|
325
|
+
{
|
|
326
|
+
media_id: z.string().describe('The MediaLibraryItem id (returned as `id` from `list_media`).')
|
|
327
|
+
},
|
|
328
|
+
async ({ media_id }) => {
|
|
329
|
+
const result = await client.post(`/v1/media/${encodeURIComponent(media_id)}/favorite`, {});
|
|
330
|
+
return {
|
|
331
|
+
content: [{
|
|
332
|
+
type: 'text',
|
|
333
|
+
text: JSON.stringify(result, null, 2)
|
|
334
|
+
}]
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// ─── unfavorite_media ──────────────────────────────────────
|
|
340
|
+
server.tool(
|
|
341
|
+
'unfavorite_media',
|
|
342
|
+
'Remove a media item from the user\'s favorites. Idempotent — calling on an item that isn\'t favorited is a no-op. Requires the media `id` from `list_media`. Use this when the user says "unfavorite", "remove from favorites", "unstar", or similar.',
|
|
343
|
+
{
|
|
344
|
+
media_id: z.string().describe('The MediaLibraryItem id (returned as `id` from `list_media`).')
|
|
345
|
+
},
|
|
346
|
+
async ({ media_id }) => {
|
|
347
|
+
const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}/favorite`);
|
|
348
|
+
return {
|
|
349
|
+
content: [{
|
|
350
|
+
type: 'text',
|
|
351
|
+
text: JSON.stringify(result, null, 2)
|
|
352
|
+
}]
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// ─── list_media_folders ────────────────────────────────────
|
|
358
|
+
server.tool(
|
|
359
|
+
'list_media_folders',
|
|
360
|
+
'List the user\'s media folders (their own + folders shared with them). Folders are user-scoped and can span multiple projects — they\'re a way for the user to group media across the library independent of project structure. Use this to discover folder IDs to pass into `list_media` via `folder_id`, or to show the user what folders exist before suggesting where to look.',
|
|
361
|
+
{},
|
|
362
|
+
async () => {
|
|
363
|
+
const result = await client.get('/v1/media/folders');
|
|
364
|
+
const folders = result.folders || [];
|
|
365
|
+
const text = JSON.stringify({ folders, count: result.count || 0 }, null, 2);
|
|
366
|
+
|
|
367
|
+
return listResult(text, {
|
|
368
|
+
widget: 'list',
|
|
369
|
+
title: 'Media Folders',
|
|
370
|
+
items: folders.map(f => ({
|
|
371
|
+
id: f.id,
|
|
372
|
+
title: f.name,
|
|
373
|
+
subtitle: f.description,
|
|
374
|
+
meta: (f.item_count || 0) + (f.item_count === 1 ? ' item' : ' items'),
|
|
375
|
+
use_hint: 'List media in my "{TITLE}" folder (folder_id: {ID}).'
|
|
376
|
+
})),
|
|
377
|
+
total: folders.length
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// ─── create_media_folder ───────────────────────────────────
|
|
383
|
+
server.tool(
|
|
384
|
+
'create_media_folder',
|
|
385
|
+
'Create a new media folder for the user. Folders are user-scoped (span all projects) and useful for grouping related assets. Returns the new folder id — pass it as `folder_id` to `list_media`, `add_media_to_folder`, etc.',
|
|
386
|
+
{
|
|
387
|
+
name: z.string().describe('Folder name (1–100 characters).'),
|
|
388
|
+
description: z.string().optional().describe('Optional description (up to 500 characters).'),
|
|
389
|
+
color: z.string().optional().describe('Optional hex color like "#3B82F6" for UI tinting. Default: Kolbo blue.'),
|
|
390
|
+
icon: z.string().optional().describe('Optional Lucide icon name (e.g. "folder", "star", "image"). Default: "folder".')
|
|
391
|
+
},
|
|
392
|
+
async ({ name, description, color, icon }) => {
|
|
393
|
+
const result = await client.post('/v1/media/folders', { name, description, color, icon });
|
|
394
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.folder || result, null, 2) }] };
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// ─── update_media_folder ───────────────────────────────────
|
|
399
|
+
server.tool(
|
|
400
|
+
'update_media_folder',
|
|
401
|
+
'Rename a folder or update its color / icon / description. Owner only. Any subset of fields may be provided — fields omitted are left unchanged.',
|
|
402
|
+
{
|
|
403
|
+
folder_id: z.string().describe('Folder id from `list_media_folders` or `create_media_folder`.'),
|
|
404
|
+
name: z.string().optional().describe('New folder name (1–100 characters).'),
|
|
405
|
+
description: z.string().optional().describe('New description (up to 500 characters). Pass "" to clear.'),
|
|
406
|
+
color: z.string().optional().describe('New hex color like "#3B82F6".'),
|
|
407
|
+
icon: z.string().optional().describe('New Lucide icon name.')
|
|
408
|
+
},
|
|
409
|
+
async ({ folder_id, name, description, color, icon }) => {
|
|
410
|
+
const body = {};
|
|
411
|
+
if (name !== undefined) body.name = name;
|
|
412
|
+
if (description !== undefined) body.description = description;
|
|
413
|
+
if (color !== undefined) body.color = color;
|
|
414
|
+
if (icon !== undefined) body.icon = icon;
|
|
415
|
+
const result = await client.put(`/v1/media/folders/${encodeURIComponent(folder_id)}`, body);
|
|
416
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.folder || result, null, 2) }] };
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// ─── delete_media_folder ───────────────────────────────────
|
|
421
|
+
server.tool(
|
|
422
|
+
'delete_media_folder',
|
|
423
|
+
'Delete a folder (soft delete — items inside are detached but NOT deleted from the user\'s media library). Owner only. ALWAYS ask the user to confirm before calling this — folder deletion is not surfaced in any "undo" flow.',
|
|
424
|
+
{
|
|
425
|
+
folder_id: z.string().describe('Folder id to delete.')
|
|
426
|
+
},
|
|
427
|
+
async ({ folder_id }) => {
|
|
428
|
+
const result = await client.delete(`/v1/media/folders/${encodeURIComponent(folder_id)}`);
|
|
429
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// ─── add_media_to_folder ───────────────────────────────────
|
|
434
|
+
server.tool(
|
|
435
|
+
'add_media_to_folder',
|
|
436
|
+
'Add one or more media items to a folder. Caller must own the folder or be a shared member. Idempotent — items already in the folder are skipped silently. Up to 500 items per call.',
|
|
437
|
+
{
|
|
438
|
+
folder_id: z.string().describe('Target folder id.'),
|
|
439
|
+
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids (from `list_media`). Up to 500.')
|
|
440
|
+
},
|
|
441
|
+
async ({ folder_id, media_ids }) => {
|
|
442
|
+
const result = await client.post(
|
|
443
|
+
`/v1/media/folders/${encodeURIComponent(folder_id)}/items`,
|
|
444
|
+
{ media_ids }
|
|
445
|
+
);
|
|
446
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
447
|
+
}
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// ─── remove_media_from_folder ──────────────────────────────
|
|
451
|
+
server.tool(
|
|
452
|
+
'remove_media_from_folder',
|
|
453
|
+
'Remove one or more media items from a folder. Caller must own the folder or be a shared member. Items themselves remain in the library. Up to 500 items per call.',
|
|
454
|
+
{
|
|
455
|
+
folder_id: z.string().describe('Folder id.'),
|
|
456
|
+
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to remove from the folder.')
|
|
457
|
+
},
|
|
458
|
+
async ({ folder_id, media_ids }) => {
|
|
459
|
+
const result = await client.delete(
|
|
460
|
+
`/v1/media/folders/${encodeURIComponent(folder_id)}/items`,
|
|
461
|
+
{ media_ids }
|
|
462
|
+
);
|
|
463
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
// ─── share_media_folder ────────────────────────────────────
|
|
468
|
+
server.tool(
|
|
469
|
+
'share_media_folder',
|
|
470
|
+
'Share a folder with one or more users by email. Owner only. Users must already have a Kolbo account; emails not found are returned in `not_found`. Shared members can list folder contents, add and remove items, but cannot delete the folder or reshare it.',
|
|
471
|
+
{
|
|
472
|
+
folder_id: z.string().describe('Folder id to share.'),
|
|
473
|
+
user_emails: z.array(z.string()).describe('Array of email addresses to grant access to. Up to 50 per call.')
|
|
474
|
+
},
|
|
475
|
+
async ({ folder_id, user_emails }) => {
|
|
476
|
+
const result = await client.post(
|
|
477
|
+
`/v1/media/folders/${encodeURIComponent(folder_id)}/share`,
|
|
478
|
+
{ user_emails }
|
|
479
|
+
);
|
|
480
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// ─── get_media ─────────────────────────────────────────────
|
|
485
|
+
server.tool(
|
|
486
|
+
'get_media',
|
|
487
|
+
'Fetch one media item\'s full details by id. Returns the same shape as items in `list_media` plus extra metadata. Use this when the user references a specific item ("tell me about this generation", "what prompt did I use for [item]").',
|
|
488
|
+
{
|
|
489
|
+
media_id: z.string().describe('MediaLibraryItem id (from `list_media`). Generation ids are also accepted as a fallback.')
|
|
490
|
+
},
|
|
491
|
+
async ({ media_id }) => {
|
|
492
|
+
const result = await client.get(`/v1/media/${encodeURIComponent(media_id)}`);
|
|
493
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.media || result, null, 2) }] };
|
|
494
|
+
}
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// ─── delete_media ──────────────────────────────────────────
|
|
498
|
+
server.tool(
|
|
499
|
+
'delete_media',
|
|
500
|
+
'Soft-delete a media item — moves it to the user\'s trash where it can be restored for 30 days. Owner only. Idempotent. Use this for "delete this image / video / song" — NOT for `permanently_delete_media`, which is irreversible.',
|
|
501
|
+
{
|
|
502
|
+
media_id: z.string().describe('MediaLibraryItem id to soft-delete.')
|
|
503
|
+
},
|
|
504
|
+
async ({ media_id }) => {
|
|
505
|
+
const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}`);
|
|
506
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// ─── restore_media ─────────────────────────────────────────
|
|
511
|
+
server.tool(
|
|
512
|
+
'restore_media',
|
|
513
|
+
'Restore a soft-deleted (trashed) media item back to the user\'s active library. Owner only. Use after `delete_media` if the user changes their mind, or when the user explicitly asks "restore [item] from trash".',
|
|
514
|
+
{
|
|
515
|
+
media_id: z.string().describe('MediaLibraryItem id to restore from trash.')
|
|
516
|
+
},
|
|
517
|
+
async ({ media_id }) => {
|
|
518
|
+
const result = await client.post(`/v1/media/${encodeURIComponent(media_id)}/restore`, {});
|
|
519
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
520
|
+
}
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
// ─── permanently_delete_media ──────────────────────────────
|
|
524
|
+
server.tool(
|
|
525
|
+
'permanently_delete_media',
|
|
526
|
+
'PERMANENTLY delete a media item — removes it from MongoDB, deletes the file from S3, removes from all folders, and deletes the source generation record. NOT REVERSIBLE — there is no recovery flow. Owner only. ALWAYS ask the user to explicitly confirm before calling this; use `delete_media` for normal "delete" intent.',
|
|
527
|
+
{
|
|
528
|
+
media_id: z.string().describe('MediaLibraryItem id to permanently delete. Cannot be undone.')
|
|
529
|
+
},
|
|
530
|
+
async ({ media_id }) => {
|
|
531
|
+
const result = await client.delete(`/v1/media/${encodeURIComponent(media_id)}/permanent`);
|
|
532
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
533
|
+
}
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
// ─── move_media ────────────────────────────────────────────
|
|
537
|
+
server.tool(
|
|
538
|
+
'move_media',
|
|
539
|
+
'Move a media item to a different project. Caller must own the item AND have access to the target project. Items in shared projects from other members cannot be moved by you. Use this when the user says "move this to project X" or wants to reorganize.',
|
|
540
|
+
{
|
|
541
|
+
media_id: z.string().describe('MediaLibraryItem id to move.'),
|
|
542
|
+
project_id: z.string().describe('Target project id (use `list_projects` to discover ids).')
|
|
543
|
+
},
|
|
544
|
+
async ({ media_id, project_id }) => {
|
|
545
|
+
const result = await client.patch(
|
|
546
|
+
`/v1/media/${encodeURIComponent(media_id)}/project`,
|
|
547
|
+
{ project_id }
|
|
548
|
+
);
|
|
549
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
550
|
+
}
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// ─── bulk_delete_media ─────────────────────────────────────
|
|
554
|
+
server.tool(
|
|
555
|
+
'bulk_delete_media',
|
|
556
|
+
'Soft-delete up to 1000 media items in one call. Items go to trash (30-day recovery). Owner only — items not owned by the user are silently skipped (count returned in response). Use this for "clean up all my old [type]" or "delete the failed generations from yesterday".',
|
|
557
|
+
{
|
|
558
|
+
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids. Up to 1000 per call.')
|
|
559
|
+
},
|
|
560
|
+
async ({ media_ids }) => {
|
|
561
|
+
const result = await client.post('/v1/media/bulk/delete', { media_ids });
|
|
562
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// ─── bulk_restore_media ────────────────────────────────────
|
|
567
|
+
server.tool(
|
|
568
|
+
'bulk_restore_media',
|
|
569
|
+
'Restore up to 1000 trashed media items at once. Owner only. Returns the count restored and how many ids weren\'t in trash (already active or not owned).',
|
|
570
|
+
{
|
|
571
|
+
media_ids: z.array(z.string()).describe('Array of trashed MediaLibraryItem ids to restore. Up to 1000.')
|
|
572
|
+
},
|
|
573
|
+
async ({ media_ids }) => {
|
|
574
|
+
const result = await client.post('/v1/media/bulk/restore', { media_ids });
|
|
575
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
576
|
+
}
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
// ─── bulk_permanently_delete_media ─────────────────────────
|
|
580
|
+
server.tool(
|
|
581
|
+
'bulk_permanently_delete_media',
|
|
582
|
+
'PERMANENTLY delete up to 1000 media items. NOT REVERSIBLE — removes from MongoDB, S3, folders, and source generation records. Owner only. ALWAYS confirm with the user before calling; this is the bulk equivalent of `permanently_delete_media`.',
|
|
583
|
+
{
|
|
584
|
+
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to permanently delete. Up to 1000. Cannot be undone.')
|
|
585
|
+
},
|
|
586
|
+
async ({ media_ids }) => {
|
|
587
|
+
const result = await client.post('/v1/media/bulk/permanent', { media_ids });
|
|
588
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
589
|
+
}
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// ─── bulk_move_media ───────────────────────────────────────
|
|
593
|
+
server.tool(
|
|
594
|
+
'bulk_move_media',
|
|
595
|
+
'Move up to 1000 media items to a different project in a single call. Caller must own ALL items AND have access to the target project — if any item isn\'t owned by the caller, the entire operation is rejected (atomic).',
|
|
596
|
+
{
|
|
597
|
+
media_ids: z.array(z.string()).describe('Array of MediaLibraryItem ids to move. Up to 1000.'),
|
|
598
|
+
project_id: z.string().describe('Target project id.')
|
|
599
|
+
},
|
|
600
|
+
async ({ media_ids, project_id }) => {
|
|
601
|
+
const result = await client.post('/v1/media/bulk/move', { media_ids, project_id });
|
|
602
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
603
|
+
}
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
// ─── move_folder_contents ──────────────────────────────────
|
|
607
|
+
server.tool(
|
|
608
|
+
'move_folder_contents',
|
|
609
|
+
'Move every media item inside a folder to a different project. Caller must own ALL items in the folder AND have access to the target project. Shared folder members cannot use this — only the item owner can move items between projects.',
|
|
610
|
+
{
|
|
611
|
+
folder_id: z.string().describe('Folder id whose contents will be moved.'),
|
|
612
|
+
project_id: z.string().describe('Target project id.')
|
|
613
|
+
},
|
|
614
|
+
async ({ folder_id, project_id }) => {
|
|
615
|
+
const result = await client.post(
|
|
616
|
+
`/v1/media/folders/${encodeURIComponent(folder_id)}/move-contents`,
|
|
617
|
+
{ project_id }
|
|
618
|
+
);
|
|
619
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// ─── get_media_stats ───────────────────────────────────────
|
|
624
|
+
server.tool(
|
|
625
|
+
'get_media_stats',
|
|
626
|
+
'Get counts and total storage size of the user\'s media (or a specific project\'s media). Returns `{ total, images, videos, audio, total_size_bytes }`. Use this for "how many videos do I have", "what\'s my storage usage", or before bulk operations to estimate scope.',
|
|
627
|
+
{
|
|
628
|
+
project_id: z.string().optional().describe('Optional project id to scope stats to one project. Omit for the user\'s personal library across all projects.')
|
|
629
|
+
},
|
|
630
|
+
async ({ project_id }) => {
|
|
631
|
+
const params = new URLSearchParams();
|
|
632
|
+
if (project_id) params.set('project_id', project_id);
|
|
633
|
+
const qs = params.toString();
|
|
634
|
+
const result = await client.get(qs ? `/v1/media/stats?${qs}` : '/v1/media/stats');
|
|
635
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.stats || result, null, 2) }] };
|
|
636
|
+
}
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
// ─── unshare_media_folder ──────────────────────────────────
|
|
640
|
+
server.tool(
|
|
641
|
+
'unshare_media_folder',
|
|
642
|
+
'Revoke a single user\'s access to a folder. Owner only. The user keeps any media they uploaded — only the folder access is removed.',
|
|
643
|
+
{
|
|
644
|
+
folder_id: z.string().describe('Folder id.'),
|
|
645
|
+
user_id: z.string().describe('User id to revoke (from the folder\'s `shared_with` array).')
|
|
646
|
+
},
|
|
647
|
+
async ({ folder_id, user_id }) => {
|
|
648
|
+
const result = await client.delete(
|
|
649
|
+
`/v1/media/folders/${encodeURIComponent(folder_id)}/share/${encodeURIComponent(user_id)}`
|
|
650
|
+
);
|
|
651
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
module.exports = { registerMediaTools };
|