@okostream/mcp 0.9.1 → 0.9.3

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/env.js CHANGED
@@ -3,9 +3,16 @@ import { homedir } from "node:os";
3
3
  import { delimiter, isAbsolute, join, parse, resolve } from "node:path";
4
4
  import { DEFAULT_EXCHANGE_URL, DEFAULT_FIREBASE_CONFIG } from "./firebaseDefaults.js";
5
5
  config({ path: join(homedir(), ".env"), quiet: true });
6
+ const USER_CONFIG_PLACEHOLDER = /^\$\{user_config\.[^{}\s]+\}$/;
7
+ function normalizeOptionalEnv(value) {
8
+ const normalized = value?.trim();
9
+ if (!normalized || USER_CONFIG_PLACEHOLDER.test(normalized))
10
+ return null;
11
+ return normalized;
12
+ }
6
13
  function readOptionalEnv(...names) {
7
14
  for (const name of names) {
8
- const value = process.env[name]?.trim();
15
+ const value = normalizeOptionalEnv(process.env[name]);
9
16
  if (value)
10
17
  return value;
11
18
  }
@@ -13,33 +20,39 @@ function readOptionalEnv(...names) {
13
20
  }
14
21
  function readEnvWithDefault(defaultValue, ...names) {
15
22
  for (const name of names) {
16
- const value = process.env[name]?.trim();
23
+ const value = normalizeOptionalEnv(process.env[name]);
17
24
  if (value)
18
25
  return value;
19
26
  }
20
27
  return defaultValue;
21
28
  }
29
+ function disableImageRoots(reason) {
30
+ console.error(`[oko-mcp] Local image reads disabled: ${reason}`);
31
+ return [];
32
+ }
22
33
  function readImageRoots() {
23
- const rawRoots = process.env.OKO_IMAGE_ROOTS?.trim();
34
+ const rawRoots = readOptionalEnv("OKO_IMAGE_ROOTS");
24
35
  if (!rawRoots)
25
36
  return [];
26
- return rawRoots.split(delimiter).map((value) => {
37
+ const roots = [];
38
+ for (const value of rawRoots.split(delimiter)) {
27
39
  const root = value.trim();
28
40
  if (!root) {
29
- throw new Error("OKO_IMAGE_ROOTS contains an empty directory entry.");
41
+ return disableImageRoots("OKO_IMAGE_ROOTS contains an empty directory entry.");
30
42
  }
31
43
  const expanded = root === "~" || root.startsWith("~/")
32
44
  ? join(homedir(), root.slice(2))
33
45
  : root;
34
46
  if (!isAbsolute(expanded)) {
35
- throw new Error(`OKO_IMAGE_ROOTS entries must be absolute paths: ${root}`);
47
+ return disableImageRoots("OKO_IMAGE_ROOTS entries must be absolute paths.");
36
48
  }
37
49
  const resolved = resolve(expanded);
38
50
  if (resolved === parse(resolved).root) {
39
- throw new Error("OKO_IMAGE_ROOTS must not allow an entire filesystem root.");
51
+ return disableImageRoots("OKO_IMAGE_ROOTS must not allow an entire filesystem root.");
40
52
  }
41
- return resolved;
42
- });
53
+ roots.push(resolved);
54
+ }
55
+ return roots;
43
56
  }
44
57
  export const env = {
45
58
  apiKey: readOptionalEnv("OKO_API_KEY"),
@@ -0,0 +1,37 @@
1
+ export function summarizeSessionRepair(input) {
2
+ const fixed = !input.verificationError
3
+ && input.firestorePointer === null
4
+ && input.rtdbPointer === null;
5
+ const fulfilled = new Set(input.attempts
6
+ .filter((attempt) => attempt.status === "fulfilled")
7
+ .map((attempt) => attempt.path));
8
+ const cleared = [
9
+ ...(input.firestorePointer === null ? ["events.activeAnalyticsSessionId"] : []),
10
+ ...(input.rtdbPointer === null ? ["ctrl/activeSessionId"] : []),
11
+ ...(input.sessionId && fulfilled.has(`activeSessions/${input.sessionId}`)
12
+ ? [`activeSessions/${input.sessionId}`]
13
+ : []),
14
+ ...(input.sessionId
15
+ && input.deleteRtdbSessionData
16
+ && fulfilled.has(`events/${input.eventId}/sessions/${input.sessionId}`)
17
+ ? [`events/${input.eventId}/sessions/${input.sessionId}`]
18
+ : []),
19
+ ];
20
+ const warnings = input.attempts
21
+ .filter((attempt) => attempt.status === "rejected")
22
+ .map((attempt) => `${attempt.path}: ${attempt.error || "cleanup failed"}`);
23
+ if (input.verificationError) {
24
+ warnings.push(`postcondition verification: ${input.verificationError}`);
25
+ }
26
+ return {
27
+ eventId: input.eventId,
28
+ sessionId: input.sessionId,
29
+ fixed,
30
+ cleared,
31
+ warnings,
32
+ remainingPointers: {
33
+ firestore: input.firestorePointer,
34
+ rtdb: input.rtdbPointer,
35
+ },
36
+ };
37
+ }
package/dist/tools.js CHANGED
@@ -13,6 +13,7 @@ import { env } from "./env.js";
13
13
  import { ipLiteralFamily, isPublicUnicastAddress, normalizeIpLiteralHostname, } from "./networkPolicy.js";
14
14
  import { chatRtdb, firestore, functions, rtdb } from "./firebase.js";
15
15
  import { OKO_CONNECTION_STATUSES } from "./onboarding.js";
16
+ import { summarizeSessionRepair } from "./sessionRepair.js";
16
17
  import { canonicalTelegramChannelSchema, telegramChannelSchema, } from "./telegramChannelSchema.js";
17
18
  import { csvCell, requireConfirm, safeString, serialize, textResult } from "./utils.js";
18
19
  import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
@@ -867,13 +868,6 @@ function eventLinks(eventId) {
867
868
  analyticsUrl: analyticsUrl(eventId),
868
869
  };
869
870
  }
870
- function assertEventReadable(data, uid) {
871
- const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
872
- const editors = Array.isArray(data.editors) ? data.editors : [];
873
- if (data.ownerId !== uid && !access.includes(uid) && !editors.includes(uid)) {
874
- throw new Error("Event is not accessible to this organizer.");
875
- }
876
- }
877
871
  const EVENT_ACCESS_FIELD_BY_MODE = {
878
872
  read: "accessibleBy",
879
873
  edit: "editors",
@@ -885,22 +879,44 @@ const EVENT_ACCESS_FIELD_BY_MODE = {
885
879
  moderate: "chatModerators",
886
880
  notify: "notificationSenders",
887
881
  };
888
- function assertEventAccess(data, uid, mode) {
882
+ function grantAllowsMode(data, mode) {
889
883
  const field = EVENT_ACCESS_FIELD_BY_MODE[mode];
890
- const principals = Array.isArray(data[field]) ? data[field] : [];
891
- if (data.ownerId !== uid && !principals.includes(uid)) {
892
- throw new Error(`Event does not grant ${mode} access to this organizer.`);
893
- }
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";
894
901
  }
895
902
  async function readEvent(eventId, uid, mode = "read") {
896
- 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
+ ]);
897
911
  if (!snap.exists())
898
912
  throw new Error("Event not found.");
899
913
  const data = snap.data();
900
- if (mode === "read")
901
- assertEventReadable(data, uid);
902
- else
903
- 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
+ }
904
920
  if (data.trashedAt && mode !== "read" && mode !== "delete") {
905
921
  throw new Error("Event is in trash. Restore it before making changes or live actions.");
906
922
  }
@@ -1046,7 +1062,7 @@ function initialEventAccess(uid) {
1046
1062
  notificationSenders: [uid],
1047
1063
  };
1048
1064
  }
1049
- async function addEventWithRtdbMirror(uid, workspaceId, eventData) {
1065
+ async function addEventWithRtdbMirror(eventData) {
1050
1066
  const eventRef = await addDoc(collection(firestore, "events"), eventData);
1051
1067
  try {
1052
1068
  const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
@@ -1465,7 +1481,7 @@ export function registerTools(server) {
1465
1481
  args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
1466
1482
  returns: "workspaceId, uid, and updated=true.",
1467
1483
  examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
1468
- notes: ["Changing a share triggers OKO CF18 fan-out to event access, RTDB workspaceMembers, and chat roles."],
1484
+ notes: ["Changing a share materializes lightweight event grants and updates RTDB workspace/chat mirrors."],
1469
1485
  }),
1470
1486
  inputSchema: {
1471
1487
  workspaceId: z.string().min(1),
@@ -1497,7 +1513,7 @@ export function registerTools(server) {
1497
1513
  args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
1498
1514
  returns: "workspaceId, uid, removed=true.",
1499
1515
  examples: ["Use after listing workspace access and confirming the exact uid."],
1500
- notes: ["Removing a share triggers OKO CF18 reverse fan-out."],
1516
+ notes: ["Removing a share revokes its event grants and RTDB workspace/chat mirrors."],
1501
1517
  }),
1502
1518
  inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
1503
1519
  outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
@@ -1582,7 +1598,7 @@ export function registerTools(server) {
1582
1598
  args: [
1583
1599
  "limit: maximum events to return, default 30, max 100.",
1584
1600
  "cursor: timestamp cursor returned as nextCursor by the previous page.",
1585
- "workspaceId: optional client-side workspace filter.",
1601
+ "workspaceId: optional workspace filter.",
1586
1602
  ],
1587
1603
  returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1588
1604
  examples: ["Use limit=10 first; if hasMore is true, call again with cursor=nextCursor."],
@@ -1597,17 +1613,42 @@ export function registerTools(server) {
1597
1613
  }, async (args) => {
1598
1614
  const { uid } = await authed(["events:read"]);
1599
1615
  const constraints = [
1600
- where("accessibleBy", "array-contains", uid),
1616
+ ...(args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
1601
1617
  orderBy("createdAt", "desc"),
1602
1618
  limit(args.limit + 1),
1603
1619
  ];
1604
1620
  if (args.cursor)
1605
- constraints.splice(2, 0, startAfter(parseTimestampCursor(args.cursor)));
1606
- const snap = await getDocs(query(collection(firestore, "events"), ...constraints));
1607
- const { page, hasMore } = pickPage(snap.docs, args.limit);
1608
- let events = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
1609
- if (args.workspaceId)
1610
- events = events.filter((event) => event.workspaceId === args.workspaceId);
1621
+ constraints.splice(constraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
1622
+ const snap = await getDocs(query(collection(firestore, "eventAccess", uid, "grants"), ...constraints)).catch((error) => {
1623
+ if (!isFirestorePermissionDenied(error))
1624
+ throw error;
1625
+ return null;
1626
+ });
1627
+ if (snap !== null) {
1628
+ const { page, hasMore } = pickPage(snap.docs, args.limit);
1629
+ const eventDocuments = await Promise.all(page.map((item) => (getDoc(doc(firestore, "events", item.id)))));
1630
+ const events = eventDocuments.flatMap((item) => (item.exists()
1631
+ ? [{ id: item.id, ...serialize(item.data()) }]
1632
+ : []));
1633
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1634
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1635
+ }
1636
+ const legacyConstraints = [
1637
+ where("accessibleBy", "array-contains", uid),
1638
+ orderBy("createdAt", "desc"),
1639
+ limit(args.limit + 1),
1640
+ ];
1641
+ if (args.cursor) {
1642
+ legacyConstraints.splice(legacyConstraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
1643
+ }
1644
+ const legacySnap = await getDocs(query(collection(firestore, "events"), ...legacyConstraints));
1645
+ const { page, hasMore } = pickPage(legacySnap.docs, args.limit);
1646
+ const events = page
1647
+ .map((item) => ({
1648
+ id: item.id,
1649
+ ...serialize(item.data()),
1650
+ }))
1651
+ .filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId);
1611
1652
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1612
1653
  return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1613
1654
  });
@@ -1678,7 +1719,7 @@ export function registerTools(server) {
1678
1719
  const workspaceId = args.workspaceId ?? uid;
1679
1720
  await assertCanCreateInWorkspace(uid, workspaceId);
1680
1721
  const video = normalizeVideo(args.videoUrl);
1681
- const docRef = await addEventWithRtdbMirror(uid, workspaceId, {
1722
+ const docRef = await addEventWithRtdbMirror({
1682
1723
  ownerId: uid,
1683
1724
  workspaceId,
1684
1725
  ...initialEventAccess(uid),
@@ -2221,7 +2262,7 @@ export function registerTools(server) {
2221
2262
  const source = await readEvent(eventId, uid, "read");
2222
2263
  const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
2223
2264
  await assertCanCreateInWorkspace(uid, workspaceId);
2224
- const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
2265
+ const newRef = await addEventWithRtdbMirror({
2225
2266
  ownerId: uid,
2226
2267
  workspaceId,
2227
2268
  ...initialEventAccess(uid),
@@ -2404,24 +2445,65 @@ export function registerTools(server) {
2404
2445
  const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
2405
2446
  requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
2406
2447
  const cleanup = [
2407
- updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2408
- remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2448
+ {
2449
+ path: "events.activeAnalyticsSessionId",
2450
+ promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2451
+ },
2452
+ {
2453
+ path: "ctrl/activeSessionId",
2454
+ promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2455
+ },
2409
2456
  ];
2410
- if (sessionId)
2411
- cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
2412
- if (sessionId && deleteRtdbSessionData)
2413
- cleanup.push(remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)));
2414
- const results = await Promise.allSettled(cleanup);
2415
- const warnings = results
2416
- .filter((result) => result.status === "rejected")
2417
- .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
2418
- return textResult({
2457
+ if (sessionId) {
2458
+ cleanup.push({
2459
+ path: `activeSessions/${sessionId}`,
2460
+ promise: remove(ref(rtdb, `activeSessions/${sessionId}`)),
2461
+ });
2462
+ }
2463
+ if (sessionId && deleteRtdbSessionData) {
2464
+ cleanup.push({
2465
+ path: `events/${eventId}/sessions/${sessionId}`,
2466
+ promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
2467
+ });
2468
+ }
2469
+ const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
2470
+ const attempts = settled.map((result, index) => {
2471
+ const operation = cleanup[index];
2472
+ if (!operation)
2473
+ throw new Error("Session repair result has no matching cleanup operation.");
2474
+ return {
2475
+ path: operation.path,
2476
+ status: result.status,
2477
+ ...(result.status === "rejected" ? {
2478
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
2479
+ } : {}),
2480
+ };
2481
+ });
2482
+ let firestorePointer = null;
2483
+ let rtdbPointer = null;
2484
+ let verificationError;
2485
+ try {
2486
+ const [eventAfter, ctrlAfter] = await Promise.all([
2487
+ getDoc(doc(firestore, "events", eventId)),
2488
+ get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2489
+ ]);
2490
+ firestorePointer = eventAfter.exists()
2491
+ ? safeString(eventAfter.data().activeAnalyticsSessionId) || null
2492
+ : null;
2493
+ rtdbPointer = safeString(ctrlAfter.val()) || null;
2494
+ }
2495
+ catch (error) {
2496
+ verificationError = error instanceof Error ? error.message : String(error);
2497
+ }
2498
+ return textResult(summarizeSessionRepair({
2419
2499
  eventId,
2420
2500
  sessionId: sessionId || null,
2421
- fixed: warnings.length === 0,
2422
- cleared: ["events.activeAnalyticsSessionId", "ctrl/activeSessionId", ...(sessionId ? [`activeSessions/${sessionId}`] : []), ...(sessionId && deleteRtdbSessionData ? [`events/${eventId}/sessions/${sessionId}`] : [])],
2423
- warnings,
2424
- });
2501
+ deleteRtdbSessionData,
2502
+ attempts,
2503
+ firestorePointer,
2504
+ rtdbPointer,
2505
+ verificationError,
2506
+ }));
2425
2507
  });
2426
2508
  server.registerTool("oko_delete_session", {
2427
2509
  title: "Delete analytics session",
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 +1,2 @@
1
- export const OKO_MCP_VERSION = "0.9.1";
1
+ // Generated by scripts/generateMcpVersion.mjs from mcp/package.json. Do not edit.
2
+ export const OKO_MCP_VERSION = "0.9.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {
@@ -31,7 +31,7 @@
31
31
  "oko-mcp": "dist/server.js"
32
32
  },
33
33
  "scripts": {
34
- "prebuild": "node ../scripts/generateMcpContract.mjs --check",
34
+ "prebuild": "node ../scripts/generateMcpVersion.mjs && node ../scripts/generateMcpContract.mjs --check",
35
35
  "build": "tsc && node -e \"require('node:fs').chmodSync('dist/server.js', 0o755)\"",
36
36
  "test": "npm run build && node --test test/*.test.mjs",
37
37
  "smoke:tarball": "node test/tarball-smoke.mjs",