@okostream/mcp 0.12.12 → 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 +164 -57
  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.12";
116294
+ var OKO_MCP_VERSION = "0.12.13";
116295
116295
 
116296
116296
  // build/auth.js
116297
116297
  var state = null;
@@ -116702,7 +116702,16 @@ var MAX_CARD_BUTTON_LABEL_LENGTH = 80;
116702
116702
  var MAX_CARD_BUTTON_URL_LENGTH = 1e3;
116703
116703
  var MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
116704
116704
  var REMOTE_IMAGE_TIMEOUT_MS = 15e3;
116705
+ var REMOTE_IMAGE_TOTAL_TIMEOUT_MS = 4e4;
116705
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
+ }
116706
116715
  var ALLOWED_REMOTE_IMAGE_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
116707
116716
  function storagePathFromDownloadUrl(downloadUrl) {
116708
116717
  if (!downloadUrl)
@@ -116816,7 +116825,7 @@ function shareLinkHint(url) {
116816
116825
  }
116817
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.";
116818
116827
  }
116819
- async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
116828
+ async function readRemoteImage(rawUrl, redirects = 0, notes = [], deadlineAt = Date.now() + REMOTE_IMAGE_TOTAL_TIMEOUT_MS) {
116820
116829
  if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
116821
116830
  throw new Error("Image URL has too many redirects.");
116822
116831
  }
@@ -116827,21 +116836,28 @@ async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
116827
116836
  }
116828
116837
  let lastConnectError = null;
116829
116838
  for (const candidate of pinned.addresses) {
116830
- const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects);
116839
+ const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects, deadlineAt);
116831
116840
  if ("buffer" in attempt)
116832
116841
  return attempt.buffer;
116833
116842
  if ("redirectTo" in attempt)
116834
- return readRemoteImage(attempt.redirectTo, redirects + 1, notes);
116843
+ return readRemoteImage(attempt.redirectTo, redirects + 1, notes, deadlineAt);
116835
116844
  lastConnectError = attempt.connectError;
116836
116845
  }
116837
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"}.`);
116838
116847
  }
116839
- 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
+ }
116840
116856
  const dispatcher = new import_undici.Agent({
116841
116857
  connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
116842
116858
  });
116843
116859
  const controller = new AbortController();
116844
- const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
116860
+ const timeout = setTimeout(() => controller.abort(), budget.ms);
116845
116861
  try {
116846
116862
  let response;
116847
116863
  try {
@@ -116853,7 +116869,7 @@ async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
116853
116869
  });
116854
116870
  } catch (err) {
116855
116871
  if (err instanceof Error && err.name === "AbortError") {
116856
- 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.`);
116857
116873
  }
116858
116874
  return { connectError: err instanceof Error ? err : new Error(String(err)) };
116859
116875
  }
@@ -118047,6 +118063,27 @@ function markdownMetric(label, value, suffix = "") {
118047
118063
  const metric = value === null || value === void 0 || value === "" ? "n/a" : String(value);
118048
118064
  return `- ${label}: ${metric}${suffix}`;
118049
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
+ };
118050
118087
  function sessionReportMarkdown({ eventId, event, sessionId, session }) {
118051
118088
  const lines = [
118052
118089
  `# OKO session report`,
@@ -118076,6 +118113,12 @@ function sessionReportMarkdown({ eventId, event, sessionId, session }) {
118076
118113
  markdownMetric("CTA clicks", session.ctaClicks),
118077
118114
  markdownMetric("Cards clicks", session.cardsClicks)
118078
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
+ }
118079
118122
  return lines.join("\n");
118080
118123
  }
118081
118124
  var TELEGRAM_COPYABLE_KEYS = ["enabled", "channelIds", "autoReminder"];
@@ -118434,10 +118477,32 @@ async function addEventWithRtdbMirror(eventData) {
118434
118477
  try {
118435
118478
  const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
118436
118479
  await ensureRuntime({ eventId: eventRef.id });
118480
+ return { ref: eventRef, runtime: { status: "ready" } };
118437
118481
  } catch (error3) {
118438
- 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
+ };
118439
118493
  }
118440
- 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
+ ];
118441
118506
  }
118442
118507
 
118443
118508
  // build/tools/setupValidation.js
@@ -118787,7 +118852,7 @@ function registerEventTools(server2) {
118787
118852
  const workspaceId = args.workspaceId ?? uid;
118788
118853
  await assertCanCreateInWorkspace(uid, workspaceId);
118789
118854
  const video = normalizeVideo(args.videoUrl);
118790
- const docRef = await addEventWithRtdbMirror(buildCreateWebinarDocument({
118855
+ const created = await addEventWithRtdbMirror(buildCreateWebinarDocument({
118791
118856
  uid,
118792
118857
  workspaceId,
118793
118858
  title: args.title,
@@ -118798,11 +118863,14 @@ function registerEventTools(server2) {
118798
118863
  chatEnabled: args.chatEnabled,
118799
118864
  createdAt: serverTimestamp2()
118800
118865
  }));
118866
+ const docRef = created.ref;
118801
118867
  return textResult({
118802
118868
  id: docRef.id,
118803
118869
  ...eventLinks(docRef.id),
118804
118870
  workspaceId,
118871
+ runtime: created.runtime,
118805
118872
  hints: [
118873
+ ...eventRuntimeHint(created.runtime),
118806
118874
  "The page is live at publicUrl immediately; OKO has no draft state.",
118807
118875
  "Next: oko_configure_blocks for chat/CTA/placeholder, oko_configure_cards for cards, image uploads, then oko_validate_event_setup.",
118808
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.'
@@ -119317,7 +119385,6 @@ var MCP_AUDIT_TARGET_KEYS = /* @__PURE__ */ new Set([
119317
119385
  "postId",
119318
119386
  "messageId",
119319
119387
  "leadId",
119320
- "participantUid",
119321
119388
  "uid",
119322
119389
  "channelId",
119323
119390
  "cardId",
@@ -119733,7 +119800,7 @@ function registerLifecycleTools(server2) {
119733
119800
  await assertCanCreateInWorkspace(uid, workspaceId);
119734
119801
  const sourceHasTelegram = !!source.telegram && typeof source.telegram === "object";
119735
119802
  const mayCopyTelegram = sourceHasTelegram && currentScopes().includes("telegram:write") && await eventGrantsMode(eventId, uid, "notify");
119736
- const newRef = await addEventWithRtdbMirror({
119803
+ const duplicated = await addEventWithRtdbMirror({
119737
119804
  ownerId: uid,
119738
119805
  workspaceId,
119739
119806
  createdAt: serverTimestamp2(),
@@ -119750,14 +119817,17 @@ function registerLifecycleTools(server2) {
119750
119817
  // (review 02.09, R3b-5).
119751
119818
  ...mayCopyTelegram ? { telegram: copyableTelegram(source.telegram) } : {}
119752
119819
  });
119820
+ const newRef = duplicated.ref;
119753
119821
  return textResult({
119754
119822
  id: newRef.id,
119755
119823
  sourceEventId: eventId,
119756
119824
  workspaceId,
119825
+ runtime: duplicated.runtime,
119757
119826
  ...eventLinks(newRef.id),
119758
119827
  liveStateNotCopied: ["chatMode", "placeholderVisible", "liveBadge", "block visibility overrides"],
119759
119828
  telegramCopied: mayCopyTelegram,
119760
119829
  hints: [
119830
+ ...eventRuntimeHint(duplicated.runtime),
119761
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."] : [],
119762
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.",
119763
119833
  "The copy is live at publicUrl immediately; edit title, eventDate, and video with oko_update_event, then run oko_validate_event_setup."
@@ -120132,6 +120202,63 @@ function summarizeSessionRepair(input) {
120132
120202
  }
120133
120203
 
120134
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
+ }
120135
120262
  function registerSessionTools(server2) {
120136
120263
  server2.registerTool("oko_start_session", {
120137
120264
  title: "Start analytics session",
@@ -120193,7 +120320,11 @@ function registerSessionTools(server2) {
120193
120320
  ],
120194
120321
  returns: "eventId, cleared sessionId if found, and cleaned paths.",
120195
120322
  examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
120196
- 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
+ ]
120197
120328
  }),
120198
120329
  inputSchema: {
120199
120330
  eventId: eventIdField,
@@ -120208,41 +120339,11 @@ function registerSessionTools(server2) {
120208
120339
  const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
120209
120340
  const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
120210
120341
  requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
120211
- const cleanup = [
120212
- {
120213
- path: "events.activeAnalyticsSessionId",
120214
- promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() })
120215
- },
120216
- {
120217
- path: "ctrl/activeSessionId",
120218
- promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`))
120219
- }
120220
- ];
120221
- if (sessionId) {
120222
- cleanup.push({
120223
- path: `activeSessions/${sessionId}`,
120224
- promise: remove(ref(rtdb, `activeSessions/${sessionId}`))
120225
- });
120226
- }
120342
+ await assertSessionHasNothingToEnd(eventId, sessionId);
120343
+ const attempts = await releaseSessionPointers(eventId, sessionId);
120227
120344
  if (sessionId && deleteRtdbSessionData) {
120228
- cleanup.push({
120229
- path: `events/${eventId}/sessions/${sessionId}`,
120230
- promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`))
120231
- });
120345
+ attempts.push(await attemptCleanup(`events/${eventId}/sessions/${sessionId}`, () => remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`))));
120232
120346
  }
120233
- const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
120234
- const attempts = settled.map((result, index) => {
120235
- const operation = cleanup[index];
120236
- if (!operation)
120237
- throw new Error("Session repair result has no matching cleanup operation.");
120238
- return {
120239
- path: operation.path,
120240
- status: result.status,
120241
- ...result.status === "rejected" ? {
120242
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
120243
- } : {}
120244
- };
120245
- });
120246
120347
  let firestorePointer = null;
120247
120348
  let rtdbPointer = null;
120248
120349
  let verificationError;
@@ -120278,7 +120379,11 @@ function registerSessionTools(server2) {
120278
120379
  ],
120279
120380
  returns: "eventId, sessionId, deleted=true, and cleaned paths.",
120280
120381
  examples: ["Use only after confirming the exact session id with the organizer."],
120281
- 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
+ ]
120282
120387
  }),
120283
120388
  inputSchema: {
120284
120389
  eventId: eventIdField,
@@ -120293,17 +120398,19 @@ function registerSessionTools(server2) {
120293
120398
  const event = await readEvent(eventId, uid, "session");
120294
120399
  requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
120295
120400
  const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
120296
- const cleanup = [];
120401
+ const warnings = [];
120297
120402
  if (isActive) {
120298
- cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
120299
- cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
120300
- cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
120301
- }
120302
- cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
120303
- const results = await Promise.allSettled(cleanup);
120304
- const warnings = results.filter((result) => result.status === "rejected").map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
120305
- if (warnings.length > 0)
120306
- 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
+ }
120307
120414
  const rtdbCleanupWarnings = [];
120308
120415
  if (deleteRtdbData) {
120309
120416
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.12.12",
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": {