@okostream/mcp 0.3.0 → 0.4.1
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 +17 -8
- package/dist/guides.js +215 -0
- package/dist/server.js +5 -1
- package/dist/tools.js +416 -71
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -59,6 +59,12 @@ Every tool returns JSON text plus MCP `structuredContent`. List-like tools use b
|
|
|
59
59
|
- `oko_get_session_report` returns a compact structured session report plus markdown suitable for a human handoff.
|
|
60
60
|
- Oversized responses are truncated with `truncated: true` and a `truncationMessage`.
|
|
61
61
|
|
|
62
|
+
The server also exposes MCP-native onboarding:
|
|
63
|
+
|
|
64
|
+
- `instructions` on initialize with the supported webinar creation flow and product invariants.
|
|
65
|
+
- Resources: `oko://guide/create-webinar`, `oko://guide/platform`, `oko://guide/security`, `oko://schema/event`, `oko://capabilities`.
|
|
66
|
+
- Prompts: `create_webinar_from_brief`, `audit_event_setup`, `prepare_session_report`.
|
|
67
|
+
|
|
62
68
|
This package is published as source-available runtime distribution for OKO customers. `license: UNLICENSED` means all rights reserved unless a separate agreement says otherwise.
|
|
63
69
|
|
|
64
70
|
## Local Development
|
|
@@ -93,6 +99,7 @@ All tools are prefixed with `oko_` for safe discovery next to other MCP servers.
|
|
|
93
99
|
Core discovery/read tools:
|
|
94
100
|
|
|
95
101
|
- `oko_whoami`, `oko_list_workspaces`, `oko_list_my_events`, `oko_get_event`
|
|
102
|
+
- `oko_validate_event_setup`
|
|
96
103
|
- `oko_update_profile`, `oko_list_notifications`, `oko_mark_notifications_read`
|
|
97
104
|
- `oko_get_live_state`, `oko_get_analytics`, `oko_get_session_report`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_delete_lead`
|
|
98
105
|
|
|
@@ -101,7 +108,7 @@ Event/setup/media tools:
|
|
|
101
108
|
- `oko_create_webinar`, `oko_update_event`, `oko_delete_event`
|
|
102
109
|
- `oko_duplicate_event`, `oko_move_event`
|
|
103
110
|
- `oko_configure_blocks`, `oko_configure_cards`
|
|
104
|
-
- `oko_upload_image`, `oko_delete_image`
|
|
111
|
+
- `oko_upload_image`, `oko_upload_card_images_batch`, `oko_wait_for_image_variants`, `oko_delete_image`
|
|
105
112
|
|
|
106
113
|
Live/session/chat/NMO tools:
|
|
107
114
|
|
|
@@ -126,13 +133,15 @@ Dangerous or external side-effect tools require `confirm: true`.
|
|
|
126
133
|
2. `oko_list_my_events` should return only events visible to that UID.
|
|
127
134
|
3. `oko_create_webinar` should create a test webinar and RTDB owner/workspace mirror.
|
|
128
135
|
4. `oko_configure_cards` should create/update `blocks.cards.items` and preserve card image fields by stable card id.
|
|
129
|
-
5. `
|
|
130
|
-
6. `
|
|
131
|
-
7. `
|
|
132
|
-
8. `
|
|
133
|
-
9. `
|
|
134
|
-
10. `
|
|
135
|
-
11. `
|
|
136
|
+
5. `oko_upload_card_images_batch` should upload multiple card images, then return ready `imageVariants`.
|
|
137
|
+
6. `oko_validate_event_setup` should catch missing images/forms/buttons/video/NMO/Telegram/variants on a deliberately broken event.
|
|
138
|
+
7. `oko_start_session` -> `oko_fix_orphan_session` -> `oko_delete_session` should leave no active session pointers on a disposable test event.
|
|
139
|
+
8. `oko_duplicate_event`, workspace tools, and `oko_move_event` should preserve Firestore access rules and RTDB owner/workspace mirrors.
|
|
140
|
+
9. `oko_import_nmo_participants` should call CF24 and return per-row results; clean up test participants after import.
|
|
141
|
+
10. `oko_get_session_report` should return markdown and structured session data for a completed session.
|
|
142
|
+
11. `oko_list_notifications` -> `oko_mark_notifications_read` should read and clear an own disposable notification.
|
|
143
|
+
12. `oko_get_analytics` should read an existing event with analytics sessions.
|
|
144
|
+
13. MCP protocol smoke should report 59 tools, 5 resources, 3 prompts, and non-empty initialize instructions.
|
|
136
145
|
|
|
137
146
|
CLI help:
|
|
138
147
|
|
package/dist/guides.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const OKO_MCP_INSTRUCTIONS = [
|
|
3
|
+
"You are controlling OKO Stream as an organizer through MCP tools.",
|
|
4
|
+
"Start every new workspace session with oko_whoami, then respect the returned scopes.",
|
|
5
|
+
"OKO events have no draft/live/ended status. Start/End controls manage analytics sessions only, never video streaming.",
|
|
6
|
+
"For webinar creation, prefer this flow: oko_create_webinar, oko_configure_blocks, oko_configure_cards, image uploads, oko_validate_event_setup, then return public/setup/analytics links.",
|
|
7
|
+
"For card images, create stable card ids first, then use oko_upload_card_images_batch or oko_upload_image plus oko_wait_for_image_variants.",
|
|
8
|
+
"Before destructive actions, show the exact target and require an explicit user confirmation; then pass confirm=true.",
|
|
9
|
+
"Use oko_validate_event_setup before telling the organizer that a webinar is ready.",
|
|
10
|
+
"Do not bypass OKO APIs or Firebase rules; this server is the supported organizer surface.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
const GUIDE_RESOURCES = [
|
|
13
|
+
{
|
|
14
|
+
name: "oko_mcp_create_webinar",
|
|
15
|
+
uri: "oko://guide/create-webinar",
|
|
16
|
+
title: "Create an OKO Webinar",
|
|
17
|
+
description: "Recommended call order for creating and validating a webinar.",
|
|
18
|
+
text: [
|
|
19
|
+
"# Create an OKO Webinar",
|
|
20
|
+
"",
|
|
21
|
+
"1. Call `oko_whoami` and inspect scopes.",
|
|
22
|
+
"2. If the workspace is unclear, call `oko_list_workspaces`.",
|
|
23
|
+
"3. Call `oko_create_webinar` with title/date/workspace and video URL when known.",
|
|
24
|
+
"4. Call `oko_configure_blocks` for video, forms, CTA, banner, Telegram, and NMO settings.",
|
|
25
|
+
"5. Call `oko_configure_cards` with stable card ids before uploading card images.",
|
|
26
|
+
"6. Upload images with `oko_upload_card_images_batch` or `oko_upload_image`, then wait for variants.",
|
|
27
|
+
"7. Call `oko_validate_event_setup` and fix blocking findings.",
|
|
28
|
+
"8. Return public, setup, analytics, registration, and login links that apply to the event.",
|
|
29
|
+
"",
|
|
30
|
+
"Important: OKO has no event status. Do not invent draft/live/ended lifecycle states.",
|
|
31
|
+
].join("\n"),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "oko_mcp_platform",
|
|
35
|
+
uri: "oko://guide/platform",
|
|
36
|
+
title: "OKO Platform Invariants",
|
|
37
|
+
description: "Core OKO product rules that MCP clients must respect.",
|
|
38
|
+
text: [
|
|
39
|
+
"# OKO Platform Invariants",
|
|
40
|
+
"",
|
|
41
|
+
"- OKO events have no `draft`, `live`, or `ended` status field.",
|
|
42
|
+
"- Public event pages render whenever the event exists and normal access rules allow it.",
|
|
43
|
+
"- Start and End controls manage analytics sessions only; they never start or stop the video stream.",
|
|
44
|
+
"- Setup writes Firestore event structure. Live analytics and controls use RTDB mirrors and session nodes.",
|
|
45
|
+
"- Chat lives in the separate chat RTDB. Moderator actions require chat scopes and normal event access.",
|
|
46
|
+
"- NMO participant import is handled by the scoped Cloud Function `importNmoParticipants`.",
|
|
47
|
+
"- Image variants are asynchronous. A master upload is not fully ready until `imageVariants` are present.",
|
|
48
|
+
].join("\n"),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "oko_mcp_security",
|
|
52
|
+
uri: "oko://guide/security",
|
|
53
|
+
title: "OKO MCP Security Model",
|
|
54
|
+
description: "API-key, scopes, confirmation, and Firebase rules boundaries.",
|
|
55
|
+
text: [
|
|
56
|
+
"# OKO MCP Security Model",
|
|
57
|
+
"",
|
|
58
|
+
"The MCP server acts as the organizer user represented by `OKO_API_KEY`. It is not a platform-admin backdoor.",
|
|
59
|
+
"",
|
|
60
|
+
"Security layers:",
|
|
61
|
+
"- API keys are stored hashed in Firestore and the full secret is shown only once in the OKO UI.",
|
|
62
|
+
"- `exchangeApiKey` mints short-lived Firebase custom tokens with legacy `scopes` and rules-readable `mcpScopes`.",
|
|
63
|
+
"- MCP tools check scopes before executing.",
|
|
64
|
+
"- Firestore, RTDB, chat RTDB, and Storage rules also check `mcpScopes` for direct Firebase SDK misuse.",
|
|
65
|
+
"- Destructive or external side-effect tools require explicit `confirm:true`.",
|
|
66
|
+
"- Wrong API-key exchanges are counted per keyId; after 10 failures the keyId is locked for 15 minutes.",
|
|
67
|
+
"",
|
|
68
|
+
"- `events:read`: list and read event documents.",
|
|
69
|
+
"- `events:write`: create, duplicate, move, and update event metadata.",
|
|
70
|
+
"- `events:delete`: delete event records.",
|
|
71
|
+
"- `setup:write`: configure blocks, forms, cards, and images.",
|
|
72
|
+
"- `analytics:read`: read live state, sessions, reports, chat history.",
|
|
73
|
+
"- `analytics:write` + `live:control`: start/end/fix/delete analytics sessions.",
|
|
74
|
+
"- `live:control`: live block visibility and viewer refresh.",
|
|
75
|
+
"- `chat:write`: send organizer chat messages.",
|
|
76
|
+
"- `chat:moderate`: delete, mute, ban, unmute, unban, and chat settings.",
|
|
77
|
+
"- `telegram:write`: claim/list channels and create/cancel/publish Telegram posts.",
|
|
78
|
+
"- `workspace:read` / `workspace:write`: workspace, members, invites, notifications.",
|
|
79
|
+
"- `nmo:write`: NMO participants and checkpoints.",
|
|
80
|
+
"- `leads:read`: read form submissions; lead deletion also requires `setup:write`.",
|
|
81
|
+
].join("\n"),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "oko_event_schema",
|
|
85
|
+
uri: "oko://schema/event",
|
|
86
|
+
title: "OKO Event Schema Notes",
|
|
87
|
+
description: "Compact schema notes for fields most often edited by MCP clients.",
|
|
88
|
+
text: [
|
|
89
|
+
"# OKO Event Schema Notes",
|
|
90
|
+
"",
|
|
91
|
+
"Common event fields:",
|
|
92
|
+
"- `title`: user-visible webinar title.",
|
|
93
|
+
"- `meta.eventDate`: scheduled date.",
|
|
94
|
+
"- `video.originalUrl` / `video.embedUrl`: raw and normalized video URLs.",
|
|
95
|
+
"- `forms[]`: lead forms with `id`, `type`, `title`, `fields[]`, consent copy, and submit/success text.",
|
|
96
|
+
"- `blocks.primaryCta`: CTA copy, `buttonUrl` or `formId`, and visibility.",
|
|
97
|
+
"- `blocks.promoBanner`: banner copy, link/form binding, image refs, variants, and visibility.",
|
|
98
|
+
"- `blocks.cards.items[]`: stable `id`, copy, optional `buttonUrl` or `formId`, image refs, and `imageVariants`.",
|
|
99
|
+
"- `telegram.channelIds`: claimed Telegram channels for notifications.",
|
|
100
|
+
"- `nmoMode`, `nmoCriteria`, `participantUids`: NMO mode and participant gates.",
|
|
101
|
+
"- `workspaceId`, `accessibleBy`, `editors`: access mirrors required by rules and dashboard queries.",
|
|
102
|
+
"",
|
|
103
|
+
"Do not remove unknown fields when updating nested config. Use dedicated MCP tools instead of whole-document rewrites.",
|
|
104
|
+
].join("\n"),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "oko_mcp_capabilities",
|
|
108
|
+
uri: "oko://capabilities",
|
|
109
|
+
title: "OKO MCP Capabilities",
|
|
110
|
+
description: "High-level workflows available through OKO MCP.",
|
|
111
|
+
text: [
|
|
112
|
+
"# OKO MCP Capabilities",
|
|
113
|
+
"",
|
|
114
|
+
"## Webinar setup",
|
|
115
|
+
"- Create, duplicate, move, update, validate, and delete webinars.",
|
|
116
|
+
"- Configure forms, CTA, banner, cards, video placeholder, Telegram, and NMO settings.",
|
|
117
|
+
"- Upload images from local paths, data URLs, or HTTPS URLs and wait for variants.",
|
|
118
|
+
"",
|
|
119
|
+
"## Live analytics",
|
|
120
|
+
"- Read live state, start/end analytics sessions, refresh viewers, and change block visibility.",
|
|
121
|
+
"- Read compact analytics, session reports, leads, chat history, and NMO participation data.",
|
|
122
|
+
"- Fix or delete orphan sessions only after explicit confirmation.",
|
|
123
|
+
"",
|
|
124
|
+
"## Collaboration and comms",
|
|
125
|
+
"- Manage workspaces, members, invites, notifications, and Telegram post lifecycle.",
|
|
126
|
+
"- Send organizer chat messages and moderate chat users when scoped.",
|
|
127
|
+
"- Import/remove NMO participants through scoped Cloud Functions.",
|
|
128
|
+
].join("\n"),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
function promptMessage(text) {
|
|
132
|
+
return {
|
|
133
|
+
messages: [{
|
|
134
|
+
role: "user",
|
|
135
|
+
content: { type: "text", text },
|
|
136
|
+
}],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function registerGuides(server) {
|
|
140
|
+
for (const resource of GUIDE_RESOURCES) {
|
|
141
|
+
server.registerResource(resource.name, resource.uri, {
|
|
142
|
+
title: resource.title,
|
|
143
|
+
description: resource.description,
|
|
144
|
+
mimeType: "text/markdown",
|
|
145
|
+
}, async (uri) => ({
|
|
146
|
+
contents: [{
|
|
147
|
+
uri: uri.toString(),
|
|
148
|
+
mimeType: "text/markdown",
|
|
149
|
+
text: resource.text,
|
|
150
|
+
}],
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
server.registerPrompt("create_webinar_from_brief", {
|
|
154
|
+
title: "Create Webinar From Brief",
|
|
155
|
+
description: "Plan and execute a complete OKO webinar setup from an organizer brief.",
|
|
156
|
+
argsSchema: {
|
|
157
|
+
brief: z.string().min(1).describe("Organizer brief with topic, date, audience, blocks, images, and integrations."),
|
|
158
|
+
workspaceId: z.string().optional().describe("Optional OKO workspace id."),
|
|
159
|
+
},
|
|
160
|
+
}, async ({ brief, workspaceId }) => promptMessage([
|
|
161
|
+
"Create an OKO webinar from this brief using MCP tools.",
|
|
162
|
+
"",
|
|
163
|
+
"Required flow:",
|
|
164
|
+
"1. Call oko_whoami and verify scopes.",
|
|
165
|
+
"2. Create the webinar in the requested workspace if provided.",
|
|
166
|
+
"3. Configure metadata, blocks, forms, Telegram, NMO, and cards as needed.",
|
|
167
|
+
"4. Upload images only after target slots/cards exist; wait for image variants.",
|
|
168
|
+
"5. Run oko_validate_event_setup and fix actionable errors.",
|
|
169
|
+
"6. Return public, setup, analytics, registration/login links as applicable.",
|
|
170
|
+
"",
|
|
171
|
+
workspaceId ? `Workspace id: ${workspaceId}` : "Workspace id: not specified; choose the personal workspace unless the user clarifies.",
|
|
172
|
+
"",
|
|
173
|
+
"Brief:",
|
|
174
|
+
brief,
|
|
175
|
+
].join("\n")));
|
|
176
|
+
server.registerPrompt("audit_event_setup", {
|
|
177
|
+
title: "Audit Event Setup",
|
|
178
|
+
description: "Audit an OKO event for readiness and produce a fix plan.",
|
|
179
|
+
argsSchema: {
|
|
180
|
+
eventId: z.string().min(1).describe("OKO event id to audit."),
|
|
181
|
+
focus: z.string().optional().describe("Optional focus area, for example NMO, Telegram, forms, cards, live."),
|
|
182
|
+
},
|
|
183
|
+
}, async ({ eventId, focus }) => promptMessage([
|
|
184
|
+
`Audit OKO event ${eventId}.`,
|
|
185
|
+
"",
|
|
186
|
+
"Required flow:",
|
|
187
|
+
"1. Call oko_whoami and verify scopes.",
|
|
188
|
+
"2. Call oko_get_event and oko_validate_event_setup.",
|
|
189
|
+
"3. If relevant, inspect live state, Telegram posts, participants, leads, and image variant readiness.",
|
|
190
|
+
"4. Separate errors that block launch from warnings and polish suggestions.",
|
|
191
|
+
"5. Propose exact MCP tool calls needed to fix issues; execute only if the organizer asked for implementation.",
|
|
192
|
+
"",
|
|
193
|
+
focus ? `Focus: ${focus}` : "Focus: full setup readiness.",
|
|
194
|
+
].join("\n")));
|
|
195
|
+
server.registerPrompt("prepare_session_report", {
|
|
196
|
+
title: "Prepare Session Report",
|
|
197
|
+
description: "Prepare an organizer-friendly report from OKO analytics session data.",
|
|
198
|
+
argsSchema: {
|
|
199
|
+
eventId: z.string().min(1).describe("OKO event id."),
|
|
200
|
+
sessionId: z.string().optional().describe("Optional analytics session id; omit to choose from sessions list."),
|
|
201
|
+
audience: z.string().optional().describe("Optional report audience, for example internal, client, NMO."),
|
|
202
|
+
},
|
|
203
|
+
}, async ({ eventId, sessionId, audience }) => promptMessage([
|
|
204
|
+
`Prepare a session report for OKO event ${eventId}.`,
|
|
205
|
+
"",
|
|
206
|
+
"Required flow:",
|
|
207
|
+
"1. Call oko_whoami and verify analytics:read.",
|
|
208
|
+
sessionId ? `2. Use session id ${sessionId}.` : "2. Call oko_get_analytics and choose the relevant ended session id from returned sessions.",
|
|
209
|
+
"3. Call oko_get_session_report.",
|
|
210
|
+
"4. Summarize attendance, engagement, chat, CTA/cards, geo, and NMO if present.",
|
|
211
|
+
"5. Call out data caveats separately from conclusions.",
|
|
212
|
+
"",
|
|
213
|
+
audience ? `Audience: ${audience}` : "Audience: organizer/internal.",
|
|
214
|
+
].join("\n")));
|
|
215
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { OKO_MCP_INSTRUCTIONS, registerGuides } from "./guides.js";
|
|
4
5
|
import { registerTools } from "./tools.js";
|
|
5
6
|
if (process.argv.includes("--help")) {
|
|
6
7
|
process.stdout.write([
|
|
@@ -22,8 +23,11 @@ if (process.argv.includes("--help")) {
|
|
|
22
23
|
}
|
|
23
24
|
const server = new McpServer({
|
|
24
25
|
name: "oko-mcp-server",
|
|
25
|
-
version: "0.
|
|
26
|
+
version: "0.4.1",
|
|
27
|
+
}, {
|
|
28
|
+
instructions: OKO_MCP_INSTRUCTIONS,
|
|
26
29
|
});
|
|
30
|
+
registerGuides(server);
|
|
27
31
|
registerTools(server);
|
|
28
32
|
const transport = new StdioServerTransport();
|
|
29
33
|
await server.connect(transport);
|
package/dist/tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import sharp from "sharp";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { addDoc, arrayRemove, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
9
|
+
import { addDoc, arrayRemove, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, runTransaction as runFirestoreTransaction, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
10
10
|
import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
|
|
11
11
|
import { httpsCallable } from "firebase/functions";
|
|
12
12
|
import { deleteObject, getDownloadURL, ref as storageRef, uploadBytes, } from "firebase/storage";
|
|
@@ -144,6 +144,35 @@ const deleteImageOutputSchema = z.object({
|
|
|
144
144
|
storageDeletesAttempted: z.number().int().nonnegative(),
|
|
145
145
|
storageDeleteWarnings: z.array(z.string()),
|
|
146
146
|
}).passthrough();
|
|
147
|
+
const imageVariantsOutputSchema = z.object({
|
|
148
|
+
eventId: z.string(),
|
|
149
|
+
kind: z.enum(IMAGE_KINDS),
|
|
150
|
+
cardId: z.string().nullable(),
|
|
151
|
+
imageId: z.string().nullable(),
|
|
152
|
+
imageUrl: z.string().url().nullable(),
|
|
153
|
+
imageMasterUrl: z.string().url().nullable(),
|
|
154
|
+
imageVariants: unknownRecordSchema.nullable(),
|
|
155
|
+
ready: z.boolean(),
|
|
156
|
+
reason: z.string().nullable(),
|
|
157
|
+
elapsedMs: z.number().int().nonnegative(),
|
|
158
|
+
}).passthrough();
|
|
159
|
+
const cardImagesBatchOutputSchema = z.object({
|
|
160
|
+
eventId: z.string(),
|
|
161
|
+
uploads: z.array(unknownRecordSchema),
|
|
162
|
+
variants: z.array(unknownRecordSchema),
|
|
163
|
+
ready: z.boolean(),
|
|
164
|
+
warnings: z.array(z.string()),
|
|
165
|
+
}).passthrough();
|
|
166
|
+
const setupValidationOutputSchema = z.object({
|
|
167
|
+
eventId: z.string(),
|
|
168
|
+
ok: z.boolean(),
|
|
169
|
+
score: z.number().int().min(0).max(100),
|
|
170
|
+
errors: z.array(unknownRecordSchema),
|
|
171
|
+
warnings: z.array(unknownRecordSchema),
|
|
172
|
+
suggestions: z.array(unknownRecordSchema),
|
|
173
|
+
nextActions: z.array(z.string()),
|
|
174
|
+
links: unknownRecordSchema,
|
|
175
|
+
}).passthrough();
|
|
147
176
|
const workspaceAccessOutputSchema = z.object({
|
|
148
177
|
workspaceId: z.string(),
|
|
149
178
|
workspace: unknownRecordSchema,
|
|
@@ -511,6 +540,7 @@ function readImageRef(eventData, kind, cardId) {
|
|
|
511
540
|
imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
512
541
|
imageUrl: safeString(banner.imageUrl) || null,
|
|
513
542
|
imageMasterUrl: safeString(banner.imageMasterUrl) || null,
|
|
543
|
+
imageVariants: readImageVariants(banner.imageVariants),
|
|
514
544
|
};
|
|
515
545
|
}
|
|
516
546
|
if (kind === "videoPlaceholder") {
|
|
@@ -520,6 +550,7 @@ function readImageRef(eventData, kind, cardId) {
|
|
|
520
550
|
imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
521
551
|
imageUrl: safeString(placeholder.imageUrl) || null,
|
|
522
552
|
imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
|
|
553
|
+
imageVariants: readImageVariants(placeholder.imageVariants),
|
|
523
554
|
};
|
|
524
555
|
}
|
|
525
556
|
if (!cardId)
|
|
@@ -533,6 +564,7 @@ function readImageRef(eventData, kind, cardId) {
|
|
|
533
564
|
imageId: cardId,
|
|
534
565
|
imageUrl: safeString(item.imageUrl) || null,
|
|
535
566
|
imageMasterUrl: safeString(item.imageMasterUrl) || null,
|
|
567
|
+
imageVariants: readImageVariants(item.imageVariants),
|
|
536
568
|
};
|
|
537
569
|
}
|
|
538
570
|
function cardItemsWithImage(eventData, cardId, patch) {
|
|
@@ -693,6 +725,141 @@ async function uploadMasterImage(path, image) {
|
|
|
693
725
|
});
|
|
694
726
|
return getDownloadURL(ref);
|
|
695
727
|
}
|
|
728
|
+
async function updateCardImageInTransaction(eventId, cardId, patch) {
|
|
729
|
+
const eventRef = doc(firestore, "events", eventId);
|
|
730
|
+
await runFirestoreTransaction(firestore, async (transaction) => {
|
|
731
|
+
const snap = await transaction.get(eventRef);
|
|
732
|
+
if (!snap.exists())
|
|
733
|
+
throw new Error("Event not found.");
|
|
734
|
+
const data = snap.data();
|
|
735
|
+
transaction.update(eventRef, {
|
|
736
|
+
"blocks.cards.items": cardItemsWithImage(data, cardId, patch),
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function imageVariantReason(ref) {
|
|
741
|
+
if (!ref.imageMasterUrl && !ref.imageUrl)
|
|
742
|
+
return "image_missing";
|
|
743
|
+
if (!ref.imageMasterUrl)
|
|
744
|
+
return "master_missing";
|
|
745
|
+
if (!ref.imageVariants)
|
|
746
|
+
return "variants_pending";
|
|
747
|
+
if (!ref.imageUrl)
|
|
748
|
+
return "image_url_pending";
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
function imageVariantStatus(eventId, eventData, kind, cardId, elapsedMs) {
|
|
752
|
+
const ref = readImageRef(eventData, kind, cardId ?? undefined);
|
|
753
|
+
const reason = imageVariantReason(ref);
|
|
754
|
+
return {
|
|
755
|
+
eventId,
|
|
756
|
+
kind,
|
|
757
|
+
cardId,
|
|
758
|
+
imageId: ref.imageId,
|
|
759
|
+
imageUrl: ref.imageUrl,
|
|
760
|
+
imageMasterUrl: ref.imageMasterUrl,
|
|
761
|
+
imageVariants: ref.imageVariants,
|
|
762
|
+
ready: reason === null,
|
|
763
|
+
reason,
|
|
764
|
+
elapsedMs,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function sleep(ms) {
|
|
768
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
769
|
+
}
|
|
770
|
+
async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
|
|
771
|
+
const startedAt = Date.now();
|
|
772
|
+
const deadline = startedAt + timeoutSec * 1000;
|
|
773
|
+
let latest = targets.map((target) => ({
|
|
774
|
+
eventId,
|
|
775
|
+
kind: target.kind,
|
|
776
|
+
cardId: target.cardId,
|
|
777
|
+
imageId: target.cardId,
|
|
778
|
+
imageUrl: null,
|
|
779
|
+
imageMasterUrl: null,
|
|
780
|
+
imageVariants: null,
|
|
781
|
+
ready: false,
|
|
782
|
+
reason: "not_checked",
|
|
783
|
+
elapsedMs: 0,
|
|
784
|
+
}));
|
|
785
|
+
while (true) {
|
|
786
|
+
const elapsedMs = Date.now() - startedAt;
|
|
787
|
+
const event = await readEvent(eventId, uid, "read");
|
|
788
|
+
latest = targets.map((target) => imageVariantStatus(eventId, event, target.kind, target.cardId, elapsedMs));
|
|
789
|
+
if (latest.every((item) => item.ready) || Date.now() >= deadline)
|
|
790
|
+
return latest;
|
|
791
|
+
await sleep(Math.min(2000, Math.max(250, deadline - Date.now())));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
795
|
+
if (kind === "card" && !cardId)
|
|
796
|
+
throw new Error("cardId is required when kind is card.");
|
|
797
|
+
if (kind !== "card" && cardId)
|
|
798
|
+
throw new Error("cardId is only valid when kind is card.");
|
|
799
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
800
|
+
const previous = readImageRef(event, kind, cardId);
|
|
801
|
+
const sourceBuffer = await readImageSource(source);
|
|
802
|
+
const masterBuffer = await compressToMasterWebp(sourceBuffer);
|
|
803
|
+
const imageId = kind === "card" ? cardId : randomUUID();
|
|
804
|
+
const masterPath = imageStoragePath(eventId, uid, kind, imageId);
|
|
805
|
+
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
806
|
+
const deleteWarnings = [];
|
|
807
|
+
let deletesAttempted = 0;
|
|
808
|
+
if (kind === "card" && previousMasterPath === masterPath) {
|
|
809
|
+
const variantPaths = variantPathsFromMasterPath(masterPath);
|
|
810
|
+
deletesAttempted += variantPaths.length;
|
|
811
|
+
deleteWarnings.push(...await deleteStoragePaths(variantPaths));
|
|
812
|
+
}
|
|
813
|
+
let masterUrl = null;
|
|
814
|
+
try {
|
|
815
|
+
masterUrl = await uploadMasterImage(masterPath, masterBuffer);
|
|
816
|
+
if (kind === "promoBanner") {
|
|
817
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
818
|
+
"blocks.promoBanner.imageMasterUrl": masterUrl,
|
|
819
|
+
"blocks.promoBanner.imageUrl": masterUrl,
|
|
820
|
+
"blocks.promoBanner.imageVariants": null,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
else if (kind === "videoPlaceholder") {
|
|
824
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
825
|
+
"blocks.video.placeholder.imageMasterUrl": masterUrl,
|
|
826
|
+
"blocks.video.placeholder.imageUrl": masterUrl,
|
|
827
|
+
"blocks.video.placeholder.imageVariants": null,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
await updateCardImageInTransaction(eventId, cardId, {
|
|
832
|
+
imageUrl: masterUrl,
|
|
833
|
+
imageMasterUrl: masterUrl,
|
|
834
|
+
imageVariants: null,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
if (masterUrl) {
|
|
840
|
+
await deleteStoragePaths([masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
|
|
841
|
+
}
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
844
|
+
if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
845
|
+
const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
|
|
846
|
+
deletesAttempted += cleanup.attempted;
|
|
847
|
+
deleteWarnings.push(...cleanup.warnings);
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
eventId,
|
|
851
|
+
kind,
|
|
852
|
+
imageId,
|
|
853
|
+
storagePath: masterPath,
|
|
854
|
+
masterUrl,
|
|
855
|
+
variantsPending: true,
|
|
856
|
+
sourceBytes: sourceBuffer.byteLength,
|
|
857
|
+
masterBytes: masterBuffer.byteLength,
|
|
858
|
+
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
859
|
+
storageDeletesAttempted: deletesAttempted,
|
|
860
|
+
storageDeleteWarnings: deleteWarnings,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
696
863
|
async function authed(scopes) {
|
|
697
864
|
return ensureAuth(scopes);
|
|
698
865
|
}
|
|
@@ -763,6 +930,145 @@ async function readEvent(eventId, uid, mode = "read") {
|
|
|
763
930
|
assertEventReadable(data, uid);
|
|
764
931
|
return data;
|
|
765
932
|
}
|
|
933
|
+
function asRecord(value) {
|
|
934
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
935
|
+
}
|
|
936
|
+
function validateEventSetup(eventId, event) {
|
|
937
|
+
const errors = [];
|
|
938
|
+
const warnings = [];
|
|
939
|
+
const suggestions = [];
|
|
940
|
+
const add = (target, code, path, message) => {
|
|
941
|
+
target.push({ code, path, message });
|
|
942
|
+
};
|
|
943
|
+
const blocks = asRecord(event.blocks);
|
|
944
|
+
const forms = Array.isArray(event.forms) ? event.forms.filter((item) => item && typeof item === "object") : [];
|
|
945
|
+
const formIds = eventFormIds(event);
|
|
946
|
+
const title = safeString(event.title).trim();
|
|
947
|
+
if (!title)
|
|
948
|
+
add(errors, "title_missing", "title", "Event title is required.");
|
|
949
|
+
const video = asRecord(event.video);
|
|
950
|
+
if (!safeString(video.embedUrl).trim()) {
|
|
951
|
+
add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured.");
|
|
952
|
+
}
|
|
953
|
+
const meta = asRecord(event.meta);
|
|
954
|
+
const eventDateMs = toMillis(meta.eventDate);
|
|
955
|
+
if (eventDateMs == null) {
|
|
956
|
+
add(suggestions, "date_missing", "meta.eventDate", "Add the webinar date/time if it is known.");
|
|
957
|
+
}
|
|
958
|
+
else if (eventDateMs < Date.now() - 60 * 60 * 1000) {
|
|
959
|
+
add(warnings, "date_in_past", "meta.eventDate", "Configured webinar date appears to be in the past.");
|
|
960
|
+
}
|
|
961
|
+
const duplicateFormIds = new Set();
|
|
962
|
+
const seenFormIds = new Set();
|
|
963
|
+
forms.forEach((form, index) => {
|
|
964
|
+
const id = safeString(form.id).trim();
|
|
965
|
+
if (!id)
|
|
966
|
+
add(errors, "form_id_missing", `forms[${index}].id`, "Every form must have a stable id.");
|
|
967
|
+
else if (seenFormIds.has(id))
|
|
968
|
+
duplicateFormIds.add(id);
|
|
969
|
+
else
|
|
970
|
+
seenFormIds.add(id);
|
|
971
|
+
const fields = Array.isArray(form.fields) ? form.fields : [];
|
|
972
|
+
if (fields.length === 0)
|
|
973
|
+
add(warnings, "form_fields_missing", `forms[${index}].fields`, "Lead form has no fields.");
|
|
974
|
+
});
|
|
975
|
+
for (const id of duplicateFormIds)
|
|
976
|
+
add(errors, "form_id_duplicate", "forms", `Duplicate form id: ${id}.`);
|
|
977
|
+
const checkFormBinding = (path, formId) => {
|
|
978
|
+
const id = safeString(formId).trim();
|
|
979
|
+
if (id && !formIds.has(id))
|
|
980
|
+
add(errors, "form_binding_missing", path, `Referenced formId "${id}" does not exist in event.forms.`);
|
|
981
|
+
};
|
|
982
|
+
const promo = asRecord(blocks.promoBanner);
|
|
983
|
+
if (promo.enabled === true) {
|
|
984
|
+
checkFormBinding("blocks.promoBanner.formId", promo.formId);
|
|
985
|
+
if (!safeString(promo.link).trim() && !safeString(promo.formId).trim()) {
|
|
986
|
+
add(suggestions, "promo_target_missing", "blocks.promoBanner", "Enabled promo banner has no link or form binding.");
|
|
987
|
+
}
|
|
988
|
+
if (!safeString(promo.imageUrl).trim()) {
|
|
989
|
+
add(warnings, "promo_image_missing", "blocks.promoBanner.imageUrl", "Enabled promo banner has no image.");
|
|
990
|
+
}
|
|
991
|
+
else if (safeString(promo.imageMasterUrl).trim() && !readImageVariants(promo.imageVariants)) {
|
|
992
|
+
add(warnings, "promo_variants_pending", "blocks.promoBanner.imageVariants", "Promo banner image variants are not ready yet.");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const cta = asRecord(blocks.primaryCta);
|
|
996
|
+
if (cta.enabled === true) {
|
|
997
|
+
checkFormBinding("blocks.primaryCta.formId", cta.formId);
|
|
998
|
+
if (!safeString(cta.title).trim() && !safeString(cta.text).trim()) {
|
|
999
|
+
add(suggestions, "cta_copy_missing", "blocks.primaryCta", "Enabled CTA has no title or text.");
|
|
1000
|
+
}
|
|
1001
|
+
if (!safeString(cta.buttonLabel).trim()) {
|
|
1002
|
+
add(warnings, "cta_button_missing", "blocks.primaryCta.buttonLabel", "Enabled CTA has no button label.");
|
|
1003
|
+
}
|
|
1004
|
+
if (safeString(cta.buttonLabel).trim() && !safeString(cta.buttonUrl).trim() && !safeString(cta.formId).trim()) {
|
|
1005
|
+
add(warnings, "cta_target_missing", "blocks.primaryCta", "CTA button has no URL or form binding.");
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const videoPlaceholder = asRecord(asRecord(blocks.video).placeholder);
|
|
1009
|
+
if (videoPlaceholder.enabled === true && videoPlaceholder.mode === "image") {
|
|
1010
|
+
if (!safeString(videoPlaceholder.imageUrl).trim()) {
|
|
1011
|
+
add(warnings, "placeholder_image_missing", "blocks.video.placeholder.imageUrl", "Image placeholder mode is enabled but no image is configured.");
|
|
1012
|
+
}
|
|
1013
|
+
else if (safeString(videoPlaceholder.imageMasterUrl).trim() && !readImageVariants(videoPlaceholder.imageVariants)) {
|
|
1014
|
+
add(warnings, "placeholder_variants_pending", "blocks.video.placeholder.imageVariants", "Video placeholder image variants are not ready yet.");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const cards = readCardsConfig(event);
|
|
1018
|
+
if (cards.enabled) {
|
|
1019
|
+
if (cards.items.length === 0)
|
|
1020
|
+
add(warnings, "cards_empty", "blocks.cards.items", "Cards block is enabled but has no cards.");
|
|
1021
|
+
const cardIds = new Set();
|
|
1022
|
+
cards.items.forEach((card, index) => {
|
|
1023
|
+
const path = `blocks.cards.items[${index}]`;
|
|
1024
|
+
if (cardIds.has(card.id))
|
|
1025
|
+
add(errors, "card_id_duplicate", `${path}.id`, `Duplicate card id: ${card.id}.`);
|
|
1026
|
+
cardIds.add(card.id);
|
|
1027
|
+
if (!card.title && !card.text)
|
|
1028
|
+
add(suggestions, "card_copy_missing", path, "Card has no title or text.");
|
|
1029
|
+
if (card.buttonLabel && !card.buttonUrl && !card.formId)
|
|
1030
|
+
add(warnings, "card_target_missing", path, "Card button has no URL or form binding.");
|
|
1031
|
+
if (card.formId && !formIds.has(card.formId))
|
|
1032
|
+
add(errors, "card_form_missing", `${path}.formId`, `Referenced formId "${card.formId}" does not exist.`);
|
|
1033
|
+
if (card.imageMasterUrl && !card.imageVariants)
|
|
1034
|
+
add(warnings, "card_variants_pending", `${path}.imageVariants`, "Card image variants are not ready yet.");
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
const telegram = asRecord(event.telegram);
|
|
1038
|
+
const telegramChannels = Array.isArray(telegram.channelIds) ? telegram.channelIds : [];
|
|
1039
|
+
if (telegram.enabled === true && telegramChannels.length === 0 && typeof telegram.channelId !== "number") {
|
|
1040
|
+
add(warnings, "telegram_channel_missing", "telegram.channelIds", "Telegram is enabled but no channel is configured.");
|
|
1041
|
+
}
|
|
1042
|
+
if (event.nmoMode === true) {
|
|
1043
|
+
const criteria = asRecord(event.nmoCriteria);
|
|
1044
|
+
const watch = typeof criteria.minWatchTimeRatio === "number" ? criteria.minWatchTimeRatio : 0.8;
|
|
1045
|
+
const checkpoints = typeof criteria.minCheckpointRatio === "number" ? criteria.minCheckpointRatio : 0.8;
|
|
1046
|
+
if (watch < 0 || watch > 1 || checkpoints < 0 || checkpoints > 1) {
|
|
1047
|
+
add(errors, "nmo_criteria_invalid", "nmoCriteria", "NMO criteria ratios must be between 0 and 1.");
|
|
1048
|
+
}
|
|
1049
|
+
const participantUids = Array.isArray(event.participantUids) ? event.participantUids : [];
|
|
1050
|
+
if (participantUids.length === 0) {
|
|
1051
|
+
add(warnings, "nmo_participants_missing", "participantUids", "NMO mode is enabled but no participants are registered yet.");
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const nextActions = [
|
|
1055
|
+
...errors.map((item) => `Fix error: ${item.message}`),
|
|
1056
|
+
...warnings.slice(0, 5).map((item) => `Review warning: ${item.message}`),
|
|
1057
|
+
];
|
|
1058
|
+
if (nextActions.length === 0)
|
|
1059
|
+
nextActions.push("No blocking setup issues found.");
|
|
1060
|
+
const penalty = errors.length * 25 + warnings.length * 8 + suggestions.length * 2;
|
|
1061
|
+
return {
|
|
1062
|
+
eventId,
|
|
1063
|
+
ok: errors.length === 0,
|
|
1064
|
+
score: Math.max(0, Math.min(100, 100 - penalty)),
|
|
1065
|
+
errors,
|
|
1066
|
+
warnings,
|
|
1067
|
+
suggestions,
|
|
1068
|
+
nextActions,
|
|
1069
|
+
links: eventLinks(eventId, event),
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
766
1072
|
function canEditEventWithPermissions(perms) {
|
|
767
1073
|
return perms.editSetup === true || perms.controlLive === true || perms.startSession === true;
|
|
768
1074
|
}
|
|
@@ -1618,6 +1924,26 @@ export function registerTools(server) {
|
|
|
1618
1924
|
const data = await readEvent(eventId, uid, "read");
|
|
1619
1925
|
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
|
|
1620
1926
|
});
|
|
1927
|
+
server.registerTool("oko_validate_event_setup", {
|
|
1928
|
+
title: "Validate event setup",
|
|
1929
|
+
description: toolDescription({
|
|
1930
|
+
summary: "Run a read-only setup readiness lint for one OKO event.",
|
|
1931
|
+
args: ["eventId: OKO event document id."],
|
|
1932
|
+
returns: "ok flag, score, errors, warnings, suggestions, next actions, and useful links.",
|
|
1933
|
+
examples: [
|
|
1934
|
+
"Use after creating/configuring a webinar and before telling the organizer it is ready.",
|
|
1935
|
+
"Use before live-session prep to catch missing video, pending image variants, invalid form bindings, or NMO participant gaps.",
|
|
1936
|
+
],
|
|
1937
|
+
notes: ["This tool does not modify the event and does not start/end analytics sessions."],
|
|
1938
|
+
}),
|
|
1939
|
+
inputSchema: { eventId: eventIdField },
|
|
1940
|
+
outputSchema: setupValidationOutputSchema,
|
|
1941
|
+
annotations: readOnlyTool,
|
|
1942
|
+
}, async ({ eventId }) => {
|
|
1943
|
+
const { uid } = await authed(["events:read"]);
|
|
1944
|
+
const data = await readEvent(eventId, uid, "read");
|
|
1945
|
+
return textResult(validateEventSetup(eventId, data));
|
|
1946
|
+
});
|
|
1621
1947
|
server.registerTool("oko_create_webinar", {
|
|
1622
1948
|
title: "Create webinar",
|
|
1623
1949
|
description: toolDescription({
|
|
@@ -1944,72 +2270,89 @@ export function registerTools(server) {
|
|
|
1944
2270
|
annotations: writeTool,
|
|
1945
2271
|
}, async ({ eventId, kind, cardId, source }) => {
|
|
1946
2272
|
const { uid } = await authed(["setup:write"]);
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
imageVariants: null,
|
|
1984
|
-
});
|
|
1985
|
-
}
|
|
1986
|
-
await updateDoc(doc(firestore, "events", eventId), patch);
|
|
1987
|
-
}
|
|
1988
|
-
catch (err) {
|
|
1989
|
-
if (masterUrl) {
|
|
1990
|
-
await deleteStoragePaths([masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
|
|
1991
|
-
}
|
|
1992
|
-
throw err;
|
|
1993
|
-
}
|
|
1994
|
-
if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
1995
|
-
const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
|
|
1996
|
-
deletesAttempted += cleanup.attempted;
|
|
1997
|
-
deleteWarnings.push(...cleanup.warnings);
|
|
2273
|
+
return textResult(await uploadEventImageForUser(uid, eventId, kind, cardId, source));
|
|
2274
|
+
});
|
|
2275
|
+
server.registerTool("oko_upload_card_images_batch", {
|
|
2276
|
+
title: "Upload card images batch",
|
|
2277
|
+
description: toolDescription({
|
|
2278
|
+
summary: "Upload images for multiple existing cards, preserving card metadata with Firestore transactions and waiting for generated variants.",
|
|
2279
|
+
args: [
|
|
2280
|
+
"eventId: event to edit.",
|
|
2281
|
+
"images: 1-6 card image uploads. Each cardId must already exist in blocks.cards.items.",
|
|
2282
|
+
"timeoutSec: max wait for all CF-generated image variants, default 60 seconds.",
|
|
2283
|
+
],
|
|
2284
|
+
returns: "eventId, per-card upload details, per-card variant readiness, ready flag, and warnings.",
|
|
2285
|
+
examples: [
|
|
2286
|
+
"Use after oko_configure_cards has returned stable card ids.",
|
|
2287
|
+
"Use for webinar cards where an agent generated several local images and needs to attach all of them.",
|
|
2288
|
+
],
|
|
2289
|
+
notes: [
|
|
2290
|
+
"This is a workflow tool for cards only. Use oko_upload_image for promoBanner or videoPlaceholder.",
|
|
2291
|
+
"The tool does not create cards; it only attaches images to existing cards.",
|
|
2292
|
+
],
|
|
2293
|
+
}),
|
|
2294
|
+
inputSchema: {
|
|
2295
|
+
eventId: eventIdField,
|
|
2296
|
+
images: z.array(z.object({
|
|
2297
|
+
cardId: cardIdSchema.describe("Existing card id."),
|
|
2298
|
+
source: imageSourceSchema.describe("Image source for this card."),
|
|
2299
|
+
}).strict()).min(1).max(MAX_CARD_ITEMS).describe("Card images to upload."),
|
|
2300
|
+
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait for all generated variants."),
|
|
2301
|
+
},
|
|
2302
|
+
outputSchema: cardImagesBatchOutputSchema,
|
|
2303
|
+
annotations: writeTool,
|
|
2304
|
+
}, async ({ eventId, images, timeoutSec }) => {
|
|
2305
|
+
const { uid } = await authed(["setup:write"]);
|
|
2306
|
+
const uploads = [];
|
|
2307
|
+
for (const image of images) {
|
|
2308
|
+
uploads.push(await uploadEventImageForUser(uid, eventId, "card", image.cardId, image.source));
|
|
1998
2309
|
}
|
|
2310
|
+
const variants = await waitForImageVariantTargets(uid, eventId, images.map((image) => ({ kind: "card", cardId: image.cardId })), timeoutSec);
|
|
2311
|
+
const warnings = variants
|
|
2312
|
+
.filter((item) => !item.ready)
|
|
2313
|
+
.map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`);
|
|
1999
2314
|
return textResult({
|
|
2000
2315
|
eventId,
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
variantsPending: true,
|
|
2006
|
-
sourceBytes: sourceBuffer.byteLength,
|
|
2007
|
-
masterBytes: masterBuffer.byteLength,
|
|
2008
|
-
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
2009
|
-
storageDeletesAttempted: deletesAttempted,
|
|
2010
|
-
storageDeleteWarnings: deleteWarnings,
|
|
2316
|
+
uploads,
|
|
2317
|
+
variants,
|
|
2318
|
+
ready: variants.every((item) => item.ready),
|
|
2319
|
+
warnings,
|
|
2011
2320
|
});
|
|
2012
2321
|
});
|
|
2322
|
+
server.registerTool("oko_wait_for_image_variants", {
|
|
2323
|
+
title: "Wait for image variants",
|
|
2324
|
+
description: toolDescription({
|
|
2325
|
+
summary: "Poll an event image slot until Cloud Function generated thumb/card/hero variants are visible in Firestore.",
|
|
2326
|
+
args: [
|
|
2327
|
+
"eventId: event to inspect.",
|
|
2328
|
+
"kind: promoBanner, videoPlaceholder, or card.",
|
|
2329
|
+
"cardId: required when kind=card.",
|
|
2330
|
+
"timeoutSec: max wait, default 60 seconds.",
|
|
2331
|
+
],
|
|
2332
|
+
returns: "image URLs, imageVariants, ready flag, pending reason, and elapsed time.",
|
|
2333
|
+
examples: ["Use after oko_upload_image to avoid checking the UI before image variants are ready."],
|
|
2334
|
+
notes: ["Read-only. It does not trigger variant generation; it only waits for CF4's Firestore update."],
|
|
2335
|
+
}),
|
|
2336
|
+
inputSchema: {
|
|
2337
|
+
eventId: eventIdField,
|
|
2338
|
+
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
2339
|
+
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
2340
|
+
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait."),
|
|
2341
|
+
},
|
|
2342
|
+
outputSchema: imageVariantsOutputSchema,
|
|
2343
|
+
annotations: readOnlyTool,
|
|
2344
|
+
}, async ({ eventId, kind, cardId, timeoutSec }) => {
|
|
2345
|
+
const { uid } = await authed(["events:read"]);
|
|
2346
|
+
if (kind === "card" && !cardId)
|
|
2347
|
+
throw new Error("cardId is required when kind is card.");
|
|
2348
|
+
if (kind !== "card" && cardId)
|
|
2349
|
+
throw new Error("cardId is only valid when kind is card.");
|
|
2350
|
+
const statuses = await waitForImageVariantTargets(uid, eventId, [{ kind, cardId: cardId ?? null }], timeoutSec);
|
|
2351
|
+
const status = statuses[0];
|
|
2352
|
+
if (!status)
|
|
2353
|
+
throw new Error("Image variant status was not produced.");
|
|
2354
|
+
return textResult(status);
|
|
2355
|
+
});
|
|
2013
2356
|
server.registerTool("oko_delete_image", {
|
|
2014
2357
|
title: "Delete event image",
|
|
2015
2358
|
description: toolDescription({
|
|
@@ -2042,25 +2385,27 @@ export function registerTools(server) {
|
|
|
2042
2385
|
const previous = readImageRef(event, kind, cardId);
|
|
2043
2386
|
requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
|
|
2044
2387
|
const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
|
|
2045
|
-
const patch = {};
|
|
2046
2388
|
if (kind === "promoBanner") {
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2389
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
2390
|
+
"blocks.promoBanner.imageMasterUrl": deleteField(),
|
|
2391
|
+
"blocks.promoBanner.imageUrl": deleteField(),
|
|
2392
|
+
"blocks.promoBanner.imageVariants": deleteField(),
|
|
2393
|
+
});
|
|
2050
2394
|
}
|
|
2051
2395
|
else if (kind === "videoPlaceholder") {
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2396
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
2397
|
+
"blocks.video.placeholder.imageMasterUrl": deleteField(),
|
|
2398
|
+
"blocks.video.placeholder.imageUrl": deleteField(),
|
|
2399
|
+
"blocks.video.placeholder.imageVariants": deleteField(),
|
|
2400
|
+
});
|
|
2055
2401
|
}
|
|
2056
2402
|
else {
|
|
2057
|
-
|
|
2403
|
+
await updateCardImageInTransaction(eventId, cardId, {
|
|
2058
2404
|
imageUrl: null,
|
|
2059
2405
|
imageMasterUrl: null,
|
|
2060
2406
|
imageVariants: null,
|
|
2061
2407
|
});
|
|
2062
2408
|
}
|
|
2063
|
-
await updateDoc(doc(firestore, "events", eventId), patch);
|
|
2064
2409
|
return textResult({
|
|
2065
2410
|
eventId,
|
|
2066
2411
|
kind,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okostream/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server for managing OKO Stream webinars as an organizer.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"oko-mcp": "dist/server.js"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
-
"build": "tsc",
|
|
24
|
+
"build": "tsc && node -e \"require('node:fs').chmodSync('dist/server.js', 0o755)\"",
|
|
25
25
|
"prepublishOnly": "npm run build",
|
|
26
26
|
"start": "node dist/server.js"
|
|
27
27
|
},
|