@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 +96 -36
- package/dist/utils.js +0 -3
- package/dist/version.js +1 -1
- package/package.json +2 -2
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
|
|
882
|
+
function grantAllowsMode(data, mode) {
|
|
890
883
|
const field = EVENT_ACCESS_FIELD_BY_MODE[mode];
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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(
|
|
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: ["
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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(
|
|
1607
|
-
const snap = await getDocs(query(collection(firestore, "
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
53
|
+
"undici": "7.29.0",
|
|
54
54
|
"zod": "^3.25.76"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|