lotux-mcp 0.18.0 → 0.21.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.
Files changed (4) hide show
  1. package/index.mjs +3 -934
  2. package/package.json +3 -1
  3. package/tools.d.mts +8 -0
  4. package/tools.mjs +1104 -0
package/index.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
- import { z } from "zod";
13
+ import { registerTools, SERVER_INFO } from "./tools.mjs";
14
14
 
15
15
  const API = (process.env.LOTUX_API_URL || "").replace(/\/$/, "");
16
16
  const TOKEN = process.env.LOTUX_MCP_TOKEN || "";
@@ -48,939 +48,8 @@ async function rpc(method, params = {}) {
48
48
  return data.result;
49
49
  }
50
50
 
51
- const ok = (result) => ({
52
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
53
- });
54
-
55
- const server = new McpServer({ name: "lotux", version: "0.18.0" });
56
-
57
- // ── Reads ─────────────────────────────────────────────────────────────────────
58
-
59
- server.tool(
60
- "list_brands",
61
- "List the LOTUX brands (and the agency) you can access, with each brand's slug and client_status (active / archived / internal). Use the returned id as account_id for other tools.",
62
- {},
63
- async () => ok(await rpc("list_brands"))
64
- );
65
-
66
- // ── Client workspaces (agency team only) ──────────────────────────────────────
67
- // Where a client's things live for the Cowork editors: Dropbox client folder,
68
- // raw-footage folders, the agentic workspace folder (01_CLIENTS/<slug>) and the
69
- // second brain (Kennisbank).
70
-
71
- server.tool(
72
- "get_brand",
73
- "One client's workspace settings: slug, client_status, second brain (Kennisbank id + name, shared with the client or not), workspace_folder, dropbox_client_path, raw_footage_paths, Claude project name, the Claude project kit (instructions text) and the active work claims.",
74
- { account_id: z.string().describe("Brand id from list_brands") },
75
- async (args) => ok(await rpc("get_brand", args))
76
- );
77
-
78
- server.tool(
79
- "update_brand_settings",
80
- "Change a client's workspace settings. Omitted fields stay as they are. slug is refused once client setup has run (it names the workspace folder). dropbox_client_path follows the Dropbox connection and workspace_folder follows the slug, so neither is set here.",
81
- {
82
- account_id: z.string().describe("Brand id from list_brands"),
83
- client_status: z.enum(["active", "archived", "internal"]).optional(),
84
- slug: z.string().optional().describe("Lowercase letters, digits and hyphens"),
85
- kb_workspace_id: z.string().nullable().optional().describe("Kennisbank workspace id (list_kb_workspaces) to link as the second brain"),
86
- raw_footage_paths: z.array(z.string()).optional().describe("Dropbox paths, e.g. /CLIENTS/Active/Dr. Kegel/01_video/01_raw footage"),
87
- claude_project_name: z.string().optional(),
88
- },
89
- async (args) => ok(await rpc("update_brand_settings", args))
90
- );
91
-
92
- server.tool(
93
- "run_client_setup",
94
- "Run client setup for a brand: slug, second brain (an \"Agentic workspace\" page plus Client Feedback and Basic Rules folders in its \"Exposed to MCP\" section), the agentic workspace folder + CLIENT.md settings block, and (once the brand has connected Dropbox) the linked Dropbox client folder. It already runs by itself when a brand is created and when it connects Dropbox; run it again to fill gaps. Only creates what's missing; running it twice changes nothing. Skipped for clients that aren't active. dry_run: true returns the plan without changing anything. Returns a report per step (created / linked / updated / already there / failed + reason).",
95
- {
96
- account_id: z.string().describe("Brand id from list_brands"),
97
- dry_run: z.boolean().optional(),
98
- },
99
- async (args) => ok(await rpc("run_client_setup", args))
100
- );
101
-
102
- server.tool(
103
- "list_kb_folders",
104
- "The folders in a Kennisbank workspace's \"Exposed to MCP\" section, the only part of a second brain the MCP can see (id, name, parent_id, path; parent_id null = directly in the section). Use a folder id as folder_id in create_kb_doc.",
105
- { workspace_id: z.string().describe("Workspace id from list_kb_workspaces or get_brand") },
106
- async (args) => ok(await rpc("list_kb_folders", args))
107
- );
108
-
109
- server.tool(
110
- "create_kb_folder",
111
- "Create a folder in a Kennisbank workspace's \"Exposed to MCP\" section (at its top level, or inside parent_id). Returns the folder id.",
112
- {
113
- workspace_id: z.string(),
114
- name: z.string(),
115
- parent_id: z.string().optional().describe("Parent folder id from list_kb_folders"),
116
- },
117
- async (args) => ok(await rpc("create_kb_folder", args))
118
- );
119
-
120
- // ── Work claims ───────────────────────────────────────────────────────────────
121
- // Several editors work on one client at once, never on the same piece. Claim
122
- // before you write files; the database decides who's first (Dropbox is too slow
123
- // for lock files). Claiming again while you hold it renews it.
124
-
125
- server.tool(
126
- "claim_work",
127
- "Claim a piece of work before writing files for it. kind: concept (ref = concept id; prep / edit / revise / re-render one concept; 6 h), inventory (the brand's inventory run; 6 h), brand_shared (files several concepts of the brand use: logos, shared media, shared code; 15 min), house_rules (the shared house rules or style spec, agency-wide; 15 min). Returns {ok: true, claim_id, expires_at} or {ok: false, held_by, since, expires_at} when someone else holds it: then don't write, ask or wait. A concept claim warns when you're not its editor and sets you as editor when it has none.",
128
- {
129
- account_id: z.string().describe("Brand id from list_brands (house_rules: any brand of the agency, or the agency)"),
130
- kind: z.enum(["concept", "inventory", "brand_shared", "house_rules"]),
131
- ref: z.string().optional().describe("Concept id, for kind concept"),
132
- note: z.string().optional().describe("What you're doing, e.g. \"render v2 of the 9:16\""),
133
- },
134
- async (args) => ok(await rpc("claim_work", args))
135
- );
136
-
137
- server.tool(
138
- "renew_claim",
139
- "Extend a claim you hold (long renders, long inventory runs). Returns the new expires_at. Fails when it already expired or was released: claim again.",
140
- { claim_id: z.string() },
141
- async (args) => ok(await rpc("renew_claim", args))
142
- );
143
-
144
- server.tool(
145
- "release_work",
146
- "Release a claim as soon as you're done writing, so the next editor can start.",
147
- { claim_id: z.string() },
148
- async (args) => ok(await rpc("release_work", args))
149
- );
150
-
151
- server.tool(
152
- "list_claims",
153
- "Active claims: who is working on what right now, since when, until when. With a brand's account_id: that brand plus the agency-wide house_rules claim; empty: every brand.",
154
- { account_id: z.string().optional() },
155
- async (args) => ok(await rpc("list_claims", args))
156
- );
157
-
158
- // ── Deliverables ──────────────────────────────────────────────────────────────
159
- // Never guess a file name: LOTUX generates it (the brand's naming convention)
160
- // and moves the "Concept <code>" folder between stage folders. Versions stay
161
- // with you: <base_name>.mp4, then <base_name>_v2.mp4, _v3 …; move the previous
162
- // version into archive_folder. _var<N> is a variation (its own concept), not a version.
163
-
164
- server.tool(
165
- "get_deliverable",
166
- "The file name and folder for one deliverable: base_name (exactly the name LOTUX generates for that hook and language, without extension or version), extension (mp4 for video), folder (the concept's current stage folder in Dropbox, /CLIENTS/Active/<Brand>/01_video/02_deliverables/<stage>/Concept <code>) and archive_folder (folder + /Archive). Name the concept by concept_id, or account_id + code (\"8\" or \"c8\"); variation 2 = the concept's _Var2.",
167
- {
168
- concept_id: z.string().optional(),
169
- account_id: z.string().optional().describe("Brand id, with code"),
170
- code: z.string().optional().describe("Concept code, e.g. 8 or c8"),
171
- hook: z.string().describe("h1, h2, h3 … (h for a single hook, \"Hook 2\" works too), b1 for the body-only export"),
172
- language: z.string().optional().describe("English / en … (default: the concept's base language)"),
173
- variation: z.number().int().optional().describe("2 for the concept's _Var2"),
174
- kind: z.enum(["video", "static", "carousel"]).optional().describe("Default video"),
175
- },
176
- async (args) => ok(await rpc("get_deliverable", args))
177
- );
178
-
179
- server.tool(
180
- "list_deliverables",
181
- "Every deliverable name of a concept: base_name per hook and language, for the concept and for each of its variations, each with its folder and archive_folder.",
182
- {
183
- concept_id: z.string().optional(),
184
- account_id: z.string().optional().describe("Brand id, with code"),
185
- code: z.string().optional(),
186
- },
187
- async (args) => ok(await rpc("list_deliverables", args))
188
- );
189
-
190
- server.tool(
191
- "list_entities",
192
- "List a brand's products, personas, angles, use-cases and offers (the building blocks for a concept). Pass account_id from list_brands.",
193
- { account_id: z.string().describe("Brand (or agency) id from list_brands") },
194
- async ({ account_id }) => ok(await rpc("list_entities", { account_id }))
195
- );
196
-
197
- server.tool(
198
- "list_concepts",
199
- "List a brand's existing concepts (id, code like '109', title, stage). Use this to find the id of a concept the user names before editing it.",
200
- { account_id: z.string().describe("Brand id from list_brands") },
201
- async ({ account_id }) => ok(await rpc("list_concepts", { account_id }))
202
- );
203
-
204
- server.tool(
205
- "get_concept",
206
- "Read the full state of one concept by id: fields, linked products/personas/angles/use-cases, scripts (markdown per language + shotlist), and linked copy. Use to confirm what was written.",
207
- { concept_id: z.string() },
208
- async ({ concept_id }) => ok(await rpc("get_concept", { concept_id }))
209
- );
210
-
211
- server.tool(
212
- "list_options",
213
- "Valid values for a concept's fields: buckets, sub_buckets, funnel_stages, messaging_frameworks, variants, formats (per-brand), countries, languages. Call before set_concept_fields so you use real keys (e.g. funnel 'tof', not 'top of funnel').",
214
- { account_id: z.string().describe("Brand id from list_brands") },
215
- async ({ account_id }) => ok(await rpc("list_options", { account_id }))
216
- );
217
-
218
- server.tool(
219
- "list_copy",
220
- "List a brand's existing copy items (returns ids for link_copy). Optionally filter by type (adcopy/headline/description) or a text query.",
221
- {
222
- account_id: z.string().describe("Brand id from list_brands"),
223
- type: z.enum(["adcopy", "headline", "description"]).optional(),
224
- query: z.string().optional(),
225
- },
226
- async (args) => ok(await rpc("list_copy", args))
227
- );
228
-
229
- server.tool(
230
- "list_members",
231
- "List the agency team members (id + name) assignable to a concept's roles. Use the ids with set_concept_fields (strategist_id, copy_checker_id, editor_id, media_buyer_id, final_check_id).",
232
- { account_id: z.string().describe("Brand id from list_brands") },
233
- async ({ account_id }) => ok(await rpc("list_members", { account_id }))
234
- );
235
-
236
- // ── Concept ────────────────────────────────────────────────────────────────────
237
-
238
- server.tool(
239
- "create_concept",
240
- "Create a new concept in the brand's first (Idea) stage, optionally linking products/personas/angles/offers/use-cases (a concept needs ≥1 product, persona, angle and offer before it can enter Copy check). Returns { id, script_id } — the concept id plus the id of its pre-seeded script (starter template). Write the script with save_script(script_id, body); do NOT add_script for the first script. A human moves the concept through the copy-check gate later in the app.",
241
- {
242
- account_id: z.string().describe("Brand id from list_brands"),
243
- title: z.string(),
244
- bucket: z
245
- .string()
246
- .optional()
247
- .describe("Main bucket, e.g. video / static / carousel (the brand's bucket key). Defaults to video."),
248
- funnel_stage: z.string().optional(),
249
- format: z.string().optional(),
250
- product_ids: z.array(z.string()).optional(),
251
- persona_ids: z.array(z.string()).optional(),
252
- angle_ids: z.array(z.string()).optional(),
253
- use_case_ids: z.array(z.string()).optional(),
254
- offer_ids: z.array(z.string()).optional().describe("Offer ids from list_entities (offers)"),
255
- },
256
- async (args) => ok(await rpc("create_concept", args))
257
- );
258
-
259
- server.tool(
260
- "set_concept_fields",
261
- "Update fields on an existing concept — including renaming it via title. Any omitted field is left unchanged; the id arrays, when given, replace the full set of links. Assign team roles by passing a person id from list_members into strategist_id / copy_checker_id / editor_id / media_buyer_id / final_check_id.",
262
- {
263
- concept_id: z.string(),
264
- title: z.string().optional().describe("Rename the concept (blank is ignored — title is required)"),
265
- funnel_stage: z.string().optional(),
266
- format: z.string().optional(),
267
- messaging_framework: z.string().optional(),
268
- variant: z.string().optional(),
269
- country: z.string().optional(),
270
- promotion: z.string().optional(),
271
- brief: z.string().optional().describe("Concept brief (markdown)"),
272
- base_language: z.string().optional(),
273
- sub_buckets: z.array(z.string()).optional(),
274
- bucket: z.string().optional().describe("Main bucket option key (see list_options.fields)"),
275
- fields: z
276
- .record(z.union([z.string(), z.array(z.string()), z.null()]))
277
- .optional()
278
- .describe("Brand-defined concept fields by key → option key(s); see list_options.fields"),
279
- product_ids: z.array(z.string()).optional(),
280
- persona_ids: z.array(z.string()).optional(),
281
- angle_ids: z.array(z.string()).optional(),
282
- use_case_ids: z.array(z.string()).optional(),
283
- offer_ids: z.array(z.string()).optional(),
284
- landing_page_ids: z
285
- .array(z.string())
286
- .optional()
287
- .describe("Landing page ids from list_landing_pages; replaces the concept's set"),
288
- landing_page_id: z
289
- .string()
290
- .optional()
291
- .describe("Convenience for a single landing page — ignored if landing_page_ids is given"),
292
- // Team roles — pass a member id from list_members.
293
- strategist_id: z.string().optional().describe("Member id from list_members"),
294
- copy_checker_id: z.string().optional().describe("Member id from list_members"),
295
- editor_id: z.string().optional().describe("Member id from list_members"),
296
- media_buyer_id: z.string().optional().describe("Member id from list_members"),
297
- final_check_id: z.string().optional().describe("Member id from list_members"),
298
- },
299
- // A concept can run several landing pages (one per market), so the column is
300
- // a list. Fold the single-id convenience into it; an explicit list wins.
301
- async ({ concept_id, landing_page_id, ...patch }) => {
302
- if (landing_page_id && patch.landing_page_ids === undefined) {
303
- patch.landing_page_ids = [landing_page_id];
304
- }
305
- return ok(await rpc("set_concept_fields", { concept_id, patch }));
306
- }
307
- );
308
-
309
- // ── Scripts (document model) ────────────────────────────────────────────────
310
-
311
- const SCRIPT_BODY_DOC =
312
- "Markdown. Use labelled lines: `- **Hook:** …` (or `- **Hook 1:** …`, `- **Hook 2:** …` for several hooks), `- **1:** …`, `- **2:** …` for body lines, `- **CTA:** …` for the close. Hook ad-names are derived from the Hook lines. Free text (headings, paragraphs) is fine around them.";
313
-
314
- server.tool(
315
- "save_script",
316
- "Write a concept script: the markdown body for one language and/or the shotlist and/or a rename. Any omitted part is left unchanged. script_id comes from create_concept (script_id) or get_concept (scripts[].id). language defaults to the concept's base language; pass a translation language (full-written, e.g. Dutch) to write that language's version — a new language is added automatically. Returns the full script.",
317
- {
318
- script_id: z.string(),
319
- language: z.string().optional().describe("Full-written language name, e.g. English / Dutch. Defaults to the base language."),
320
- body: z.string().optional().describe(SCRIPT_BODY_DOC),
321
- shotlist: z.array(z.string()).optional().describe("Ordered list of shots / b-roll (one line each). Replaces the whole list."),
322
- name: z.string().optional().describe("Rename the script"),
323
- },
324
- async (args) => ok(await rpc("save_script", args))
325
- );
326
-
327
- server.tool(
328
- "add_script",
329
- "Add another script to a concept (every concept already has one — use save_script on it first). Returns the new script (starter template in the base language).",
330
- { concept_id: z.string(), name: z.string().optional() },
331
- async (args) => ok(await rpc("add_script", args))
332
- );
333
-
334
- server.tool(
335
- "delete_script",
336
- "Delete a script (all its language versions + shotlist). Irreversible.",
337
- { script_id: z.string() },
338
- async ({ script_id }) => ok(await rpc("delete_script", { script_id }))
339
- );
340
-
341
- server.tool(
342
- "remove_script_language",
343
- "Remove one translation language from a script. The base language cannot be removed.",
344
- { script_id: z.string(), language: z.string() },
345
- async (args) => ok(await rpc("remove_script_language", args))
346
- );
347
-
348
- // ── Beat compat (older prompts / scripts) ───────────────────────────────────
349
-
350
- server.tool(
351
- "set_beats",
352
- "Compatibility — prefer save_script. Sends an ordered beat list (Hook / Body / CTA lines + optional visual notes) and stores it as the concept's script document: lines become `- **Hook:**` / `- **1:**` / `- **CTA:**` markdown, visual notes become the shotlist. Replaces the whole script text for that language (default: the base language). Returns script_id + synthetic beat_ids ('<script_id>#<index>') for set_beat_translations.",
353
- {
354
- concept_id: z.string(),
355
- beats: z.array(
356
- z.object({
357
- beat_type: z.enum(["hook", "body", "cta"]).optional(),
358
- text: z.string().optional().describe("Spoken line"),
359
- visual_note: z.string().optional().describe("What to show — lands in the shotlist"),
360
- })
361
- ),
362
- language: z.string().optional().describe("Full-written language, defaults to the concept's base language"),
363
- },
364
- async (args) => ok(await rpc("set_beats", args))
365
- );
366
-
367
- server.tool(
368
- "set_beat_translations",
369
- "Compatibility — prefer save_script with a language. Writes one language's translated lines onto the concept's script: items are matched to the base script's lines by beat_id ('<script_id>#<index>' from set_beats) or by array order; a missing/empty text keeps the base line. Labels (Hook / 1 / CTA) are preserved.",
370
- {
371
- concept_id: z.string(),
372
- language: z.string().describe("Full-written language, e.g. Dutch"),
373
- items: z.array(
374
- z.object({
375
- beat_id: z.string().optional(),
376
- text: z.string().optional(),
377
- visual_note: z.string().optional().describe("Ignored — shotlists are language-independent"),
378
- })
379
- ),
380
- },
381
- async (args) => ok(await rpc("set_beat_translations", args))
382
- );
383
-
384
- server.tool(
385
- "delete_concept",
386
- "Delete an entire concept by id. Removes its scripts, entity links and copy links. Irreversible — confirm the id via list_concepts / get_concept first.",
387
- { concept_id: z.string() },
388
- async ({ concept_id }) => ok(await rpc("delete_concept", { concept_id }))
389
- );
390
-
391
- server.tool(
392
- "set_base_language",
393
- "Set the concept's base script language, e.g. English. Existing base-language script texts are re-labelled to the new language.",
394
- { concept_id: z.string(), language: z.string() },
395
- async (args) => ok(await rpc("set_base_language", args))
396
- );
397
-
398
- server.tool(
399
- "create_copy",
400
- "Create a brand-scoped ad copy item (adcopy / headline / description) and link it to a concept. Returns the new copy id. To add another language to an existing copy use add_copy_translation instead.",
401
- {
402
- account_id: z.string().describe("Brand id from list_brands"),
403
- concept_id: z.string(),
404
- type: z.enum(["adcopy", "headline", "description"]),
405
- text: z.string(),
406
- product_ids: z.array(z.string()).optional(),
407
- promotion: z.string().optional(),
408
- country: z.string().optional(),
409
- language: z.string().optional().describe("Full-written name, e.g. English"),
410
- funnel_stage: z.string().optional(),
411
- },
412
- async (args) => ok(await rpc("create_copy", args))
413
- );
414
-
415
- server.tool(
416
- "link_copy",
417
- "Link an existing copy item to a concept.",
418
- { concept_id: z.string(), copy_item_id: z.string() },
419
- async (args) => ok(await rpc("link_copy", args))
420
- );
421
-
422
- server.tool(
423
- "unlink_copy",
424
- "Unlink a copy item from a concept. The copy stays in the brand library; only the link is removed.",
425
- { concept_id: z.string(), copy_item_id: z.string() },
426
- async (args) => ok(await rpc("unlink_copy", args))
427
- );
428
-
429
- server.tool(
430
- "update_copy",
431
- "Edit an existing copy item in place (fix a typo, correct the language or the product). Only the fields you pass change. Status is deliberately not settable here — approving or rejecting stays in the app.",
432
- {
433
- copy_item_id: z.string().describe("From list_copy or get_concept"),
434
- text: z.string().optional(),
435
- type: z.enum(["adcopy", "headline", "description"]).optional(),
436
- product_ids: z.array(z.string()).optional(),
437
- promotion: z.string().optional(),
438
- country: z.string().optional(),
439
- language: z.string().optional().describe("Full-written name, e.g. English"),
440
- funnel_stage: z.string().optional(),
441
- },
442
- async (args) => ok(await rpc("update_copy", args))
443
- );
444
-
445
- server.tool(
446
- "delete_copy",
447
- "Delete a copy item from the brand library. It disappears from every concept it was linked to. To keep the copy and remove it from one concept, use unlink_copy instead.",
448
- { copy_item_id: z.string() },
449
- async (args) => ok(await rpc("delete_copy", args))
450
- );
451
-
452
- server.tool(
453
- "add_copy_translation",
454
- "Add a language variant to an existing copy ('primary text') so it reads as one copy with a language switcher in the app. Clones the base copy's products/promotion/country/funnel with a new language + text and groups them (translation_group_id). base_copy_id comes from list_copy or get_concept; language is a full-written name (e.g. Dutch). Optionally pass concept_id to also link the new variant to that concept. Returns the new copy id.",
455
- {
456
- base_copy_id: z.string().describe("The existing copy to add a translation to"),
457
- language: z.string().describe("Full-written name, e.g. Dutch"),
458
- text: z.string().describe("The translated copy text"),
459
- concept_id: z.string().optional().describe("Optional — also link the new variant to this concept"),
460
- },
461
- async (args) => ok(await rpc("add_copy_translation", args))
462
- );
463
-
464
- // ── Landing pages ────────────────────────────────────────────────────────
465
-
466
- server.tool(
467
- "list_landing_pages",
468
- "A brand's landing pages, plus the agency-wide pool. Use it to get an id for set_concept_fields (landing_page_id / landing_page_ids) or for an offer's landing_page_id.",
469
- { account_id: z.string().describe("Brand id from list_brands") },
470
- async (args) => ok(await rpc("list_landing_pages", args))
471
- );
472
-
473
- server.tool(
474
- "create_landing_page",
475
- "Create a landing page for a brand. `type` is required and feeds the ad-name token (pdp = product page, hp = homepage, cp = category page, li = listicle, adv = advertorial, lp = landing page). `id_code` is the short suffix ad names use (pdpa1). Tag `country` when the brand runs several markets — that is how a concept picks the right page.",
476
- {
477
- account_id: z.string().describe("Brand id from list_brands"),
478
- title: z.string().describe("The page's name in LOTUX"),
479
- url: z.string(),
480
- type: z.enum(["pdp", "hp", "cp", "li", "adv", "lp"]),
481
- id_code: z.string().optional(),
482
- country: z.string().optional().describe("2-letter code, e.g. NL"),
483
- language: z.string().optional().describe("Full-written name, e.g. Dutch"),
484
- },
485
- async (args) => ok(await rpc("create_landing_page", args))
486
- );
487
-
488
- // ── Iterate ──────────────────────────────────────────────────────────────
489
-
490
- server.tool(
491
- "iterate_concept",
492
- "Spawn a variation of a concept, the same as the app's Iterate button. The new concept is coded {root}_Var{n} (89 becomes 89_Var1), lands in Idea, and inherits the products / personas / angles / offers, team, brief, scripts, copy and references. Variations are flat: iterating 89_Var2 makes 89_Var3 off 89. Timeline, media buying and landing pages are reset, and base_language goes back to English even though the copied script texts keep their own languages. Returns { id, concept_code, script_id }; script_id is null when the source had no script.",
493
- { concept_id: z.string() },
494
- async (args) => ok(await rpc("iterate_concept", args))
495
- );
496
-
497
- // ── Brand-DB entities (products / personas / angles / use-cases / offers) ──────
498
-
499
- server.tool(
500
- "create_entity",
501
- "Create a NEW brand-DB entity — a product, persona, use_case, angle or offer that doesn't exist yet (to attach an EXISTING one to a concept, pass its id to set_concept_fields instead). name is required; description optional. `extras` carries the typed fields: products take average_price / margin_pct / cm_target (target contribution-margin %) / ltv_multiplier / be_ncroas / product_link / type / status; personas take awareness_stage / hypothesis; angles take belief_shift (core idea goes in description); offers take offer_type / value / starts_on / ends_on / landing_page_id / hypothesis / conclusion / status. Omit unknown fields. Personas/use_cases/angles/offers get an auto id_code (p1/u1/a1/o1), returned in the result. Link the new entity to a concept afterwards with set_concept_fields.",
502
- {
503
- account_id: z.string().describe("Brand id from list_brands"),
504
- kind: z.enum(["products", "personas", "angles", "use_cases", "offers"]),
505
- name: z.string(),
506
- description: z.string().optional(),
507
- extras: z.record(z.any()).optional().describe("Typed fields keyed by column name (see list_entities output shape)"),
508
- },
509
- async (args) => ok(await rpc("create_entity", args))
510
- );
511
-
512
- server.tool(
513
- "update_entity",
514
- "Update a brand-DB entity by id. Omitted fields are left unchanged; a blank name is ignored (name is required). Use extras to patch typed fields.",
515
- {
516
- entity_id: z.string(),
517
- kind: z.enum(["products", "personas", "angles", "use_cases", "offers"]),
518
- name: z.string().optional(),
519
- description: z.string().optional(),
520
- extras: z.record(z.any()).optional(),
521
- },
522
- async (args) => ok(await rpc("update_entity", args))
523
- );
524
-
525
- server.tool(
526
- "delete_entity",
527
- "Delete a brand-DB entity (product / persona / use_case / angle / offer) by id. Its links to concepts are removed with it. Irreversible — confirm the id via list_entities first.",
528
- {
529
- entity_id: z.string(),
530
- kind: z.enum(["products", "personas", "angles", "use_cases", "offers"]),
531
- },
532
- async (args) => ok(await rpc("delete_entity", args))
533
- );
534
-
535
- // ── Kennisbank (knowledge base) ─────────────────────────────────────────────────
536
-
537
- server.tool(
538
- "list_kb_workspaces",
539
- "List the Kennisbank (knowledge base) workspaces you can access — markdown docs the team and AI share. Returns id, name, visibility. Use a workspace id with list_kb_docs / create_kb_doc. Only each workspace's \"Exposed to MCP\" section is visible to the MCP; the rest is the team's own notes.",
540
- {},
541
- async () => ok(await rpc("list_kb_workspaces"))
542
- );
543
-
544
- server.tool(
545
- "list_kb_docs",
546
- "List the pages in a Kennisbank workspace's \"Exposed to MCP\" section (id, title, folder path inside the section; \"\" = its top level). Pages outside the section are not listed. Use a doc id with get_kb_doc.",
547
- { workspace_id: z.string().describe("Workspace id from list_kb_workspaces") },
548
- async ({ workspace_id }) => ok(await rpc("list_kb_docs", { workspace_id }))
549
- );
550
-
551
- server.tool(
552
- "get_kb_doc",
553
- "Read one Kennisbank page's full markdown content by id. Only pages in the \"Exposed to MCP\" section can be read.",
554
- { doc_id: z.string() },
555
- async ({ doc_id }) => ok(await rpc("get_kb_doc", { doc_id }))
556
- );
557
-
558
- server.tool(
559
- "create_kb_doc",
560
- "Create a new markdown page in a Kennisbank workspace's \"Exposed to MCP\" section. Returns the new doc id. content is markdown; folder_id is optional (top of the section if omitted) and must be a folder from list_kb_folders.",
561
- {
562
- workspace_id: z.string().describe("Workspace id from list_kb_workspaces"),
563
- title: z.string(),
564
- content: z.string().optional().describe("Markdown body"),
565
- folder_id: z.string().optional(),
566
- },
567
- async (args) => ok(await rpc("create_kb_doc", args))
568
- );
569
-
570
- server.tool(
571
- "update_kb_doc",
572
- "Update a Kennisbank page's title and/or markdown content (pages in the \"Exposed to MCP\" section only). Omitted fields are left unchanged.",
573
- {
574
- doc_id: z.string(),
575
- title: z.string().optional(),
576
- content: z.string().optional().describe("Markdown body (replaces the page content)"),
577
- },
578
- async (args) => ok(await rpc("update_kb_doc", args))
579
- );
580
-
581
- // ── Content flywheels (Marketing → Content, agency-only) ─────────────────────
582
- // A flywheel = one long-form YouTube video + every asset made from it. LOTUX
583
- // only TRACKS content: creating a flywheel creates no assets — your skills push
584
- // them (push_assets / add_asset). Anything you write lands in "to_review"; a
585
- // human approves in LOTUX. Built for re-runs: give flywheels and assets a stable
586
- // `key` (flywheel = the video's content folder, asset = e.g. "clip-3") and every
587
- // push updates instead of duplicating. account_id is optional everywhere
588
- // (defaults to your agency). Call content_options first for the allowed values.
589
-
590
- const PLATFORMS = ["youtube", "youtube_shorts", "instagram", "tiktok", "linkedin", "x", "email", "meta_ads", "opt_in_page", "google_doc", "internal"];
591
- // Statuses follow production. You may set the first five; revisions,
592
- // approved, to_launch, live, testing, scaling, turned_off and archived are a human's call.
593
- const STATUSES = ["scripting", "production", "ready_for_post", "post_production", "to_review", "revisions", "approved", "to_launch", "live", "testing", "scaling", "turned_off", "archived"];
594
- const CLAUDE_STATUSES = ["scripting", "production", "ready_for_post", "post_production", "to_review"];
595
- // video = "To long form".
596
- const CTAS = ["none", "video", "lead_magnet", "dm_keyword", "booking", "follow"];
597
- // long_form carries the YouTube pieces: title = YouTube title, body =
598
- // description + chapters, cover_url = thumbnail. clip / creator_video: cover_url
599
- // = reel cover.
600
- const ASSET_TYPES = ["long_form", "clip", "creator_video", "story_sequence", "carousel", "post", "email", "vsl_ad", "follower_ad", "lead_magnet_ad", "lead_magnet_page", "other"];
601
- const attachment = z.object({ url: z.string(), label: z.string().optional() });
602
- const flywheelRef = {
603
- flywheel_id: z.string().optional(),
604
- flywheel_key: z.string().optional().describe("The key you gave the flywheel (instead of flywheel_id)"),
605
- };
606
- const assetRef = {
607
- asset_id: z.string().optional(),
608
- code: z.union([z.string(), z.number()]).optional().describe('The content ID ("57") instead of asset_id'),
609
- key: z.string().optional().describe("The asset's key — with flywheel_id or flywheel_key — instead of asset_id"),
610
- ...flywheelRef,
611
- };
612
- // Every asset field. Omitted = left as is (on update) or the type's default (on create).
613
- const assetFields = {
614
- asset_type: z.enum(ASSET_TYPES).optional(),
615
- title: z.string().optional().describe("Card title. long_form: the YouTube title"),
616
- ad_format: z.enum(["video", "static"]).optional().describe('VSL + Follower ads: its Dropbox folder (Video/ or Static/). Left out = "static" when the title says static, else video'),
617
- platform: z.enum(PLATFORMS).optional().describe("Defaults from the type"),
618
- cross_post: z.array(z.enum(PLATFORMS)).optional().describe("Also posted on (defaults from the type)"),
619
- account: z.enum(["personal", "company"]).optional().describe("personal = Niels, company = the agency"),
620
- cta: z.enum(CTAS).optional(),
621
- dm_keyword: z.string().optional(),
622
- dm_reply: z.enum(["booking", "lead_magnet"]).optional().describe("For a dm_keyword CTA: what the DM reply sends"),
623
- destination_url: z.string().optional().describe("Override the flywheel URL this CTA would use"),
624
- posting_date: z.string().optional().describe("YYYY-MM-DD"),
625
- deadline: z.string().optional().describe("YYYY-MM-DD; defaults to posting date − the agency's lead days"),
626
- owner: z.string().optional().describe('"claude", "none" or a profile id (see content_options.owners)'),
627
- status: z.enum(CLAUDE_STATUSES).optional(),
628
- must_post: z.boolean().optional(),
629
- body: z.string().optional().describe("Markdown content. long_form: YouTube description + chapters"),
630
- attachments: z.array(attachment).optional().describe("Replaces all links (Figma, Drive, Dropbox…)"),
631
- cover_url: z.string().optional().describe("long_form: thumbnail · clip / creator_video: reel cover"),
632
- raw_footage_url: z.string().optional().describe("Folder with the raw footage this piece is cut from (Dropbox / Drive). Several assets may share one"),
633
- final_asset_url: z.string().optional().describe("The exported deliverable (file or folder link). Signed off with the asset"),
634
- published_url: z.string().optional(),
635
- notes: z.string().optional(),
636
- klaviyo_message_id: z.string().optional().describe("Email cards: the Klaviyo flow message id this card became (after the flow is built)"),
637
- };
638
-
639
- server.tool(
640
- "content_options",
641
- "Allowed values for content: asset types (with their default platform / cross-posts / CTA and what body + cover_url mean), platforms, CTAs, DM reply targets, accounts, statuses (and which you may set), owners (team members + claude), the default deadline lead and the rules. Call this first.",
642
- { account_id: z.string().optional() },
643
- async (args) => ok(await rpc("content_options", args))
644
- );
645
-
646
- server.tool(
647
- "create_flywheel",
648
- "Create a content flywheel for a long-form YouTube video. With a key that already exists you get that flywheel back (created: false) — safe to re-run. Creates no assets. Returns { id, created }.",
649
- {
650
- key: z.string().optional().describe("Your stable id for this video, e.g. the content folder '2026-10 Claude Code'"),
651
- title: z.string().describe("Video title"),
652
- d0: z.string().describe("Long-form publish date, YYYY-MM-DD"),
653
- topic: z.string().optional(),
654
- youtube_url: z.string().optional(),
655
- lead_magnet_url: z.string().optional().describe("Lead magnet page: email form that redirects to the video"),
656
- booking_url: z.string().optional(),
657
- transcript: z.string().optional().describe("Full transcript text"),
658
- notes: z.string().optional(),
659
- account_id: z.string().optional(),
660
- },
661
- async (args) => ok(await rpc("create_flywheel", args))
662
- );
663
-
664
- server.tool(
665
- "update_flywheel",
666
- "Update a flywheel: title, topic, status, URLs, transcript, notes. Changing d0 moves every asset that isn't live by the same number of days. Omitted fields are left unchanged.",
667
- {
668
- ...flywheelRef,
669
- title: z.string().optional(),
670
- topic: z.string().optional(),
671
- d0: z.string().optional().describe("YYYY-MM-DD — shifts the assets with it"),
672
- status: z.enum(["planning", "production", "live", "done"]).optional(),
673
- youtube_url: z.string().optional(),
674
- lead_magnet_url: z.string().optional(),
675
- booking_url: z.string().optional(),
676
- transcript: z.string().optional(),
677
- notes: z.string().optional(),
678
- new_key: z.string().optional().describe("Give the flywheel a (new) key"),
679
- klaviyo_flow_id: z.string().optional().describe("The Klaviyo flow built for this video (LM · <key>, 4 emails for sign-ups); its setup check ticks once the flow is live"),
680
- klaviyo_campaign_id: z.string().optional().describe("The Klaviyo announcement campaign (one \"new video\" email to the list); its setup check ticks once it is scheduled"),
681
- account_id: z.string().optional(),
682
- },
683
- async (args) => ok(await rpc("update_flywheel", args))
684
- );
685
-
686
- server.tool(
687
- "list_flywheels",
688
- "List content flywheels with progress (approved / total), next deadline and overdue count. Filter by status, a title/key search, or an exact key.",
689
- {
690
- status: z.enum(["planning", "production", "live", "done"]).optional(),
691
- query: z.string().optional().describe("Matches the title or key"),
692
- key: z.string().optional(),
693
- account_id: z.string().optional(),
694
- },
695
- async (args) => ok(await rpc("list_flywheels", args))
696
- );
697
-
698
- server.tool(
699
- "get_flywheel",
700
- "One flywheel and all its assets (each with `link` = where its CTA / DM reply points, and `warnings`). Bodies and the transcript only when asked.",
701
- {
702
- ...flywheelRef,
703
- include_bodies: z.boolean().optional(),
704
- include_transcript: z.boolean().optional(),
705
- account_id: z.string().optional(),
706
- },
707
- async (args) => ok(await rpc("get_flywheel", args))
708
- );
709
-
710
- server.tool(
711
- "push_assets",
712
- "Push many assets into a flywheel in one call (max 100). Each item is upserted on its `key`: new keys are created (asset_type + title required), existing keys are updated with only the fields you send. Content lands in to_review. Every piece gets a content ID; its name is \"<code> - <title>\" (use it for file names). Returns counts + per-item { key, id, code, name, created, status, link, warnings } or { key, error }.",
713
- {
714
- ...flywheelRef,
715
- assets: z.array(z.object({ key: z.string().optional(), ...assetFields })).max(100),
716
- account_id: z.string().optional(),
717
- },
718
- async (args) => ok(await rpc("push_assets", args))
719
- );
720
-
721
- server.tool(
722
- "add_asset",
723
- "Push one asset. With a key that exists on the flywheel it updates that asset instead. The type fills platform, cross-posts and CTA when you leave them out. With content it lands in to_review, owned by Claude; without, as an unassigned slot in scripting.",
724
- {
725
- ...flywheelRef,
726
- key: z.string().optional(),
727
- ...assetFields,
728
- account_id: z.string().optional(),
729
- },
730
- async (args) => ok(await rpc("add_asset", args))
731
- );
732
-
733
- server.tool(
734
- "get_asset",
735
- "One asset in full — body, attachments, cover/thumbnail, raw footage + final asset links, resolved link, warnings — plus its activity (status changes and comments, newest first). Use it to read what a human asked to change.",
736
- { ...assetRef, account_id: z.string().optional() },
737
- async (args) => ok(await rpc("get_asset", args))
738
- );
739
-
740
- server.tool(
741
- "list_assets",
742
- "Assets across flywheels with filters. Find work: status revisions (a human asked for changes), scripting + owner claude, posting_to next week, overdue.",
743
- {
744
- ...flywheelRef,
745
- key: z.string().optional(),
746
- code: z.union([z.string(), z.number()]).optional().describe('A content ID ("57")'),
747
- asset_type: z.array(z.enum(ASSET_TYPES)).optional(),
748
- status: z.array(z.enum(STATUSES)).optional(),
749
- platform: z.array(z.enum(PLATFORMS)).optional().describe("Matches the main platform or a cross-post"),
750
- account: z.enum(["personal", "company"]).optional(),
751
- owner: z.string().optional().describe('"claude", "none" (unassigned) or a profile id'),
752
- overdue: z.boolean().optional(),
753
- posting_from: z.string().optional().describe("YYYY-MM-DD"),
754
- posting_to: z.string().optional().describe("YYYY-MM-DD"),
755
- include_bodies: z.boolean().optional(),
756
- limit: z.number().int().min(1).max(500).optional(),
757
- account_id: z.string().optional(),
758
- },
759
- async (args) => ok(await rpc("list_assets", args))
760
- );
761
-
762
- server.tool(
763
- "update_asset",
764
- "Update an asset by asset_id (or flywheel + key). Changing body, attachments, cover_url or final_asset_url moves it to to_review (even if it was approved) so a human signs it off. Omitted fields are left unchanged.",
765
- {
766
- ...assetRef,
767
- ...assetFields,
768
- add_attachments: z.array(attachment).optional().describe("Appends links"),
769
- new_key: z.string().optional().describe("Give the asset a (new) key"),
770
- account_id: z.string().optional(),
771
- },
772
- async (args) => ok(await rpc("update_asset", args))
773
- );
774
-
775
- server.tool(
776
- "set_asset_status",
777
- "Move an asset through scripting, production, ready_for_post, post_production or to_review (to_review hands it to a human). Revisions, approved, to_launch, live, testing, scaling, turned_off and archived are a human's call in LOTUX.",
778
- {
779
- ...assetRef,
780
- status: z.enum(CLAUDE_STATUSES),
781
- comment: z.string().optional().describe("Shown in the asset's activity"),
782
- account_id: z.string().optional(),
783
- },
784
- async (args) => ok(await rpc("set_asset_status", args))
785
- );
786
-
787
- server.tool(
788
- "add_comment",
789
- "Leave a comment on an asset (shown in its activity in LOTUX).",
790
- { ...assetRef, comment: z.string(), account_id: z.string().optional() },
791
- async (args) => ok(await rpc("add_comment", args))
792
- );
793
-
794
- server.tool(
795
- "delete_asset",
796
- "Delete an asset. An approved or live one needs force: true.",
797
- { ...assetRef, force: z.boolean().optional(), account_id: z.string().optional() },
798
- async (args) => ok(await rpc("delete_asset", args))
799
- );
800
-
801
- server.tool(
802
- "get_setup_checks",
803
- "A flywheel's setup checklist: the steps outside LOTUX (Instagram auto-reply per lead-magnet DM keyword, reel covers, YouTube scheduling, the Klaviyo flow + keep-warm campaign). Each with check_key, status (open / done / n_a), due date, owner, and whether LOTUX ticks it itself (auto).",
804
- { ...flywheelRef, account_id: z.string().optional() },
805
- async (args) => ok(await rpc("get_setup_checks", args))
806
- );
807
-
808
- server.tool(
809
- "set_setup_check",
810
- "Tick a setup step after you did it (e.g. check_key \"dm-keyword:EXIT\" once the Instagram auto-reply is set up AND tested). status done / open / n_a, plus an optional note. Auto checks (klaviyo-*, lead-magnet-page) tick themselves. Logged as done by Claude.",
811
- {
812
- ...flywheelRef,
813
- check_key: z.string().describe('From get_setup_checks, e.g. "dm-keyword:EXIT", "reel-covers", "youtube"'),
814
- status: z.enum(["open", "done", "n_a"]).optional(),
815
- note: z.string().optional(),
816
- account_id: z.string().optional(),
817
- },
818
- async (args) => ok(await rpc("set_setup_check", args))
819
- );
820
-
821
- // ---------------------------------------------------------------------------
822
- // HR (agency only): the team and the role templates (Responsibilities).
823
- // Needs HR access in LOTUX. Pay, contracts, documents, deleting people and
824
- // invites stay in the app on purpose.
825
- // ---------------------------------------------------------------------------
826
-
827
- const kpiShape = z.object({
828
- label: z.string(),
829
- target: z.string().nullable().optional(),
830
- source: z.string().nullable().optional(),
831
- });
832
-
833
- server.tool(
834
- "list_team",
835
- "Everyone at the agency in HR: name, job role, lead, is_lead, status (onboarding / active / left), employment, start/end date, LOTUX login yes/no. Archived and left people only with include_archived. No pay.",
836
- { include_archived: z.boolean().optional(), account_id: z.string().optional().describe("Agency id; omit for your own agency") },
837
- async (args) => ok(await rpc("list_team", args))
838
- );
839
-
840
- server.tool(
841
- "get_team_member",
842
- "One person in HR: role, lead, who they lead, contact fields, their own KPIs (else the role's apply), access checklist and their last 20 notes. No pay.",
843
- { member: z.string().describe("Person id or (part of) their name"), account_id: z.string().optional() },
844
- async (args) => ok(await rpc("get_team_member", args))
845
- );
846
-
847
- server.tool(
848
- "update_team_member",
849
- "Change a person in HR. Only the fields you send change. role and lead take a name or id (null clears). archived true hides them from HR lists, the lead picker and payroll (data kept); false restores. kpis replaces their own KPI list ([] = use the role's KPIs). Can't change pay, delete people or send invites — do that in the app.",
850
- {
851
- member: z.string().describe("Person id or name"),
852
- name: z.string().optional(),
853
- role: z.string().nullable().optional().describe("Job role name or id (list_job_roles)"),
854
- lead: z.string().nullable().optional().describe("Lead's name or id"),
855
- is_lead: z.boolean().optional(),
856
- status: z.enum(["onboarding", "active", "left"]).optional(),
857
- employment: z.enum(["employee", "freelance"]).nullable().optional(),
858
- start_date: z.string().nullable().optional().describe("YYYY-MM-DD"),
859
- end_date: z.string().nullable().optional().describe("YYYY-MM-DD"),
860
- lotux_email: z.string().nullable().optional(),
861
- personal_email: z.string().nullable().optional(),
862
- phone: z.string().nullable().optional(),
863
- archived: z.boolean().optional(),
864
- kpis: z.array(kpiShape).optional(),
865
- account_id: z.string().optional(),
866
- },
867
- async (args) => ok(await rpc("update_team_member", args))
868
- );
869
-
870
- server.tool(
871
- "list_job_roles",
872
- "The role templates in HR → Responsibilities: id, name, one-line summary and how many people have the role.",
873
- { account_id: z.string().optional() },
874
- async (args) => ok(await rpc("list_job_roles", args))
875
- );
876
-
877
- server.tool(
878
- "get_job_role",
879
- "One role template in full: title, one-liner, what they obsess over, who they work with, what failure looks like, what they're not responsible for, responsibilities, KPIs, capacity, the LOTUX columns they own, and who has the role.",
880
- { role: z.string().describe("Role id or exact name"), account_id: z.string().optional() },
881
- async (args) => ok(await rpc("get_job_role", args))
882
- );
883
-
884
- const roleFields = {
885
- role_label: z.string().nullable().optional(),
886
- title: z.string().nullable().optional(),
887
- one_line: z.string().nullable().optional(),
888
- obsess_over: z.string().nullable().optional(),
889
- works_with: z.string().nullable().optional(),
890
- failure_looks_like: z.string().nullable().optional(),
891
- not_responsible_for: z.string().nullable().optional(),
892
- kpi_headline: z.string().nullable().optional(),
893
- kpi_detail: z.string().nullable().optional(),
894
- capacity_count: z.number().int().nonnegative().nullable().optional(),
895
- capacity_unit: z.enum(["clients", "accounts", "brands"]).nullable().optional(),
896
- software_columns: z.array(z.string()).optional().describe("LOTUX columns this role owns"),
897
- responsibilities: z.array(z.string()).optional().describe("Replaces the whole list, in order"),
898
- kpis: z.array(kpiShape).optional().describe("Replaces the whole list, in order"),
899
- };
900
-
901
- server.tool(
902
- "update_job_role",
903
- "Change a role template (HR → Responsibilities). Only the fields you send change; responsibilities and kpis replace the whole list, so send the full list. new_name renames the role.",
904
- {
905
- role: z.string().describe("Role id or exact name"),
906
- new_name: z.string().optional(),
907
- ...roleFields,
908
- account_id: z.string().optional(),
909
- },
910
- async (args) => ok(await rpc("update_job_role", args))
911
- );
912
-
913
- server.tool(
914
- "create_job_role",
915
- "Create a role template (starts with the standard Start here flow) and fill it in one go. An existing role with the same name is returned (and updated with the fields you send) instead of a duplicate.",
916
- { name: z.string(), ...roleFields, account_id: z.string().optional() },
917
- async (args) => ok(await rpc("create_job_role", args))
918
- );
919
-
920
- // ── SOPs (Brain → SOPs) ────────────────────────────────────────────────────
921
- // Claude reads SOPs and SUGGESTS changes; a person approves them in LOTUX.
922
- // There is deliberately no tool to approve, edit or publish an SOP.
923
-
924
- const sopStep = z.object({
925
- title: z.string(),
926
- detail: z.string().optional(),
927
- sub: z.array(z.string()).optional().describe("Sub-points"),
928
- when: z.string().optional().describe('Timing chip, e.g. "Day 0, 60 min"'),
929
- });
930
- const sopContent = {
931
- summary: z.string().optional().describe("One line, shown on the card"),
932
- purpose: z.string().optional(),
933
- scope: z.string().optional().describe('"At a glance": lines like "When: …" / "Trigger: …"'),
934
- materials: z.array(z.string()).optional().describe("Before you start"),
935
- steps: z.array(sopStep).optional().describe("The FULL ordered list of steps. Who does a step is kept from the step at the same position."),
936
- done_when: z.string().optional(),
937
- troubleshooting: z.array(z.object({ risk: z.string(), fix: z.string() })).optional().describe("Watch out"),
938
- tools: z.array(z.string()).optional(),
939
- video_url: z.string().nullable().optional().describe("Walkthrough link (http/https)"),
940
- };
941
-
942
- server.tool(
943
- "list_sops",
944
- "The SOPs in LOTUX (Brain → SOPs): code, title, phase, status, owner, number of steps and open suggestions. Archived ones are left out unless you ask for status archived.",
945
- {
946
- phase: z.string().optional().describe("Phase key, e.g. lead, sell, contract, shoot, launch, run"),
947
- status: z.enum(["draft", "review", "published", "archived"]).optional(),
948
- search: z.string().optional(),
949
- account_id: z.string().optional().describe("Omit for the agency"),
950
- },
951
- async (args) => ok(await rpc("list_sops", args))
952
- );
953
-
954
- server.tool(
955
- "get_sop",
956
- "One SOP in full: purpose, at a glance, before you start, steps (with who does them), done when, watch out, tools, open suggestions and the changelog.",
957
- { sop: z.string().describe("SOP id, code (e.g. OC1) or title"), account_id: z.string().optional() },
958
- async (args) => ok(await rpc("get_sop", args))
959
- );
960
-
961
- server.tool(
962
- "suggest_sop_change",
963
- "Suggest a change to an SOP. Nothing changes until the SOP owner or an editor approves it in LOTUX; the changelog then shows you (via Claude) as the author. Send only the fields you change; lists (steps, materials, troubleshooting, tools) replace the whole list. One open suggestion per person per SOP: a new one replaces your earlier one.",
964
- {
965
- sop: z.string().describe("SOP id, code or title"),
966
- changes: z.object(sopContent).extend({ title: z.string().optional() }),
967
- reason: z.string().describe("Why this change is needed (the approver reads it)"),
968
- account_id: z.string().optional(),
969
- },
970
- async (args) => ok(await rpc("suggest_sop_change", args))
971
- );
972
-
973
- server.tool(
974
- "create_sop_draft",
975
- "Create a new SOP as a DRAFT (needs Brain edit rights). It is not live: a person sets owner and category and publishes it in LOTUX. The changelog logs it as created by you (via Claude). An existing title is refused: suggest a change to that SOP instead.",
976
- {
977
- title: z.string(),
978
- phase: z.string().optional().describe("Board column: lead, sell, contract, team_onboard, client_onboard, preprod, shoot, production, launch, run, retain, offboard, always, team"),
979
- ...sopContent,
980
- account_id: z.string().optional(),
981
- },
982
- async (args) => ok(await rpc("create_sop_draft", args))
983
- );
51
+ const server = new McpServer(SERVER_INFO);
52
+ registerTools(server, rpc);
984
53
 
985
54
  async function main() {
986
55
  const transport = new StdioServerTransport();