@okostream/mcp 0.9.0 → 0.9.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/dist/env.js CHANGED
@@ -3,9 +3,16 @@ import { homedir } from "node:os";
3
3
  import { delimiter, isAbsolute, join, parse, resolve } from "node:path";
4
4
  import { DEFAULT_EXCHANGE_URL, DEFAULT_FIREBASE_CONFIG } from "./firebaseDefaults.js";
5
5
  config({ path: join(homedir(), ".env"), quiet: true });
6
+ const USER_CONFIG_PLACEHOLDER = /^\$\{user_config\.[^{}\s]+\}$/;
7
+ function normalizeOptionalEnv(value) {
8
+ const normalized = value?.trim();
9
+ if (!normalized || USER_CONFIG_PLACEHOLDER.test(normalized))
10
+ return null;
11
+ return normalized;
12
+ }
6
13
  function readOptionalEnv(...names) {
7
14
  for (const name of names) {
8
- const value = process.env[name]?.trim();
15
+ const value = normalizeOptionalEnv(process.env[name]);
9
16
  if (value)
10
17
  return value;
11
18
  }
@@ -13,33 +20,39 @@ function readOptionalEnv(...names) {
13
20
  }
14
21
  function readEnvWithDefault(defaultValue, ...names) {
15
22
  for (const name of names) {
16
- const value = process.env[name]?.trim();
23
+ const value = normalizeOptionalEnv(process.env[name]);
17
24
  if (value)
18
25
  return value;
19
26
  }
20
27
  return defaultValue;
21
28
  }
29
+ function disableImageRoots(reason) {
30
+ console.error(`[oko-mcp] Local image reads disabled: ${reason}`);
31
+ return [];
32
+ }
22
33
  function readImageRoots() {
23
- const rawRoots = process.env.OKO_IMAGE_ROOTS?.trim();
34
+ const rawRoots = readOptionalEnv("OKO_IMAGE_ROOTS");
24
35
  if (!rawRoots)
25
36
  return [];
26
- return rawRoots.split(delimiter).map((value) => {
37
+ const roots = [];
38
+ for (const value of rawRoots.split(delimiter)) {
27
39
  const root = value.trim();
28
40
  if (!root) {
29
- throw new Error("OKO_IMAGE_ROOTS contains an empty directory entry.");
41
+ return disableImageRoots("OKO_IMAGE_ROOTS contains an empty directory entry.");
30
42
  }
31
43
  const expanded = root === "~" || root.startsWith("~/")
32
44
  ? join(homedir(), root.slice(2))
33
45
  : root;
34
46
  if (!isAbsolute(expanded)) {
35
- throw new Error(`OKO_IMAGE_ROOTS entries must be absolute paths: ${root}`);
47
+ return disableImageRoots("OKO_IMAGE_ROOTS entries must be absolute paths.");
36
48
  }
37
49
  const resolved = resolve(expanded);
38
50
  if (resolved === parse(resolved).root) {
39
- throw new Error("OKO_IMAGE_ROOTS must not allow an entire filesystem root.");
51
+ return disableImageRoots("OKO_IMAGE_ROOTS must not allow an entire filesystem root.");
40
52
  }
41
- return resolved;
42
- });
53
+ roots.push(resolved);
54
+ }
55
+ return roots;
43
56
  }
44
57
  export const env = {
45
58
  apiKey: readOptionalEnv("OKO_API_KEY"),
@@ -0,0 +1,37 @@
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
+ }
@@ -0,0 +1,13 @@
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();
package/dist/tools.js CHANGED
@@ -13,6 +13,8 @@ import { env } from "./env.js";
13
13
  import { ipLiteralFamily, isPublicUnicastAddress, normalizeIpLiteralHostname, } from "./networkPolicy.js";
14
14
  import { chatRtdb, firestore, functions, rtdb } from "./firebase.js";
15
15
  import { OKO_CONNECTION_STATUSES } from "./onboarding.js";
16
+ import { summarizeSessionRepair } from "./sessionRepair.js";
17
+ import { canonicalTelegramChannelSchema, telegramChannelSchema, } from "./telegramChannelSchema.js";
16
18
  import { csvCell, requireConfirm, safeString, serialize, textResult } from "./utils.js";
17
19
  import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
18
20
  const boolOrNull = z.boolean().nullable();
@@ -206,7 +208,7 @@ const workspaceAccessOutputSchema = z.object({
206
208
  invites: z.array(unknownRecordSchema),
207
209
  }).passthrough();
208
210
  const telegramChannelsOutputSchema = z.object({
209
- channels: z.array(unknownRecordSchema),
211
+ channels: z.array(canonicalTelegramChannelSchema),
210
212
  count: z.number().int().nonnegative(),
211
213
  }).passthrough();
212
214
  const telegramPostsOutputSchema = z.object({
@@ -273,15 +275,6 @@ const cardItemInputSchema = z.object({
273
275
  buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
274
276
  formId: cardIdSchema.nullable().optional(),
275
277
  }).strict();
276
- const telegramChannelSchema = z.object({
277
- chatId: z.number().int(),
278
- title: z.string().max(200).optional(),
279
- username: z.string().max(120).nullable().optional(),
280
- }).strict();
281
- const canonicalTelegramChannelSchema = telegramChannelSchema.extend({
282
- workspaceIds: z.array(z.string()).max(50),
283
- owned: z.boolean(),
284
- }).strict();
285
278
  const profileDisplayNameSchema = z.string().max(40).nullable();
286
279
  const timezoneSchema = z.string()
287
280
  .min(1)
@@ -2412,24 +2405,65 @@ export function registerTools(server) {
2412
2405
  const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
2413
2406
  requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
2414
2407
  const cleanup = [
2415
- updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2416
- remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2408
+ {
2409
+ path: "events.activeAnalyticsSessionId",
2410
+ promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2411
+ },
2412
+ {
2413
+ path: "ctrl/activeSessionId",
2414
+ promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2415
+ },
2417
2416
  ];
2418
- if (sessionId)
2419
- cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
2420
- if (sessionId && deleteRtdbSessionData)
2421
- cleanup.push(remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)));
2422
- const results = await Promise.allSettled(cleanup);
2423
- const warnings = results
2424
- .filter((result) => result.status === "rejected")
2425
- .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
2426
- return textResult({
2417
+ if (sessionId) {
2418
+ cleanup.push({
2419
+ path: `activeSessions/${sessionId}`,
2420
+ promise: remove(ref(rtdb, `activeSessions/${sessionId}`)),
2421
+ });
2422
+ }
2423
+ if (sessionId && deleteRtdbSessionData) {
2424
+ cleanup.push({
2425
+ path: `events/${eventId}/sessions/${sessionId}`,
2426
+ promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
2427
+ });
2428
+ }
2429
+ const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
2430
+ const attempts = settled.map((result, index) => {
2431
+ const operation = cleanup[index];
2432
+ if (!operation)
2433
+ throw new Error("Session repair result has no matching cleanup operation.");
2434
+ return {
2435
+ path: operation.path,
2436
+ status: result.status,
2437
+ ...(result.status === "rejected" ? {
2438
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
2439
+ } : {}),
2440
+ };
2441
+ });
2442
+ let firestorePointer = null;
2443
+ let rtdbPointer = null;
2444
+ let verificationError;
2445
+ try {
2446
+ const [eventAfter, ctrlAfter] = await Promise.all([
2447
+ getDoc(doc(firestore, "events", eventId)),
2448
+ get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2449
+ ]);
2450
+ firestorePointer = eventAfter.exists()
2451
+ ? safeString(eventAfter.data().activeAnalyticsSessionId) || null
2452
+ : null;
2453
+ rtdbPointer = safeString(ctrlAfter.val()) || null;
2454
+ }
2455
+ catch (error) {
2456
+ verificationError = error instanceof Error ? error.message : String(error);
2457
+ }
2458
+ return textResult(summarizeSessionRepair({
2427
2459
  eventId,
2428
2460
  sessionId: sessionId || null,
2429
- fixed: warnings.length === 0,
2430
- cleared: ["events.activeAnalyticsSessionId", "ctrl/activeSessionId", ...(sessionId ? [`activeSessions/${sessionId}`] : []), ...(sessionId && deleteRtdbSessionData ? [`events/${eventId}/sessions/${sessionId}`] : [])],
2431
- warnings,
2432
- });
2461
+ deleteRtdbSessionData,
2462
+ attempts,
2463
+ firestorePointer,
2464
+ rtdbPointer,
2465
+ verificationError,
2466
+ }));
2433
2467
  });
2434
2468
  server.registerTool("oko_delete_session", {
2435
2469
  title: "Delete analytics session",
@@ -2937,7 +2971,7 @@ export function registerTools(server) {
2937
2971
  "eventId: optional event; when provided, include channels explicitly shared to that event's workspace.",
2938
2972
  "includeWorkspaceChannels: deprecated compatibility field; eventId is required to resolve workspace sharing safely.",
2939
2973
  ],
2940
- returns: "canonical channels with chatId, title, username, workspaceIds, and owned flag.",
2974
+ returns: "canonical channels with identity, workspace sharing, ownership, subscriber count, and connection health.",
2941
2975
  examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
2942
2976
  }),
2943
2977
  inputSchema: {
package/dist/version.js CHANGED
@@ -1 +1,2 @@
1
- export const OKO_MCP_VERSION = "0.9.0";
1
+ // Generated by scripts/generateMcpVersion.mjs from mcp/package.json. Do not edit.
2
+ export const OKO_MCP_VERSION = "0.9.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {
@@ -31,7 +31,7 @@
31
31
  "oko-mcp": "dist/server.js"
32
32
  },
33
33
  "scripts": {
34
- "prebuild": "node ../scripts/generateMcpContract.mjs --check",
34
+ "prebuild": "node ../scripts/generateMcpVersion.mjs && node ../scripts/generateMcpContract.mjs --check",
35
35
  "build": "tsc && node -e \"require('node:fs').chmodSync('dist/server.js', 0o755)\"",
36
36
  "test": "npm run build && node --test test/*.test.mjs",
37
37
  "smoke:tarball": "node test/tarball-smoke.mjs",