@okostream/mcp 0.12.11 → 0.12.13

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.
Files changed (2) hide show
  1. package/dist/server.js +240 -75
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -116291,7 +116291,7 @@ function sanitizeScopes(scopes) {
116291
116291
  }
116292
116292
 
116293
116293
  // build/version.js
116294
- var OKO_MCP_VERSION = "0.12.11";
116294
+ var OKO_MCP_VERSION = "0.12.13";
116295
116295
 
116296
116296
  // build/auth.js
116297
116297
  var state = null;
@@ -116416,9 +116416,8 @@ async function exchangeApiKey() {
116416
116416
  }
116417
116417
  throw status === 401 ? new OkoAuthError("invalid_key", "OKO API key is invalid or revoked. Create a fresh key in the OKO MCP install center and replace OKO_API_KEY.") : new OkoAuthError("unavailable", [
116418
116418
  `OKO organizer authentication failed with HTTP ${status}.`,
116419
- serverRefusalNote(errorBody),
116420
- "Check your connection and try again."
116421
- ].filter(Boolean).join(" "));
116419
+ serverRefusalNote(errorBody) ?? "Check your connection and try again."
116420
+ ].join(" "));
116422
116421
  }
116423
116422
  const data = await response.json();
116424
116423
  if (typeof data.customToken !== "string") {
@@ -116631,6 +116630,20 @@ var EVENT_ACCESS_FIELD_BY_MODE = {
116631
116630
  moderate: "chatModerators",
116632
116631
  notify: "notificationSenders"
116633
116632
  };
116633
+ var EVENT_ACCESS_COMPOSITION_FIELDS = [
116634
+ ...Object.values(EVENT_ACCESS_FIELD_BY_MODE),
116635
+ "moderators",
116636
+ "participantUids"
116637
+ ];
116638
+ function eventDocumentForOutput(data) {
116639
+ const projected = {};
116640
+ for (const [key, value] of Object.entries(data)) {
116641
+ if (EVENT_ACCESS_COMPOSITION_FIELDS.includes(key))
116642
+ continue;
116643
+ projected[key] = value;
116644
+ }
116645
+ return projected;
116646
+ }
116634
116647
  function grantAllowsMode(data, mode) {
116635
116648
  const field2 = EVENT_ACCESS_FIELD_BY_MODE[mode];
116636
116649
  return asRecord(data.capabilities)[field2] === true;
@@ -116689,7 +116702,16 @@ var MAX_CARD_BUTTON_LABEL_LENGTH = 80;
116689
116702
  var MAX_CARD_BUTTON_URL_LENGTH = 1e3;
116690
116703
  var MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
116691
116704
  var REMOTE_IMAGE_TIMEOUT_MS = 15e3;
116705
+ var REMOTE_IMAGE_TOTAL_TIMEOUT_MS = 4e4;
116692
116706
  var REMOTE_IMAGE_MAX_REDIRECTS = 3;
116707
+ function remoteImageHopBudget(deadlineAt, now, perHopMs) {
116708
+ const left = deadlineAt - now;
116709
+ if (left <= 0)
116710
+ return { kind: "exhausted" };
116711
+ if (left < perHopMs)
116712
+ return { kind: "budget", ms: left, capped: true };
116713
+ return { kind: "budget", ms: perHopMs, capped: false };
116714
+ }
116693
116715
  var ALLOWED_REMOTE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
116694
116716
  function storagePathFromDownloadUrl(downloadUrl) {
116695
116717
  if (!downloadUrl)
@@ -116803,7 +116825,7 @@ function shareLinkHint(url) {
116803
116825
  }
116804
116826
  return " Use a direct link to the image file (right-click the image \u2192 copy image address), or download the file and pass source.path.";
116805
116827
  }
116806
- async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
116828
+ async function readRemoteImage(rawUrl, redirects = 0, notes = [], deadlineAt = Date.now() + REMOTE_IMAGE_TOTAL_TIMEOUT_MS) {
116807
116829
  if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
116808
116830
  throw new Error("Image URL has too many redirects.");
116809
116831
  }
@@ -116814,21 +116836,28 @@ async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
116814
116836
  }
116815
116837
  let lastConnectError = null;
116816
116838
  for (const candidate of pinned.addresses) {
116817
- const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects);
116839
+ const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt);
116818
116840
  if ("buffer" in attempt)
116819
116841
  return attempt.buffer;
116820
116842
  if ("redirectTo" in attempt)
116821
- return readRemoteImage(attempt.redirectTo, redirects + 1, notes);
116843
+ return readRemoteImage(attempt.redirectTo, redirects + 1, notes, deadlineAt);
116822
116844
  lastConnectError = attempt.connectError;
116823
116845
  }
116824
116846
  throw new Error(`Image URL host ${url.hostname} could not be reached at any of its addresses (${pinned.addresses.map((item) => item.address).join(", ")}): ${lastConnectError?.message ?? "connection failed"}.`);
116825
116847
  }
116826
- async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
116848
+ function totalBudgetMessage() {
116849
+ return `Image URL did not finish loading within ${REMOTE_IMAGE_TOTAL_TIMEOUT_MS / 1e3} seconds, including redirects.`;
116850
+ }
116851
+ async function readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt) {
116852
+ const budget = remoteImageHopBudget(deadlineAt, Date.now(), REMOTE_IMAGE_TIMEOUT_MS);
116853
+ if (budget.kind === "exhausted") {
116854
+ throw new Error(totalBudgetMessage());
116855
+ }
116827
116856
  const dispatcher = new import_undici.Agent({
116828
116857
  connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
116829
116858
  });
116830
116859
  const controller = new AbortController();
116831
- const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
116860
+ const timeout = setTimeout(() => controller.abort(), budget.ms);
116832
116861
  try {
116833
116862
  let response;
116834
116863
  try {
@@ -116840,7 +116869,7 @@ async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
116840
116869
  });
116841
116870
  } catch (err) {
116842
116871
  if (err instanceof Error && err.name === "AbortError") {
116843
- throw new Error("Image URL fetch timed out.");
116872
+ throw new Error(budget.capped ? totalBudgetMessage() : `Image URL did not respond within ${REMOTE_IMAGE_TIMEOUT_MS / 1e3} seconds.`);
116844
116873
  }
116845
116874
  return { connectError: err instanceof Error ? err : new Error(String(err)) };
116846
116875
  }
@@ -118034,6 +118063,27 @@ function markdownMetric(label, value, suffix = "") {
118034
118063
  const metric = value === null || value === void 0 || value === "" ? "n/a" : String(value);
118035
118064
  return `- ${label}: ${metric}${suffix}`;
118036
118065
  }
118066
+ function reportQualityCaveats(dataQuality) {
118067
+ if (!dataQuality || typeof dataQuality !== "object" || Array.isArray(dataQuality))
118068
+ return [];
118069
+ const marks = dataQuality;
118070
+ const caveats = [];
118071
+ if (marks.baselineSource === "none")
118072
+ caveats.push("baselineMissing");
118073
+ if (marks.startedAtSource === "end_time")
118074
+ caveats.push("startTimeUnknown");
118075
+ if (marks.watchTime === "unavailable")
118076
+ caveats.push("watchTimeUnmeasured");
118077
+ if (marks.watchTime === "unknown_duration")
118078
+ caveats.push("watchTimeUncapped");
118079
+ return caveats;
118080
+ }
118081
+ var REPORT_QUALITY_CAVEAT_TEXT = {
118082
+ baselineMissing: "The session baseline was not saved: every figure below is counted from zero and includes everything the event accumulated before this broadcast.",
118083
+ startTimeUnknown: "The broadcast start time was not saved: the report is stamped with the moment it ended and its duration reads zero.",
118084
+ watchTimeUnmeasured: "Watch time was not measured: the engagement snapshot could not be read.",
118085
+ watchTimeUncapped: "Watch time is not capped by the broadcast length \u2014 it is unknown, so nothing vouches for the total, average and median times."
118086
+ };
118037
118087
  function sessionReportMarkdown({ eventId, event, sessionId, session }) {
118038
118088
  const lines = [
118039
118089
  `# OKO session report`,
@@ -118063,6 +118113,12 @@ function sessionReportMarkdown({ eventId, event, sessionId, session }) {
118063
118113
  markdownMetric("CTA clicks", session.ctaClicks),
118064
118114
  markdownMetric("Cards clicks", session.cardsClicks)
118065
118115
  ];
118116
+ const caveats = reportQualityCaveats(session.dataQuality);
118117
+ if (caveats.length > 0) {
118118
+ lines.push("", "## What this report did not measure");
118119
+ for (const caveat of caveats)
118120
+ lines.push(`- ${REPORT_QUALITY_CAVEAT_TEXT[caveat]}`);
118121
+ }
118066
118122
  return lines.join("\n");
118067
118123
  }
118068
118124
  var TELEGRAM_COPYABLE_KEYS = ["enabled", "channelIds", "autoReminder"];
@@ -118421,10 +118477,32 @@ async function addEventWithRtdbMirror(eventData) {
118421
118477
  try {
118422
118478
  const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
118423
118479
  await ensureRuntime({ eventId: eventRef.id });
118480
+ return { ref: eventRef, runtime: { status: "ready" } };
118424
118481
  } catch (error3) {
118425
- console.error(`[OKO MCP] RTDB mirror deferred to onEventCreated for event ${eventRef.id}:`, error3);
118482
+ const rawCode = error3?.code;
118483
+ const code = typeof rawCode === "string" ? rawCode : "unknown";
118484
+ console.error(`[OKO MCP] ensureEventRuntime failed for event ${eventRef.id}:`, error3);
118485
+ return {
118486
+ ref: eventRef,
118487
+ runtime: {
118488
+ status: "failed",
118489
+ code,
118490
+ message: error3 instanceof Error ? error3.message : String(error3)
118491
+ }
118492
+ };
118426
118493
  }
118427
- return eventRef;
118494
+ }
118495
+ function eventRuntimeHint(runtime) {
118496
+ if (runtime.status === "ready")
118497
+ return [];
118498
+ if (runtime.code === "functions/not-found") {
118499
+ return [
118500
+ "The event document was created, but the server reports the event as already gone and could not confirm its runtime. Do not hand the links out: re-read it with oko_get_event and tell the organizer the creation did not complete."
118501
+ ];
118502
+ }
118503
+ return [
118504
+ `The event was created, but the server could not confirm its runtime mirrors (${runtime.code}). Chat and access rules may lag behind: run oko_validate_event_setup before relying on the links, and say so to the organizer.`
118505
+ ];
118428
118506
  }
118429
118507
 
118430
118508
  // build/tools/setupValidation.js
@@ -118576,17 +118654,48 @@ function assertScheduleHasDate(fresh, patch) {
118576
118654
  return;
118577
118655
  throw new Error("sessionMode 'schedule' requires meta.eventDate: without a date OKO can never start the session automatically. Set eventDate in the same call, or switch sessionMode to 'manual' in the same call to clear the date.");
118578
118656
  }
118657
+ var BLOCK_FORM_BINDING_FIELDS = [
118658
+ "blocks.promoBanner.formId",
118659
+ "blocks.primaryCta.formId"
118660
+ ];
118661
+ function formIdSet(formsValue) {
118662
+ return new Set((Array.isArray(formsValue) ? formsValue : []).map((form) => typeof form?.id === "string" ? form.id : null).filter((id) => Boolean(id)));
118663
+ }
118664
+ function formBindingsAfterPatch(fresh, patch) {
118665
+ const bindings = [];
118666
+ for (const field2 of BLOCK_FORM_BINDING_FIELDS) {
118667
+ const value = valueAfterPatch(fresh, patch, field2);
118668
+ if (typeof value === "string" && value)
118669
+ bindings.push({ field: field2, formId: value });
118670
+ }
118671
+ const cards = valueAfterPatch(fresh, patch, "blocks.cards.items");
118672
+ if (Array.isArray(cards)) {
118673
+ cards.forEach((card, index) => {
118674
+ const formId = card && typeof card === "object" && !Array.isArray(card) ? card.formId : null;
118675
+ if (typeof formId === "string" && formId) {
118676
+ bindings.push({ field: `blocks.cards.items[${index}].formId`, formId });
118677
+ }
118678
+ });
118679
+ }
118680
+ return bindings;
118681
+ }
118579
118682
  function assertFormBindingsExist(fresh, patch) {
118580
- const formsAfterPatch = valueAfterPatch(fresh, patch, "forms");
118581
- const knownFormIds = new Set((Array.isArray(formsAfterPatch) ? formsAfterPatch : []).map((form) => typeof form?.id === "string" ? form.id : null).filter((id) => Boolean(id)));
118582
- for (const field2 of ["blocks.promoBanner.formId", "blocks.primaryCta.formId"]) {
118683
+ const formsAfter = formIdSet(valueAfterPatch(fresh, patch, "forms"));
118684
+ for (const field2 of BLOCK_FORM_BINDING_FIELDS) {
118583
118685
  if (!Object.prototype.hasOwnProperty.call(patch, field2))
118584
118686
  continue;
118585
118687
  const value = patch[field2];
118586
- if (typeof value === "string" && value && !knownFormIds.has(value)) {
118688
+ if (typeof value === "string" && value && !formsAfter.has(value)) {
118587
118689
  throw new Error(`${field2}: form "${value}" not found in event.forms. Create the form in the same call (or beforehand) \u2014 a button bound to a missing form does nothing for viewers.`);
118588
118690
  }
118589
118691
  }
118692
+ const formsBefore = formIdSet(valueAfterPatch(fresh, {}, "forms"));
118693
+ const orphaned = formBindingsAfterPatch(fresh, patch).filter((binding) => !formsAfter.has(binding.formId) && formsBefore.has(binding.formId));
118694
+ if (orphaned.length === 0)
118695
+ return;
118696
+ const listed = orphaned.map((binding) => `${binding.field} -> "${binding.formId}"`).join(", ");
118697
+ const cardsAffected = orphaned.some((binding) => binding.field.startsWith("blocks.cards."));
118698
+ throw new Error(`This call removes a form that buttons still point at: ${listed}. A button bound to a missing form does nothing for viewers, so the removal is refused while something still points at it. Unbind them first: pass promoBanner: {formId: null} and/or primaryCta: {formId: null} in this same call` + (cardsAffected ? ", and clear the card bindings with oko_configure_cards before removing the form." : "."));
118590
118699
  }
118591
118700
  function configureBlocksScopes(args) {
118592
118701
  return Object.keys(telegramBlocksPatch(args)).length > 0 ? ["setup:write", "telegram:write"] : ["setup:write"];
@@ -118600,7 +118709,7 @@ function registerEventTools(server2) {
118600
118709
  "limit: maximum events to return, default 30, max 100.",
118601
118710
  "cursor: pagination cursor returned as nextCursor by the previous page; reuse it with the same filters.",
118602
118711
  "workspaceId: optional workspace filter.",
118603
- 'detail: "compact" (default) returns the light card projection; "full" returns complete event documents.',
118712
+ 'detail: "compact" (default) returns the light card projection; "full" returns event documents without the access composition, same shape as oko_get_event.',
118604
118713
  "from/to: optional ISO timestamps bounding meta.eventDate; sorting switches to eventDate descending and undated events are excluded."
118605
118714
  ],
118606
118715
  returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
@@ -118648,7 +118757,10 @@ function registerEventTools(server2) {
118648
118757
  let events;
118649
118758
  if (args.detail === "full") {
118650
118759
  const eventDocuments = await Promise.all(pageItems.map((item) => getDoc(doc(firestore, "events", item.id))));
118651
- events = eventDocuments.flatMap((item) => item.exists() ? [{ id: item.id, ...serialize(item.data()) }] : []);
118760
+ events = eventDocuments.flatMap((item) => item.exists() ? [{
118761
+ id: item.id,
118762
+ ...serialize(eventDocumentForOutput(item.data()))
118763
+ }] : []);
118652
118764
  } else {
118653
118765
  events = pageItems.map((item) => {
118654
118766
  const data = serialize(item.data());
@@ -118668,7 +118780,7 @@ function registerEventTools(server2) {
118668
118780
  description: toolDescription({
118669
118781
  summary: "Read one OKO event visible to the organizer.",
118670
118782
  args: ["eventId: OKO event document id."],
118671
- returns: "full event document fields plus id and public event URL.",
118783
+ returns: "event document fields plus id and public event URL. The event's access composition (the historical uid arrays) is never returned; ask oko_list_workspace_access for who has access.",
118672
118784
  examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
118673
118785
  notes: [
118674
118786
  "blocks.chat.mode and blocks.chat.visibility are both legacy: the event page reads neither \u2014 the chat panel exists exactly when blocks.chat.enabled is true, and sending is opened or closed live with oko_set_chat_mode. Ignore both fields."
@@ -118680,7 +118792,11 @@ function registerEventTools(server2) {
118680
118792
  }, async ({ eventId }) => {
118681
118793
  const { uid } = await authed(["events:read"]);
118682
118794
  const data = await readEvent(eventId, uid, "read");
118683
- return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId) });
118795
+ return textResult({
118796
+ id: eventId,
118797
+ ...serialize(eventDocumentForOutput(data)),
118798
+ ...eventLinks(eventId)
118799
+ });
118684
118800
  });
118685
118801
  server2.registerTool("oko_validate_event_setup", {
118686
118802
  title: "Validate event setup",
@@ -118736,7 +118852,7 @@ function registerEventTools(server2) {
118736
118852
  const workspaceId = args.workspaceId ?? uid;
118737
118853
  await assertCanCreateInWorkspace(uid, workspaceId);
118738
118854
  const video = normalizeVideo(args.videoUrl);
118739
- const docRef = await addEventWithRtdbMirror(buildCreateWebinarDocument({
118855
+ const created = await addEventWithRtdbMirror(buildCreateWebinarDocument({
118740
118856
  uid,
118741
118857
  workspaceId,
118742
118858
  title: args.title,
@@ -118747,11 +118863,14 @@ function registerEventTools(server2) {
118747
118863
  chatEnabled: args.chatEnabled,
118748
118864
  createdAt: serverTimestamp2()
118749
118865
  }));
118866
+ const docRef = created.ref;
118750
118867
  return textResult({
118751
118868
  id: docRef.id,
118752
118869
  ...eventLinks(docRef.id),
118753
118870
  workspaceId,
118871
+ runtime: created.runtime,
118754
118872
  hints: [
118873
+ ...eventRuntimeHint(created.runtime),
118755
118874
  "The page is live at publicUrl immediately; OKO has no draft state.",
118756
118875
  "Next: oko_configure_blocks for chat/CTA/placeholder, oko_configure_cards for cards, image uploads, then oko_validate_event_setup.",
118757
118876
  'The video placeholder (countdown/cover) is only shown to viewers after oko_set_block_visibility with block "placeholder" and visible true; enabling it in oko_configure_blocks alone keeps it hidden.'
@@ -118830,7 +118949,7 @@ function registerEventTools(server2) {
118830
118949
  args: [
118831
118950
  "eventId: event to edit.",
118832
118951
  "chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
118833
- "forms: replaces configured lead forms when provided.",
118952
+ "forms: replaces configured lead forms when provided; removing a form that a button still points at is refused.",
118834
118953
  "telegramChannelIds: replaces configured Telegram channels when provided; needs the telegram:write scope AND the notification permission on the event, and is refused before anything is written when either is missing."
118835
118954
  ],
118836
118955
  returns: "event id and the list of Firestore fields updated.",
@@ -118840,7 +118959,8 @@ function registerEventTools(server2) {
118840
118959
  "promoBanner has no text field by design: the banner is image+link. blocks.promoBanner.text seen in event documents is legacy and is never rendered on the event page.",
118841
118960
  'videoPlaceholder.enabled only allows the placeholder; viewers see it after oko_set_block_visibility with block "placeholder" and visible true (a live override, stored outside the event document). Chat, CTA, banner and cards appear as soon as they are enabled here.',
118842
118961
  "Setup toggle vs live override: enabled=false removes a block from the page for everyone and persists (its uploaded image stays); oko_set_block_visibility hides or shows an enabled block temporarily and is the tool for the live show. Uploading an image never enables a block.",
118843
- "telegramChannelIds is the only field here that is not a page setting: it decides which channel carries this event's messages, and the automatic reminder goes out under the event owner's name. Omit the field to configure blocks with setup:write alone; pass it only with a key that holds telegram:write and for an organizer who may send notifications for this event."
118962
+ "telegramChannelIds is the only field here that is not a page setting: it decides which channel carries this event's messages, and the automatic reminder goes out under the event owner's name. Omit the field to configure blocks with setup:write alone; pass it only with a key that holds telegram:write and for an organizer who may send notifications for this event.",
118963
+ "forms and the buttons bound to them travel together in both directions: binding a button to a form that does not exist is refused, and so is replacing forms in a way that removes a form the banner, the CTA or a card button still points at. Unbind in the same call with promoBanner/primaryCta formId:null; card bindings are cleared with oko_configure_cards first. A binding that was already broken before the call is not touched and does not block it."
118844
118964
  ]
118845
118965
  }),
118846
118966
  inputSchema: {
@@ -119265,7 +119385,6 @@ var MCP_AUDIT_TARGET_KEYS = /* @__PURE__ */ new Set([
119265
119385
  "postId",
119266
119386
  "messageId",
119267
119387
  "leadId",
119268
- "participantUid",
119269
119388
  "uid",
119270
119389
  "channelId",
119271
119390
  "cardId",
@@ -119681,7 +119800,7 @@ function registerLifecycleTools(server2) {
119681
119800
  await assertCanCreateInWorkspace(uid, workspaceId);
119682
119801
  const sourceHasTelegram = !!source.telegram && typeof source.telegram === "object";
119683
119802
  const mayCopyTelegram = sourceHasTelegram && currentScopes().includes("telegram:write") && await eventGrantsMode(eventId, uid, "notify");
119684
- const newRef = await addEventWithRtdbMirror({
119803
+ const duplicated = await addEventWithRtdbMirror({
119685
119804
  ownerId: uid,
119686
119805
  workspaceId,
119687
119806
  createdAt: serverTimestamp2(),
@@ -119698,14 +119817,17 @@ function registerLifecycleTools(server2) {
119698
119817
  // (review 02.09, R3b-5).
119699
119818
  ...mayCopyTelegram ? { telegram: copyableTelegram(source.telegram) } : {}
119700
119819
  });
119820
+ const newRef = duplicated.ref;
119701
119821
  return textResult({
119702
119822
  id: newRef.id,
119703
119823
  sourceEventId: eventId,
119704
119824
  workspaceId,
119825
+ runtime: duplicated.runtime,
119705
119826
  ...eventLinks(newRef.id),
119706
119827
  liveStateNotCopied: ["chatMode", "placeholderVisible", "liveBadge", "block visibility overrides"],
119707
119828
  telegramCopied: mayCopyTelegram,
119708
119829
  hints: [
119830
+ ...eventRuntimeHint(duplicated.runtime),
119709
119831
  ...sourceHasTelegram && !mayCopyTelegram ? ["Telegram config was NOT copied: this key needs telegram:write and the notification permission on the source event. The copy starts with Telegram off; ask the event owner to grant the permission, then configure channels on the copy."] : [],
119710
119832
  "Live state was not copied: chat mode is public and the placeholder is hidden on the copy. If the source had chat off or the placeholder shown, set them again with oko_set_chat_mode and oko_set_block_visibility on the new event.",
119711
119833
  "The copy is live at publicUrl immediately; edit title, eventDate, and video with oko_update_event, then run oko_validate_event_setup."
@@ -119792,6 +119914,7 @@ function registerLiveTools(server2) {
119792
119914
  examples: ["Use visible=false to hide the CTA live; use visible=null to return to setup defaults."],
119793
119915
  notes: [
119794
119916
  "block=chat with visible=false PAUSES message input: viewers keep seeing the chat history but cannot send. It does not hide the chat panel.",
119917
+ "Chat is the only block whose override the SERVER enforces: it is written as the chat mode and mirrored into the chat security rules, so a paused chat refuses messages from every client. The other four overrides are page presentation only \u2014 they change what viewers see, not what the server accepts.",
119795
119918
  "Live overrides sit on top of setup: a block disabled in setup (oko_configure_blocks enabled=false) stays absent whatever the override; visible=null returns an enabled block to its setup default. Use overrides for the live show, setup toggles for permanent changes."
119796
119919
  ]
119797
119920
  }),
@@ -119846,10 +119969,10 @@ function registerLiveTools(server2) {
119846
119969
  server2.registerTool("oko_refresh_viewers", {
119847
119970
  title: "Refresh viewers",
119848
119971
  description: toolDescription({
119849
- summary: "Trigger a live viewer refresh tick for an event.",
119972
+ summary: "Refresh what viewers see: recount the online viewers and make open event pages re-read their config.",
119850
119973
  args: ["eventId: event whose viewers should refresh.", "confirm: must be true."],
119851
- returns: "eventId and refreshed=true.",
119852
- examples: ["Use after a setup/live change if viewers should reload state immediately."]
119974
+ returns: "eventId, refreshed=true, and online \u2014 the recounted number of viewers present.",
119975
+ examples: ["Use after a setup/live change if viewers should reload state immediately.", "Use when the online counter looks wrong: it is recounted from the actual presence nodes."]
119853
119976
  }),
119854
119977
  inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to refresh connected viewers.") },
119855
119978
  outputSchema: basicEventOutputSchema,
@@ -119858,8 +119981,13 @@ function registerLiveTools(server2) {
119858
119981
  const { uid } = await authed(["live:control"]);
119859
119982
  await readEvent(eventId, uid, "live");
119860
119983
  requireConfirm(confirm, "oko_refresh_viewers", { eventId });
119861
- await runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1);
119862
- return textResult({ eventId, refreshed: true });
119984
+ const resyncPresence = httpsCallable(functions, "resyncPresence");
119985
+ const [resynced] = await Promise.all([
119986
+ resyncPresence({ eventId }),
119987
+ runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1)
119988
+ ]);
119989
+ const online = typeof resynced.data?.online === "number" ? resynced.data.online : null;
119990
+ return textResult({ eventId, refreshed: true, online });
119863
119991
  });
119864
119992
  server2.registerTool("oko_set_live_badge", {
119865
119993
  title: "Set live badge",
@@ -120074,6 +120202,63 @@ function summarizeSessionRepair(input) {
120074
120202
  }
120075
120203
 
120076
120204
  // build/tools/sessions.js
120205
+ function sessionBaselineFromRead(read) {
120206
+ if (!read.ok)
120207
+ return "unread";
120208
+ return read.exists ? "present" : "absent";
120209
+ }
120210
+ function sessionRepairAction(input) {
120211
+ if (input.liveSessionBaseline === "unread")
120212
+ return "unknown";
120213
+ return input.liveSessionBaseline === "present" ? "end" : "release";
120214
+ }
120215
+ function sessionRepairRefusal(action) {
120216
+ if (action === "end") {
120217
+ return "This session still has working state in RTDB, so clearing its pointers would leave a running broadcast without a report. End it instead: oko_end_session saves the report and clears the pointers itself.";
120218
+ }
120219
+ if (action === "unknown") {
120220
+ return "Could not read the session working state (events/{eventId}/sessions/{sessionId}/baseline), so it is unknown whether the session is still running. Nothing was changed; retry, or end the session with oko_end_session.";
120221
+ }
120222
+ return null;
120223
+ }
120224
+ async function assertSessionHasNothingToEnd(eventId, sessionId) {
120225
+ if (!sessionId)
120226
+ return;
120227
+ let read;
120228
+ try {
120229
+ const snap = await get(ref(rtdb, `events/${eventId}/sessions/${sessionId}/baseline`));
120230
+ read = { ok: true, exists: snap.exists() };
120231
+ } catch {
120232
+ read = { ok: false };
120233
+ }
120234
+ const refusal = sessionRepairRefusal(sessionRepairAction({ liveSessionBaseline: sessionBaselineFromRead(read) }));
120235
+ if (refusal)
120236
+ throw new Error(refusal);
120237
+ }
120238
+ async function attemptCleanup(path, run) {
120239
+ try {
120240
+ await run();
120241
+ return { path, status: "fulfilled" };
120242
+ } catch (error3) {
120243
+ return { path, status: "rejected", error: error3 instanceof Error ? error3.message : String(error3) };
120244
+ }
120245
+ }
120246
+ async function releaseSessionPointers(eventId, sessionId) {
120247
+ const rtdbUpdates = {
120248
+ [`events/${eventId}/ctrl/activeSessionId`]: null
120249
+ };
120250
+ if (sessionId)
120251
+ rtdbUpdates[`activeSessions/${sessionId}`] = null;
120252
+ const rtdbPaths = sessionId ? ["ctrl/activeSessionId", `activeSessions/${sessionId}`] : ["ctrl/activeSessionId"];
120253
+ const rtdbOutcome = await attemptCleanup("rtdb", () => update(ref(rtdb), rtdbUpdates));
120254
+ const attempts = rtdbPaths.map((path) => ({ ...rtdbOutcome, path }));
120255
+ if (rtdbOutcome.status === "rejected") {
120256
+ attempts.push({ path: "events.activeAnalyticsSessionId", status: "rejected", error: "skipped: RTDB pointers were not released" });
120257
+ return attempts;
120258
+ }
120259
+ attempts.push(await attemptCleanup("events.activeAnalyticsSessionId", () => updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() })));
120260
+ return attempts;
120261
+ }
120077
120262
  function registerSessionTools(server2) {
120078
120263
  server2.registerTool("oko_start_session", {
120079
120264
  title: "Start analytics session",
@@ -120135,7 +120320,11 @@ function registerSessionTools(server2) {
120135
120320
  ],
120136
120321
  returns: "eventId, cleared sessionId if found, and cleaned paths.",
120137
120322
  examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
120138
- notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."]
120323
+ notes: [
120324
+ "This does not stop the video stream; OKO Start/End controls analytics sessions only.",
120325
+ "Refuses when the session still has working state in RTDB: clearing the pointers would leave a running broadcast without a report. Use oko_end_session for that case.",
120326
+ "Both RTDB pointers are cleared in one atomic update, and the Firestore pointer only after it succeeds: the repair leaves either all pointers cleared or none."
120327
+ ]
120139
120328
  }),
120140
120329
  inputSchema: {
120141
120330
  eventId: eventIdField,
@@ -120150,41 +120339,11 @@ function registerSessionTools(server2) {
120150
120339
  const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
120151
120340
  const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
120152
120341
  requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
120153
- const cleanup = [
120154
- {
120155
- path: "events.activeAnalyticsSessionId",
120156
- promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() })
120157
- },
120158
- {
120159
- path: "ctrl/activeSessionId",
120160
- promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`))
120161
- }
120162
- ];
120163
- if (sessionId) {
120164
- cleanup.push({
120165
- path: `activeSessions/${sessionId}`,
120166
- promise: remove(ref(rtdb, `activeSessions/${sessionId}`))
120167
- });
120168
- }
120342
+ await assertSessionHasNothingToEnd(eventId, sessionId);
120343
+ const attempts = await releaseSessionPointers(eventId, sessionId);
120169
120344
  if (sessionId && deleteRtdbSessionData) {
120170
- cleanup.push({
120171
- path: `events/${eventId}/sessions/${sessionId}`,
120172
- promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`))
120173
- });
120345
+ attempts.push(await attemptCleanup(`events/${eventId}/sessions/${sessionId}`, () => remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`))));
120174
120346
  }
120175
- const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
120176
- const attempts = settled.map((result, index) => {
120177
- const operation = cleanup[index];
120178
- if (!operation)
120179
- throw new Error("Session repair result has no matching cleanup operation.");
120180
- return {
120181
- path: operation.path,
120182
- status: result.status,
120183
- ...result.status === "rejected" ? {
120184
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
120185
- } : {}
120186
- };
120187
- });
120188
120347
  let firestorePointer = null;
120189
120348
  let rtdbPointer = null;
120190
120349
  let verificationError;
@@ -120220,7 +120379,11 @@ function registerSessionTools(server2) {
120220
120379
  ],
120221
120380
  returns: "eventId, sessionId, deleted=true, and cleaned paths.",
120222
120381
  examples: ["Use only after confirming the exact session id with the organizer."],
120223
- notes: ["Destructive. If the session is active, active pointers are cleared first. RTDB session data cleanup is best-effort because rules may deny deleting historical session snapshots."]
120382
+ notes: [
120383
+ "Destructive. If the session is active, all three pointers are released first \u2014 atomically in RTDB, then in Firestore \u2014 and only then is the document deleted; the platform refuses to delete the report of a session it still considers active.",
120384
+ "Refuses an active session that still has working state in RTDB: end it with oko_end_session, which saves the report.",
120385
+ "RTDB session data cleanup is best-effort because rules may deny deleting historical session snapshots."
120386
+ ]
120224
120387
  }),
120225
120388
  inputSchema: {
120226
120389
  eventId: eventIdField,
@@ -120235,17 +120398,19 @@ function registerSessionTools(server2) {
120235
120398
  const event = await readEvent(eventId, uid, "session");
120236
120399
  requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
120237
120400
  const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
120238
- const cleanup = [];
120401
+ const warnings = [];
120239
120402
  if (isActive) {
120240
- cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
120241
- cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
120242
- cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
120243
- }
120244
- cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
120245
- const results = await Promise.allSettled(cleanup);
120246
- const warnings = results.filter((result) => result.status === "rejected").map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
120247
- if (warnings.length > 0)
120248
- throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
120403
+ await assertSessionHasNothingToEnd(eventId, sessionId);
120404
+ const attempts = await releaseSessionPointers(eventId, sessionId);
120405
+ warnings.push(...attempts.filter((attempt) => attempt.status === "rejected").map((attempt) => `${attempt.path}: ${attempt.error || "cleanup failed"}`));
120406
+ if (warnings.length > 0)
120407
+ throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
120408
+ }
120409
+ try {
120410
+ await deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId));
120411
+ } catch (error3) {
120412
+ throw new Error(`Session delete partially failed: ${error3 instanceof Error ? error3.message : String(error3)}`);
120413
+ }
120249
120414
  const rtdbCleanupWarnings = [];
120250
120415
  if (deleteRtdbData) {
120251
120416
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {