@okostream/mcp 0.5.0 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2 - 2026-07-10
4
+
5
+ - Fixed webinar create/duplicate output schemas so successful writes cannot be reported as protocol failures.
6
+ - Made Firestore event creation compensate automatically when the required RTDB mirror fails.
7
+ - Removed a stale RTDB mirror rewrite from metadata updates.
8
+ - Routed event deletion through an authorized server callable that cleans Firestore subcollections, both RTDB instances, Storage, participants, active sessions, and scheduled Telegram posts.
9
+ - Added a repeatable production smoke workflow against an exact published npm version.
10
+
11
+ ## 0.5.1 - 2026-07-10
12
+
13
+ - Replaced hand-maintained remote-image IP ranges with a tested public-unicast policy covering IPv4, IPv6, mapped, reserved, documentation, benchmark, multicast, and private address classes.
14
+
3
15
  ## 0.5.0 - 2026-07-10
4
16
 
5
17
  - Isolated every API key behind a synthetic Firebase identity with immediate revocation across Firestore, main RTDB, and chat RTDB.
@@ -0,0 +1,18 @@
1
+ import ipaddr from "ipaddr.js";
2
+ export function normalizeIpLiteralHostname(hostname) {
3
+ return hostname.startsWith("[") && hostname.endsWith("]")
4
+ ? hostname.slice(1, -1)
5
+ : hostname;
6
+ }
7
+ export function ipLiteralFamily(address) {
8
+ const normalized = normalizeIpLiteralHostname(address);
9
+ if (!ipaddr.isValid(normalized))
10
+ return 0;
11
+ return ipaddr.parse(normalized).kind() === "ipv4" ? 4 : 6;
12
+ }
13
+ export function isPublicUnicastAddress(address) {
14
+ const normalized = normalizeIpLiteralHostname(address);
15
+ if (!ipaddr.isValid(normalized))
16
+ return false;
17
+ return ipaddr.parse(normalized).range() === "unicast";
18
+ }
package/dist/server.js CHANGED
@@ -23,7 +23,7 @@ if (process.argv.includes("--help")) {
23
23
  }
24
24
  const server = new McpServer({
25
25
  name: "oko-mcp-server",
26
- version: "0.5.0",
26
+ version: "0.5.2",
27
27
  }, {
28
28
  instructions: OKO_MCP_INSTRUCTIONS,
29
29
  });
package/dist/tools.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { lookup } from "node:dns/promises";
2
2
  import { stat, readFile, realpath } from "node:fs/promises";
3
- import { isIP } from "node:net";
4
3
  import { homedir } from "node:os";
5
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import { randomUUID } from "node:crypto";
@@ -12,6 +11,7 @@ import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, r
12
11
  import { httpsCallable } from "firebase/functions";
13
12
  import { ensureAuth, currentScopes } from "./auth.js";
14
13
  import { env } from "./env.js";
14
+ import { ipLiteralFamily, isPublicUnicastAddress, normalizeIpLiteralHostname, } from "./networkPolicy.js";
15
15
  import { chatRtdb, firestore, functions, rtdb } from "./firebase.js";
16
16
  import { csvCell, requireConfirm, safeString, serialize, textResult } from "./utils.js";
17
17
  import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
@@ -77,11 +77,21 @@ const updateOutputSchema = z.object({
77
77
  eventId: z.string().optional(),
78
78
  updated: z.array(z.string()).optional(),
79
79
  }).passthrough();
80
- const createWebinarOutputSchema = {
81
- id: z.string(),
80
+ const eventLinksOutputShape = {
82
81
  url: z.string().url(),
83
- workspaceId: z.string(),
82
+ setupUrl: z.string().url(),
83
+ analyticsUrl: z.string().url(),
84
+ registrationUrl: z.string().url(),
85
+ loginUrl: z.string().url(),
86
+ nmoRegistrationUrl: z.string().url().optional(),
87
+ nmoLoginUrl: z.string().url().optional(),
84
88
  };
89
+ const createWebinarOutputSchema = z.object({
90
+ id: z.string(),
91
+ workspaceId: z.string(),
92
+ sourceEventId: z.string().optional(),
93
+ ...eventLinksOutputShape,
94
+ }).passthrough();
85
95
  const cursorMetaSchema = {
86
96
  count: z.number().int().nonnegative(),
87
97
  limit: z.number().int().positive(),
@@ -100,7 +110,7 @@ const eventsOutputSchema = z.object({
100
110
  }).passthrough();
101
111
  const eventOutputSchema = z.object({
102
112
  id: z.string(),
103
- url: z.string().url(),
113
+ ...eventLinksOutputShape,
104
114
  }).passthrough();
105
115
  const liveStateOutputSchema = z.object({
106
116
  eventId: z.string(),
@@ -372,33 +382,6 @@ function isBlockedHostname(hostname) {
372
382
  const host = hostname.toLowerCase();
373
383
  return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
374
384
  }
375
- function isBlockedIp(address) {
376
- const normalized = address.toLowerCase().startsWith("::ffff:")
377
- ? address.slice(7)
378
- : address;
379
- const version = isIP(normalized);
380
- if (version === 4) {
381
- const [a = -1, b = -1] = normalized.split(".").map((part) => Number(part));
382
- return a === 0
383
- || a === 10
384
- || a === 127
385
- || (a === 100 && b >= 64 && b <= 127)
386
- || (a === 169 && b === 254)
387
- || (a === 172 && b >= 16 && b <= 31)
388
- || (a === 192 && b === 168)
389
- || (a === 198 && (b === 18 || b === 19))
390
- || a >= 224;
391
- }
392
- if (version === 6) {
393
- return normalized === "::"
394
- || normalized === "::1"
395
- || normalized.startsWith("fc")
396
- || normalized.startsWith("fd")
397
- || normalized.startsWith("fe80:")
398
- || normalized.startsWith("ff");
399
- }
400
- return false;
401
- }
402
385
  async function resolveRemoteImageAddress(url) {
403
386
  if (url.protocol !== "https:") {
404
387
  throw new Error("Image URL must use https://.");
@@ -409,15 +392,17 @@ async function resolveRemoteImageAddress(url) {
409
392
  if (isBlockedHostname(url.hostname)) {
410
393
  throw new Error("Image URL host is not allowed. Use a public HTTPS image URL.");
411
394
  }
412
- if (isIP(url.hostname)) {
413
- if (isBlockedIp(url.hostname)) {
395
+ const literalAddress = normalizeIpLiteralHostname(url.hostname);
396
+ const literalFamily = ipLiteralFamily(literalAddress);
397
+ if (literalFamily !== 0) {
398
+ if (!isPublicUnicastAddress(literalAddress)) {
414
399
  throw new Error("Image URL points to a private/local IP address, which is not allowed.");
415
400
  }
416
- return { address: url.hostname, family: isIP(url.hostname) };
401
+ return { address: literalAddress, family: literalFamily };
417
402
  }
418
403
  const addresses = await lookup(url.hostname, { all: true });
419
- if (addresses.length === 0 || addresses.some((item) => isBlockedIp(item.address))) {
420
- throw new Error("Image URL resolves to a private/local IP address, which is not allowed.");
404
+ if (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address))) {
405
+ throw new Error("Image URL resolves to a non-public IP address, which is not allowed.");
421
406
  }
422
407
  const selected = addresses[0];
423
408
  if (!selected || (selected.family !== 4 && selected.family !== 6)) {
@@ -1124,6 +1109,22 @@ function initialEventAccess(uid) {
1124
1109
  notificationSenders: [uid],
1125
1110
  };
1126
1111
  }
1112
+ async function addEventWithRtdbMirror(uid, workspaceId, eventData) {
1113
+ const eventRef = await addDoc(collection(firestore, "events"), eventData);
1114
+ try {
1115
+ await update(ref(rtdb, `events/${eventRef.id}`), { ownerId: uid, workspaceId });
1116
+ }
1117
+ catch (error) {
1118
+ try {
1119
+ await deleteDoc(eventRef);
1120
+ }
1121
+ catch (rollbackError) {
1122
+ throw new AggregateError([error, rollbackError], `RTDB mirror failed for event ${eventRef.id}, and Firestore rollback also failed.`);
1123
+ }
1124
+ throw error;
1125
+ }
1126
+ return eventRef;
1127
+ }
1127
1128
  async function assertCanCreateInWorkspace(uid, workspaceId) {
1128
1129
  if (workspaceId === uid)
1129
1130
  return;
@@ -2065,7 +2066,7 @@ export function registerTools(server) {
2065
2066
  const workspaceId = args.workspaceId ?? uid;
2066
2067
  await assertCanCreateInWorkspace(uid, workspaceId);
2067
2068
  const video = normalizeVideo(args.videoUrl);
2068
- const docRef = await addDoc(collection(firestore, "events"), {
2069
+ const docRef = await addEventWithRtdbMirror(uid, workspaceId, {
2069
2070
  ownerId: uid,
2070
2071
  workspaceId,
2071
2072
  ...initialEventAccess(uid),
@@ -2083,10 +2084,6 @@ export function registerTools(server) {
2083
2084
  promoBanner: { enabled: false, text: "", link: "", visibility: "always" },
2084
2085
  },
2085
2086
  });
2086
- await update(ref(rtdb, `events/${docRef.id}`), {
2087
- ownerId: uid,
2088
- workspaceId,
2089
- });
2090
2087
  return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
2091
2088
  });
2092
2089
  server.registerTool("oko_update_event", {
@@ -2121,7 +2118,7 @@ export function registerTools(server) {
2121
2118
  if (requiredScopes.size === 0)
2122
2119
  throw new Error("Provide at least one field to update.");
2123
2120
  const { uid } = await authed([...requiredScopes]);
2124
- const data = await readEvent(args.eventId, uid, "edit");
2121
+ await readEvent(args.eventId, uid, "edit");
2125
2122
  const patch = {};
2126
2123
  if (args.title !== undefined)
2127
2124
  patch.title = args.title.trim();
@@ -2139,10 +2136,6 @@ export function registerTools(server) {
2139
2136
  if (args.pinned !== undefined)
2140
2137
  patch.pinned = args.pinned;
2141
2138
  await updateDoc(doc(firestore, "events", args.eventId), patch);
2142
- await update(ref(rtdb, `events/${args.eventId}`), {
2143
- ownerId: safeString(data.ownerId, uid),
2144
- workspaceId: safeString(data.workspaceId, uid),
2145
- });
2146
2139
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
2147
2140
  });
2148
2141
  server.registerTool("oko_configure_blocks", {
@@ -2524,11 +2517,11 @@ export function registerTools(server) {
2524
2517
  server.registerTool("oko_delete_event", {
2525
2518
  title: "Delete event",
2526
2519
  description: toolDescription({
2527
- summary: "Delete an OKO event document.",
2520
+ summary: "Delete an OKO event and its associated Firestore, RTDB, chat, Storage, participant, and scheduled-post data.",
2528
2521
  args: ["eventId: event to delete.", "confirm: must be true."],
2529
- returns: "deleted event id.",
2522
+ returns: "deleted event id and cross-store cleanup counts.",
2530
2523
  examples: ["Use only after the user explicitly confirms the exact event title/id."],
2531
- notes: ["Destructive. Does not control stream state; it deletes the event record."],
2524
+ notes: ["Destructive. The server validates the current per-event delete capability and performs idempotent cross-store cleanup."],
2532
2525
  }),
2533
2526
  inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true for deletion.") },
2534
2527
  outputSchema: z.object({ deleted: z.string() }).passthrough(),
@@ -2537,8 +2530,9 @@ export function registerTools(server) {
2537
2530
  const { uid } = await authed(["events:delete"]);
2538
2531
  const data = await readEvent(eventId, uid, "delete");
2539
2532
  requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
2540
- await deleteDoc(doc(firestore, "events", eventId));
2541
- return textResult({ deleted: eventId });
2533
+ const deleteEvent = httpsCallable(functions, "deleteEventCompletely");
2534
+ const result = await deleteEvent({ eventId });
2535
+ return textResult(result.data);
2542
2536
  });
2543
2537
  server.registerTool("oko_duplicate_event", {
2544
2538
  title: "Duplicate event",
@@ -2560,7 +2554,7 @@ export function registerTools(server) {
2560
2554
  const source = await readEvent(eventId, uid, "read");
2561
2555
  const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
2562
2556
  await assertCanCreateInWorkspace(uid, workspaceId);
2563
- const newRef = await addDoc(collection(firestore, "events"), {
2557
+ const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
2564
2558
  ownerId: uid,
2565
2559
  workspaceId,
2566
2560
  ...initialEventAccess(uid),
@@ -2576,7 +2570,6 @@ export function registerTools(server) {
2576
2570
  ...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
2577
2571
  ...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
2578
2572
  });
2579
- await update(ref(rtdb, `events/${newRef.id}`), { ownerId: uid, workspaceId });
2580
2573
  return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
2581
2574
  });
2582
2575
  server.registerTool("oko_move_event", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "MCP server for managing OKO Stream webinars as an organizer.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "test": "npm run build && node --test test/*.test.mjs",
27
27
  "eval:contract": "npm run build && node eval/contract-eval.mjs",
28
28
  "eval:model": "npm run build && node eval/model-eval.mjs",
29
+ "smoke:prod": "node eval/prod-smoke.mjs",
29
30
  "prepublishOnly": "npm run build",
30
31
  "start": "node dist/server.js"
31
32
  },
@@ -36,6 +37,7 @@
36
37
  "@modelcontextprotocol/sdk": "^1.29.0",
37
38
  "dotenv": "^17.2.3",
38
39
  "firebase": "^12.16.0",
40
+ "ipaddr.js": "2.4.0",
39
41
  "sharp": "^0.35.3",
40
42
  "undici": "7.28.0",
41
43
  "zod": "^3.25.76"