@okostream/mcp 0.9.2 → 0.10.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
@@ -868,13 +868,6 @@ function eventLinks(eventId) {
868
868
  analyticsUrl: analyticsUrl(eventId),
869
869
  };
870
870
  }
871
- function assertEventReadable(data, uid) {
872
- const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
873
- const editors = Array.isArray(data.editors) ? data.editors : [];
874
- if (data.ownerId !== uid && !access.includes(uid) && !editors.includes(uid)) {
875
- throw new Error("Event is not accessible to this organizer.");
876
- }
877
- }
878
871
  const EVENT_ACCESS_FIELD_BY_MODE = {
879
872
  read: "accessibleBy",
880
873
  edit: "editors",
@@ -886,22 +879,44 @@ const EVENT_ACCESS_FIELD_BY_MODE = {
886
879
  moderate: "chatModerators",
887
880
  notify: "notificationSenders",
888
881
  };
889
- function assertEventAccess(data, uid, mode) {
882
+ function grantAllowsMode(data, mode) {
890
883
  const field = EVENT_ACCESS_FIELD_BY_MODE[mode];
891
- const principals = Array.isArray(data[field]) ? data[field] : [];
892
- if (data.ownerId !== uid && !principals.includes(uid)) {
893
- throw new Error(`Event does not grant ${mode} access to this organizer.`);
894
- }
884
+ return asRecord(data.capabilities)[field] === true;
885
+ }
886
+ function legacyEventAllowsMode(data, uid, mode) {
887
+ if (data.ownerId === uid)
888
+ return true;
889
+ const fields = mode === "read"
890
+ ? ["accessibleBy", "editors"]
891
+ : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
892
+ return fields.some((field) => (Array.isArray(data[field]) && data[field].includes(uid)));
893
+ }
894
+ function isFirestorePermissionDenied(error) {
895
+ if (!error || typeof error !== "object")
896
+ return false;
897
+ const code = error.code;
898
+ return code === "permission-denied"
899
+ || code === "firestore/permission-denied"
900
+ || code === "PERMISSION_DENIED";
895
901
  }
896
902
  async function readEvent(eventId, uid, mode = "read") {
897
- const snap = await getDoc(doc(firestore, "events", eventId));
903
+ const [snap, grant] = await Promise.all([
904
+ getDoc(doc(firestore, "events", eventId)),
905
+ getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error) => {
906
+ if (!isFirestorePermissionDenied(error))
907
+ throw error;
908
+ return null;
909
+ }),
910
+ ]);
898
911
  if (!snap.exists())
899
912
  throw new Error("Event not found.");
900
913
  const data = snap.data();
901
- if (mode === "read")
902
- assertEventReadable(data, uid);
903
- else
904
- assertEventAccess(data, uid, mode);
914
+ const allowed = grant === null
915
+ ? legacyEventAllowsMode(data, uid, mode)
916
+ : data.ownerId === uid || (grant.exists() && grantAllowsMode(grant.data(), mode));
917
+ if (!allowed) {
918
+ throw new Error(`Event does not grant ${mode} access to this organizer.`);
919
+ }
905
920
  if (data.trashedAt && mode !== "read" && mode !== "delete") {
906
921
  throw new Error("Event is in trash. Restore it before making changes or live actions.");
907
922
  }
@@ -1047,7 +1062,7 @@ function initialEventAccess(uid) {
1047
1062
  notificationSenders: [uid],
1048
1063
  };
1049
1064
  }
1050
- async function addEventWithRtdbMirror(uid, workspaceId, eventData) {
1065
+ async function addEventWithRtdbMirror(eventData) {
1051
1066
  const eventRef = await addDoc(collection(firestore, "events"), eventData);
1052
1067
  try {
1053
1068
  const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
@@ -1328,7 +1343,7 @@ export function registerTools(server) {
1328
1343
  ],
1329
1344
  returns: "uid, updated field names, and the resulting profile snapshot.",
1330
1345
  examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
1331
- notes: ["This edits only the current organizer's own userProfiles document and does not require extra workspace/event scopes."],
1346
+ notes: ["Edits the organizer profile that owns this key. Requires workspace:write: the key holder is not necessarily the organizer, so a read-only key must not rewrite the owner's name, avatar or timezone."],
1332
1347
  }),
1333
1348
  inputSchema: {
1334
1349
  displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
@@ -1337,7 +1352,7 @@ export function registerTools(server) {
1337
1352
  outputSchema: profileOutputSchema,
1338
1353
  annotations: idempotentWriteTool,
1339
1354
  }, async ({ displayName, timezone }) => {
1340
- const { uid } = await authed([]);
1355
+ const { uid } = await authed(["workspace:write"]);
1341
1356
  const patch = { updatedAt: serverTimestamp() };
1342
1357
  if (displayName !== undefined)
1343
1358
  patch.displayName = displayName?.trim() || null;
@@ -1449,11 +1464,17 @@ export function registerTools(server) {
1449
1464
  ]);
1450
1465
  const members = [];
1451
1466
  for (const share of sharesSnap.docs) {
1452
- const profileSnap = await getDoc(doc(firestore, "userProfiles", share.id));
1467
+ // Правила разрешают читать чужой профиль только владельцу платформы,
1468
+ // поэтому на любом рабочем пространстве с участниками это чтение
1469
+ // заканчивается отказом. Без обработки инструмент падал целиком вместо
1470
+ // того, чтобы вернуть состав команды без необязательных профилей.
1471
+ const profile = await getDoc(doc(firestore, "userProfiles", share.id))
1472
+ .then((snap) => (snap.exists() ? serialize(snap.data()) : null))
1473
+ .catch(() => null);
1453
1474
  members.push({
1454
1475
  uid: share.id,
1455
1476
  ...serialize(share.data()),
1456
- profile: profileSnap.exists() ? serialize(profileSnap.data()) : null,
1477
+ profile,
1457
1478
  });
1458
1479
  }
1459
1480
  const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
@@ -1466,7 +1487,7 @@ export function registerTools(server) {
1466
1487
  args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
1467
1488
  returns: "workspaceId, uid, and updated=true.",
1468
1489
  examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
1469
- notes: ["Changing a share triggers OKO CF18 fan-out to event access, RTDB workspaceMembers, and chat roles."],
1490
+ notes: ["Changing a share materializes lightweight event grants and updates RTDB workspace/chat mirrors."],
1470
1491
  }),
1471
1492
  inputSchema: {
1472
1493
  workspaceId: z.string().min(1),
@@ -1498,7 +1519,7 @@ export function registerTools(server) {
1498
1519
  args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
1499
1520
  returns: "workspaceId, uid, removed=true.",
1500
1521
  examples: ["Use after listing workspace access and confirming the exact uid."],
1501
- notes: ["Removing a share triggers OKO CF18 reverse fan-out."],
1522
+ notes: ["Removing a share revokes its event grants and RTDB workspace/chat mirrors."],
1502
1523
  }),
1503
1524
  inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
1504
1525
  outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
@@ -1583,7 +1604,7 @@ export function registerTools(server) {
1583
1604
  args: [
1584
1605
  "limit: maximum events to return, default 30, max 100.",
1585
1606
  "cursor: timestamp cursor returned as nextCursor by the previous page.",
1586
- "workspaceId: optional client-side workspace filter.",
1607
+ "workspaceId: optional workspace filter.",
1587
1608
  ],
1588
1609
  returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1589
1610
  examples: ["Use limit=10 first; if hasMore is true, call again with cursor=nextCursor."],
@@ -1598,17 +1619,42 @@ export function registerTools(server) {
1598
1619
  }, async (args) => {
1599
1620
  const { uid } = await authed(["events:read"]);
1600
1621
  const constraints = [
1601
- where("accessibleBy", "array-contains", uid),
1622
+ ...(args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
1602
1623
  orderBy("createdAt", "desc"),
1603
1624
  limit(args.limit + 1),
1604
1625
  ];
1605
1626
  if (args.cursor)
1606
- constraints.splice(2, 0, startAfter(parseTimestampCursor(args.cursor)));
1607
- const snap = await getDocs(query(collection(firestore, "events"), ...constraints));
1608
- const { page, hasMore } = pickPage(snap.docs, args.limit);
1609
- let events = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
1610
- if (args.workspaceId)
1611
- events = events.filter((event) => event.workspaceId === args.workspaceId);
1627
+ constraints.splice(constraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
1628
+ const snap = await getDocs(query(collection(firestore, "eventAccess", uid, "grants"), ...constraints)).catch((error) => {
1629
+ if (!isFirestorePermissionDenied(error))
1630
+ throw error;
1631
+ return null;
1632
+ });
1633
+ if (snap !== null) {
1634
+ 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) });
1641
+ }
1642
+ const legacyConstraints = [
1643
+ where("accessibleBy", "array-contains", uid),
1644
+ orderBy("createdAt", "desc"),
1645
+ limit(args.limit + 1),
1646
+ ];
1647
+ if (args.cursor) {
1648
+ legacyConstraints.splice(legacyConstraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
1649
+ }
1650
+ const legacySnap = await getDocs(query(collection(firestore, "events"), ...legacyConstraints));
1651
+ const { page, hasMore } = pickPage(legacySnap.docs, args.limit);
1652
+ const events = page
1653
+ .map((item) => ({
1654
+ id: item.id,
1655
+ ...serialize(item.data()),
1656
+ }))
1657
+ .filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId);
1612
1658
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1613
1659
  return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1614
1660
  });
@@ -1679,7 +1725,7 @@ export function registerTools(server) {
1679
1725
  const workspaceId = args.workspaceId ?? uid;
1680
1726
  await assertCanCreateInWorkspace(uid, workspaceId);
1681
1727
  const video = normalizeVideo(args.videoUrl);
1682
- const docRef = await addEventWithRtdbMirror(uid, workspaceId, {
1728
+ const docRef = await addEventWithRtdbMirror({
1683
1729
  ownerId: uid,
1684
1730
  workspaceId,
1685
1731
  ...initialEventAccess(uid),
@@ -1703,9 +1749,12 @@ export function registerTools(server) {
1703
1749
  title: "Update event metadata",
1704
1750
  description: toolDescription({
1705
1751
  summary: "Update safe top-level event/setup metadata fields.",
1706
- args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned."],
1752
+ args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned, sessionMode, durationMinutes."],
1707
1753
  returns: "event id and the list of Firestore fields updated.",
1708
1754
  examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
1755
+ notes: [
1756
+ "sessionMode 'schedule' makes OKO start and end the analytics session automatically at eventDate for durationMinutes; 'manual' keeps the Start/End buttons and a Telegram reminder 10 minutes before eventDate. This is still an analytics session, not an event status.",
1757
+ ],
1709
1758
  }),
1710
1759
  inputSchema: {
1711
1760
  eventId: eventIdField,
@@ -1715,6 +1764,8 @@ export function registerTools(server) {
1715
1764
  speakerName: z.string().max(160).nullable().optional().describe("New speaker display name, or null to clear."),
1716
1765
  description: z.string().max(3000).nullable().optional().describe("New description, or null to clear."),
1717
1766
  pinned: z.boolean().optional().describe("Whether this event is pinned in the dashboard."),
1767
+ sessionMode: z.enum(["manual", "schedule"]).optional().describe("Analytics session mode: manual Start/End buttons, or automatic start/end on schedule at eventDate."),
1768
+ durationMinutes: z.number().int().min(15).max(1440).optional().describe("Event duration in minutes (15-1440) used by schedule mode to end the session."),
1718
1769
  },
1719
1770
  outputSchema: updateOutputSchema,
1720
1771
  annotations: writeTool,
@@ -1725,7 +1776,9 @@ export function registerTools(server) {
1725
1776
  if (args.videoUrl !== undefined
1726
1777
  || args.eventDate !== undefined
1727
1778
  || args.speakerName !== undefined
1728
- || args.description !== undefined) {
1779
+ || args.description !== undefined
1780
+ || args.sessionMode !== undefined
1781
+ || args.durationMinutes !== undefined) {
1729
1782
  requiredScopes.add("setup:write");
1730
1783
  }
1731
1784
  if (requiredScopes.size === 0)
@@ -1748,6 +1801,10 @@ export function registerTools(server) {
1748
1801
  patch["meta.description"] = args.description?.trim() || null;
1749
1802
  if (args.pinned !== undefined)
1750
1803
  patch.pinned = args.pinned;
1804
+ if (args.sessionMode !== undefined)
1805
+ patch.analyticsSessionMode = args.sessionMode;
1806
+ if (args.durationMinutes !== undefined)
1807
+ patch["meta.durationMinutes"] = args.durationMinutes;
1751
1808
  await updateDoc(doc(firestore, "events", args.eventId), patch);
1752
1809
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
1753
1810
  });
@@ -2222,7 +2279,7 @@ export function registerTools(server) {
2222
2279
  const source = await readEvent(eventId, uid, "read");
2223
2280
  const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
2224
2281
  await assertCanCreateInWorkspace(uid, workspaceId);
2225
- const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
2282
+ const newRef = await addEventWithRtdbMirror({
2226
2283
  ownerId: uid,
2227
2284
  workspaceId,
2228
2285
  ...initialEventAccess(uid),
@@ -2288,6 +2345,9 @@ export function registerTools(server) {
2288
2345
  args: ["eventId: event to control.", "block: chat, banner, cta, cards, or placeholder.", "visible: true/false, or null to reset override.", "confirm: must be true."],
2289
2346
  returns: "eventId, block, and effective override value.",
2290
2347
  examples: ["Use visible=false to hide the CTA live; use visible=null to return to setup defaults."],
2348
+ notes: [
2349
+ "block=chat with visible=false PAUSES message input: viewers keep seeing the chat history but cannot send. It does not hide the chat panel.",
2350
+ ],
2291
2351
  }),
2292
2352
  inputSchema: {
2293
2353
  eventId: eventIdField,
package/dist/utils.js CHANGED
@@ -19,9 +19,6 @@ export function serialize(value) {
19
19
  }
20
20
  return out;
21
21
  }
22
- export function docJson(snap) {
23
- return { id: snap.id, ...serialize(snap.data()) };
24
- }
25
22
  function compactForText(value, depth = 0) {
26
23
  if (typeof value === "string" && value.length > MAX_COMPACT_STRING_LENGTH) {
27
24
  return `${value.slice(0, MAX_COMPACT_STRING_LENGTH)}... [truncated ${value.length - MAX_COMPACT_STRING_LENGTH} chars]`;
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.9.2";
2
+ export const OKO_MCP_VERSION = "0.10.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {
@@ -50,7 +50,7 @@
50
50
  "dotenv": "^17.2.3",
51
51
  "firebase": "^12.16.0",
52
52
  "ipaddr.js": "2.4.0",
53
- "undici": "7.28.0",
53
+ "undici": "7.29.0",
54
54
  "zod": "^3.25.76"
55
55
  },
56
56
  "devDependencies": {