@okostream/mcp 0.5.4 → 0.6.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-07-10
4
+
5
+ - Replaced legacy profile/workspace Telegram arrays with canonical server-owned channel operations.
6
+ - Added personal Telegram linking, explicit workspace sharing, channel disconnect, and event-scoped channel discovery.
7
+ - Added 30-day event trash, restore, and exact-title permanent deletion tools; retained `oko_delete_event` as a recoverable trash alias.
8
+ - Prevented cancellation of `delivery_unknown` Telegram posts and resolved channels server-side before publication to reduce duplicate or unauthorized sends.
9
+
3
10
  ## 0.5.4 - 2026-07-10
4
11
 
5
12
  - Separated NMO from the standard webinar workflow throughout instructions, resources, prompts, README, and the companion skill.
package/README.md CHANGED
@@ -127,7 +127,8 @@ Core discovery/read tools:
127
127
 
128
128
  Event/setup/media tools:
129
129
 
130
- - `oko_create_webinar`, `oko_update_event`, `oko_delete_event`
130
+ - `oko_create_webinar`, `oko_update_event`, `oko_delete_event` (30-day trash compatibility alias)
131
+ - `oko_trash_event`, `oko_restore_event`, `oko_delete_event_permanently`
131
132
  - `oko_duplicate_event`, `oko_move_event`
132
133
  - `oko_configure_blocks`, `oko_configure_cards`
133
134
  - `oko_upload_image`, `oko_upload_card_images_batch`, `oko_wait_for_image_variants`, `oko_delete_image`
@@ -145,7 +146,9 @@ Workspace and Telegram tools:
145
146
  - `oko_create_workspace`, `oko_update_workspace`, `oko_delete_workspace`
146
147
  - `oko_list_workspace_access`, `oko_update_workspace_member`, `oko_remove_workspace_member`
147
148
  - `oko_create_workspace_invite`, `oko_list_workspace_invites`, `oko_revoke_workspace_invite`, `oko_claim_workspace_invite`
148
- - `oko_list_telegram_channels`, `oko_claim_telegram_channel`, `oko_list_telegram_posts`, `oko_cancel_telegram_post`, `oko_publish_telegram_post`
149
+ - `oko_create_telegram_link`, `oko_list_telegram_channels`, `oko_claim_telegram_channel`
150
+ - `oko_set_telegram_channel_workspaces`, `oko_disconnect_telegram_channel`
151
+ - `oko_list_telegram_posts`, `oko_cancel_telegram_post`, `oko_publish_telegram_post`
149
152
 
150
153
  Dangerous or external side-effect tools require `confirm: true`.
151
154
 
@@ -163,7 +166,7 @@ Dangerous or external side-effect tools require `confirm: true`.
163
166
  10. `oko_get_session_report` should return markdown and structured session data for a completed session.
164
167
  11. `oko_list_notifications` -> `oko_mark_notifications_read` should read and clear an own disposable notification.
165
168
  12. `oko_get_analytics` should read an existing event with analytics sessions.
166
- 13. MCP protocol smoke should report 59 tools, 7 resources, 3 prompts, and non-empty initialize instructions.
169
+ 13. MCP protocol smoke should report 65 tools, 7 resources, 3 prompts, and non-empty initialize instructions.
167
170
 
168
171
  CLI help:
169
172
 
package/dist/guides.js CHANGED
@@ -118,14 +118,14 @@ const GUIDE_RESOURCES = [
118
118
  "",
119
119
  "- `events:read`: list and read event documents.",
120
120
  "- `events:write`: create, duplicate, move, and update event metadata.",
121
- "- `events:delete`: delete event records.",
121
+ "- `events:delete`: move events to trash, restore them, and permanently delete already-trashed events.",
122
122
  "- `setup:write`: configure blocks, forms, cards, and images.",
123
123
  "- `analytics:read`: read live state, sessions, reports, chat history.",
124
124
  "- `analytics:write` + `live:control`: start/end/fix/delete analytics sessions.",
125
125
  "- `live:control`: live block visibility and viewer refresh.",
126
126
  "- `chat:write`: send organizer chat messages.",
127
127
  "- `chat:moderate`: delete, mute, ban, unmute, unban, and chat settings.",
128
- "- `telegram:write`: claim/list channels and create/cancel/publish Telegram posts.",
128
+ "- `telegram:write`: link personal Telegram, claim/list/share/disconnect channels, and create/cancel/publish Telegram posts.",
129
129
  "- `workspace:read` / `workspace:write`: workspace, members, invites, notifications.",
130
130
  "- `nmo:write`: NMO participants and checkpoints.",
131
131
  "- `leads:read`: read form submissions; lead deletion also requires `setup:write`.",
@@ -166,16 +166,18 @@ const GUIDE_RESOURCES = [
166
166
  "# Connect Telegram and Publish Webinar Posts",
167
167
  "",
168
168
  "## Claim a channel",
169
- "1. Add `@OKOStreamBot` to the Telegram channel as an administrator with permission to post.",
170
- "2. The bot provides a claim code for that channel. Copy it exactly; do not invent a channel ID.",
171
- "3. Call `oko_claim_telegram_channel` with the claim code, optional workspace ids, and `confirm:true`.",
172
- "4. Call `oko_list_telegram_channels` and choose a channel by its returned title and id.",
173
- "5. Call `oko_configure_blocks` with `telegramChannelIds` to connect the claimed channel to an event.",
169
+ "1. Call `oko_create_telegram_link`, open its one-time @OKOStreamBot link in the organizer's personal Telegram account, and complete the link there.",
170
+ "2. Add `@OKOStreamBot` to the target channel as an administrator with permission to post.",
171
+ "3. The bot provides a claim code for that channel. Copy it exactly; do not invent a channel ID.",
172
+ "4. Call `oko_claim_telegram_channel` with the claim code, optional owned workspace ids, and `confirm:true`. The server verifies that the linked personal Telegram user is a channel administrator.",
173
+ "5. Call `oko_list_telegram_channels` and choose a channel by its returned title and id. Pass `eventId` when workspace-shared channels should be included.",
174
+ "6. Use `oko_set_telegram_channel_workspaces` to replace explicit workspace sharing, or `oko_disconnect_telegram_channel` to disconnect an owned channel; both require confirmation.",
175
+ "7. Call `oko_configure_blocks` with `telegramChannelIds` to connect the claimed channel to an event.",
174
176
  "",
175
177
  "## Publish safely",
176
178
  "1. Use `oko_publish_telegram_post` with the event id, returned channel id, caption, optional image/button, and `confirm:true`.",
177
179
  "2. Include `scheduleAt` for a scheduled post. Without it, the tool sends immediately to the real channel, so show the exact channel and caption before confirmation.",
178
- "3. Use `oko_list_telegram_posts` to inspect draft, scheduled, sent, or failed posts. Only cancel draft, scheduled, or failed posts with `oko_cancel_telegram_post` and `confirm:true`.",
180
+ "3. Use `oko_list_telegram_posts` to inspect draft, scheduled, sent, failed, or delivery-unknown posts. Only cancel draft, pending, scheduled, or failed posts with `oko_cancel_telegram_post` and `confirm:true`; review delivery-unknown manually to avoid duplicate publication.",
179
181
  "",
180
182
  "## Automatic reminders",
181
183
  "OKO has a platform auto-reminder job for opted-in events. The current MCP surface does not toggle `telegram.autoReminder`, so an MCP-only workflow should create a scheduled reminder post explicitly. Use the web setup if the organizer specifically needs the platform auto-reminder setting.",
@@ -190,7 +192,7 @@ const GUIDE_RESOURCES = [
190
192
  "# OKO MCP Capabilities",
191
193
  "",
192
194
  "## Webinar setup",
193
- "- Create, duplicate, move, update, validate, and delete webinars.",
195
+ "- Create, duplicate, move, update, and validate webinars; trash them for 30 days, restore them, or permanently delete an already-trashed event with exact-title confirmation.",
194
196
  "- Configure forms, CTA, banner, cards, video placeholder, and Telegram settings for standard webinars.",
195
197
  "- Upload images from local paths, data URLs, or HTTPS URLs and wait for variants.",
196
198
  "",
package/dist/server.js CHANGED
@@ -23,7 +23,7 @@ if (process.argv.includes("--help")) {
23
23
  }
24
24
  const server = new McpServer({
25
25
  name: "oko-mcp-server",
26
- version: "0.5.4",
26
+ version: "0.6.0",
27
27
  }, {
28
28
  instructions: OKO_MCP_INSTRUCTIONS,
29
29
  });
package/dist/tools.js CHANGED
@@ -2,7 +2,7 @@ import { lookup } from "node:dns/promises";
2
2
  import { stat, readFile, realpath } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { randomUUID } from "node:crypto";
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
6
  import sharp from "sharp";
7
7
  import { Agent, fetch as undiciFetch } from "undici";
8
8
  import { z } from "zod";
@@ -43,7 +43,7 @@ const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
43
43
  const MAX_NMO_IMPORT_PARTICIPANTS = 200;
44
44
  const MAX_WORKSPACE_NAME_LENGTH = 120;
45
45
  const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
46
- const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed"];
46
+ const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed", "delivery_unknown"];
47
47
  const readOnlyTool = {
48
48
  readOnlyHint: true,
49
49
  destructiveHint: false,
@@ -145,6 +145,8 @@ const imageOutputSchema = z.object({
145
145
  variantsPending: z.boolean(),
146
146
  sourceBytes: z.number().int().nonnegative(),
147
147
  masterBytes: z.number().int().nonnegative(),
148
+ imageWidth: z.number().int().positive(),
149
+ imageHeight: z.number().int().positive(),
148
150
  note: z.string(),
149
151
  }).passthrough();
150
152
  const deleteImageOutputSchema = z.object({
@@ -275,6 +277,10 @@ const telegramChannelSchema = z.object({
275
277
  title: z.string().max(200).optional(),
276
278
  username: z.string().max(120).nullable().optional(),
277
279
  }).strict();
280
+ const canonicalTelegramChannelSchema = telegramChannelSchema.extend({
281
+ workspaceIds: z.array(z.string()).max(50),
282
+ owned: z.boolean(),
283
+ }).strict();
278
284
  const profileDisplayNameSchema = z.string().max(40).nullable();
279
285
  const timezoneSchema = z.string()
280
286
  .min(1)
@@ -346,7 +352,7 @@ function pickPage(items, max) {
346
352
  };
347
353
  }
348
354
  function randomId() {
349
- return Math.random().toString(36).slice(2) + Date.now().toString(36);
355
+ return randomUUID();
350
356
  }
351
357
  function imageStoragePath(eventId, uid, kind, imageId) {
352
358
  return `events/${eventId}/${uid}/images/${kind}/${imageId}/master.webp`;
@@ -631,6 +637,8 @@ function readCardsConfig(eventData) {
631
637
  imageUrl: safeString(item.imageUrl).trim() || null,
632
638
  imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
633
639
  imageVariants: readImageVariants(item.imageVariants),
640
+ imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
641
+ imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
634
642
  formId: safeString(item.formId).trim() || null,
635
643
  };
636
644
  });
@@ -702,6 +710,8 @@ function buildCardsItems({ inputs, existing, formIds, }) {
702
710
  imageUrl: previous?.imageUrl ?? null,
703
711
  imageMasterUrl: previous?.imageMasterUrl ?? null,
704
712
  imageVariants: previous?.imageVariants ?? null,
713
+ imageWidth: previous?.imageWidth ?? null,
714
+ imageHeight: previous?.imageHeight ?? null,
705
715
  formId,
706
716
  };
707
717
  });
@@ -814,49 +824,28 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
814
824
  const previous = readImageRef(event, kind, cardId);
815
825
  const sourceBuffer = await readImageSource(source);
816
826
  const masterBuffer = await compressToMasterWebp(sourceBuffer);
817
- const imageId = kind === "card" ? cardId : randomUUID();
818
- const masterPath = imageStoragePath(eventId, uid, kind, imageId);
819
827
  const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
820
828
  const deleteWarnings = [];
821
829
  let deletesAttempted = 0;
822
- if (kind === "card" && previousMasterPath === masterPath) {
823
- const variantPaths = variantPathsFromMasterPath(masterPath);
830
+ const expectedCardPath = kind === "card" ? imageStoragePath(eventId, uid, kind, cardId) : null;
831
+ if (expectedCardPath && previousMasterPath === expectedCardPath) {
832
+ const variantPaths = variantPathsFromMasterPath(expectedCardPath);
824
833
  deletesAttempted += variantPaths.length;
825
834
  deleteWarnings.push(...await deleteStoragePaths(eventId, variantPaths));
826
835
  }
827
- let masterUrl = null;
836
+ let uploaded = null;
828
837
  try {
829
- const uploaded = await uploadMasterImage(eventId, kind, cardId, masterBuffer);
830
- masterUrl = uploaded.masterUrl;
831
- if (kind === "promoBanner") {
832
- await updateDoc(doc(firestore, "events", eventId), {
833
- "blocks.promoBanner.imageMasterUrl": masterUrl,
834
- "blocks.promoBanner.imageUrl": masterUrl,
835
- "blocks.promoBanner.imageVariants": null,
836
- });
837
- }
838
- else if (kind === "videoPlaceholder") {
839
- await updateDoc(doc(firestore, "events", eventId), {
840
- "blocks.video.placeholder.imageMasterUrl": masterUrl,
841
- "blocks.video.placeholder.imageUrl": masterUrl,
842
- "blocks.video.placeholder.imageVariants": null,
843
- });
844
- }
845
- else {
846
- await updateCardImageInTransaction(eventId, cardId, {
847
- imageUrl: masterUrl,
848
- imageMasterUrl: masterUrl,
849
- imageVariants: null,
850
- });
851
- }
838
+ uploaded = await uploadMasterImage(eventId, kind, cardId, masterBuffer);
852
839
  }
853
840
  catch (err) {
854
- if (masterUrl) {
855
- await deleteStoragePaths(eventId, [masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
841
+ if (uploaded) {
842
+ await deleteStoragePaths(eventId, [uploaded.storagePath, ...variantPathsFromMasterPath(uploaded.storagePath)]).catch(() => []);
856
843
  }
857
844
  throw err;
858
845
  }
859
- if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
846
+ if (!uploaded)
847
+ throw new Error("Image upload did not return a result.");
848
+ if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
860
849
  const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
861
850
  deletesAttempted += cleanup.attempted;
862
851
  deleteWarnings.push(...cleanup.warnings);
@@ -864,12 +853,14 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
864
853
  return {
865
854
  eventId,
866
855
  kind,
867
- imageId,
868
- storagePath: masterPath,
869
- masterUrl,
856
+ imageId: uploaded.imageId,
857
+ storagePath: uploaded.storagePath,
858
+ masterUrl: uploaded.masterUrl,
870
859
  variantsPending: true,
871
860
  sourceBytes: sourceBuffer.byteLength,
872
- masterBytes: masterBuffer.byteLength,
861
+ masterBytes: uploaded.masterBytes,
862
+ imageWidth: uploaded.imageWidth,
863
+ imageHeight: uploaded.imageHeight,
873
864
  note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
874
865
  storageDeletesAttempted: deletesAttempted,
875
866
  storageDeleteWarnings: deleteWarnings,
@@ -955,6 +946,9 @@ async function readEvent(eventId, uid, mode = "read") {
955
946
  assertEventReadable(data, uid);
956
947
  else
957
948
  assertEventAccess(data, uid, mode);
949
+ if (data.trashedAt && mode !== "read" && mode !== "delete") {
950
+ throw new Error("Event is in trash. Restore it before making changes or live actions.");
951
+ }
958
952
  return data;
959
953
  }
960
954
  function asRecord(value) {
@@ -1112,16 +1106,11 @@ function initialEventAccess(uid) {
1112
1106
  async function addEventWithRtdbMirror(uid, workspaceId, eventData) {
1113
1107
  const eventRef = await addDoc(collection(firestore, "events"), eventData);
1114
1108
  try {
1115
- await update(ref(rtdb, `events/${eventRef.id}`), { ownerId: uid, workspaceId });
1109
+ const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
1110
+ await ensureRuntime({ eventId: eventRef.id });
1116
1111
  }
1117
1112
  catch (error) {
1118
- try {
1119
- await deleteDoc(eventRef);
1120
- }
1121
- catch (rollbackError) {
1122
- throw new AggregateError([error, rollbackError], `RTDB mirror failed for event ${eventRef.id}, and Firestore rollback also failed.`);
1123
- }
1124
- throw error;
1113
+ console.error(`[OKO MCP] RTDB mirror deferred to onEventCreated for event ${eventRef.id}:`, error);
1125
1114
  }
1126
1115
  return eventRef;
1127
1116
  }
@@ -1160,7 +1149,7 @@ function normalizeWorkspacePermissions(value) {
1160
1149
  return out;
1161
1150
  }
1162
1151
  function randomInviteCode() {
1163
- return Math.random().toString(36).slice(2, 8).toUpperCase();
1152
+ return randomBytes(8).toString("hex").toUpperCase();
1164
1153
  }
1165
1154
  function normalizeEmail(value) {
1166
1155
  return value.trim().toLowerCase();
@@ -1236,43 +1225,6 @@ function parseNmoParticipantsCsv(csv) {
1236
1225
  return parsed.data;
1237
1226
  });
1238
1227
  }
1239
- async function readTelegramChannels(uid, includeWorkspaceChannels) {
1240
- const channelsById = new Map();
1241
- const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
1242
- const profileChannels = Array.isArray(profileSnap.data()?.telegramChannels) ? profileSnap.data()?.telegramChannels : [];
1243
- for (const raw of profileChannels) {
1244
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
1245
- continue;
1246
- const ch = raw;
1247
- if (typeof ch.chatId !== "number")
1248
- continue;
1249
- channelsById.set(ch.chatId, { ...serialize(ch), source: "profile" });
1250
- }
1251
- if (includeWorkspaceChannels) {
1252
- const workspaces = await listVisibleWorkspaces(uid, true);
1253
- for (const workspace of workspaces) {
1254
- const workspaceId = safeString(workspace.id);
1255
- const workspaceName = safeString(workspace.name);
1256
- const channels = Array.isArray(workspace.telegramChannels) ? workspace.telegramChannels : [];
1257
- for (const raw of channels) {
1258
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
1259
- continue;
1260
- const ch = raw;
1261
- if (typeof ch.chatId !== "number")
1262
- continue;
1263
- const prev = channelsById.get(ch.chatId) ?? {};
1264
- const visibleInWorkspaces = Array.isArray(prev.visibleInWorkspaces) ? prev.visibleInWorkspaces : [];
1265
- channelsById.set(ch.chatId, {
1266
- ...prev,
1267
- ...serialize(ch),
1268
- source: prev.source ? "profile+workspace" : "workspace",
1269
- visibleInWorkspaces: [...visibleInWorkspaces, { id: workspaceId, name: workspaceName }],
1270
- });
1271
- }
1272
- }
1273
- }
1274
- return [...channelsById.values()].sort((a, b) => safeString(a.title).localeCompare(safeString(b.title)));
1275
- }
1276
1228
  async function listVisibleWorkspaces(uid, includeShared) {
1277
1229
  const out = [];
1278
1230
  const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
@@ -1291,34 +1243,6 @@ async function listVisibleWorkspaces(uid, includeShared) {
1291
1243
  }
1292
1244
  return out;
1293
1245
  }
1294
- async function syncTelegramChannelToWorkspaces(uid, channel, workspaceIds) {
1295
- const updated = [];
1296
- if (workspaceIds.length === 0)
1297
- return updated;
1298
- const profileRef = doc(firestore, "userProfiles", uid);
1299
- const profileSnap = await getDoc(profileRef);
1300
- const channels = Array.isArray(profileSnap.data()?.telegramChannels) ? [...profileSnap.data().telegramChannels] : [];
1301
- const nextProfileChannels = channels.map((item) => {
1302
- if (item.chatId !== channel.chatId)
1303
- return item;
1304
- const current = Array.isArray(item.workspaceIds) ? item.workspaceIds.filter((id) => typeof id === "string") : [];
1305
- return { ...item, workspaceIds: [...new Set([...current, ...workspaceIds])] };
1306
- });
1307
- await setDoc(profileRef, { telegramChannels: nextProfileChannels }, { merge: true });
1308
- for (const workspaceId of workspaceIds) {
1309
- await readWorkspaceForOwner(workspaceId, uid);
1310
- const wsRef = doc(firestore, "workspaces", workspaceId);
1311
- const wsSnap = await getDoc(wsRef);
1312
- const wsChannels = Array.isArray(wsSnap.data()?.telegramChannels) ? wsSnap.data().telegramChannels : [];
1313
- if (!wsChannels.some((item) => item.chatId === channel.chatId)) {
1314
- await updateDoc(wsRef, {
1315
- telegramChannels: [...wsChannels, { chatId: channel.chatId, title: channel.title ?? "", username: channel.username ?? null }],
1316
- });
1317
- updated.push(workspaceId);
1318
- }
1319
- }
1320
- return updated;
1321
- }
1322
1246
  async function readAgg(eventId) {
1323
1247
  const snap = await get(ref(rtdb, `events/${eventId}/agg`));
1324
1248
  const agg = (snap.val() ?? {});
@@ -2171,7 +2095,10 @@ export function registerTools(server) {
2171
2095
  outputSchema: updateOutputSchema,
2172
2096
  annotations: writeTool,
2173
2097
  }, async (args) => {
2174
- const { uid } = await authed(["setup:write"]);
2098
+ const requiredScopes = ["setup:write"];
2099
+ if (args.nmo !== undefined)
2100
+ requiredScopes.push("nmo:write");
2101
+ const { uid } = await authed(requiredScopes);
2175
2102
  await readEvent(args.eventId, uid, "edit");
2176
2103
  const patch = {};
2177
2104
  if (args.chat?.enabled !== undefined)
@@ -2489,6 +2416,8 @@ export function registerTools(server) {
2489
2416
  "blocks.promoBanner.imageMasterUrl": deleteField(),
2490
2417
  "blocks.promoBanner.imageUrl": deleteField(),
2491
2418
  "blocks.promoBanner.imageVariants": deleteField(),
2419
+ "blocks.promoBanner.imageWidth": deleteField(),
2420
+ "blocks.promoBanner.imageHeight": deleteField(),
2492
2421
  });
2493
2422
  }
2494
2423
  else if (kind === "videoPlaceholder") {
@@ -2496,6 +2425,8 @@ export function registerTools(server) {
2496
2425
  "blocks.video.placeholder.imageMasterUrl": deleteField(),
2497
2426
  "blocks.video.placeholder.imageUrl": deleteField(),
2498
2427
  "blocks.video.placeholder.imageVariants": deleteField(),
2428
+ "blocks.video.placeholder.imageWidth": deleteField(),
2429
+ "blocks.video.placeholder.imageHeight": deleteField(),
2499
2430
  });
2500
2431
  }
2501
2432
  else {
@@ -2503,6 +2434,8 @@ export function registerTools(server) {
2503
2434
  imageUrl: null,
2504
2435
  imageMasterUrl: null,
2505
2436
  imageVariants: null,
2437
+ imageWidth: null,
2438
+ imageHeight: null,
2506
2439
  });
2507
2440
  }
2508
2441
  return textResult({
@@ -2515,25 +2448,92 @@ export function registerTools(server) {
2515
2448
  });
2516
2449
  });
2517
2450
  server.registerTool("oko_delete_event", {
2518
- title: "Delete event",
2451
+ title: "Move event to trash",
2519
2452
  description: toolDescription({
2520
- summary: "Delete an OKO event and its associated Firestore, RTDB, chat, Storage, participant, and scheduled-post data.",
2521
- args: ["eventId: event to delete.", "confirm: must be true."],
2522
- returns: "deleted event id and cross-store cleanup counts.",
2523
- examples: ["Use only after the user explicitly confirms the exact event title/id."],
2524
- notes: ["Destructive. The server validates the current per-event delete capability and performs idempotent cross-store cleanup."],
2453
+ summary: "Move an OKO event to the 30-day trash. This compatibility alias has the same behavior as oko_trash_event.",
2454
+ args: ["eventId: event to move to trash.", "confirm: must be true."],
2455
+ returns: "trashed event id and purge deadline in epoch milliseconds.",
2456
+ examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
2457
+ notes: ["Recoverable for 30 days with oko_restore_event. This does not create an event lifecycle status."],
2525
2458
  }),
2526
- inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true for deletion.") },
2527
- outputSchema: z.object({ deleted: z.string() }).passthrough(),
2459
+ inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
2460
+ outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
2528
2461
  annotations: destructiveTool,
2529
2462
  }, async ({ eventId, confirm }) => {
2530
2463
  const { uid } = await authed(["events:delete"]);
2531
2464
  const data = await readEvent(eventId, uid, "delete");
2532
2465
  requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
2533
- const deleteEvent = httpsCallable(functions, "deleteEventCompletely");
2534
- const result = await deleteEvent({ eventId });
2466
+ const trash = httpsCallable(functions, "trashEvent");
2467
+ const result = await trash({ eventId });
2535
2468
  return textResult(result.data);
2536
2469
  });
2470
+ server.registerTool("oko_trash_event", {
2471
+ title: "Move event to trash",
2472
+ description: toolDescription({
2473
+ summary: "Move an OKO event to the recoverable 30-day trash.",
2474
+ args: ["eventId: event to move to trash.", "confirm: must be true."],
2475
+ returns: "trashed event id and purge deadline in epoch milliseconds.",
2476
+ examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
2477
+ notes: ["End any active analytics session first. Restore with oko_restore_event before the purge deadline."],
2478
+ }),
2479
+ inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
2480
+ outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
2481
+ annotations: destructiveTool,
2482
+ }, async ({ eventId, confirm }) => {
2483
+ const { uid } = await authed(["events:delete"]);
2484
+ const event = await readEvent(eventId, uid, "delete");
2485
+ requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
2486
+ const trash = httpsCallable(functions, "trashEvent");
2487
+ return textResult((await trash({ eventId })).data);
2488
+ });
2489
+ server.registerTool("oko_restore_event", {
2490
+ title: "Restore event",
2491
+ description: toolDescription({
2492
+ summary: "Restore an OKO event from the 30-day trash before its purge deadline.",
2493
+ args: ["eventId: trashed event to restore."],
2494
+ returns: "restored event id.",
2495
+ examples: ["Use when the organizer asks to undo a trash action."],
2496
+ notes: ["Idempotent. Fails after the trash retention deadline has expired."],
2497
+ }),
2498
+ inputSchema: { eventId: eventIdField },
2499
+ outputSchema: z.object({ restored: z.string() }).passthrough(),
2500
+ annotations: idempotentWriteTool,
2501
+ }, async ({ eventId }) => {
2502
+ const { uid } = await authed(["events:delete"]);
2503
+ await readEvent(eventId, uid, "delete");
2504
+ const restore = httpsCallable(functions, "restoreEvent");
2505
+ return textResult((await restore({ eventId })).data);
2506
+ });
2507
+ server.registerTool("oko_delete_event_permanently", {
2508
+ title: "Delete event permanently",
2509
+ description: toolDescription({
2510
+ summary: "Permanently delete a trashed OKO event and all associated Firestore, RTDB, chat, Storage, participant, session, and Telegram data.",
2511
+ args: [
2512
+ "eventId: trashed event to delete permanently.",
2513
+ "confirmTitle: exact current event title, including case and spacing.",
2514
+ "confirm: must be true.",
2515
+ ],
2516
+ returns: "deleted event id and cross-store cleanup counts.",
2517
+ examples: ["Read the event first, show its exact title, and proceed only after the organizer explicitly types or confirms that exact title."],
2518
+ notes: ["Irreversible. The event must already be in trash; use oko_trash_event first."],
2519
+ }),
2520
+ inputSchema: {
2521
+ eventId: eventIdField,
2522
+ confirmTitle: z.string().max(500).describe("Exact current event title."),
2523
+ confirm: z.boolean().optional().describe("Required true for permanent deletion."),
2524
+ },
2525
+ outputSchema: z.object({ deleted: z.string(), cleanup: unknownRecordSchema }).passthrough(),
2526
+ annotations: destructiveTool,
2527
+ }, async ({ eventId, confirmTitle, confirm }) => {
2528
+ const { uid } = await authed(["events:delete"]);
2529
+ const event = await readEvent(eventId, uid, "delete");
2530
+ const title = safeString(event.title);
2531
+ if (confirmTitle !== title)
2532
+ throw new Error("confirmTitle must exactly match the current event title.");
2533
+ requireConfirm(confirm, "oko_delete_event_permanently", { eventId, title });
2534
+ const remove = httpsCallable(functions, "deleteEventCompletely");
2535
+ return textResult((await remove({ eventId, confirmTitle })).data);
2536
+ });
2537
2537
  server.registerTool("oko_duplicate_event", {
2538
2538
  title: "Duplicate event",
2539
2539
  description: toolDescription({
@@ -2552,6 +2552,8 @@ export function registerTools(server) {
2552
2552
  }, async ({ eventId, targetWorkspaceId }) => {
2553
2553
  const { uid } = await authed(["events:write"]);
2554
2554
  const source = await readEvent(eventId, uid, "read");
2555
+ if (source.nmoMode === true)
2556
+ await authed(["nmo:write"]);
2555
2557
  const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
2556
2558
  await assertCanCreateInWorkspace(uid, workspaceId);
2557
2559
  const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
@@ -3191,7 +3193,13 @@ export function registerTools(server) {
3191
3193
  requireConfirm(confirm, "oko_send_chat_message", { eventId, name, text });
3192
3194
  const role = await getChatRole(eventId, uid);
3193
3195
  const msgRef = push(ref(chatRtdb, `messages/${eventId}`));
3194
- await set(msgRef, { text, uid, name, role, ts: rtdbServerTimestamp() });
3196
+ if (!msgRef.key)
3197
+ throw new Error("Could not allocate chat message id");
3198
+ const timestamp = rtdbServerTimestamp();
3199
+ await update(ref(chatRtdb), {
3200
+ [`messages/${eventId}/${msgRef.key}`]: { text, uid, name, role, ts: timestamp },
3201
+ [`rate_limits/${eventId}/${uid}`]: { ts: timestamp, messageId: msgRef.key },
3202
+ });
3195
3203
  await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1);
3196
3204
  return textResult({ eventId, messageId: msgRef.key, sent: true });
3197
3205
  });
@@ -3656,20 +3664,48 @@ export function registerTools(server) {
3656
3664
  await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
3657
3665
  return textResult({ markedRead: ids.length, notificationIds: ids });
3658
3666
  });
3667
+ server.registerTool("oko_create_telegram_link", {
3668
+ title: "Link personal Telegram",
3669
+ description: toolDescription({
3670
+ summary: "Create a short-lived Telegram deep link that binds the organizer's personal Telegram identity to OKO.",
3671
+ args: [],
3672
+ returns: "@OKOStreamBot link and expiry in seconds.",
3673
+ examples: ["Open the returned link in the organizer's Telegram account before claiming a channel."],
3674
+ notes: ["The link expires in 15 minutes and can be used only once. Opening it is a user action outside MCP."],
3675
+ }),
3676
+ inputSchema: {},
3677
+ outputSchema: z.object({ url: z.string().url(), expiresIn: z.number().int().positive() }).passthrough(),
3678
+ annotations: writeTool,
3679
+ }, async () => {
3680
+ await authed(["telegram:write"]);
3681
+ const createLink = httpsCallable(functions, "createTelegramLink");
3682
+ return textResult((await createLink({})).data);
3683
+ });
3659
3684
  server.registerTool("oko_list_telegram_channels", {
3660
3685
  title: "List Telegram channels",
3661
3686
  description: toolDescription({
3662
3687
  summary: "List Telegram channels available to the authenticated organizer.",
3663
- args: ["includeWorkspaceChannels: include channels exposed through visible workspaces, default true."],
3664
- returns: "channels with chatId, title, username, source, and visible workspace metadata.",
3688
+ args: [
3689
+ "eventId: optional event; when provided, include channels explicitly shared to that event's workspace.",
3690
+ "includeWorkspaceChannels: deprecated compatibility field; eventId is required to resolve workspace sharing safely.",
3691
+ ],
3692
+ returns: "canonical channels with chatId, title, username, workspaceIds, and owned flag.",
3665
3693
  examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
3666
3694
  }),
3667
- inputSchema: { includeWorkspaceChannels: z.boolean().default(true).describe("Include workspace-level channels visible to this organizer.") },
3695
+ inputSchema: {
3696
+ eventId: eventIdField.optional().describe("Optional event used to include channels shared to its workspace."),
3697
+ includeWorkspaceChannels: z.boolean().optional().describe("Deprecated; retained for client compatibility."),
3698
+ },
3668
3699
  outputSchema: telegramChannelsOutputSchema,
3669
3700
  annotations: readOnlyTool,
3670
- }, async ({ includeWorkspaceChannels }) => {
3671
- const { uid } = await authed(["telegram:write"]);
3672
- const channels = await readTelegramChannels(uid, includeWorkspaceChannels);
3701
+ }, async ({ eventId }) => {
3702
+ await authed(["telegram:write"]);
3703
+ const listChannels = httpsCallable(functions, "listTelegramChannels");
3704
+ const result = await listChannels(eventId ? { eventId } : {});
3705
+ const parsed = z.array(canonicalTelegramChannelSchema).safeParse(result.data.channels);
3706
+ if (!parsed.success)
3707
+ throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
3708
+ const channels = parsed.data.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
3673
3709
  return textResult({ channels, count: channels.length });
3674
3710
  });
3675
3711
  server.registerTool("oko_claim_telegram_channel", {
@@ -3678,12 +3714,12 @@ export function registerTools(server) {
3678
3714
  summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
3679
3715
  args: [
3680
3716
  "claimCode: code posted by the bot after it was added to a channel.",
3681
- "workspaceIds: optional workspaces to expose this channel to after claim.",
3717
+ "workspaceIds: optional owned workspaces to expose this channel to after claim.",
3682
3718
  "confirm: must be true.",
3683
3719
  ],
3684
3720
  returns: "claimed channel and workspace ids updated.",
3685
- examples: ["Add @OKOStreamBot to a Telegram channel, copy the claim code, then call this tool."],
3686
- notes: ["Workspace sync requires the authenticated organizer to own each workspace."],
3721
+ examples: ["Link personal Telegram first, add @OKOStreamBot as channel administrator, copy the claim code, then call this tool."],
3722
+ notes: ["The linked personal Telegram user must be a channel administrator. Workspace sharing is server-validated."],
3687
3723
  }),
3688
3724
  inputSchema: {
3689
3725
  claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
@@ -3693,7 +3729,7 @@ export function registerTools(server) {
3693
3729
  outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
3694
3730
  annotations: writeTool,
3695
3731
  }, async ({ claimCode, workspaceIds, confirm }) => {
3696
- const { uid } = await authed(["telegram:write"]);
3732
+ await authed(["telegram:write"]);
3697
3733
  requireConfirm(confirm, "oko_claim_telegram_channel", { workspaceIds: workspaceIds.join(",") });
3698
3734
  const claimChannel = httpsCallable(functions, "claimTelegramChannel");
3699
3735
  const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
@@ -3702,8 +3738,54 @@ export function registerTools(server) {
3702
3738
  const parsed = telegramChannelSchema.safeParse(channelRaw);
3703
3739
  if (!parsed.success)
3704
3740
  throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
3705
- const workspaceIdsUpdated = await syncTelegramChannelToWorkspaces(uid, parsed.data, workspaceIds);
3706
- return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated });
3741
+ if (workspaceIds.length > 0) {
3742
+ const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
3743
+ await setWorkspaces({ channelId: parsed.data.chatId, workspaceIds });
3744
+ }
3745
+ return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated: workspaceIds });
3746
+ });
3747
+ server.registerTool("oko_set_telegram_channel_workspaces", {
3748
+ title: "Share Telegram channel to workspaces",
3749
+ description: toolDescription({
3750
+ summary: "Replace the owned Telegram channel's explicit OKO workspace sharing list.",
3751
+ args: ["channelId: owned Telegram channel id.", "workspaceIds: complete replacement list, up to 50.", "confirm: must be true."],
3752
+ returns: "channel id and the applied workspace ids.",
3753
+ examples: ["Use an empty workspaceIds list to make the channel private to its owner again."],
3754
+ notes: ["This replaces, rather than appends to, the current sharing list. Every workspace must be owned by the organizer."],
3755
+ }),
3756
+ inputSchema: {
3757
+ channelId: z.number().int().describe("Owned Telegram channel id."),
3758
+ workspaceIds: z.array(z.string().min(1).max(128)).max(50).describe("Complete replacement workspace sharing list."),
3759
+ confirm: z.boolean().optional().describe("Required true to change workspace sharing."),
3760
+ },
3761
+ outputSchema: z.object({ success: z.boolean(), channelId: z.number().int(), workspaceIds: z.array(z.string()) }).passthrough(),
3762
+ annotations: writeTool,
3763
+ }, async ({ channelId, workspaceIds, confirm }) => {
3764
+ await authed(["telegram:write"]);
3765
+ requireConfirm(confirm, "oko_set_telegram_channel_workspaces", { channelId, workspaceIds: workspaceIds.join(",") });
3766
+ const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
3767
+ return textResult((await setWorkspaces({ channelId, workspaceIds })).data);
3768
+ });
3769
+ server.registerTool("oko_disconnect_telegram_channel", {
3770
+ title: "Disconnect Telegram channel",
3771
+ description: toolDescription({
3772
+ summary: "Disconnect an owned Telegram channel from OKO and clear all workspace sharing.",
3773
+ args: ["channelId: owned Telegram channel id.", "confirm: must be true."],
3774
+ returns: "disconnected channel id.",
3775
+ examples: ["Use only after showing the exact channel selected from oko_list_telegram_channels."],
3776
+ notes: ["Existing sent Telegram messages are not deleted. The channel must be claimed again before future publication."],
3777
+ }),
3778
+ inputSchema: {
3779
+ channelId: z.number().int().describe("Owned Telegram channel id."),
3780
+ confirm: z.boolean().optional().describe("Required true to disconnect the channel."),
3781
+ },
3782
+ outputSchema: z.object({ success: z.boolean(), channelId: z.number().int() }).passthrough(),
3783
+ annotations: destructiveTool,
3784
+ }, async ({ channelId, confirm }) => {
3785
+ await authed(["telegram:write"]);
3786
+ requireConfirm(confirm, "oko_disconnect_telegram_channel", { channelId });
3787
+ const disconnect = httpsCallable(functions, "disconnectTelegramChannel");
3788
+ return textResult((await disconnect({ channelId })).data);
3707
3789
  });
3708
3790
  server.registerTool("oko_list_telegram_posts", {
3709
3791
  title: "List Telegram posts",
@@ -3757,14 +3839,18 @@ export function registerTools(server) {
3757
3839
  outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
3758
3840
  annotations: destructiveTool,
3759
3841
  }, async ({ postId, confirm }) => {
3760
- await authed(["telegram:write"]);
3842
+ const { uid } = await authed(["telegram:write"]);
3761
3843
  const postRef = doc(firestore, "telegramPosts", postId);
3762
3844
  const postSnap = await getDoc(postRef);
3763
3845
  if (!postSnap.exists())
3764
3846
  throw new Error("Telegram post not found.");
3765
3847
  const post = postSnap.data();
3766
- if (post.status === "sent")
3767
- throw new Error("Telegram post was already sent and cannot be canceled through OKO.");
3848
+ if (typeof post.eventId !== "string")
3849
+ throw new Error("Telegram post has no valid event.");
3850
+ await readEvent(post.eventId, uid, "notify");
3851
+ if (!["draft", "pending", "scheduled", "failed"].includes(safeString(post.status))) {
3852
+ throw new Error("Only draft, pending, scheduled, or failed Telegram posts can be canceled. A sent or delivery-unknown post must be reviewed manually.");
3853
+ }
3768
3854
  requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
3769
3855
  await deleteDoc(postRef);
3770
3856
  return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
@@ -3799,10 +3885,19 @@ export function registerTools(server) {
3799
3885
  }, async (args) => {
3800
3886
  const { uid } = await authed(["telegram:write"]);
3801
3887
  await readEvent(args.eventId, uid, "notify");
3888
+ const listChannels = httpsCallable(functions, "listTelegramChannels");
3889
+ const availableResult = await listChannels({ eventId: args.eventId });
3890
+ const available = z.array(canonicalTelegramChannelSchema).safeParse(availableResult.data.channels);
3891
+ if (!available.success)
3892
+ throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
3893
+ const channel = available.data.find((item) => item.chatId === args.channelId);
3894
+ if (!channel)
3895
+ throw new Error("Telegram channel is not available for this event. List channels with this eventId and choose a returned channel.");
3802
3896
  const scheduled = !!args.scheduleAt;
3803
3897
  requireConfirm(args.confirm, "oko_publish_telegram_post", {
3804
3898
  eventId: args.eventId,
3805
3899
  channelId: args.channelId,
3900
+ channelTitle: channel.title ?? "",
3806
3901
  caption: args.caption,
3807
3902
  scheduleAt: args.scheduleAt ?? null,
3808
3903
  });
@@ -3811,6 +3906,7 @@ export function registerTools(server) {
3811
3906
  ownerId: uid,
3812
3907
  ownerEmail: "",
3813
3908
  channelId: args.channelId,
3909
+ channelKey: String(args.channelId),
3814
3910
  caption: args.caption.trim(),
3815
3911
  imageUrl: args.imageUrl?.trim() || null,
3816
3912
  buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server for managing OKO Stream webinars as an organizer.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",