@okostream/mcp 0.12.1 → 0.12.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.
@@ -1,122 +0,0 @@
1
- // Канонический адрес платформы. Живёт здесь, потому что onboarding — базовый
2
- // модуль без зависимостей: его импортируют и сервер, и инструменты, и гиды.
3
- // Домен нужен MCP-клиенту как ссылка для организатора, поэтому адреса
4
- // собираются из одного места, а не пишутся заново в каждом инструменте.
5
- export const APP_ORIGIN = "https://okostream.app";
6
- export const OKO_ONBOARDING_URLS = {
7
- website: APP_ORIGIN,
8
- signIn: `${APP_ORIGIN}/auth?redirect=/app/mcp`,
9
- installCenter: `${APP_ORIGIN}/app/mcp`,
10
- };
11
- export const OKO_PRODUCT_INFO = {
12
- name: "OKO Stream",
13
- summary: "OKO Stream is a webinar operations platform. It adds an event page, configurable audience blocks, chat, analytics, forms, and integrations around an organizer-supplied video player.",
14
- videoBoundary: "OKO does not host or start the underlying video stream. Organizers provide a YouTube, iframe, or other player URL.",
15
- accountRequirement: "An active OKO organizer account is required to access webinar data or perform actions.",
16
- accessModel: "Organizer access is currently provided by invitation. An invitation is required before a new organizer can create an API key.",
17
- };
18
- export const OKO_CONNECTION_STATUSES = [
19
- "connected",
20
- "not_configured",
21
- "invalid_key",
22
- "update_required",
23
- "rate_limited",
24
- "unavailable",
25
- ];
26
- function nextSteps(status, minimumVersion) {
27
- switch (status) {
28
- case "connected":
29
- return [{
30
- id: "continue",
31
- label: "Continue with the requested OKO workflow.",
32
- }];
33
- case "not_configured":
34
- return [
35
- {
36
- id: "obtain_access",
37
- label: "Obtain an OKO organizer invitation if you do not already have active organizer access.",
38
- url: OKO_ONBOARDING_URLS.website,
39
- },
40
- {
41
- id: "create_key",
42
- label: "Sign in to the OKO MCP install center and create a scoped API key.",
43
- url: OKO_ONBOARDING_URLS.installCenter,
44
- },
45
- {
46
- id: "configure_client",
47
- label: "Set OKO_API_KEY in the MCP client configuration, then restart or reconnect the server.",
48
- },
49
- ];
50
- case "invalid_key":
51
- return [
52
- {
53
- id: "replace_key",
54
- label: "Create a fresh scoped key in the OKO MCP install center and replace OKO_API_KEY.",
55
- url: OKO_ONBOARDING_URLS.installCenter,
56
- },
57
- {
58
- id: "restart_client",
59
- label: "Restart or reconnect the MCP server after updating the key.",
60
- },
61
- ];
62
- case "update_required":
63
- return [
64
- {
65
- id: "update_package",
66
- label: `Update to @okostream/mcp@latest${minimumVersion ? ` (minimum ${minimumVersion})` : ""}.`,
67
- command: "npx -y @okostream/mcp@latest",
68
- },
69
- {
70
- id: "restart_client",
71
- label: "Restart or reconnect the MCP server after updating.",
72
- },
73
- ];
74
- case "rate_limited":
75
- return [{
76
- id: "retry_later",
77
- label: "Wait one minute, then reconnect the MCP server.",
78
- }];
79
- case "unavailable":
80
- return [{
81
- id: "retry",
82
- label: "Check network access to OKO Stream, then reconnect the MCP server.",
83
- }];
84
- }
85
- }
86
- export function buildConnectionStatus(status, options = {}) {
87
- const connected = status === "connected";
88
- const errorCode = connected
89
- ? null
90
- : status === "update_required"
91
- ? "CLIENT_UPGRADE_REQUIRED"
92
- : status === "rate_limited"
93
- ? "RATE_LIMITED"
94
- : status === "unavailable"
95
- ? "SERVICE_UNAVAILABLE"
96
- : "AUTH_REQUIRED";
97
- const message = connected
98
- ? "OKO MCP is connected to an active organizer account."
99
- : status === "not_configured"
100
- ? "OKO MCP is installed, but OKO_API_KEY is not configured."
101
- : status === "invalid_key"
102
- ? "OKO MCP is installed, but the configured API key is invalid or revoked."
103
- : status === "update_required"
104
- ? "This OKO MCP client version is no longer supported."
105
- : status === "rate_limited"
106
- ? "OKO temporarily rate-limited the connection attempt."
107
- : "OKO MCP could not verify the organizer connection.";
108
- return {
109
- connected,
110
- status,
111
- errorCode,
112
- message,
113
- product: OKO_PRODUCT_INFO,
114
- organizerAccountRequired: true,
115
- credentialConfigured: status !== "not_configured",
116
- links: OKO_ONBOARDING_URLS,
117
- nextSteps: nextSteps(status, options.minimumVersion),
118
- ...(options.minimumVersion ? { minimumVersion: options.minimumVersion } : {}),
119
- ...(options.uid ? { uid: options.uid } : {}),
120
- ...(options.scopes ? { scopes: options.scopes } : {}),
121
- };
122
- }
package/dist/scopes.js DELETED
@@ -1,22 +0,0 @@
1
- // Generated by scripts/generateMcpContract.mjs from mcp/public-contract.json.
2
- export const MCP_SCOPES = [
3
- "events:read",
4
- "events:write",
5
- "events:delete",
6
- "setup:write",
7
- "analytics:read",
8
- "analytics:write",
9
- "live:control",
10
- "chat:write",
11
- "chat:moderate",
12
- "telegram:write",
13
- "workspace:read",
14
- "workspace:write",
15
- "leads:read"
16
- ];
17
- const SCOPE_SET = new Set(MCP_SCOPES);
18
- export function sanitizeScopes(scopes) {
19
- if (!Array.isArray(scopes))
20
- return [];
21
- return scopes.filter((scope) => typeof scope === "string" && SCOPE_SET.has(scope));
22
- }
@@ -1,37 +0,0 @@
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
- }
@@ -1,13 +0,0 @@
1
- import { z } from "zod";
2
- export const telegramChannelSchema = z.object({
3
- chatId: z.number().int(),
4
- title: z.string().max(200).optional(),
5
- username: z.string().max(120).nullable().optional(),
6
- }).strict();
7
- export const canonicalTelegramChannelSchema = telegramChannelSchema.extend({
8
- workspaceIds: z.array(z.string()).max(50),
9
- owned: z.boolean(),
10
- subscriberCount: z.number().int().nonnegative().nullable().optional(),
11
- health: z.enum(["connected", "needs_attention", "unknown"]).optional(),
12
- healthCheckedAt: z.number().int().nonnegative().nullable().optional(),
13
- }).strict();
@@ -1,77 +0,0 @@
1
- // Состояние подключения, личность организатора и его профиль.
2
- //
3
- // oko_connection_status намеренно работает БЕЗ ключа: сервер должен уметь
4
- // объяснить, что он не настроен, а не падать при старте. Остальные два
5
- // требуют живой авторизации.
6
- import { z } from "zod";
7
- import { serialize, textResult } from "../utils.js";
8
- import { authed } from "./context.js";
9
- import { connectionStatusOutputSchema, idempotentWriteTool, profileDisplayNameSchema, profileOutputSchema, readOnlyTool, timezoneSchema, toolDescription } from "./schemas.js";
10
- import { currentScopes, getConnectionStatus } from "../auth.js";
11
- import { doc, getDoc, serverTimestamp, setDoc } from "firebase/firestore";
12
- import { firestore } from "../firebase.js";
13
- export function registerAccountTools(server) {
14
- server.registerTool("oko_connection_status", {
15
- title: "Check OKO connection",
16
- description: toolDescription({
17
- summary: "Check whether this MCP process is connected to an active OKO organizer account.",
18
- returns: "A factual connection status, product boundary, and setup links. This tool works without OKO_API_KEY and never changes data.",
19
- examples: [
20
- "Use when an OKO request is made and the organizer connection state is unknown.",
21
- "Use after installing the package without a key to explain the exact next setup step.",
22
- ],
23
- notes: ["This tool does not create accounts, invitations, or API keys."],
24
- }),
25
- outputSchema: connectionStatusOutputSchema,
26
- annotations: readOnlyTool,
27
- }, async () => textResult(await getConnectionStatus()));
28
- server.registerTool("oko_whoami", {
29
- title: "Who am I",
30
- description: toolDescription({
31
- summary: "Return the authenticated OKO organizer uid and granted MCP scopes.",
32
- returns: "uid plus the exact scopes granted by OKO API key exchange. Use this before write workflows to check permissions.",
33
- examples: ['Use when starting a session: call first, then choose tools allowed by "scopes".'],
34
- }),
35
- outputSchema: {
36
- uid: z.string(),
37
- scopes: z.array(z.string()),
38
- },
39
- annotations: readOnlyTool,
40
- }, async () => {
41
- const auth = await authed([]);
42
- return textResult({ uid: auth.uid, scopes: currentScopes() });
43
- });
44
- server.registerTool("oko_update_profile", {
45
- title: "Update profile",
46
- description: toolDescription({
47
- summary: "Update the authenticated organizer profile fields used by OKO UI.",
48
- args: [
49
- "displayName: optional display name, max 40 chars; null clears it.",
50
- "timezone: optional IANA timezone such as Europe/Moscow or UTC.",
51
- ],
52
- returns: "uid, updated field names, and the resulting profile snapshot.",
53
- examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
54
- 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."],
55
- }),
56
- inputSchema: {
57
- displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
58
- timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC."),
59
- },
60
- outputSchema: profileOutputSchema,
61
- annotations: idempotentWriteTool,
62
- }, async ({ displayName, timezone }) => {
63
- const { uid } = await authed(["workspace:write"]);
64
- const patch = { updatedAt: serverTimestamp() };
65
- if (displayName !== undefined)
66
- patch.displayName = displayName?.trim() || null;
67
- if (timezone !== undefined)
68
- patch.timezone = timezone.trim();
69
- const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
70
- if (updated.length === 0)
71
- throw new Error("Provide displayName and/or timezone.");
72
- const profileRef = doc(firestore, "userProfiles", uid);
73
- await setDoc(profileRef, patch, { merge: true });
74
- const profileSnap = await getDoc(profileRef);
75
- return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
76
- });
77
- }
@@ -1,44 +0,0 @@
1
- // Серверный аудит записывающих инструментов MCP.
2
- //
3
- // Наружу уходят только ключи из разрешённого списка и только в усечённом
4
- // виде: аргументы инструментов могут содержать текст организатора, а аудит —
5
- // это журнал действий, не копия данных. Отказ самого аудита никогда не роняет
6
- // инструмент: журнал важен, но не важнее операции, которую он описывает.
7
- import { httpsCallable } from "firebase/functions";
8
- import { functions } from "../firebase.js";
9
- export const MCP_AUDIT_TARGET_KEYS = new Set([
10
- "eventId", "workspaceId", "targetWorkspaceId", "sessionId", "postId",
11
- "messageId", "leadId", "participantUid", "uid", "channelId", "cardId", "imageId",
12
- ]);
13
- export function auditTargets(args) {
14
- if (!args || typeof args !== "object" || Array.isArray(args))
15
- return {};
16
- const targets = {};
17
- for (const [key, value] of Object.entries(args)) {
18
- if (!MCP_AUDIT_TARGET_KEYS.has(key))
19
- continue;
20
- if (typeof value === "string")
21
- targets[key] = value.slice(0, 160);
22
- else if (typeof value === "number" || typeof value === "boolean" || value === null)
23
- targets[key] = value;
24
- }
25
- return targets;
26
- }
27
- export async function recordToolAudit(toolName, outcome, args, startedAt, error) {
28
- try {
29
- const audit = httpsCallable(functions, "recordMcpToolAudit");
30
- const errorCode = error && typeof error === "object" && "code" in error
31
- ? String(error.code ?? "error")
32
- : error instanceof Error ? error.name : null;
33
- await audit({
34
- toolName,
35
- outcome,
36
- targets: auditTargets(args),
37
- durationMs: Date.now() - startedAt,
38
- errorCode,
39
- });
40
- }
41
- catch (auditError) {
42
- console.error(`[oko-mcp] audit failed for ${toolName}:`, auditError instanceof Error ? auditError.message : String(auditError));
43
- }
44
- }
@@ -1,221 +0,0 @@
1
- // Чат события и модерация.
2
- //
3
- // Сообщения живут в отдельной базе (okostreamcore-chat), а не в основной RTDB.
4
- // Отправка — один атомарный корневой update, который пишет сообщение и метку
5
- // частоты вместе: разделить их значит открыть обход медленного режима.
6
- // Инвариант стережёт protocol.test.
7
- import { z } from "zod";
8
- import { requireConfirm, serialize, textResult } from "../utils.js";
9
- import { authed } from "./context.js";
10
- import { basicEventOutputSchema, chatOutputSchema, cursorField, destructiveTool, eventIdField, idempotentWriteTool, limitField, readOnlyTool, toolDescription, writeTool } from "./schemas.js";
11
- import { chatRtdb, rtdb } from "../firebase.js";
12
- import { cursorMeta, pickPage } from "./formatting.js";
13
- import { endBefore, get, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update } from "firebase/database";
14
- import { getChatRole } from "./workspaceAccess.js";
15
- import { readEvent } from "./eventAccess.js";
16
- export function registerChatTools(server) {
17
- server.registerTool("oko_list_recent_chat", {
18
- title: "List recent chat",
19
- description: toolDescription({
20
- summary: "Read recent chat messages from chat RTDB with cursor pagination.",
21
- args: [
22
- "eventId: event chat to inspect.",
23
- "limit: max messages to return, default 50, max 100.",
24
- "cursor: message id returned as nextCursor by the previous page to read older messages.",
25
- ],
26
- returns: "messages array sorted newest-first plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
27
- examples: ["Use limit=20 first; if hasMore is true, call again with cursor=nextCursor for older chat."],
28
- }),
29
- inputSchema: { eventId: eventIdField, limit: limitField(100, 50), cursor: cursorField },
30
- outputSchema: chatOutputSchema,
31
- annotations: readOnlyTool,
32
- }, async ({ eventId, limit: max, cursor }) => {
33
- const { uid } = await authed(["analytics:read"]);
34
- await readEvent(eventId, uid, "read");
35
- const constraints = cursor
36
- ? [orderByKey(), endBefore(cursor), limitToLast(max + 1)]
37
- : [orderByKey(), limitToLast(max + 1)];
38
- const snap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), ...constraints));
39
- // 🔴 Побайтовое сравнение, а не `localeCompare`. RTDB упорядочивает
40
- // push-ключи по кодам символов, а `localeCompare` учитывает локаль и
41
- // сортирует регистр наоборот: push-ключи содержат и цифры, и обе
42
- // раскладки букв, поэтому порядок расходился с серверным. Курсор брался
43
- // не тот, и страницы повторялись или пропускались.
44
- const entries = Object.entries((snap.val() ?? {}))
45
- .sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0));
46
- const { page, hasMore } = pickPage(entries, max);
47
- const messages = page.map(([id, message]) => ({ id, ...serialize(message) }));
48
- const nextCursor = hasMore ? messages.at(-1)?.id ?? null : null;
49
- return textResult({ messages, ...cursorMeta(messages.length, max, hasMore, nextCursor) });
50
- });
51
- server.registerTool("oko_send_chat_message", {
52
- title: "Send chat message",
53
- description: toolDescription({
54
- summary: "Send a chat message as the authenticated organizer/moderator.",
55
- args: ["eventId: target event chat.", "text: message text, max 500 chars.", "name: display name, default OKO MCP.", "confirm: must be true."],
56
- returns: "eventId, new messageId, and sent=true.",
57
- examples: ["Use for organizer announcements in chat after user confirms the exact text."],
58
- }),
59
- inputSchema: {
60
- eventId: eventIdField,
61
- text: z.string().min(1).max(500).describe("Message text."),
62
- name: z.string().min(1).max(24).default("OKO MCP").describe("Display name."),
63
- confirm: z.boolean().optional().describe("Required true to post to live chat."),
64
- },
65
- outputSchema: basicEventOutputSchema,
66
- annotations: writeTool,
67
- }, async ({ eventId, text, name, confirm }) => {
68
- const { uid } = await authed(["chat:write"]);
69
- await readEvent(eventId, uid, "read");
70
- requireConfirm(confirm, "oko_send_chat_message", { eventId, name, text });
71
- const role = await getChatRole(eventId, uid);
72
- const msgRef = push(ref(chatRtdb, `messages/${eventId}`));
73
- if (!msgRef.key)
74
- throw new Error("Could not allocate chat message id");
75
- const timestamp = rtdbServerTimestamp();
76
- // 🔴 Форма сообщения обязана совпадать с `prepareChatMessage`
77
- // (`web/src/lib/chatMessages.ts`) — единственным контрактом записи чата
78
- // по Pattern 26. Импортировать его сюда нельзя: MCP отдельный пакет, у
79
- // него нет доступа к web. Поэтому копия, и она УЖЕ разошлась: без `trim`
80
- // сообщение из одних пробелов проходило и публиковалось всем зрителям, а
81
- // имя уходило без обрезки до 24 символов.
82
- const messageText = text.trim();
83
- if (!messageText)
84
- throw new Error("Chat message text cannot be empty or whitespace only.");
85
- const messageName = name.trim().slice(0, 24) || "Organizer";
86
- await update(ref(chatRtdb), {
87
- [`messages/${eventId}/${msgRef.key}`]: { text: messageText, uid, name: messageName, role, ts: timestamp },
88
- [`rate_limits/${eventId}/${uid}`]: { ts: timestamp, messageId: msgRef.key },
89
- });
90
- await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1)
91
- .catch(() => undefined);
92
- return textResult({ eventId, messageId: msgRef.key, sent: true });
93
- });
94
- server.registerTool("oko_delete_chat_message", {
95
- title: "Delete chat message",
96
- description: toolDescription({
97
- summary: "Soft-delete a chat message through moderation metadata.",
98
- args: ["eventId: target event.", "messageId: chat RTDB message id.", "confirm: must be true."],
99
- returns: "eventId, messageId, and deleted=true.",
100
- examples: ["Use only after showing the message text/id and receiving explicit confirmation."],
101
- }),
102
- inputSchema: { eventId: eventIdField, messageId: z.string().min(1).describe("Chat RTDB message id."), confirm: z.boolean().optional().describe("Required true for deletion.") },
103
- outputSchema: basicEventOutputSchema,
104
- annotations: destructiveTool,
105
- }, async ({ eventId, messageId, confirm }) => {
106
- const { uid } = await authed(["chat:moderate"]);
107
- await readEvent(eventId, uid, "moderate");
108
- requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
109
- await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: rtdbServerTimestamp(), ts: Date.now(), by: uid });
110
- return textResult({ eventId, messageId, deleted: true });
111
- });
112
- server.registerTool("oko_mute_user", {
113
- title: "Mute chat user",
114
- description: toolDescription({
115
- summary: "Mute a chat user for a bounded time window.",
116
- args: ["eventId: target event.", "uid: Firebase uid to mute.", "minutes: mute duration, 1-1440, default 5.", "confirm: must be true."],
117
- returns: "eventId, targetUid, and mutedMinutes.",
118
- examples: ["Use after reading chat messages and confirming the exact uid with the user."],
119
- }),
120
- inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to mute."), minutes: z.number().int().min(1).max(1440).default(5).describe("Mute duration in minutes."), confirm: z.boolean().optional().describe("Required true for mute.") },
121
- outputSchema: basicEventOutputSchema,
122
- annotations: destructiveTool,
123
- }, async ({ eventId, uid: targetUid, minutes, confirm }) => {
124
- const actor = await authed(["chat:moderate"]);
125
- await readEvent(eventId, actor.uid, "moderate");
126
- requireConfirm(confirm, "oko_mute_user", { eventId, targetUid, minutes });
127
- await set(ref(chatRtdb, `mutes/${eventId}/${targetUid}`), { until: Date.now() + minutes * 60_000, by: actor.uid, reason: "manual" });
128
- return textResult({ eventId, targetUid, mutedMinutes: minutes });
129
- });
130
- server.registerTool("oko_ban_user", {
131
- title: "Ban chat user",
132
- description: toolDescription({
133
- summary: "Ban a chat user and soft-delete up to 500 recent messages from that uid.",
134
- args: ["eventId: target event.", "uid: Firebase uid to ban.", "confirm: must be true."],
135
- returns: "eventId, targetUid, banned=true, and deletedMessages count.",
136
- examples: ["Use only after explicit user confirmation; prefer mute for temporary moderation."],
137
- }),
138
- inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to ban."), confirm: z.boolean().optional().describe("Required true for ban.") },
139
- outputSchema: basicEventOutputSchema,
140
- annotations: destructiveTool,
141
- }, async ({ eventId, uid: targetUid, confirm }) => {
142
- const actor = await authed(["chat:moderate"]);
143
- await readEvent(eventId, actor.uid, "moderate");
144
- requireConfirm(confirm, "oko_ban_user", { eventId, targetUid });
145
- await set(ref(chatRtdb, `bans/${eventId}/${targetUid}`), true);
146
- const messagesSnap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), limitToLast(500)));
147
- const messages = (messagesSnap.val() ?? {});
148
- const deletes = {};
149
- for (const [messageId, message] of Object.entries(messages)) {
150
- if (message.uid === targetUid)
151
- deletes[`mod/deleted/${eventId}/${messageId}`] = { deletedAt: rtdbServerTimestamp(), ts: Date.now(), by: actor.uid, reason: "ban" };
152
- }
153
- if (Object.keys(deletes).length > 0)
154
- await update(ref(chatRtdb), deletes);
155
- return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
156
- });
157
- server.registerTool("oko_unmute_user", {
158
- title: "Unmute chat user",
159
- description: toolDescription({
160
- summary: "Remove a chat mute for a user.",
161
- args: ["eventId: target event.", "uid: Firebase uid to unmute.", "confirm: must be true."],
162
- returns: "eventId, targetUid, and unmuted=true.",
163
- examples: ["Use when a user was muted by mistake or the organizer wants to restore chat access."],
164
- }),
165
- inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unmute."), confirm: z.boolean().optional().describe("Required true for unmute.") },
166
- outputSchema: basicEventOutputSchema,
167
- annotations: idempotentWriteTool,
168
- }, async ({ eventId, uid: targetUid, confirm }) => {
169
- const actor = await authed(["chat:moderate"]);
170
- await readEvent(eventId, actor.uid, "moderate");
171
- requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
172
- await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
173
- return textResult({ eventId, targetUid, unmuted: true });
174
- });
175
- server.registerTool("oko_unban_user", {
176
- title: "Unban chat user",
177
- description: toolDescription({
178
- summary: "Remove a chat ban for a user.",
179
- args: ["eventId: target event.", "uid: Firebase uid to unban.", "confirm: must be true."],
180
- returns: "eventId, targetUid, and unbanned=true.",
181
- examples: ["Use when a user was banned by mistake or the organizer wants to restore event chat access."],
182
- }),
183
- inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unban."), confirm: z.boolean().optional().describe("Required true for unban.") },
184
- outputSchema: basicEventOutputSchema,
185
- annotations: idempotentWriteTool,
186
- }, async ({ eventId, uid: targetUid, confirm }) => {
187
- const actor = await authed(["chat:moderate"]);
188
- await readEvent(eventId, actor.uid, "moderate");
189
- requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
190
- await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
191
- return textResult({ eventId, targetUid, unbanned: true });
192
- });
193
- server.registerTool("oko_set_chat_settings", {
194
- title: "Set chat settings",
195
- description: toolDescription({
196
- summary: "Set slow mode and automod settings for an event chat.",
197
- args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "confirm: must be true."],
198
- returns: "eventId and updated RTDB setting paths.",
199
- examples: ["Use slowModeMs=5000 to slow busy chats; use automodEnabled=false as an emergency kill switch."],
200
- }),
201
- inputSchema: {
202
- eventId: eventIdField,
203
- slowModeMs: z.number().int().min(0).max(60000).optional().describe("Slow mode delay in milliseconds."),
204
- automodEnabled: z.boolean().optional().describe("Enable or disable chat automoderation."),
205
- confirm: z.boolean().optional().describe("Required true to change live chat settings."),
206
- },
207
- outputSchema: basicEventOutputSchema,
208
- annotations: idempotentWriteTool,
209
- }, async ({ eventId, slowModeMs, automodEnabled, confirm }) => {
210
- const { uid } = await authed(["chat:moderate"]);
211
- await readEvent(eventId, uid, "moderate");
212
- requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null });
213
- const patch = {};
214
- if (slowModeMs !== undefined)
215
- patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
216
- if (automodEnabled !== undefined)
217
- patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
218
- await update(ref(chatRtdb), patch);
219
- return textResult({ eventId, updated: Object.keys(patch) });
220
- });
221
- }
@@ -1,40 +0,0 @@
1
- // Общий контекст инструментов: авторизация вызова, публичные адреса
2
- // платформы и мелкие приведения типов.
3
- //
4
- // `authed` — единственная точка, через которую инструмент получает права.
5
- // Права принадлежат организатору, а ключ лишь предъявляет их: разбор области
6
- // доступа и синтетической личности живёт в `auth.ts`, здесь только вызов.
7
- import { randomUUID } from "node:crypto";
8
- import { ensureAuth } from "../auth.js";
9
- import { APP_ORIGIN } from "../onboarding.js";
10
- export function randomId() {
11
- return randomUUID();
12
- }
13
- export async function authed(scopes) {
14
- return ensureAuth(scopes);
15
- }
16
- export function eventUrl(eventId) {
17
- return `${APP_ORIGIN}/event/${eventId}`;
18
- }
19
- export function setupUrl(eventId) {
20
- return `${APP_ORIGIN}/setup/${eventId}`;
21
- }
22
- export function analyticsUrl(eventId) {
23
- return `${APP_ORIGIN}/analytics/${eventId}`;
24
- }
25
- export function sessionReportUrl(eventId, sessionId) {
26
- return `${analyticsUrl(eventId)}/report/${sessionId}`;
27
- }
28
- export function inviteUrl(code) {
29
- return `${APP_ORIGIN}/invite/${code}`;
30
- }
31
- export function eventLinks(eventId) {
32
- return {
33
- url: eventUrl(eventId),
34
- setupUrl: setupUrl(eventId),
35
- analyticsUrl: analyticsUrl(eventId),
36
- };
37
- }
38
- export function asRecord(value) {
39
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
40
- }
@@ -1,65 +0,0 @@
1
- // Контур прав MCP на конкретное событие.
2
- //
3
- // Источник истины — grant `eventAccess/{uid}/grants/{eventId}`, который
4
- // поддерживают CF7 и CF18. Legacy-путь по массивам события остаётся только
5
- // на случай, когда правила отказали в чтении grants: отличить отказ доступа
6
- // от сетевого сбоя обязательно, иначе первый молча превращается во второй.
7
- //
8
- // Сторож инварианта — `test/event-access-index.test.mjs`.
9
- import { doc, getDoc } from "firebase/firestore";
10
- import { firestore } from "../firebase.js";
11
- import { asRecord } from "./context.js";
12
- export const EVENT_ACCESS_FIELD_BY_MODE = {
13
- read: "accessibleBy",
14
- edit: "editors",
15
- delete: "deleters",
16
- live: "liveControllers",
17
- session: "sessionManagers",
18
- analytics: "analyticsViewers",
19
- report: "reportDownloaders",
20
- moderate: "chatModerators",
21
- notify: "notificationSenders",
22
- };
23
- export function grantAllowsMode(data, mode) {
24
- const field = EVENT_ACCESS_FIELD_BY_MODE[mode];
25
- return asRecord(data.capabilities)[field] === true;
26
- }
27
- export function legacyEventAllowsMode(data, uid, mode) {
28
- if (data.ownerId === uid)
29
- return true;
30
- const fields = mode === "read"
31
- ? ["accessibleBy", "editors"]
32
- : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
33
- return fields.some((field) => (Array.isArray(data[field]) && data[field].includes(uid)));
34
- }
35
- export function isFirestorePermissionDenied(error) {
36
- if (!error || typeof error !== "object")
37
- return false;
38
- const code = error.code;
39
- return code === "permission-denied"
40
- || code === "firestore/permission-denied"
41
- || code === "PERMISSION_DENIED";
42
- }
43
- export async function readEvent(eventId, uid, mode = "read") {
44
- const [snap, grant] = await Promise.all([
45
- getDoc(doc(firestore, "events", eventId)),
46
- getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error) => {
47
- if (!isFirestorePermissionDenied(error))
48
- throw error;
49
- return null;
50
- }),
51
- ]);
52
- if (!snap.exists())
53
- throw new Error("Event not found.");
54
- const data = snap.data();
55
- const allowed = grant === null
56
- ? legacyEventAllowsMode(data, uid, mode)
57
- : data.ownerId === uid || (grant.exists() && grantAllowsMode(grant.data(), mode));
58
- if (!allowed) {
59
- throw new Error(`Event does not grant ${mode} access to this organizer.`);
60
- }
61
- if (data.trashedAt && mode !== "read" && mode !== "delete") {
62
- throw new Error("Event is in trash. Restore it before making changes or live actions.");
63
- }
64
- return data;
65
- }