@okostream/mcp 0.2.0 → 0.3.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/README.md CHANGED
@@ -54,8 +54,9 @@ Every tool returns JSON text plus MCP `structuredContent`. List-like tools use b
54
54
 
55
55
  - `limit` defaults to 20-50 depending on the tool.
56
56
  - Cursor tools return both `hasMore`/`nextCursor` and `has_more`/`next_cursor`.
57
- - `oko_list_my_events`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_list_workspace_access`, `oko_list_workspace_invites`, and `oko_list_telegram_posts` support pagination.
57
+ - `oko_list_my_events`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_list_notifications`, `oko_list_workspace_access`, `oko_list_workspace_invites`, and `oko_list_telegram_posts` support pagination.
58
58
  - `oko_get_analytics` omits heavy `timeline`, baseline/final snapshots, and NMO participant rows by default. Request `includeTimeline`, `includeSnapshots`, or `includeNmoRows` only when needed.
59
+ - `oko_get_session_report` returns a compact structured session report plus markdown suitable for a human handoff.
59
60
  - Oversized responses are truncated with `truncated: true` and a `truncationMessage`.
60
61
 
61
62
  This package is published as source-available runtime distribution for OKO customers. `license: UNLICENSED` means all rights reserved unless a separate agreement says otherwise.
@@ -92,7 +93,8 @@ All tools are prefixed with `oko_` for safe discovery next to other MCP servers.
92
93
  Core discovery/read tools:
93
94
 
94
95
  - `oko_whoami`, `oko_list_workspaces`, `oko_list_my_events`, `oko_get_event`
95
- - `oko_get_live_state`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`
96
+ - `oko_update_profile`, `oko_list_notifications`, `oko_mark_notifications_read`
97
+ - `oko_get_live_state`, `oko_get_analytics`, `oko_get_session_report`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_delete_lead`
96
98
 
97
99
  Event/setup/media tools:
98
100
 
@@ -128,7 +130,9 @@ Dangerous or external side-effect tools require `confirm: true`.
128
130
  6. `oko_start_session` -> `oko_fix_orphan_session` -> `oko_delete_session` should leave no active session pointers on a disposable test event.
129
131
  7. `oko_duplicate_event`, workspace tools, and `oko_move_event` should preserve Firestore access rules and RTDB owner/workspace mirrors.
130
132
  8. `oko_import_nmo_participants` should call CF24 and return per-row results; clean up test participants after import.
131
- 9. `oko_get_analytics` should read an existing event with analytics sessions.
133
+ 9. `oko_get_session_report` should return markdown and structured session data for a completed session.
134
+ 10. `oko_list_notifications` -> `oko_mark_notifications_read` should read and clear an own disposable notification.
135
+ 11. `oko_get_analytics` should read an existing event with analytics sessions.
132
136
 
133
137
  CLI help:
134
138
 
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ if (process.argv.includes("--help")) {
22
22
  }
23
23
  const server = new McpServer({
24
24
  name: "oko-mcp-server",
25
- version: "0.2.0",
25
+ version: "0.3.0",
26
26
  });
27
27
  registerTools(server);
28
28
  const transport = new StdioServerTransport();
package/dist/tools.js CHANGED
@@ -163,6 +163,32 @@ const nmoImportOutputSchema = z.object({
163
163
  summary: unknownRecordSchema,
164
164
  results: z.array(unknownRecordSchema),
165
165
  }).passthrough();
166
+ const sessionReportOutputSchema = z.object({
167
+ eventId: z.string(),
168
+ sessionId: z.string(),
169
+ reportUrl: z.string().url(),
170
+ markdown: z.string(),
171
+ session: unknownRecordSchema,
172
+ }).passthrough();
173
+ const profileOutputSchema = z.object({
174
+ uid: z.string(),
175
+ profile: unknownRecordSchema,
176
+ updated: z.array(z.string()),
177
+ }).passthrough();
178
+ const deleteLeadOutputSchema = z.object({
179
+ eventId: z.string(),
180
+ leadId: z.string(),
181
+ deleted: z.boolean(),
182
+ }).passthrough();
183
+ const notificationsOutputSchema = z.object({
184
+ notifications: z.array(unknownRecordSchema),
185
+ unreadCountOnPage: z.number().int().nonnegative(),
186
+ ...cursorMetaSchema,
187
+ }).passthrough();
188
+ const notificationsMarkOutputSchema = z.object({
189
+ markedRead: z.number().int().nonnegative(),
190
+ notificationIds: z.array(z.string()),
191
+ }).passthrough();
166
192
  const formFieldSchema = z.object({
167
193
  id: z.string().min(1).optional(),
168
194
  label: z.string().max(120),
@@ -209,6 +235,11 @@ const telegramChannelSchema = z.object({
209
235
  title: z.string().max(200).optional(),
210
236
  username: z.string().max(120).nullable().optional(),
211
237
  }).strict();
238
+ const profileDisplayNameSchema = z.string().max(40).nullable();
239
+ const timezoneSchema = z.string()
240
+ .min(1)
241
+ .max(100)
242
+ .regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.");
212
243
  const cardsConfigOutputSchema = z.object({
213
244
  eventId: z.string(),
214
245
  updated: z.array(z.string()),
@@ -685,6 +716,9 @@ function setupUrl(eventId) {
685
716
  function analyticsUrl(eventId) {
686
717
  return `https://okostream.app/analytics/${eventId}`;
687
718
  }
719
+ function sessionReportUrl(eventId, sessionId) {
720
+ return `${analyticsUrl(eventId)}/report/${sessionId}`;
721
+ }
688
722
  function nmoRegisterUrl(eventId) {
689
723
  return `${eventUrl(eventId)}/register`;
690
724
  }
@@ -1200,6 +1234,70 @@ function compactAnalyticsSession(raw, options) {
1200
1234
  }
1201
1235
  return session;
1202
1236
  }
1237
+ function numberMetric(value) {
1238
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1239
+ }
1240
+ function formatSeconds(value) {
1241
+ const seconds = numberMetric(value);
1242
+ if (seconds == null)
1243
+ return "n/a";
1244
+ const total = Math.max(0, Math.round(seconds));
1245
+ const h = Math.floor(total / 3600);
1246
+ const m = Math.floor((total % 3600) / 60);
1247
+ const s = total % 60;
1248
+ if (h > 0)
1249
+ return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
1250
+ return `${m}m ${String(s).padStart(2, "0")}s`;
1251
+ }
1252
+ function formatDateTime(value) {
1253
+ const millis = toMillis(value);
1254
+ if (millis == null)
1255
+ return "n/a";
1256
+ return new Date(millis).toISOString();
1257
+ }
1258
+ function markdownMetric(label, value, suffix = "") {
1259
+ const metric = value === null || value === undefined || value === "" ? "n/a" : String(value);
1260
+ return `- ${label}: ${metric}${suffix}`;
1261
+ }
1262
+ function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
1263
+ const lines = [
1264
+ `# OKO session report`,
1265
+ "",
1266
+ `Event: ${safeString(event.title, eventId)}`,
1267
+ `Event URL: ${eventUrl(eventId)}`,
1268
+ `Report URL: ${sessionReportUrl(eventId, sessionId)}`,
1269
+ `Session: ${sessionId}`,
1270
+ "",
1271
+ "## Timing",
1272
+ markdownMetric("Started", formatDateTime(session.startedAt)),
1273
+ markdownMetric("Ended", formatDateTime(session.endedAt)),
1274
+ markdownMetric("Duration", formatSeconds(session.durationSec)),
1275
+ "",
1276
+ "## Audience",
1277
+ markdownMetric("Peak viewers", session.peakViewers),
1278
+ markdownMetric("Unique visitors", session.uniqueVisitors),
1279
+ markdownMetric("Views", session.viewCount),
1280
+ markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
1281
+ markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
1282
+ "",
1283
+ "## Engagement",
1284
+ markdownMetric("Chat messages", session.chatMessages),
1285
+ markdownMetric("Banner impressions", session.bannerImpressions),
1286
+ markdownMetric("Banner clicks", session.bannerClicks),
1287
+ markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
1288
+ markdownMetric("CTA clicks", session.ctaClicks),
1289
+ markdownMetric("Cards clicks", session.cardsClicks),
1290
+ ];
1291
+ const nmo = session.nmoEvaluation;
1292
+ if (nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
1293
+ const summary = nmo.summary;
1294
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
1295
+ const s = summary;
1296
+ lines.push("", "## NMO", markdownMetric("Passed", s.passed), markdownMetric("Failed", s.failed), markdownMetric("No-show", s.noShow), markdownMetric("Total", s.total));
1297
+ }
1298
+ }
1299
+ return lines.join("\n");
1300
+ }
1203
1301
  export function registerTools(server) {
1204
1302
  server.registerTool("oko_whoami", {
1205
1303
  title: "Who am I",
@@ -1217,6 +1315,39 @@ export function registerTools(server) {
1217
1315
  const auth = await authed([]);
1218
1316
  return textResult({ uid: auth.uid, scopes: currentScopes() });
1219
1317
  });
1318
+ server.registerTool("oko_update_profile", {
1319
+ title: "Update profile",
1320
+ description: toolDescription({
1321
+ summary: "Update the authenticated organizer profile fields used by OKO UI.",
1322
+ args: [
1323
+ "displayName: optional display name, max 40 chars; null clears it.",
1324
+ "timezone: optional IANA timezone such as Europe/Moscow or UTC.",
1325
+ ],
1326
+ returns: "uid, updated field names, and the resulting profile snapshot.",
1327
+ examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
1328
+ notes: ["This edits only the current organizer's own userProfiles document and does not require extra workspace/event scopes."],
1329
+ }),
1330
+ inputSchema: {
1331
+ displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
1332
+ timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC."),
1333
+ },
1334
+ outputSchema: profileOutputSchema,
1335
+ annotations: idempotentWriteTool,
1336
+ }, async ({ displayName, timezone }) => {
1337
+ const { uid } = await authed([]);
1338
+ const patch = { updatedAt: serverTimestamp() };
1339
+ if (displayName !== undefined)
1340
+ patch.displayName = displayName?.trim() || null;
1341
+ if (timezone !== undefined)
1342
+ patch.timezone = timezone.trim();
1343
+ const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
1344
+ if (updated.length === 0)
1345
+ throw new Error("Provide displayName and/or timezone.");
1346
+ const profileRef = doc(firestore, "userProfiles", uid);
1347
+ await setDoc(profileRef, patch, { merge: true });
1348
+ const profileSnap = await getDoc(profileRef);
1349
+ return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
1350
+ });
1220
1351
  server.registerTool("oko_list_workspaces", {
1221
1352
  title: "List workspaces",
1222
1353
  description: toolDescription({
@@ -2505,6 +2636,65 @@ export function registerTools(server) {
2505
2636
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
2506
2637
  return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots/includeNmoRows disabled." });
2507
2638
  });
2639
+ server.registerTool("oko_get_session_report", {
2640
+ title: "Get session report",
2641
+ description: toolDescription({
2642
+ summary: "Build a ready-to-share report for one analytics session, with compact structured data and markdown.",
2643
+ args: [
2644
+ "eventId: event to inspect.",
2645
+ "sessionId: optional analytics session id; omitted means latest session.",
2646
+ "includeTimeline: include recent timeline buckets; default false.",
2647
+ "timelineLimit: max timeline buckets when includeTimeline=true.",
2648
+ "includeSnapshots: include baseline/final snapshots; default false.",
2649
+ "includeNmoRows: include full NMO participant rows; default false.",
2650
+ ],
2651
+ returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
2652
+ examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
2653
+ }),
2654
+ inputSchema: {
2655
+ eventId: eventIdField,
2656
+ sessionId: z.string().min(1).optional().describe("analyticsSessions document id. Omit to use the latest session."),
2657
+ includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
2658
+ timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
2659
+ includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
2660
+ includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
2661
+ },
2662
+ outputSchema: sessionReportOutputSchema,
2663
+ annotations: readOnlyTool,
2664
+ }, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }) => {
2665
+ const { uid } = await authed(["analytics:read"]);
2666
+ const event = await readEvent(eventId, uid, "read");
2667
+ let resolvedSessionId = sessionId;
2668
+ let sessionData = null;
2669
+ if (resolvedSessionId) {
2670
+ const sessionSnap = await getDoc(doc(firestore, "events", eventId, "analyticsSessions", resolvedSessionId));
2671
+ if (!sessionSnap.exists())
2672
+ throw new Error("Analytics session not found.");
2673
+ sessionData = sessionSnap.data();
2674
+ }
2675
+ else {
2676
+ const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), orderBy("startedAt", "desc"), limit(1)));
2677
+ const first = snap.docs[0];
2678
+ if (!first)
2679
+ throw new Error("No analytics sessions found for this event.");
2680
+ resolvedSessionId = first.id;
2681
+ sessionData = first.data();
2682
+ }
2683
+ const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots, includeNmoRows });
2684
+ const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
2685
+ return textResult({
2686
+ eventId,
2687
+ sessionId: resolvedSessionId,
2688
+ event: {
2689
+ id: eventId,
2690
+ title: safeString(event.title, eventId),
2691
+ ...eventLinks(eventId, event),
2692
+ },
2693
+ reportUrl: sessionReportUrl(eventId, resolvedSessionId),
2694
+ markdown,
2695
+ session,
2696
+ }, { truncationHint: "Keep includeTimeline/includeSnapshots/includeNmoRows disabled or lower timelineLimit." });
2697
+ });
2508
2698
  server.registerTool("oko_list_recent_chat", {
2509
2699
  title: "List recent chat",
2510
2700
  description: toolDescription({
@@ -2910,6 +3100,108 @@ export function registerTools(server) {
2910
3100
  }
2911
3101
  return textResult({ leads, ...meta });
2912
3102
  });
3103
+ server.registerTool("oko_delete_lead", {
3104
+ title: "Delete form lead",
3105
+ description: toolDescription({
3106
+ summary: "Delete one lead/form submission from an event.",
3107
+ args: ["eventId: event id.", "leadId: events/{eventId}/leads document id.", "confirm: must be true."],
3108
+ returns: "eventId, leadId, deleted=true, and a small preview of the deleted lead.",
3109
+ examples: ["Use after listing leads and confirming the exact submission to remove."],
3110
+ notes: ["Destructive. Requires setup:write in addition to leads:read so read-only keys cannot delete leads."],
3111
+ }),
3112
+ inputSchema: {
3113
+ eventId: eventIdField,
3114
+ leadId: z.string().min(1).max(160).describe("Lead document id under events/{eventId}/leads."),
3115
+ confirm: z.boolean().optional().describe("Required true for lead deletion."),
3116
+ },
3117
+ outputSchema: deleteLeadOutputSchema,
3118
+ annotations: destructiveTool,
3119
+ }, async ({ eventId, leadId, confirm }) => {
3120
+ const { uid } = await authed(["leads:read", "setup:write"]);
3121
+ await readEvent(eventId, uid, "edit");
3122
+ const leadRef = doc(firestore, "events", eventId, "leads", leadId);
3123
+ const leadSnap = await getDoc(leadRef);
3124
+ if (!leadSnap.exists())
3125
+ throw new Error("Lead not found.");
3126
+ const lead = serialize(leadSnap.data());
3127
+ requireConfirm(confirm, "oko_delete_lead", { eventId, leadId, formId: lead.formId, source: lead.source, submittedAt: lead.submittedAt });
3128
+ await deleteDoc(leadRef);
3129
+ return textResult({ eventId, leadId, deleted: true, leadPreview: lead });
3130
+ });
3131
+ server.registerTool("oko_list_notifications", {
3132
+ title: "List notifications",
3133
+ description: toolDescription({
3134
+ summary: "List in-app notifications for the authenticated organizer.",
3135
+ args: [
3136
+ "unreadOnly: only unread notifications, default false.",
3137
+ "limit: max notifications, default 20, max 100.",
3138
+ "cursor: createdAt cursor returned as nextCursor by the previous page.",
3139
+ ],
3140
+ returns: "notifications plus unreadCountOnPage and cursor pagination metadata.",
3141
+ examples: ["Use when the organizer asks what changed in OKO or what notifications are waiting."],
3142
+ }),
3143
+ inputSchema: {
3144
+ unreadOnly: z.boolean().default(false).describe("Return only unread notifications."),
3145
+ limit: limitField(100, 20),
3146
+ cursor: cursorField,
3147
+ },
3148
+ outputSchema: notificationsOutputSchema,
3149
+ annotations: readOnlyTool,
3150
+ }, async ({ unreadOnly, limit: max, cursor }) => {
3151
+ const { uid } = await authed(["workspace:read"]);
3152
+ const fetchLimit = unreadOnly ? Math.min(max * 5 + 1, 501) : max + 1;
3153
+ const constraints = [
3154
+ orderBy("createdAt", "desc"),
3155
+ limit(fetchLimit),
3156
+ ];
3157
+ if (cursor)
3158
+ constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
3159
+ const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), ...constraints));
3160
+ const matchingDocs = unreadOnly ? snap.docs.filter((item) => item.data().read !== true) : snap.docs;
3161
+ const { page, hasMore: hasMoreInMatches } = pickPage(matchingDocs, max);
3162
+ const hasMore = hasMoreInMatches || snap.docs.length >= fetchLimit;
3163
+ const cursorSource = hasMoreInMatches ? page.at(-1) : snap.docs.at(-1);
3164
+ const notifications = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
3165
+ const nextCursor = hasMore ? timestampCursorFrom(cursorSource?.data().createdAt) : null;
3166
+ return textResult({
3167
+ notifications,
3168
+ unreadCountOnPage: notifications.filter((item) => item.read !== true).length,
3169
+ ...cursorMeta(notifications.length, max, hasMore, nextCursor),
3170
+ });
3171
+ });
3172
+ server.registerTool("oko_mark_notifications_read", {
3173
+ title: "Mark notifications read",
3174
+ description: toolDescription({
3175
+ summary: "Mark own OKO in-app notifications as read.",
3176
+ args: [
3177
+ "notificationIds: optional explicit notification ids, max 100.",
3178
+ "allUnread: true to mark unread notifications on the first page, max controlled by limit.",
3179
+ "limit: when allUnread=true, max unread notifications to mark, default 100, max 200.",
3180
+ ],
3181
+ returns: "markedRead count and notification ids updated.",
3182
+ examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
3183
+ notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."],
3184
+ }),
3185
+ inputSchema: {
3186
+ notificationIds: z.array(z.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
3187
+ allUnread: z.boolean().default(false).describe("Mark unread notifications as read."),
3188
+ limit: limitField(200, 100),
3189
+ },
3190
+ outputSchema: notificationsMarkOutputSchema,
3191
+ annotations: idempotentWriteTool,
3192
+ }, async ({ notificationIds, allUnread, limit: max }) => {
3193
+ const { uid } = await authed(["workspace:read"]);
3194
+ if (Boolean(notificationIds?.length) === allUnread) {
3195
+ throw new Error("Provide either notificationIds or allUnread=true.");
3196
+ }
3197
+ let ids = [...new Set(notificationIds ?? [])];
3198
+ if (allUnread) {
3199
+ const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), where("read", "==", false), limit(max)));
3200
+ ids = snap.docs.map((item) => item.id);
3201
+ }
3202
+ await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
3203
+ return textResult({ markedRead: ids.length, notificationIds: ids });
3204
+ });
2913
3205
  server.registerTool("oko_list_telegram_channels", {
2914
3206
  title: "List Telegram channels",
2915
3207
  description: toolDescription({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for managing OKO Stream webinars as an organizer.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",