@okostream/mcp 0.10.0 → 0.11.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/dist/tools.js CHANGED
@@ -940,7 +940,7 @@ function validateEventSetup(eventId, event) {
940
940
  add(errors, "title_missing", "title", "Event title is required.");
941
941
  const video = asRecord(event.video);
942
942
  if (!safeString(video.embedUrl).trim()) {
943
- add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured.");
943
+ add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured. This warning is for the organizer only and never appears as page text above other blocks. Without a video URL the player slot shows a small 'video not configured' notice, but when the video placeholder block is enabled and switched on, the placeholder fully covers that slot and viewers see only the placeholder.");
944
944
  }
945
945
  const meta = asRecord(event.meta);
946
946
  const eventDateMs = toMillis(meta.eventDate);
@@ -1156,6 +1156,35 @@ function timestampCursorFrom(value) {
1156
1156
  const millis = toMillis(value);
1157
1157
  return millis == null ? null : String(millis);
1158
1158
  }
1159
+ function parseIsoBound(value, label) {
1160
+ if (value === undefined)
1161
+ return null;
1162
+ const parsed = Date.parse(value);
1163
+ if (!Number.isFinite(parsed)) {
1164
+ throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
1165
+ }
1166
+ return Timestamp.fromMillis(parsed);
1167
+ }
1168
+ // Компактная карточка события из полного (сериализованного) документа —
1169
+ // той же формы, что grant-проекция в eventAccess.
1170
+ function compactEventFromFullDoc(event) {
1171
+ const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
1172
+ const telegram = (event.telegram && typeof event.telegram === "object" ? event.telegram : {});
1173
+ return {
1174
+ id: event.id,
1175
+ ownerId: event.ownerId ?? null,
1176
+ workspaceId: event.workspaceId ?? null,
1177
+ title: event.title ?? null,
1178
+ createdAt: event.createdAt ?? null,
1179
+ eventDate: meta.eventDate ?? null,
1180
+ speakerName: meta.speakerName ?? null,
1181
+ pinned: event.pinned === true,
1182
+ telegramEnabled: telegram.enabled === true,
1183
+ trashed: Boolean(event.trashedAt),
1184
+ trashedAt: event.trashedAt ?? null,
1185
+ activeAnalyticsSessionId: event.activeAnalyticsSessionId ?? null,
1186
+ };
1187
+ }
1159
1188
  function compactAnalyticsSession(raw, options) {
1160
1189
  const session = serialize(raw);
1161
1190
  const timeline = Array.isArray(session.timeline) ? session.timeline : null;
@@ -1603,24 +1632,40 @@ export function registerTools(server) {
1603
1632
  summary: "List OKO events visible to the authenticated organizer.",
1604
1633
  args: [
1605
1634
  "limit: maximum events to return, default 30, max 100.",
1606
- "cursor: timestamp cursor returned as nextCursor by the previous page.",
1635
+ "cursor: pagination cursor returned as nextCursor by the previous page; reuse it with the same filters.",
1607
1636
  "workspaceId: optional workspace filter.",
1637
+ 'detail: "compact" (default) returns the light card projection; "full" returns complete event documents.',
1638
+ "from/to: optional ISO timestamps bounding meta.eventDate; sorting switches to eventDate descending and undated events are excluded.",
1608
1639
  ],
1609
1640
  returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1610
- examples: ["Use limit=10 first; if hasMore is true, call again with cursor=nextCursor."],
1641
+ examples: [
1642
+ "Use limit=10 first; if hasMore is true, call again with cursor=nextCursor.",
1643
+ 'For "today\'s webinar" pass from/to covering the day; use detail="full" only when block contents are needed.',
1644
+ ],
1611
1645
  }),
1612
1646
  inputSchema: {
1613
1647
  limit: limitField(),
1614
1648
  cursor: cursorField,
1615
1649
  workspaceId: z.string().optional().describe("Optional workspace id to filter returned events."),
1650
+ detail: z.enum(["compact", "full"]).default("compact").describe('Response shape: "compact" card projection (default) or "full" event documents.'),
1651
+ from: z.string().optional().describe("Include only events with meta.eventDate at or after this ISO timestamp."),
1652
+ to: z.string().optional().describe("Include only events with meta.eventDate at or before this ISO timestamp."),
1616
1653
  },
1617
1654
  outputSchema: eventsOutputSchema,
1618
1655
  annotations: readOnlyTool,
1619
1656
  }, async (args) => {
1620
1657
  const { uid } = await authed(["events:read"]);
1658
+ const fromTs = parseIsoBound(args.from, "from");
1659
+ const toTs = parseIsoBound(args.to, "to");
1660
+ const dateFiltered = fromTs !== null || toTs !== null;
1661
+ const orderField = dateFiltered ? "eventDate" : "createdAt";
1621
1662
  const constraints = [
1622
- ...(args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
1623
- orderBy("createdAt", "desc"),
1663
+ // При датном фильтре workspaceId отсеивается по загруженной странице:
1664
+ // составного индекса grants(workspaceId+eventDate) нет намеренно.
1665
+ ...(!dateFiltered && args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
1666
+ ...(fromTs ? [where("eventDate", ">=", fromTs)] : []),
1667
+ ...(toTs ? [where("eventDate", "<=", toTs)] : []),
1668
+ orderBy(orderField, "desc"),
1624
1669
  limit(args.limit + 1),
1625
1670
  ];
1626
1671
  if (args.cursor)
@@ -1632,12 +1677,29 @@ export function registerTools(server) {
1632
1677
  });
1633
1678
  if (snap !== null) {
1634
1679
  const { page, hasMore } = pickPage(snap.docs, args.limit);
1635
- const eventDocuments = await Promise.all(page.map((item) => (getDoc(doc(firestore, "events", item.id)))));
1636
- const events = eventDocuments.flatMap((item) => (item.exists()
1637
- ? [{ id: item.id, ...serialize(item.data()) }]
1638
- : []));
1639
- const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1640
- return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1680
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data()[orderField]) : null;
1681
+ const pageItems = dateFiltered && args.workspaceId
1682
+ ? page.filter((item) => item.data().workspaceId === args.workspaceId)
1683
+ : page;
1684
+ let events;
1685
+ if (args.detail === "full") {
1686
+ const eventDocuments = await Promise.all(pageItems.map((item) => (getDoc(doc(firestore, "events", item.id)))));
1687
+ events = eventDocuments.flatMap((item) => (item.exists()
1688
+ ? [{ id: item.id, ...serialize(item.data()) }]
1689
+ : []));
1690
+ }
1691
+ else {
1692
+ // Компактный режим отдаёт grant-проекцию как есть — без чтения
1693
+ // полных документов событий.
1694
+ events = pageItems.map((item) => {
1695
+ const data = serialize(item.data());
1696
+ delete data.schemaVersion;
1697
+ delete data.uid;
1698
+ delete data.eventId;
1699
+ return { id: item.id, ...data };
1700
+ });
1701
+ }
1702
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
1641
1703
  }
1642
1704
  const legacyConstraints = [
1643
1705
  where("accessibleBy", "array-contains", uid),
@@ -1649,14 +1711,26 @@ export function registerTools(server) {
1649
1711
  }
1650
1712
  const legacySnap = await getDocs(query(collection(firestore, "events"), ...legacyConstraints));
1651
1713
  const { page, hasMore } = pickPage(legacySnap.docs, args.limit);
1714
+ const fromMs = fromTs ? fromTs.toMillis() : null;
1715
+ const toMs = toTs ? toTs.toMillis() : null;
1652
1716
  const events = page
1653
1717
  .map((item) => ({
1654
1718
  id: item.id,
1655
1719
  ...serialize(item.data()),
1656
1720
  }))
1657
- .filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId);
1721
+ .filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId)
1722
+ .filter((event) => {
1723
+ if (fromMs === null && toMs === null)
1724
+ return true;
1725
+ const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
1726
+ const ms = typeof meta.eventDate === "string" ? Date.parse(meta.eventDate) : NaN;
1727
+ if (!Number.isFinite(ms))
1728
+ return false;
1729
+ return (fromMs === null || ms >= fromMs) && (toMs === null || ms <= toMs);
1730
+ })
1731
+ .map((event) => (args.detail === "full" ? event : compactEventFromFullDoc(event)));
1658
1732
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1659
- return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1733
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
1660
1734
  });
1661
1735
  server.registerTool("oko_get_event", {
1662
1736
  title: "Get event",
@@ -1820,6 +1894,10 @@ export function registerTools(server) {
1820
1894
  ],
1821
1895
  returns: "event id and the list of Firestore fields updated.",
1822
1896
  examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
1897
+ notes: [
1898
+ "A block toggle and its image are independent: enabling a block here works with an image already uploaded via oko_upload_image, and uploading an image never enables the block by itself.",
1899
+ "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.",
1900
+ ],
1823
1901
  }),
1824
1902
  inputSchema: {
1825
1903
  eventId: eventIdField,
@@ -2001,6 +2079,7 @@ export function registerTools(server) {
2001
2079
  'Use kind="card" with cardId after the card item already exists.',
2002
2080
  ],
2003
2081
  notes: [
2082
+ "Uploading an image does not enable its block: toggle the block independently via oko_configure_blocks, in either order.",
2004
2083
  "The MCP server reads local paths on the organizer machine.",
2005
2084
  "Remote URLs must be public HTTPS images, max 15MB; private/local hosts are blocked.",
2006
2085
  "For card images, imageId is the cardId so OKO CF4 can update the correct item.",
@@ -2393,6 +2472,105 @@ export function registerTools(server) {
2393
2472
  await set(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), Date.now());
2394
2473
  return textResult({ eventId, refreshed: true });
2395
2474
  });
2475
+ server.registerTool("oko_set_live_badge", {
2476
+ title: "Set live badge",
2477
+ description: toolDescription({
2478
+ summary: "Control the public 'Live' badge on the event page.",
2479
+ args: [
2480
+ "eventId: event to control.",
2481
+ "mode: session (follows the analytics session, product default), manual (badge shown now for durationMinutes), schedule (shown around meta.eventDate), hidden (never shown).",
2482
+ "durationMinutes: badge window for manual/schedule, 15-1440 minutes, default 120.",
2483
+ "confirm: must be true.",
2484
+ ],
2485
+ returns: "eventId, mode, durationMinutes.",
2486
+ examples: ['Use mode="manual" during an unscheduled stream; return to mode="session" afterwards.'],
2487
+ notes: ["The badge is a page indicator only; it never starts or stops video or analytics."],
2488
+ }),
2489
+ inputSchema: {
2490
+ eventId: eventIdField,
2491
+ mode: z.enum(["session", "manual", "schedule", "hidden"]).describe("Live badge mode."),
2492
+ durationMinutes: z.number().int().min(15).max(1440).optional().describe("Badge window in minutes for manual/schedule modes; default 120."),
2493
+ confirm: z.boolean().optional().describe("Required true to change the public live badge."),
2494
+ },
2495
+ outputSchema: basicEventOutputSchema,
2496
+ annotations: idempotentWriteTool,
2497
+ }, async ({ eventId, mode, durationMinutes, confirm }) => {
2498
+ const { uid } = await authed(["live:control"]);
2499
+ await readEvent(eventId, uid, "live");
2500
+ requireConfirm(confirm, "oko_set_live_badge", { eventId, mode, durationMinutes: durationMinutes ?? null });
2501
+ const value = { mode, durationMinutes: durationMinutes ?? 120 };
2502
+ await set(ref(rtdb, `events/${eventId}/ctrl/liveBadge`), value);
2503
+ return textResult({ eventId, ...value });
2504
+ });
2505
+ server.registerTool("oko_set_chat_mode", {
2506
+ title: "Set chat mode",
2507
+ description: toolDescription({
2508
+ summary: "Set who can send chat messages on the public event page.",
2509
+ args: [
2510
+ "eventId: event to control.",
2511
+ "mode: public (anyone), auth (signed-in viewers only), owner (organizers only), off (input closed, history stays visible), or null to reset to the product default (public).",
2512
+ "confirm: must be true.",
2513
+ ],
2514
+ returns: "eventId and the effective chat mode.",
2515
+ examples: ['Use mode="off" to pause a chat storm; use mode=null or "public" to reopen.'],
2516
+ notes: [
2517
+ 'This is the full replacement for the legacy pause via oko_set_block_visibility(block="chat"): prefer oko_set_chat_mode.',
2518
+ "History always stays visible to viewers; mode only gates sending.",
2519
+ ],
2520
+ }),
2521
+ inputSchema: {
2522
+ eventId: eventIdField,
2523
+ mode: z.enum(["public", "auth", "owner", "off"]).nullable().describe("Chat mode; null resets the override to the default."),
2524
+ confirm: z.boolean().optional().describe("Required true to change the live chat mode."),
2525
+ },
2526
+ outputSchema: basicEventOutputSchema,
2527
+ annotations: idempotentWriteTool,
2528
+ }, async ({ eventId, mode, confirm }) => {
2529
+ const { uid } = await authed(["live:control"]);
2530
+ await readEvent(eventId, uid, "live");
2531
+ requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
2532
+ const target = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
2533
+ if (mode === null)
2534
+ await remove(target);
2535
+ else
2536
+ await set(target, mode);
2537
+ return textResult({ eventId, mode });
2538
+ });
2539
+ server.registerTool("oko_force_viewer_reload", {
2540
+ title: "Force viewer reload",
2541
+ description: toolDescription({
2542
+ summary: "Force a hard page reload for every connected viewer (emergency recovery).",
2543
+ args: ["eventId: event whose viewer pages must reload.", "confirm: must be true."],
2544
+ returns: "eventId and reloaded=true.",
2545
+ examples: ["Use only when viewer pages are stuck (broken bundle or state) and oko_refresh_viewers did not help."],
2546
+ notes: [
2547
+ "Server enforces a 120-second cooldown per event; a repeated call returns the seconds left.",
2548
+ "Viewers reload with up to 4 seconds of jitter. Prefer oko_refresh_viewers for ordinary content updates.",
2549
+ ],
2550
+ }),
2551
+ inputSchema: {
2552
+ eventId: eventIdField,
2553
+ confirm: z.boolean().optional().describe("Required true to hard-reload all viewer pages."),
2554
+ },
2555
+ outputSchema: basicEventOutputSchema,
2556
+ annotations: writeTool,
2557
+ }, async ({ eventId, confirm }) => {
2558
+ const { uid } = await authed(["live:control"]);
2559
+ await readEvent(eventId, uid, "live");
2560
+ requireConfirm(confirm, "oko_force_viewer_reload", { eventId });
2561
+ const forceReload = httpsCallable(functions, "forceViewerReload");
2562
+ try {
2563
+ await forceReload({ eventId });
2564
+ }
2565
+ catch (error) {
2566
+ const details = error.details;
2567
+ if (typeof details?.retryInSec === "number") {
2568
+ throw new Error(`Force reload is cooling down: retry in ${details.retryInSec} seconds.`);
2569
+ }
2570
+ throw error;
2571
+ }
2572
+ return textResult({ eventId, reloaded: true });
2573
+ });
2396
2574
  server.registerTool("oko_start_session", {
2397
2575
  title: "Start analytics session",
2398
2576
  description: toolDescription({
package/dist/utils.js CHANGED
@@ -52,7 +52,7 @@ export function textResult(data, options = {}) {
52
52
  structuredContent = {
53
53
  ...compactForText(structuredContent),
54
54
  truncated: true,
55
- truncationMessage: `Response JSON exceeded ${maxChars} characters. ${options.truncationHint ?? "Use limit/cursor or narrower include options."}`,
55
+ truncationMessage: `Response JSON exceeded ${maxChars} characters. ${options.truncationHint ?? "Use a smaller limit or cursor pagination."}`,
56
56
  };
57
57
  }
58
58
  return {
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/generateMcpVersion.mjs from mcp/package.json. Do not edit.
2
- export const OKO_MCP_VERSION = "0.10.0";
2
+ export const OKO_MCP_VERSION = "0.11.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {