@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 +12 -0
- package/dist/networkPolicy.js +18 -0
- package/dist/server.js +1 -1
- package/dist/tools.js +47 -54
- package/package.json +3 -1
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
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
|
|
81
|
-
id: z.string(),
|
|
80
|
+
const eventLinksOutputShape = {
|
|
82
81
|
url: z.string().url(),
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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:
|
|
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) =>
|
|
420
|
-
throw new Error("Image URL resolves to a
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
2541
|
-
|
|
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
|
|
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.
|
|
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"
|