@okostream/mcp 0.1.0 → 0.1.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/README.md CHANGED
@@ -106,6 +106,8 @@ Write/control tools include:
106
106
  - `oko_create_webinar`
107
107
  - `oko_update_event`
108
108
  - `oko_configure_blocks`
109
+ - `oko_upload_image`
110
+ - `oko_delete_image`
109
111
  - `oko_set_block_visibility`
110
112
  - `oko_refresh_viewers`
111
113
  - `oko_start_session`
@@ -128,7 +130,9 @@ Dangerous or external side-effect tools require `confirm: true`.
128
130
  1. `oko_whoami` should return the organizer UID and granted scopes.
129
131
  2. `oko_list_my_events` should return only events visible to that UID.
130
132
  3. `oko_create_webinar` should create a test webinar and RTDB owner/workspace mirror.
131
- 4. `oko_get_analytics` should read an existing event with analytics sessions.
133
+ 4. `oko_upload_image` should accept a local path or HTTPS image URL, upload `master.webp`, and let OKO generate variants.
134
+ 5. `oko_delete_image` should clear image fields and best-effort delete master/variants.
135
+ 6. `oko_get_analytics` should read an existing event with analytics sessions.
132
136
 
133
137
  CLI help:
134
138
 
package/dist/firebase.js CHANGED
@@ -3,6 +3,7 @@ import { getAuth } from "firebase/auth";
3
3
  import { getDatabase } from "firebase/database";
4
4
  import { getFirestore } from "firebase/firestore";
5
5
  import { getFunctions } from "firebase/functions";
6
+ import { getStorage } from "firebase/storage";
6
7
  import { env } from "./env.js";
7
8
  export const firebaseApp = getApps().length ? getApp() : initializeApp({
8
9
  apiKey: env.firebase.apiKey,
@@ -20,3 +21,4 @@ export const chatRtdb = env.firebase.chatDatabaseURL
20
21
  ? getDatabase(firebaseApp, env.firebase.chatDatabaseURL)
21
22
  : getDatabase(firebaseApp);
22
23
  export const functions = getFunctions(firebaseApp, "us-central1");
24
+ export const storage = getStorage(firebaseApp);
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ if (process.argv.includes("--help")) {
22
22
  }
23
23
  const server = new McpServer({
24
24
  name: "oko-mcp-server",
25
- version: "0.1.0",
25
+ version: "0.1.1",
26
26
  });
27
27
  registerTools(server);
28
28
  const transport = new StdioServerTransport();
package/dist/tools.js CHANGED
@@ -1,12 +1,31 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { stat, readFile } from "node:fs/promises";
3
+ import { isIP } from "node:net";
4
+ import { homedir } from "node:os";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import sharp from "sharp";
1
8
  import { z } from "zod";
2
9
  import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
3
10
  import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
4
11
  import { httpsCallable } from "firebase/functions";
12
+ import { deleteObject, getDownloadURL, ref as storageRef, uploadBytes, } from "firebase/storage";
5
13
  import { ensureAuth, currentScopes } from "./auth.js";
6
- import { chatRtdb, firestore, functions, rtdb } from "./firebase.js";
14
+ import { chatRtdb, firestore, functions, rtdb, storage } from "./firebase.js";
7
15
  import { csvCell, requireConfirm, safeString, serialize, textResult } from "./utils.js";
8
16
  import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
9
17
  const boolOrNull = z.boolean().nullable();
18
+ const IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
19
+ const IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
20
+ const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
21
+ const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
22
+ const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
23
+ const TARGET_MASTER_IMAGE_BYTES = 600 * 1024;
24
+ const MAX_MASTER_IMAGE_DIMENSION = 1600;
25
+ const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
26
+ const REMOTE_IMAGE_MAX_REDIRECTS = 3;
27
+ const ALLOWED_SOURCE_IMAGE_FORMATS = new Set(["jpeg", "png", "webp", "gif", "avif"]);
28
+ const ALLOWED_REMOTE_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
10
29
  const MAX_FIRESTORE_TIMELINE_BUCKETS = 1500;
11
30
  const DEFAULT_LIST_LIMIT = 30;
12
31
  const MAX_LIST_LIMIT = 100;
@@ -95,6 +114,25 @@ const leadsOutputSchema = z.object({
95
114
  csv: z.string().optional(),
96
115
  ...cursorMetaSchema,
97
116
  }).passthrough();
117
+ const imageOutputSchema = z.object({
118
+ eventId: z.string(),
119
+ kind: z.enum(IMAGE_KINDS),
120
+ imageId: z.string(),
121
+ storagePath: z.string(),
122
+ masterUrl: z.string().url(),
123
+ variantsPending: z.boolean(),
124
+ sourceBytes: z.number().int().nonnegative(),
125
+ masterBytes: z.number().int().nonnegative(),
126
+ note: z.string(),
127
+ }).passthrough();
128
+ const deleteImageOutputSchema = z.object({
129
+ eventId: z.string(),
130
+ kind: z.enum(IMAGE_KINDS),
131
+ imageId: z.string().nullable(),
132
+ deleted: z.boolean(),
133
+ storageDeletesAttempted: z.number().int().nonnegative(),
134
+ storageDeleteWarnings: z.array(z.string()),
135
+ }).passthrough();
98
136
  const formFieldSchema = z.object({
99
137
  id: z.string().min(1).optional(),
100
138
  label: z.string().max(120),
@@ -112,6 +150,12 @@ const formSchema = z.object({
112
150
  consentRequired: z.boolean().default(true),
113
151
  fields: z.array(formFieldSchema).max(30),
114
152
  }).strict();
153
+ const imageSourceSchema = z.object({
154
+ path: z.string().min(1).optional().describe("Local image path on the MCP client machine."),
155
+ url: z.string().url().optional().describe("HTTPS image URL to fetch from the MCP client machine."),
156
+ }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
157
+ message: "Provide exactly one image source: path or url.",
158
+ });
115
159
  const workspacePermissionsSchema = z.object({
116
160
  createEvents: z.boolean().optional(),
117
161
  editSetup: z.boolean().optional(),
@@ -154,6 +198,285 @@ function pickPage(items, max) {
154
198
  function randomId() {
155
199
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
156
200
  }
201
+ function imageStoragePath(eventId, uid, kind, imageId) {
202
+ return `events/${eventId}/${uid}/images/${kind}/${imageId}/master.webp`;
203
+ }
204
+ function storagePathFromDownloadUrl(downloadUrl) {
205
+ if (!downloadUrl)
206
+ return null;
207
+ const match = downloadUrl.match(/\/o\/([^?]+)/);
208
+ if (!match)
209
+ return null;
210
+ const encodedPath = match[1];
211
+ if (!encodedPath)
212
+ return null;
213
+ try {
214
+ return decodeURIComponent(encodedPath);
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ function variantPathsFromMasterPath(masterPath) {
221
+ if (!masterPath.endsWith("/master.webp"))
222
+ return [];
223
+ return IMAGE_VARIANT_NAMES.map((name) => masterPath.replace(/master\.webp$/, name));
224
+ }
225
+ function resolveLocalImagePath(inputPath) {
226
+ const expanded = inputPath === "~" || inputPath.startsWith("~/")
227
+ ? join(homedir(), inputPath.slice(2))
228
+ : inputPath;
229
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
230
+ }
231
+ function isBlockedHostname(hostname) {
232
+ const host = hostname.toLowerCase();
233
+ return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
234
+ }
235
+ function isBlockedIp(address) {
236
+ const normalized = address.toLowerCase().startsWith("::ffff:")
237
+ ? address.slice(7)
238
+ : address;
239
+ const version = isIP(normalized);
240
+ if (version === 4) {
241
+ const [a = -1, b = -1] = normalized.split(".").map((part) => Number(part));
242
+ return a === 0
243
+ || a === 10
244
+ || a === 127
245
+ || (a === 169 && b === 254)
246
+ || (a === 172 && b >= 16 && b <= 31)
247
+ || (a === 192 && b === 168);
248
+ }
249
+ if (version === 6) {
250
+ return normalized === "::1"
251
+ || normalized.startsWith("fc")
252
+ || normalized.startsWith("fd")
253
+ || normalized.startsWith("fe80:");
254
+ }
255
+ return false;
256
+ }
257
+ async function assertRemoteImageUrlAllowed(url) {
258
+ if (url.protocol !== "https:") {
259
+ throw new Error("Image URL must use https://.");
260
+ }
261
+ if (url.username || url.password) {
262
+ throw new Error("Image URL must not include embedded credentials.");
263
+ }
264
+ if (isBlockedHostname(url.hostname)) {
265
+ throw new Error("Image URL host is not allowed. Use a public HTTPS image URL.");
266
+ }
267
+ if (isIP(url.hostname)) {
268
+ if (isBlockedIp(url.hostname)) {
269
+ throw new Error("Image URL points to a private/local IP address, which is not allowed.");
270
+ }
271
+ return;
272
+ }
273
+ const addresses = await lookup(url.hostname, { all: true });
274
+ if (addresses.some((item) => isBlockedIp(item.address))) {
275
+ throw new Error("Image URL resolves to a private/local IP address, which is not allowed.");
276
+ }
277
+ }
278
+ async function responseBufferWithLimit(response, maxBytes) {
279
+ const body = response.body;
280
+ if (!body) {
281
+ const bytes = Buffer.from(await response.arrayBuffer());
282
+ if (bytes.byteLength > maxBytes)
283
+ throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
284
+ return bytes;
285
+ }
286
+ const reader = body.getReader();
287
+ const chunks = [];
288
+ let total = 0;
289
+ try {
290
+ while (true) {
291
+ const { done, value } = await reader.read();
292
+ if (done)
293
+ break;
294
+ const chunk = Buffer.from(value);
295
+ total += chunk.byteLength;
296
+ if (total > maxBytes) {
297
+ throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
298
+ }
299
+ chunks.push(chunk);
300
+ }
301
+ }
302
+ finally {
303
+ reader.releaseLock();
304
+ }
305
+ return Buffer.concat(chunks, total);
306
+ }
307
+ async function readRemoteImage(rawUrl, redirects = 0) {
308
+ if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
309
+ throw new Error("Image URL has too many redirects.");
310
+ }
311
+ const url = new URL(rawUrl);
312
+ await assertRemoteImageUrlAllowed(url);
313
+ const controller = new AbortController();
314
+ const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
315
+ try {
316
+ const response = await fetch(url, {
317
+ redirect: "manual",
318
+ signal: controller.signal,
319
+ headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
320
+ });
321
+ if (response.status >= 300 && response.status < 400) {
322
+ const location = response.headers.get("location");
323
+ if (!location)
324
+ throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
325
+ return readRemoteImage(new URL(location, url).toString(), redirects + 1);
326
+ }
327
+ if (!response.ok) {
328
+ throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
329
+ }
330
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
331
+ if (!contentType || !contentType.startsWith("image/")) {
332
+ throw new Error("Image URL must return Content-Type image/*.");
333
+ }
334
+ if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
335
+ throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
336
+ }
337
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
338
+ if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
339
+ throw new Error("Image URL is larger than 15MB.");
340
+ }
341
+ return responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
342
+ }
343
+ catch (err) {
344
+ if (err instanceof Error && err.name === "AbortError") {
345
+ throw new Error("Image URL fetch timed out.");
346
+ }
347
+ throw err;
348
+ }
349
+ finally {
350
+ clearTimeout(timeout);
351
+ }
352
+ }
353
+ async function readLocalImage(inputPath) {
354
+ const filePath = resolveLocalImagePath(inputPath);
355
+ const info = await stat(filePath);
356
+ if (!info.isFile())
357
+ throw new Error("Image path must point to a regular file.");
358
+ if (info.size > MAX_SOURCE_IMAGE_BYTES)
359
+ throw new Error("Image file is larger than 15MB.");
360
+ return readFile(filePath);
361
+ }
362
+ async function readImageSource(source) {
363
+ if (source.path)
364
+ return readLocalImage(source.path);
365
+ if (source.url)
366
+ return readRemoteImage(source.url);
367
+ throw new Error("Provide image source path or url.");
368
+ }
369
+ async function compressToMasterWebp(input) {
370
+ const metadata = await sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS }).metadata();
371
+ if (!metadata.format || !ALLOWED_SOURCE_IMAGE_FORMATS.has(metadata.format)) {
372
+ throw new Error(`Unsupported image format ${metadata.format ?? "unknown"}. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
373
+ }
374
+ const transformer = sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS })
375
+ .rotate()
376
+ .resize({
377
+ width: MAX_MASTER_IMAGE_DIMENSION,
378
+ height: MAX_MASTER_IMAGE_DIMENSION,
379
+ fit: "inside",
380
+ withoutEnlargement: true,
381
+ });
382
+ let last = null;
383
+ for (const quality of [88, 84, 80, 76, 72]) {
384
+ const out = await transformer.clone().webp({ quality }).toBuffer();
385
+ last = out;
386
+ if (out.byteLength <= TARGET_MASTER_IMAGE_BYTES)
387
+ return out;
388
+ }
389
+ if (!last)
390
+ throw new Error("Image compression failed.");
391
+ if (last.byteLength > MAX_MASTER_IMAGE_BYTES) {
392
+ throw new Error("Compressed image is still larger than Firebase Storage 10MB limit.");
393
+ }
394
+ return last;
395
+ }
396
+ function readImageRef(eventData, kind, cardId) {
397
+ const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
398
+ if (kind === "promoBanner") {
399
+ const banner = (blocks.promoBanner && typeof blocks.promoBanner === "object" ? blocks.promoBanner : {});
400
+ return {
401
+ imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
402
+ imageUrl: safeString(banner.imageUrl) || null,
403
+ imageMasterUrl: safeString(banner.imageMasterUrl) || null,
404
+ };
405
+ }
406
+ if (kind === "videoPlaceholder") {
407
+ const video = (blocks.video && typeof blocks.video === "object" ? blocks.video : {});
408
+ const placeholder = (video.placeholder && typeof video.placeholder === "object" ? video.placeholder : {});
409
+ return {
410
+ imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
411
+ imageUrl: safeString(placeholder.imageUrl) || null,
412
+ imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
413
+ };
414
+ }
415
+ if (!cardId)
416
+ throw new Error("cardId is required when kind is card.");
417
+ const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
418
+ const items = Array.isArray(cards.items) ? cards.items : [];
419
+ const item = items.find((raw) => Boolean(raw) && typeof raw === "object" && raw.id === cardId);
420
+ if (!item)
421
+ throw new Error(`Card ${cardId} not found in blocks.cards.items. Create/configure the card before uploading its image.`);
422
+ return {
423
+ imageId: cardId,
424
+ imageUrl: safeString(item.imageUrl) || null,
425
+ imageMasterUrl: safeString(item.imageMasterUrl) || null,
426
+ };
427
+ }
428
+ function cardItemsWithImage(eventData, cardId, patch) {
429
+ const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
430
+ const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
431
+ const items = Array.isArray(cards.items) ? cards.items : [];
432
+ let found = false;
433
+ const next = items.map((raw) => {
434
+ const item = (raw && typeof raw === "object" ? raw : {});
435
+ if (item.id !== cardId)
436
+ return item;
437
+ found = true;
438
+ return { ...item, ...patch };
439
+ });
440
+ if (!found)
441
+ throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
442
+ return next;
443
+ }
444
+ async function deleteStoragePaths(paths) {
445
+ const warnings = [];
446
+ for (const path of [...new Set(paths.filter(Boolean))]) {
447
+ try {
448
+ await deleteObject(storageRef(storage, path));
449
+ }
450
+ catch (err) {
451
+ const msg = err instanceof Error ? err.message : String(err);
452
+ warnings.push(`${path}: ${msg}`);
453
+ }
454
+ }
455
+ return warnings;
456
+ }
457
+ async function deleteStorageUrls(masterUrl, fallbackUrl) {
458
+ const paths = [];
459
+ const masterPath = storagePathFromDownloadUrl(masterUrl);
460
+ if (masterPath) {
461
+ paths.push(masterPath, ...variantPathsFromMasterPath(masterPath));
462
+ }
463
+ else {
464
+ const fallbackPath = storagePathFromDownloadUrl(fallbackUrl);
465
+ if (fallbackPath)
466
+ paths.push(fallbackPath);
467
+ }
468
+ const warnings = await deleteStoragePaths(paths);
469
+ return { attempted: [...new Set(paths)].length, warnings };
470
+ }
471
+ async function uploadMasterImage(path, image) {
472
+ const ref = storageRef(storage, path);
473
+ await uploadBytes(ref, new Uint8Array(image), {
474
+ contentType: "image/webp",
475
+ cacheControl: "private, no-store",
476
+ customMetadata: { "Cache-Control": "private, no-store" },
477
+ });
478
+ return getDownloadURL(ref);
479
+ }
157
480
  async function authed(scopes) {
158
481
  return ensureAuth(scopes);
159
482
  }
@@ -787,6 +1110,163 @@ export function registerTools(server) {
787
1110
  await updateDoc(doc(firestore, "events", args.eventId), patch);
788
1111
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
789
1112
  });
1113
+ server.registerTool("oko_upload_image", {
1114
+ title: "Upload event image",
1115
+ description: toolDescription({
1116
+ summary: "Upload and compress an image for an OKO setup block, then write the master URL back to Firestore.",
1117
+ args: [
1118
+ "eventId: event to edit.",
1119
+ "kind: promoBanner, videoPlaceholder, or card.",
1120
+ "cardId: required when kind=card; must match an existing blocks.cards.items id.",
1121
+ "source: exactly one of local path or HTTPS image URL.",
1122
+ ],
1123
+ returns: "eventId, kind, imageId, Storage path, masterUrl, byte counts, and a note that CF-generated variants are pending.",
1124
+ examples: [
1125
+ 'Use kind="promoBanner" with source.url to set a webinar banner from an image link.',
1126
+ 'Use kind="card" with cardId after the card item already exists.',
1127
+ ],
1128
+ notes: [
1129
+ "The MCP server reads local paths on the organizer machine.",
1130
+ "Remote URLs must be public HTTPS images, max 15MB; private/local hosts are blocked.",
1131
+ "For card images, imageId is the cardId so OKO CF4 can update the correct item.",
1132
+ ],
1133
+ }),
1134
+ inputSchema: {
1135
+ eventId: eventIdField,
1136
+ kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
1137
+ cardId: z.string().min(1).optional().describe("Required when kind=card."),
1138
+ source: imageSourceSchema.describe("Image source: exactly one of path or url."),
1139
+ },
1140
+ outputSchema: imageOutputSchema,
1141
+ annotations: writeTool,
1142
+ }, async ({ eventId, kind, cardId, source }) => {
1143
+ const { uid } = await authed(["setup:write"]);
1144
+ if (kind === "card" && !cardId)
1145
+ throw new Error("cardId is required when kind is card.");
1146
+ if (kind !== "card" && cardId)
1147
+ throw new Error("cardId is only valid when kind is card.");
1148
+ const event = await readEvent(eventId, uid, "edit");
1149
+ const previous = readImageRef(event, kind, cardId);
1150
+ const sourceBuffer = await readImageSource(source);
1151
+ const masterBuffer = await compressToMasterWebp(sourceBuffer);
1152
+ const imageId = kind === "card" ? cardId : randomUUID();
1153
+ const masterPath = imageStoragePath(eventId, uid, kind, imageId);
1154
+ const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
1155
+ const deleteWarnings = [];
1156
+ let deletesAttempted = 0;
1157
+ if (kind === "card" && previousMasterPath === masterPath) {
1158
+ const variantPaths = variantPathsFromMasterPath(masterPath);
1159
+ deletesAttempted += variantPaths.length;
1160
+ deleteWarnings.push(...await deleteStoragePaths(variantPaths));
1161
+ }
1162
+ let masterUrl = null;
1163
+ try {
1164
+ masterUrl = await uploadMasterImage(masterPath, masterBuffer);
1165
+ const patch = {};
1166
+ if (kind === "promoBanner") {
1167
+ patch["blocks.promoBanner.imageMasterUrl"] = masterUrl;
1168
+ patch["blocks.promoBanner.imageUrl"] = masterUrl;
1169
+ patch["blocks.promoBanner.imageVariants"] = null;
1170
+ }
1171
+ else if (kind === "videoPlaceholder") {
1172
+ patch["blocks.video.placeholder.imageMasterUrl"] = masterUrl;
1173
+ patch["blocks.video.placeholder.imageUrl"] = masterUrl;
1174
+ patch["blocks.video.placeholder.imageVariants"] = null;
1175
+ }
1176
+ else {
1177
+ patch["blocks.cards.items"] = cardItemsWithImage(event, cardId, {
1178
+ imageUrl: masterUrl,
1179
+ imageMasterUrl: masterUrl,
1180
+ imageVariants: null,
1181
+ });
1182
+ }
1183
+ await updateDoc(doc(firestore, "events", eventId), patch);
1184
+ }
1185
+ catch (err) {
1186
+ if (masterUrl) {
1187
+ await deleteStoragePaths([masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
1188
+ }
1189
+ throw err;
1190
+ }
1191
+ if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
1192
+ const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
1193
+ deletesAttempted += cleanup.attempted;
1194
+ deleteWarnings.push(...cleanup.warnings);
1195
+ }
1196
+ return textResult({
1197
+ eventId,
1198
+ kind,
1199
+ imageId,
1200
+ storagePath: masterPath,
1201
+ masterUrl,
1202
+ variantsPending: true,
1203
+ sourceBytes: sourceBuffer.byteLength,
1204
+ masterBytes: masterBuffer.byteLength,
1205
+ note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
1206
+ storageDeletesAttempted: deletesAttempted,
1207
+ storageDeleteWarnings: deleteWarnings,
1208
+ });
1209
+ });
1210
+ server.registerTool("oko_delete_image", {
1211
+ title: "Delete event image",
1212
+ description: toolDescription({
1213
+ summary: "Delete an OKO setup block image from Storage and clear the corresponding Firestore image fields.",
1214
+ args: [
1215
+ "eventId: event to edit.",
1216
+ "kind: promoBanner, videoPlaceholder, or card.",
1217
+ "cardId: required when kind=card.",
1218
+ "confirm: must be true.",
1219
+ ],
1220
+ returns: "eventId, kind, deleted=true, and best-effort Storage cleanup warnings if any.",
1221
+ examples: ["Use only after confirming the exact event and image slot with the user."],
1222
+ notes: ["Destructive. Storage cleanup mirrors the web Setup screen: failed object deletes do not block clearing Firestore fields."],
1223
+ }),
1224
+ inputSchema: {
1225
+ eventId: eventIdField,
1226
+ kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
1227
+ cardId: z.string().min(1).optional().describe("Required when kind=card."),
1228
+ confirm: z.boolean().optional().describe("Required true for image deletion."),
1229
+ },
1230
+ outputSchema: deleteImageOutputSchema,
1231
+ annotations: destructiveTool,
1232
+ }, async ({ eventId, kind, cardId, confirm }) => {
1233
+ const { uid } = await authed(["setup:write"]);
1234
+ if (kind === "card" && !cardId)
1235
+ throw new Error("cardId is required when kind is card.");
1236
+ if (kind !== "card" && cardId)
1237
+ throw new Error("cardId is only valid when kind is card.");
1238
+ const event = await readEvent(eventId, uid, "edit");
1239
+ const previous = readImageRef(event, kind, cardId);
1240
+ requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
1241
+ const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
1242
+ const patch = {};
1243
+ if (kind === "promoBanner") {
1244
+ patch["blocks.promoBanner.imageMasterUrl"] = deleteField();
1245
+ patch["blocks.promoBanner.imageUrl"] = deleteField();
1246
+ patch["blocks.promoBanner.imageVariants"] = deleteField();
1247
+ }
1248
+ else if (kind === "videoPlaceholder") {
1249
+ patch["blocks.video.placeholder.imageMasterUrl"] = deleteField();
1250
+ patch["blocks.video.placeholder.imageUrl"] = deleteField();
1251
+ patch["blocks.video.placeholder.imageVariants"] = deleteField();
1252
+ }
1253
+ else {
1254
+ patch["blocks.cards.items"] = cardItemsWithImage(event, cardId, {
1255
+ imageUrl: null,
1256
+ imageMasterUrl: null,
1257
+ imageVariants: null,
1258
+ });
1259
+ }
1260
+ await updateDoc(doc(firestore, "events", eventId), patch);
1261
+ return textResult({
1262
+ eventId,
1263
+ kind,
1264
+ imageId: previous.imageId ?? (kind === "card" ? cardId : null),
1265
+ deleted: true,
1266
+ storageDeletesAttempted: cleanup.attempted,
1267
+ storageDeleteWarnings: cleanup.warnings,
1268
+ });
1269
+ });
790
1270
  server.registerTool("oko_delete_event", {
791
1271
  title: "Delete event",
792
1272
  description: toolDescription({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for managing OKO Stream webinars as an organizer.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
33
  "dotenv": "^17.2.3",
34
34
  "firebase": "^12.10.0",
35
+ "sharp": "^0.33.5",
35
36
  "zod": "^3.25.76"
36
37
  },
37
38
  "devDependencies": {