@okostream/mcp 0.1.1 → 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,7 @@ Write/control tools include:
106
106
  - `oko_create_webinar`
107
107
  - `oko_update_event`
108
108
  - `oko_configure_blocks`
109
+ - `oko_configure_cards`
109
110
  - `oko_upload_image`
110
111
  - `oko_delete_image`
111
112
  - `oko_set_block_visibility`
@@ -130,9 +131,10 @@ Dangerous or external side-effect tools require `confirm: true`.
130
131
  1. `oko_whoami` should return the organizer UID and granted scopes.
131
132
  2. `oko_list_my_events` should return only events visible to that UID.
132
133
  3. `oko_create_webinar` should create a test webinar and RTDB owner/workspace mirror.
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.
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.
136
138
 
137
139
  CLI help:
138
140
 
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.1",
25
+ version: "0.1.2",
26
26
  });
27
27
  registerTools(server);
28
28
  const transport = new StdioServerTransport();
package/dist/tools.js CHANGED
@@ -17,6 +17,13 @@ import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
17
17
  const boolOrNull = z.boolean().nullable();
18
18
  const IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
19
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;
20
27
  const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
21
28
  const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
22
29
  const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -156,6 +163,32 @@ const imageSourceSchema = z.object({
156
163
  }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
157
164
  message: "Provide exactly one image source: path or url.",
158
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();
159
192
  const workspacePermissionsSchema = z.object({
160
193
  createEvents: z.boolean().optional(),
161
194
  editSetup: z.boolean().optional(),
@@ -441,6 +474,112 @@ function cardItemsWithImage(eventData, cardId, patch) {
441
474
  throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
442
475
  return next;
443
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
+ }
444
583
  async function deleteStoragePaths(paths) {
445
584
  const warnings = [];
446
585
  for (const path of [...new Set(paths.filter(Boolean))]) {
@@ -1110,6 +1249,92 @@ export function registerTools(server) {
1110
1249
  await updateDoc(doc(firestore, "events", args.eventId), patch);
1111
1250
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
1112
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
+ });
1113
1338
  server.registerTool("oko_upload_image", {
1114
1339
  title: "Upload event image",
1115
1340
  description: toolDescription({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.1.1",
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",