@okostream/mcp 0.1.0 → 0.1.2

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,9 @@ Write/control tools include:
106
106
  - `oko_create_webinar`
107
107
  - `oko_update_event`
108
108
  - `oko_configure_blocks`
109
+ - `oko_configure_cards`
110
+ - `oko_upload_image`
111
+ - `oko_delete_image`
109
112
  - `oko_set_block_visibility`
110
113
  - `oko_refresh_viewers`
111
114
  - `oko_start_session`
@@ -128,7 +131,10 @@ Dangerous or external side-effect tools require `confirm: true`.
128
131
  1. `oko_whoami` should return the organizer UID and granted scopes.
129
132
  2. `oko_list_my_events` should return only events visible to that UID.
130
133
  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.
134
+ 4. `oko_configure_cards` should create/update `blocks.cards.items` and preserve card image fields by stable card id.
135
+ 5. `oko_upload_image` should accept a local path or HTTPS image URL, upload `master.webp`, and let OKO generate variants.
136
+ 6. `oko_delete_image` should clear image fields and best-effort delete master/variants.
137
+ 7. `oko_get_analytics` should read an existing event with analytics sessions.
132
138
 
133
139
  CLI help:
134
140
 
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.2",
26
26
  });
27
27
  registerTools(server);
28
28
  const transport = new StdioServerTransport();
package/dist/tools.js CHANGED
@@ -1,12 +1,38 @@
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 CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
21
+ const CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
22
+ const MAX_CARD_ITEMS = 6;
23
+ const MAX_CARD_TITLE_LENGTH = 160;
24
+ const MAX_CARD_TEXT_LENGTH = 500;
25
+ const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
26
+ const MAX_CARD_BUTTON_URL_LENGTH = 1000;
27
+ const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
28
+ const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
29
+ const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
30
+ const TARGET_MASTER_IMAGE_BYTES = 600 * 1024;
31
+ const MAX_MASTER_IMAGE_DIMENSION = 1600;
32
+ const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
33
+ const REMOTE_IMAGE_MAX_REDIRECTS = 3;
34
+ const ALLOWED_SOURCE_IMAGE_FORMATS = new Set(["jpeg", "png", "webp", "gif", "avif"]);
35
+ const ALLOWED_REMOTE_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
10
36
  const MAX_FIRESTORE_TIMELINE_BUCKETS = 1500;
11
37
  const DEFAULT_LIST_LIMIT = 30;
12
38
  const MAX_LIST_LIMIT = 100;
@@ -95,6 +121,25 @@ const leadsOutputSchema = z.object({
95
121
  csv: z.string().optional(),
96
122
  ...cursorMetaSchema,
97
123
  }).passthrough();
124
+ const imageOutputSchema = z.object({
125
+ eventId: z.string(),
126
+ kind: z.enum(IMAGE_KINDS),
127
+ imageId: z.string(),
128
+ storagePath: z.string(),
129
+ masterUrl: z.string().url(),
130
+ variantsPending: z.boolean(),
131
+ sourceBytes: z.number().int().nonnegative(),
132
+ masterBytes: z.number().int().nonnegative(),
133
+ note: z.string(),
134
+ }).passthrough();
135
+ const deleteImageOutputSchema = z.object({
136
+ eventId: z.string(),
137
+ kind: z.enum(IMAGE_KINDS),
138
+ imageId: z.string().nullable(),
139
+ deleted: z.boolean(),
140
+ storageDeletesAttempted: z.number().int().nonnegative(),
141
+ storageDeleteWarnings: z.array(z.string()),
142
+ }).passthrough();
98
143
  const formFieldSchema = z.object({
99
144
  id: z.string().min(1).optional(),
100
145
  label: z.string().max(120),
@@ -112,6 +157,38 @@ const formSchema = z.object({
112
157
  consentRequired: z.boolean().default(true),
113
158
  fields: z.array(formFieldSchema).max(30),
114
159
  }).strict();
160
+ const imageSourceSchema = z.object({
161
+ path: z.string().min(1).optional().describe("Local image path on the MCP client machine."),
162
+ url: z.string().url().optional().describe("HTTPS image URL to fetch from the MCP client machine."),
163
+ }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
164
+ message: "Provide exactly one image source: path or url.",
165
+ });
166
+ const cardIdSchema = z.string()
167
+ .regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.")
168
+ .describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
169
+ const cardItemInputSchema = z.object({
170
+ id: cardIdSchema.optional(),
171
+ title: z.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
172
+ text: z.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
173
+ buttonLabel: z.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
174
+ buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
175
+ formId: cardIdSchema.nullable().optional(),
176
+ }).strict();
177
+ const cardsConfigOutputSchema = z.object({
178
+ eventId: z.string(),
179
+ updated: z.array(z.string()),
180
+ cards: z.object({
181
+ enabled: z.boolean(),
182
+ visibility: z.enum(["always", "hidden"]),
183
+ cardImageRatio: z.enum(CARD_IMAGE_RATIOS),
184
+ items: z.array(unknownRecordSchema),
185
+ }).passthrough(),
186
+ generatedCardIds: z.array(z.string()),
187
+ removedCardIds: z.array(z.string()),
188
+ removedCardsWithImages: z.array(z.string()),
189
+ preservedImageCardIds: z.array(z.string()),
190
+ warnings: z.array(z.string()),
191
+ }).passthrough();
115
192
  const workspacePermissionsSchema = z.object({
116
193
  createEvents: z.boolean().optional(),
117
194
  editSetup: z.boolean().optional(),
@@ -154,6 +231,391 @@ function pickPage(items, max) {
154
231
  function randomId() {
155
232
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
156
233
  }
234
+ function imageStoragePath(eventId, uid, kind, imageId) {
235
+ return `events/${eventId}/${uid}/images/${kind}/${imageId}/master.webp`;
236
+ }
237
+ function storagePathFromDownloadUrl(downloadUrl) {
238
+ if (!downloadUrl)
239
+ return null;
240
+ const match = downloadUrl.match(/\/o\/([^?]+)/);
241
+ if (!match)
242
+ return null;
243
+ const encodedPath = match[1];
244
+ if (!encodedPath)
245
+ return null;
246
+ try {
247
+ return decodeURIComponent(encodedPath);
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ function variantPathsFromMasterPath(masterPath) {
254
+ if (!masterPath.endsWith("/master.webp"))
255
+ return [];
256
+ return IMAGE_VARIANT_NAMES.map((name) => masterPath.replace(/master\.webp$/, name));
257
+ }
258
+ function resolveLocalImagePath(inputPath) {
259
+ const expanded = inputPath === "~" || inputPath.startsWith("~/")
260
+ ? join(homedir(), inputPath.slice(2))
261
+ : inputPath;
262
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
263
+ }
264
+ function isBlockedHostname(hostname) {
265
+ const host = hostname.toLowerCase();
266
+ return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
267
+ }
268
+ function isBlockedIp(address) {
269
+ const normalized = address.toLowerCase().startsWith("::ffff:")
270
+ ? address.slice(7)
271
+ : address;
272
+ const version = isIP(normalized);
273
+ if (version === 4) {
274
+ const [a = -1, b = -1] = normalized.split(".").map((part) => Number(part));
275
+ return a === 0
276
+ || a === 10
277
+ || a === 127
278
+ || (a === 169 && b === 254)
279
+ || (a === 172 && b >= 16 && b <= 31)
280
+ || (a === 192 && b === 168);
281
+ }
282
+ if (version === 6) {
283
+ return normalized === "::1"
284
+ || normalized.startsWith("fc")
285
+ || normalized.startsWith("fd")
286
+ || normalized.startsWith("fe80:");
287
+ }
288
+ return false;
289
+ }
290
+ async function assertRemoteImageUrlAllowed(url) {
291
+ if (url.protocol !== "https:") {
292
+ throw new Error("Image URL must use https://.");
293
+ }
294
+ if (url.username || url.password) {
295
+ throw new Error("Image URL must not include embedded credentials.");
296
+ }
297
+ if (isBlockedHostname(url.hostname)) {
298
+ throw new Error("Image URL host is not allowed. Use a public HTTPS image URL.");
299
+ }
300
+ if (isIP(url.hostname)) {
301
+ if (isBlockedIp(url.hostname)) {
302
+ throw new Error("Image URL points to a private/local IP address, which is not allowed.");
303
+ }
304
+ return;
305
+ }
306
+ const addresses = await lookup(url.hostname, { all: true });
307
+ if (addresses.some((item) => isBlockedIp(item.address))) {
308
+ throw new Error("Image URL resolves to a private/local IP address, which is not allowed.");
309
+ }
310
+ }
311
+ async function responseBufferWithLimit(response, maxBytes) {
312
+ const body = response.body;
313
+ if (!body) {
314
+ const bytes = Buffer.from(await response.arrayBuffer());
315
+ if (bytes.byteLength > maxBytes)
316
+ throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
317
+ return bytes;
318
+ }
319
+ const reader = body.getReader();
320
+ const chunks = [];
321
+ let total = 0;
322
+ try {
323
+ while (true) {
324
+ const { done, value } = await reader.read();
325
+ if (done)
326
+ break;
327
+ const chunk = Buffer.from(value);
328
+ total += chunk.byteLength;
329
+ if (total > maxBytes) {
330
+ throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
331
+ }
332
+ chunks.push(chunk);
333
+ }
334
+ }
335
+ finally {
336
+ reader.releaseLock();
337
+ }
338
+ return Buffer.concat(chunks, total);
339
+ }
340
+ async function readRemoteImage(rawUrl, redirects = 0) {
341
+ if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
342
+ throw new Error("Image URL has too many redirects.");
343
+ }
344
+ const url = new URL(rawUrl);
345
+ await assertRemoteImageUrlAllowed(url);
346
+ const controller = new AbortController();
347
+ const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
348
+ try {
349
+ const response = await fetch(url, {
350
+ redirect: "manual",
351
+ signal: controller.signal,
352
+ headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
353
+ });
354
+ if (response.status >= 300 && response.status < 400) {
355
+ const location = response.headers.get("location");
356
+ if (!location)
357
+ throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
358
+ return readRemoteImage(new URL(location, url).toString(), redirects + 1);
359
+ }
360
+ if (!response.ok) {
361
+ throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
362
+ }
363
+ const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
364
+ if (!contentType || !contentType.startsWith("image/")) {
365
+ throw new Error("Image URL must return Content-Type image/*.");
366
+ }
367
+ if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
368
+ throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
369
+ }
370
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
371
+ if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
372
+ throw new Error("Image URL is larger than 15MB.");
373
+ }
374
+ return responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
375
+ }
376
+ catch (err) {
377
+ if (err instanceof Error && err.name === "AbortError") {
378
+ throw new Error("Image URL fetch timed out.");
379
+ }
380
+ throw err;
381
+ }
382
+ finally {
383
+ clearTimeout(timeout);
384
+ }
385
+ }
386
+ async function readLocalImage(inputPath) {
387
+ const filePath = resolveLocalImagePath(inputPath);
388
+ const info = await stat(filePath);
389
+ if (!info.isFile())
390
+ throw new Error("Image path must point to a regular file.");
391
+ if (info.size > MAX_SOURCE_IMAGE_BYTES)
392
+ throw new Error("Image file is larger than 15MB.");
393
+ return readFile(filePath);
394
+ }
395
+ async function readImageSource(source) {
396
+ if (source.path)
397
+ return readLocalImage(source.path);
398
+ if (source.url)
399
+ return readRemoteImage(source.url);
400
+ throw new Error("Provide image source path or url.");
401
+ }
402
+ async function compressToMasterWebp(input) {
403
+ const metadata = await sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS }).metadata();
404
+ if (!metadata.format || !ALLOWED_SOURCE_IMAGE_FORMATS.has(metadata.format)) {
405
+ throw new Error(`Unsupported image format ${metadata.format ?? "unknown"}. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
406
+ }
407
+ const transformer = sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS })
408
+ .rotate()
409
+ .resize({
410
+ width: MAX_MASTER_IMAGE_DIMENSION,
411
+ height: MAX_MASTER_IMAGE_DIMENSION,
412
+ fit: "inside",
413
+ withoutEnlargement: true,
414
+ });
415
+ let last = null;
416
+ for (const quality of [88, 84, 80, 76, 72]) {
417
+ const out = await transformer.clone().webp({ quality }).toBuffer();
418
+ last = out;
419
+ if (out.byteLength <= TARGET_MASTER_IMAGE_BYTES)
420
+ return out;
421
+ }
422
+ if (!last)
423
+ throw new Error("Image compression failed.");
424
+ if (last.byteLength > MAX_MASTER_IMAGE_BYTES) {
425
+ throw new Error("Compressed image is still larger than Firebase Storage 10MB limit.");
426
+ }
427
+ return last;
428
+ }
429
+ function readImageRef(eventData, kind, cardId) {
430
+ const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
431
+ if (kind === "promoBanner") {
432
+ const banner = (blocks.promoBanner && typeof blocks.promoBanner === "object" ? blocks.promoBanner : {});
433
+ return {
434
+ imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
435
+ imageUrl: safeString(banner.imageUrl) || null,
436
+ imageMasterUrl: safeString(banner.imageMasterUrl) || null,
437
+ };
438
+ }
439
+ if (kind === "videoPlaceholder") {
440
+ const video = (blocks.video && typeof blocks.video === "object" ? blocks.video : {});
441
+ const placeholder = (video.placeholder && typeof video.placeholder === "object" ? video.placeholder : {});
442
+ return {
443
+ imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
444
+ imageUrl: safeString(placeholder.imageUrl) || null,
445
+ imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
446
+ };
447
+ }
448
+ if (!cardId)
449
+ throw new Error("cardId is required when kind is card.");
450
+ const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
451
+ const items = Array.isArray(cards.items) ? cards.items : [];
452
+ const item = items.find((raw) => Boolean(raw) && typeof raw === "object" && raw.id === cardId);
453
+ if (!item)
454
+ throw new Error(`Card ${cardId} not found in blocks.cards.items. Create/configure the card before uploading its image.`);
455
+ return {
456
+ imageId: cardId,
457
+ imageUrl: safeString(item.imageUrl) || null,
458
+ imageMasterUrl: safeString(item.imageMasterUrl) || null,
459
+ };
460
+ }
461
+ function cardItemsWithImage(eventData, cardId, patch) {
462
+ const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
463
+ const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
464
+ const items = Array.isArray(cards.items) ? cards.items : [];
465
+ let found = false;
466
+ const next = items.map((raw) => {
467
+ const item = (raw && typeof raw === "object" ? raw : {});
468
+ if (item.id !== cardId)
469
+ return item;
470
+ found = true;
471
+ return { ...item, ...patch };
472
+ });
473
+ if (!found)
474
+ throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
475
+ return next;
476
+ }
477
+ function readImageVariants(value) {
478
+ if (!value || typeof value !== "object" || Array.isArray(value))
479
+ return null;
480
+ const data = value;
481
+ if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
482
+ return null;
483
+ }
484
+ return {
485
+ thumbUrl: data.thumbUrl,
486
+ cardUrl: data.cardUrl,
487
+ heroUrl: data.heroUrl,
488
+ };
489
+ }
490
+ function readCardsConfig(eventData) {
491
+ const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
492
+ const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
493
+ const rawItems = Array.isArray(raw.items) ? raw.items : [];
494
+ const items = rawItems
495
+ .filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
496
+ .map((item) => {
497
+ const fallbackId = randomUUID();
498
+ return {
499
+ id: typeof item.id === "string" && CARD_ID_PATTERN.test(item.id) ? item.id : fallbackId,
500
+ title: safeString(item.title).trim() || null,
501
+ text: safeString(item.text).trim() || null,
502
+ buttonLabel: safeString(item.buttonLabel).trim() || null,
503
+ buttonUrl: safeString(item.buttonUrl).trim() || null,
504
+ imageUrl: safeString(item.imageUrl).trim() || null,
505
+ imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
506
+ imageVariants: readImageVariants(item.imageVariants),
507
+ formId: safeString(item.formId).trim() || null,
508
+ };
509
+ });
510
+ const ratio = safeString(raw.cardImageRatio);
511
+ return {
512
+ enabled: raw.enabled === true,
513
+ visibility: raw.visibility === "hidden" ? "hidden" : "always",
514
+ cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
515
+ items,
516
+ };
517
+ }
518
+ function normalizeNullableText(value) {
519
+ if (value == null)
520
+ return null;
521
+ return value.trim() || null;
522
+ }
523
+ function normalizeCardButtonUrl(value) {
524
+ const trimmed = normalizeNullableText(value);
525
+ if (!trimmed)
526
+ return null;
527
+ let url;
528
+ try {
529
+ url = new URL(trimmed);
530
+ }
531
+ catch {
532
+ throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
533
+ }
534
+ if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
535
+ throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
536
+ }
537
+ return trimmed;
538
+ }
539
+ function eventFormIds(eventData) {
540
+ const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
541
+ return new Set(rawForms
542
+ .map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "")
543
+ .filter(Boolean));
544
+ }
545
+ function cardHasImage(item) {
546
+ return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
547
+ }
548
+ function buildCardsItems({ inputs, existing, formIds, }) {
549
+ const existingById = new Map(existing.map((item) => [item.id, item]));
550
+ const seen = new Set();
551
+ const generatedCardIds = [];
552
+ const preservedImageCardIds = [];
553
+ const items = inputs.map((input) => {
554
+ const id = input.id ?? randomUUID();
555
+ if (seen.has(id))
556
+ throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
557
+ seen.add(id);
558
+ if (!CARD_ID_PATTERN.test(id))
559
+ throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
560
+ if (!input.id)
561
+ generatedCardIds.push(id);
562
+ const formId = normalizeNullableText(input.formId);
563
+ if (formId && !formIds.has(formId)) {
564
+ throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
565
+ }
566
+ const previous = existingById.get(id);
567
+ if (previous && cardHasImage(previous))
568
+ preservedImageCardIds.push(id);
569
+ return {
570
+ id,
571
+ title: normalizeNullableText(input.title),
572
+ text: normalizeNullableText(input.text),
573
+ buttonLabel: normalizeNullableText(input.buttonLabel),
574
+ buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
575
+ imageUrl: previous?.imageUrl ?? null,
576
+ imageMasterUrl: previous?.imageMasterUrl ?? null,
577
+ imageVariants: previous?.imageVariants ?? null,
578
+ formId,
579
+ };
580
+ });
581
+ return { items, generatedCardIds, preservedImageCardIds };
582
+ }
583
+ async function deleteStoragePaths(paths) {
584
+ const warnings = [];
585
+ for (const path of [...new Set(paths.filter(Boolean))]) {
586
+ try {
587
+ await deleteObject(storageRef(storage, path));
588
+ }
589
+ catch (err) {
590
+ const msg = err instanceof Error ? err.message : String(err);
591
+ warnings.push(`${path}: ${msg}`);
592
+ }
593
+ }
594
+ return warnings;
595
+ }
596
+ async function deleteStorageUrls(masterUrl, fallbackUrl) {
597
+ const paths = [];
598
+ const masterPath = storagePathFromDownloadUrl(masterUrl);
599
+ if (masterPath) {
600
+ paths.push(masterPath, ...variantPathsFromMasterPath(masterPath));
601
+ }
602
+ else {
603
+ const fallbackPath = storagePathFromDownloadUrl(fallbackUrl);
604
+ if (fallbackPath)
605
+ paths.push(fallbackPath);
606
+ }
607
+ const warnings = await deleteStoragePaths(paths);
608
+ return { attempted: [...new Set(paths)].length, warnings };
609
+ }
610
+ async function uploadMasterImage(path, image) {
611
+ const ref = storageRef(storage, path);
612
+ await uploadBytes(ref, new Uint8Array(image), {
613
+ contentType: "image/webp",
614
+ cacheControl: "private, no-store",
615
+ customMetadata: { "Cache-Control": "private, no-store" },
616
+ });
617
+ return getDownloadURL(ref);
618
+ }
157
619
  async function authed(scopes) {
158
620
  return ensureAuth(scopes);
159
621
  }
@@ -787,6 +1249,249 @@ export function registerTools(server) {
787
1249
  await updateDoc(doc(firestore, "events", args.eventId), patch);
788
1250
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
789
1251
  });
1252
+ server.registerTool("oko_configure_cards", {
1253
+ title: "Configure event cards",
1254
+ description: toolDescription({
1255
+ summary: "Configure the Cards setup block and replace its card list. Images are managed separately through oko_upload_image and are preserved by matching card id.",
1256
+ args: [
1257
+ "eventId: event to edit.",
1258
+ "enabled: optional Cards block enabled flag.",
1259
+ "visibility: optional default visibility, always or hidden.",
1260
+ "cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
1261
+ "items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
1262
+ ],
1263
+ returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings.",
1264
+ examples: [
1265
+ "Use after oko_create_webinar to create cards before uploading per-card images.",
1266
+ "Call oko_get_event first and pass the full desired items array when editing existing cards.",
1267
+ "Use formId instead of buttonUrl when a card button should open a lead form.",
1268
+ ],
1269
+ notes: [
1270
+ "This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
1271
+ "Existing card images are preserved only when the same card id remains in the replacement list.",
1272
+ "buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
1273
+ "If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior.",
1274
+ ],
1275
+ }),
1276
+ inputSchema: {
1277
+ eventId: eventIdField,
1278
+ enabled: z.boolean().optional().describe("Whether the Cards block is enabled in setup."),
1279
+ visibility: z.enum(["always", "hidden"]).optional().describe("Default visibility when no live override exists."),
1280
+ cardImageRatio: z.enum(CARD_IMAGE_RATIOS).optional().describe("Image aspect ratio for all cards."),
1281
+ items: z.array(cardItemInputSchema).max(MAX_CARD_ITEMS).optional().describe("Full replacement list, max 6 cards. Omit to update only block settings."),
1282
+ },
1283
+ outputSchema: cardsConfigOutputSchema,
1284
+ annotations: writeTool,
1285
+ }, async (args) => {
1286
+ const { uid } = await authed(["setup:write"]);
1287
+ const event = await readEvent(args.eventId, uid, "edit");
1288
+ const currentCards = readCardsConfig(event);
1289
+ const patch = {};
1290
+ const finalCards = {
1291
+ enabled: args.enabled ?? currentCards.enabled,
1292
+ visibility: args.visibility ?? currentCards.visibility,
1293
+ cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
1294
+ items: currentCards.items,
1295
+ };
1296
+ const generatedCardIds = [];
1297
+ const preservedImageCardIds = [];
1298
+ let removedCardIds = [];
1299
+ let removedCardsWithImages = [];
1300
+ if (args.enabled !== undefined)
1301
+ patch["blocks.cards.enabled"] = args.enabled;
1302
+ if (args.visibility !== undefined)
1303
+ patch["blocks.cards.visibility"] = args.visibility;
1304
+ if (args.cardImageRatio !== undefined)
1305
+ patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
1306
+ if (args.items !== undefined) {
1307
+ const built = buildCardsItems({
1308
+ inputs: args.items,
1309
+ existing: currentCards.items,
1310
+ formIds: eventFormIds(event),
1311
+ });
1312
+ finalCards.items = built.items;
1313
+ generatedCardIds.push(...built.generatedCardIds);
1314
+ preservedImageCardIds.push(...built.preservedImageCardIds);
1315
+ const nextIds = new Set(built.items.map((item) => item.id));
1316
+ const removed = currentCards.items.filter((item) => !nextIds.has(item.id));
1317
+ removedCardIds = removed.map((item) => item.id);
1318
+ removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
1319
+ patch["blocks.cards.items"] = built.items;
1320
+ }
1321
+ if (Object.keys(patch).length > 0) {
1322
+ await updateDoc(doc(firestore, "events", args.eventId), patch);
1323
+ }
1324
+ const warnings = removedCardsWithImages.length > 0
1325
+ ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
1326
+ : [];
1327
+ return textResult({
1328
+ eventId: args.eventId,
1329
+ updated: Object.keys(patch),
1330
+ cards: finalCards,
1331
+ generatedCardIds,
1332
+ removedCardIds,
1333
+ removedCardsWithImages,
1334
+ preservedImageCardIds,
1335
+ warnings,
1336
+ });
1337
+ });
1338
+ server.registerTool("oko_upload_image", {
1339
+ title: "Upload event image",
1340
+ description: toolDescription({
1341
+ summary: "Upload and compress an image for an OKO setup block, then write the master URL back to Firestore.",
1342
+ args: [
1343
+ "eventId: event to edit.",
1344
+ "kind: promoBanner, videoPlaceholder, or card.",
1345
+ "cardId: required when kind=card; must match an existing blocks.cards.items id.",
1346
+ "source: exactly one of local path or HTTPS image URL.",
1347
+ ],
1348
+ returns: "eventId, kind, imageId, Storage path, masterUrl, byte counts, and a note that CF-generated variants are pending.",
1349
+ examples: [
1350
+ 'Use kind="promoBanner" with source.url to set a webinar banner from an image link.',
1351
+ 'Use kind="card" with cardId after the card item already exists.',
1352
+ ],
1353
+ notes: [
1354
+ "The MCP server reads local paths on the organizer machine.",
1355
+ "Remote URLs must be public HTTPS images, max 15MB; private/local hosts are blocked.",
1356
+ "For card images, imageId is the cardId so OKO CF4 can update the correct item.",
1357
+ ],
1358
+ }),
1359
+ inputSchema: {
1360
+ eventId: eventIdField,
1361
+ kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
1362
+ cardId: z.string().min(1).optional().describe("Required when kind=card."),
1363
+ source: imageSourceSchema.describe("Image source: exactly one of path or url."),
1364
+ },
1365
+ outputSchema: imageOutputSchema,
1366
+ annotations: writeTool,
1367
+ }, async ({ eventId, kind, cardId, source }) => {
1368
+ const { uid } = await authed(["setup:write"]);
1369
+ if (kind === "card" && !cardId)
1370
+ throw new Error("cardId is required when kind is card.");
1371
+ if (kind !== "card" && cardId)
1372
+ throw new Error("cardId is only valid when kind is card.");
1373
+ const event = await readEvent(eventId, uid, "edit");
1374
+ const previous = readImageRef(event, kind, cardId);
1375
+ const sourceBuffer = await readImageSource(source);
1376
+ const masterBuffer = await compressToMasterWebp(sourceBuffer);
1377
+ const imageId = kind === "card" ? cardId : randomUUID();
1378
+ const masterPath = imageStoragePath(eventId, uid, kind, imageId);
1379
+ const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
1380
+ const deleteWarnings = [];
1381
+ let deletesAttempted = 0;
1382
+ if (kind === "card" && previousMasterPath === masterPath) {
1383
+ const variantPaths = variantPathsFromMasterPath(masterPath);
1384
+ deletesAttempted += variantPaths.length;
1385
+ deleteWarnings.push(...await deleteStoragePaths(variantPaths));
1386
+ }
1387
+ let masterUrl = null;
1388
+ try {
1389
+ masterUrl = await uploadMasterImage(masterPath, masterBuffer);
1390
+ const patch = {};
1391
+ if (kind === "promoBanner") {
1392
+ patch["blocks.promoBanner.imageMasterUrl"] = masterUrl;
1393
+ patch["blocks.promoBanner.imageUrl"] = masterUrl;
1394
+ patch["blocks.promoBanner.imageVariants"] = null;
1395
+ }
1396
+ else if (kind === "videoPlaceholder") {
1397
+ patch["blocks.video.placeholder.imageMasterUrl"] = masterUrl;
1398
+ patch["blocks.video.placeholder.imageUrl"] = masterUrl;
1399
+ patch["blocks.video.placeholder.imageVariants"] = null;
1400
+ }
1401
+ else {
1402
+ patch["blocks.cards.items"] = cardItemsWithImage(event, cardId, {
1403
+ imageUrl: masterUrl,
1404
+ imageMasterUrl: masterUrl,
1405
+ imageVariants: null,
1406
+ });
1407
+ }
1408
+ await updateDoc(doc(firestore, "events", eventId), patch);
1409
+ }
1410
+ catch (err) {
1411
+ if (masterUrl) {
1412
+ await deleteStoragePaths([masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
1413
+ }
1414
+ throw err;
1415
+ }
1416
+ if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
1417
+ const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
1418
+ deletesAttempted += cleanup.attempted;
1419
+ deleteWarnings.push(...cleanup.warnings);
1420
+ }
1421
+ return textResult({
1422
+ eventId,
1423
+ kind,
1424
+ imageId,
1425
+ storagePath: masterPath,
1426
+ masterUrl,
1427
+ variantsPending: true,
1428
+ sourceBytes: sourceBuffer.byteLength,
1429
+ masterBytes: masterBuffer.byteLength,
1430
+ note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
1431
+ storageDeletesAttempted: deletesAttempted,
1432
+ storageDeleteWarnings: deleteWarnings,
1433
+ });
1434
+ });
1435
+ server.registerTool("oko_delete_image", {
1436
+ title: "Delete event image",
1437
+ description: toolDescription({
1438
+ summary: "Delete an OKO setup block image from Storage and clear the corresponding Firestore image fields.",
1439
+ args: [
1440
+ "eventId: event to edit.",
1441
+ "kind: promoBanner, videoPlaceholder, or card.",
1442
+ "cardId: required when kind=card.",
1443
+ "confirm: must be true.",
1444
+ ],
1445
+ returns: "eventId, kind, deleted=true, and best-effort Storage cleanup warnings if any.",
1446
+ examples: ["Use only after confirming the exact event and image slot with the user."],
1447
+ notes: ["Destructive. Storage cleanup mirrors the web Setup screen: failed object deletes do not block clearing Firestore fields."],
1448
+ }),
1449
+ inputSchema: {
1450
+ eventId: eventIdField,
1451
+ kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
1452
+ cardId: z.string().min(1).optional().describe("Required when kind=card."),
1453
+ confirm: z.boolean().optional().describe("Required true for image deletion."),
1454
+ },
1455
+ outputSchema: deleteImageOutputSchema,
1456
+ annotations: destructiveTool,
1457
+ }, async ({ eventId, kind, cardId, confirm }) => {
1458
+ const { uid } = await authed(["setup:write"]);
1459
+ if (kind === "card" && !cardId)
1460
+ throw new Error("cardId is required when kind is card.");
1461
+ if (kind !== "card" && cardId)
1462
+ throw new Error("cardId is only valid when kind is card.");
1463
+ const event = await readEvent(eventId, uid, "edit");
1464
+ const previous = readImageRef(event, kind, cardId);
1465
+ requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
1466
+ const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
1467
+ const patch = {};
1468
+ if (kind === "promoBanner") {
1469
+ patch["blocks.promoBanner.imageMasterUrl"] = deleteField();
1470
+ patch["blocks.promoBanner.imageUrl"] = deleteField();
1471
+ patch["blocks.promoBanner.imageVariants"] = deleteField();
1472
+ }
1473
+ else if (kind === "videoPlaceholder") {
1474
+ patch["blocks.video.placeholder.imageMasterUrl"] = deleteField();
1475
+ patch["blocks.video.placeholder.imageUrl"] = deleteField();
1476
+ patch["blocks.video.placeholder.imageVariants"] = deleteField();
1477
+ }
1478
+ else {
1479
+ patch["blocks.cards.items"] = cardItemsWithImage(event, cardId, {
1480
+ imageUrl: null,
1481
+ imageMasterUrl: null,
1482
+ imageVariants: null,
1483
+ });
1484
+ }
1485
+ await updateDoc(doc(firestore, "events", eventId), patch);
1486
+ return textResult({
1487
+ eventId,
1488
+ kind,
1489
+ imageId: previous.imageId ?? (kind === "card" ? cardId : null),
1490
+ deleted: true,
1491
+ storageDeletesAttempted: cleanup.attempted,
1492
+ storageDeleteWarnings: cleanup.warnings,
1493
+ });
1494
+ });
790
1495
  server.registerTool("oko_delete_event", {
791
1496
  title: "Delete event",
792
1497
  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.2",
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": {