lotux-mcp 0.4.0 → 0.5.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 (3) hide show
  1. package/README.md +27 -0
  2. package/index.mjs +67 -104
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,3 +45,30 @@ Restart Claude, then try: *"list my LOTUX brands"*.
45
45
  |-----|-------|
46
46
  | `LOTUX_MCP_TOKEN` | your personal token from Settings → Connections |
47
47
  | `LOTUX_API_URL` | the LOTUX app URL (e.g. `https://app.lotuxagency.com`) |
48
+
49
+ ## Tools
50
+
51
+ | Tool | What it does |
52
+ |------|--------------|
53
+ | `list_brands` | Brands (+ agency) you can access |
54
+ | `list_entities` / `create_entity` / `update_entity` / `delete_entity` | A brand's products / personas / angles / use-cases |
55
+ | `list_concepts` / `get_concept` | Find a concept; read its full state (fields, links, **scripts** = markdown per language + shotlist, copy) |
56
+ | `list_options` / `list_members` | Valid field values; assignable team members |
57
+ | `create_concept` | New concept in Idea (+ optional links). Returns `{ id, script_id }` — write the script with `save_script` |
58
+ | `set_concept_fields` | Update fields / links / team roles |
59
+ | `save_script` | Write a script: markdown body for one language (`- **Hook:** …`, `- **1:** …`, `- **CTA:** …`), the shotlist, a rename. Pass `language` for a translation |
60
+ | `add_script` / `delete_script` / `remove_script_language` / `set_base_language` | More scripts per concept; language housekeeping |
61
+ | `set_beats` / `set_beat_translations` | **Compatibility** for older prompts: a beat list is stored as the script document. Prefer `save_script` |
62
+ | `list_copy` / `create_copy` / `link_copy` / `unlink_copy` / `add_copy_translation` | Ad copy library + concept links |
63
+ | `list_kb_*` / `get_kb_doc` / `create_kb_doc` / `update_kb_doc` | Kennisbank pages |
64
+
65
+ Beats (`add_beat`, `update_beat`, `delete_beat`, `set_beat_translation`, `add_language`) were
66
+ retired in Sep 2026 — scripts are documents now. Calling one returns a message pointing at the
67
+ replacement.
68
+
69
+ ## Updating
70
+
71
+ A running connector keeps the tool list it started with. To pick up new tools:
72
+
73
+ 1. Use `"args": ["-y", "lotux-mcp@latest"]` in the config (so `npx` re-resolves the version).
74
+ 2. Restart Claude (Desktop: quit + reopen; Code: `/mcp` → reconnect, or restart the session).
package/index.mjs CHANGED
@@ -52,7 +52,7 @@ const ok = (result) => ({
52
52
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
53
53
  });
54
54
 
55
- const server = new McpServer({ name: "lotux", version: "0.4.0" });
55
+ const server = new McpServer({ name: "lotux", version: "0.5.0" });
56
56
 
57
57
  // ── Reads ─────────────────────────────────────────────────────────────────────
58
58
 
@@ -79,7 +79,7 @@ server.tool(
79
79
 
80
80
  server.tool(
81
81
  "get_concept",
82
- "Read the full state of one concept by id: fields, linked products/personas/angles/use-cases, script beats (with their text), and linked copy. Use to confirm what was written.",
82
+ "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.",
83
83
  { concept_id: z.string() },
84
84
  async ({ concept_id }) => ok(await rpc("get_concept", { concept_id }))
85
85
  );
@@ -113,19 +113,21 @@ server.tool(
113
113
 
114
114
  server.tool(
115
115
  "create_concept",
116
- "Create a new concept in the brand's first (Idea) stage, optionally linking products/personas/angles/use-cases. Returns { id, beat_ids } — the concept id plus the ids of 3 pre-seeded empty Hook beats. Fill those 3 with update_beat; do NOT add_beat for the first three hooks or you get duplicates. A human moves the concept through the copy-check gate later in the app.",
116
+ "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.",
117
117
  {
118
118
  account_id: z.string().describe("Brand id from list_brands"),
119
119
  title: z.string(),
120
120
  bucket: z
121
121
  .string()
122
- .describe("Main bucket, e.g. video / static / carousel (the brand's bucket key)"),
122
+ .optional()
123
+ .describe("Main bucket, e.g. video / static / carousel (the brand's bucket key). Defaults to video."),
123
124
  funnel_stage: z.string().optional(),
124
125
  format: z.string().optional(),
125
126
  product_ids: z.array(z.string()).optional(),
126
127
  persona_ids: z.array(z.string()).optional(),
127
128
  angle_ids: z.array(z.string()).optional(),
128
129
  use_case_ids: z.array(z.string()).optional(),
130
+ offer_ids: z.array(z.string()).optional().describe("Offer ids from list_entities (offers)"),
129
131
  },
130
132
  async (args) => ok(await rpc("create_concept", args))
131
133
  );
@@ -142,14 +144,19 @@ server.tool(
142
144
  variant: z.string().optional(),
143
145
  country: z.string().optional(),
144
146
  promotion: z.string().optional(),
145
- visual_direction: z.string().optional(),
146
- description: z.string().optional(),
147
+ brief: z.string().optional().describe("Concept brief (markdown)"),
147
148
  base_language: z.string().optional(),
148
149
  sub_buckets: z.array(z.string()).optional(),
150
+ bucket: z.string().optional().describe("Main bucket option key (see list_options.fields)"),
151
+ fields: z
152
+ .record(z.union([z.string(), z.array(z.string()), z.null()]))
153
+ .optional()
154
+ .describe("Brand-defined concept fields by key → option key(s); see list_options.fields"),
149
155
  product_ids: z.array(z.string()).optional(),
150
156
  persona_ids: z.array(z.string()).optional(),
151
157
  angle_ids: z.array(z.string()).optional(),
152
158
  use_case_ids: z.array(z.string()).optional(),
159
+ offer_ids: z.array(z.string()).optional(),
153
160
  // Team roles — pass a member id from list_members.
154
161
  strategist_id: z.string().optional().describe("Member id from list_members"),
155
162
  copy_checker_id: z.string().optional().describe("Member id from list_members"),
@@ -161,139 +168,95 @@ server.tool(
161
168
  ok(await rpc("set_concept_fields", { concept_id, patch }))
162
169
  );
163
170
 
164
- // ── Script beats ────────────────────────────────────────────────────────────────
171
+ // ── Scripts (document model) ────────────────────────────────────────────────
172
+
173
+ const SCRIPT_BODY_DOC =
174
+ "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.";
165
175
 
166
176
  server.tool(
167
- "add_beat",
168
- "Add a script beat (Hook / Body / CTA) to a concept. `text` is the spoken/script line shown in the Script tab's VO/TH/TEXT field. Returns the new beat id. NOTE: new concepts already come with 3 empty Hook beats (see create_concept) — fill those with update_beat first; only add_beat for extra beats beyond them.",
177
+ "save_script",
178
+ "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.",
169
179
  {
170
- concept_id: z.string(),
171
- beat_type: z.enum(["hook", "body", "cta"]).optional(),
172
- text: z.string().optional().describe("The script line / what's said (the visible beat text)"),
173
- visual_note: z.string().optional().describe("Optional on-screen visual note"),
174
- delivery_kind: z
175
- .enum(["voiceover", "talking_head", "text_on_screen"])
176
- .optional()
177
- .describe("Per-beat delivery type dropdown"),
180
+ script_id: z.string(),
181
+ language: z.string().optional().describe("Full-written language name, e.g. English / Dutch. Defaults to the base language."),
182
+ body: z.string().optional().describe(SCRIPT_BODY_DOC),
183
+ shotlist: z.array(z.string()).optional().describe("Ordered list of shots / b-roll (one line each). Replaces the whole list."),
184
+ name: z.string().optional().describe("Rename the script"),
178
185
  },
179
- async (args) => ok(await rpc("add_beat", args))
186
+ async (args) => ok(await rpc("save_script", args))
180
187
  );
181
188
 
182
189
  server.tool(
183
- "update_beat",
184
- "Update an existing script beat's script text, type, delivery kind, or visual note. Use this (not add_beat) to edit a beat — the beat_id comes from create_concept or get_concept.",
185
- {
186
- beat_id: z.string(),
187
- beat_type: z.enum(["hook", "body", "cta"]).optional(),
188
- delivery_text: z.string().optional().describe("The script line / what's said"),
189
- visual_note: z.string().optional(),
190
- delivery_kind: z
191
- .enum(["voiceover", "talking_head", "text_on_screen"])
192
- .optional()
193
- .describe("Per-beat delivery type dropdown"),
194
- },
195
- async ({ beat_id, ...patch }) => ok(await rpc("update_beat", { beat_id, patch }))
190
+ "add_script",
191
+ "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).",
192
+ { concept_id: z.string(), name: z.string().optional() },
193
+ async (args) => ok(await rpc("add_script", args))
196
194
  );
197
195
 
198
196
  server.tool(
199
- "delete_beat",
200
- "Delete a script beat by id (its translations are removed with it). Use to remove a wrong or leftover beat instead of blanking its text.",
201
- { beat_id: z.string() },
202
- async ({ beat_id }) => ok(await rpc("delete_beat", { beat_id }))
197
+ "delete_script",
198
+ "Delete a script (all its language versions + shotlist). Irreversible.",
199
+ { script_id: z.string() },
200
+ async ({ script_id }) => ok(await rpc("delete_script", { script_id }))
203
201
  );
204
202
 
203
+ server.tool(
204
+ "remove_script_language",
205
+ "Remove one translation language from a script. The base language cannot be removed.",
206
+ { script_id: z.string(), language: z.string() },
207
+ async (args) => ok(await rpc("remove_script_language", args))
208
+ );
209
+
210
+ // ── Beat compat (older prompts / scripts) ───────────────────────────────────
211
+
205
212
  server.tool(
206
213
  "set_beats",
207
- "Set a concept's ENTIRE script in one call (the efficient way to build or restructure beats). AUTHORITATIVE: the beats array becomes the full ordered script — order = array order, beats with an id are updated in place, id-less beats are inserted, and any existing beat you omit is deleted. Preserves translations on kept beats. Returns { beat_ids } in final order — feed those to set_beat_translation for per-language text. Prefer this over repeated add_beat/update_beat/delete_beat. Read the current beats with get_concept first so you resend the ones you want to keep.",
214
+ "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.",
208
215
  {
209
216
  concept_id: z.string(),
210
- beats: z
211
- .array(
212
- z.object({
213
- id: z.string().optional().describe("Existing beat id to update; omit to insert a new beat"),
214
- beat_type: z.enum(["hook", "body", "cta"]).optional(),
215
- text: z.string().optional().describe("The script line / what's said"),
216
- visual_note: z.string().optional(),
217
- delivery_kind: z.enum(["voiceover", "talking_head", "text_on_screen"]).optional(),
218
- })
219
- )
220
- .describe("The full ordered beat list. Anything not included is deleted."),
217
+ beats: z.array(
218
+ z.object({
219
+ beat_type: z.enum(["hook", "body", "cta"]).optional(),
220
+ text: z.string().optional().describe("Spoken line"),
221
+ visual_note: z.string().optional().describe("What to show — lands in the shotlist"),
222
+ })
223
+ ),
224
+ language: z.string().optional().describe("Full-written language, defaults to the concept's base language"),
221
225
  },
222
226
  async (args) => ok(await rpc("set_beats", args))
223
227
  );
224
228
 
225
229
  server.tool(
226
230
  "set_beat_translations",
227
- "Write one language's ENTIRE translated script in a single call (the efficient partner to set_beats). Registers the language (if new) and upserts each beat's translated line. Prefer this over repeated add_language + set_beat_translation. beat_id values come from set_beats or get_concept; language is a full-written name (e.g. Dutch).",
231
+ "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.",
228
232
  {
229
233
  concept_id: z.string(),
230
- language: z.string().describe("Full-written name, e.g. Dutch"),
231
- items: z
232
- .array(
233
- z.object({
234
- beat_id: z.string(),
235
- text: z.string().optional().describe("The translated script line"),
236
- visual_note: z.string().optional(),
237
- })
238
- )
239
- .describe("One entry per beat to translate for this language"),
234
+ language: z.string().describe("Full-written language, e.g. Dutch"),
235
+ items: z.array(
236
+ z.object({
237
+ beat_id: z.string().optional(),
238
+ text: z.string().optional(),
239
+ visual_note: z.string().optional().describe("Ignored — shotlists are language-independent"),
240
+ })
241
+ ),
240
242
  },
241
243
  async (args) => ok(await rpc("set_beat_translations", args))
242
244
  );
243
245
 
244
246
  server.tool(
245
247
  "delete_concept",
246
- "Delete an entire concept by id. Removes its beats, translations, entity links and copy links. Irreversible — confirm the id via list_concepts / get_concept first.",
248
+ "Delete an entire concept by id. Removes its scripts, entity links and copy links. Irreversible — confirm the id via list_concepts / get_concept first.",
247
249
  { concept_id: z.string() },
248
250
  async ({ concept_id }) => ok(await rpc("delete_concept", { concept_id }))
249
251
  );
250
252
 
251
253
  server.tool(
252
254
  "set_base_language",
253
- "Set the concept's base script language (the 'Original' tab), e.g. English.",
255
+ "Set the concept's base script language, e.g. English. Existing base-language script texts are re-labelled to the new language.",
254
256
  { concept_id: z.string(), language: z.string() },
255
257
  async (args) => ok(await rpc("set_base_language", args))
256
258
  );
257
259
 
258
- server.tool(
259
- "add_language",
260
- "Add a translation language (tab) to a concept's Script, e.g. Dutch. Use a full-written name from list_options. Then write each beat's translated line with set_beat_translation.",
261
- { concept_id: z.string(), language: z.string().describe("Full-written name, e.g. Dutch") },
262
- async (args) => ok(await rpc("add_language", args))
263
- );
264
-
265
- server.tool(
266
- "set_beat_translation",
267
- "Write one beat's translated script line for a language. beat_id comes from get_concept; language must match one added via add_language.",
268
- {
269
- beat_id: z.string(),
270
- language: z.string().describe("Full-written name, e.g. Dutch"),
271
- delivery_text: z.string().optional().describe("The translated script line / what's said"),
272
- visual_note: z.string().optional(),
273
- },
274
- async ({ beat_id, language, ...patch }) =>
275
- ok(await rpc("set_beat_translation", { beat_id, language, ...patch }))
276
- );
277
-
278
- // ── Copy ──────────────────────────────────────────────────────────────────────
279
-
280
- server.tool(
281
- "create_copy",
282
- "Create a brand-scoped ad copy item (adcopy / headline / description) and link it to a concept. Returns the new copy id.",
283
- {
284
- account_id: z.string().describe("Brand id from list_brands"),
285
- concept_id: z.string(),
286
- type: z.enum(["adcopy", "headline", "description"]),
287
- text: z.string(),
288
- product_ids: z.array(z.string()).optional(),
289
- promotion: z.string().optional(),
290
- country: z.string().optional(),
291
- language: z.string().optional(),
292
- funnel_stage: z.string().optional(),
293
- },
294
- async (args) => ok(await rpc("create_copy", args))
295
- );
296
-
297
260
  server.tool(
298
261
  "link_copy",
299
262
  "Link an existing copy item to a concept.",
@@ -324,10 +287,10 @@ server.tool(
324
287
 
325
288
  server.tool(
326
289
  "create_entity",
327
- "Create a brand-DB entity (persona / product / use_case / angle). name is required; description optional. `extras` carries the typed fields (e.g. persona awareness_stage/hypothesis, angle core idea via description + belief_shift, product average_price/margin_pct) — omit if unknown. Personas/use_cases/angles get an auto id_code (p1/u1/a1), returned in the result. Link it to a concept afterwards with set_concept_fields.",
290
+ "Create a NEW brand-DB entity — a product, persona, use_case or angle 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). Omit unknown fields. Personas/use_cases/angles get an auto id_code (p1/u1/a1), returned in the result. Link the new entity to a concept afterwards with set_concept_fields.",
328
291
  {
329
292
  account_id: z.string().describe("Brand id from list_brands"),
330
- kind: z.enum(["products", "personas", "angles", "use_cases"]),
293
+ kind: z.enum(["products", "personas", "angles", "use_cases", "offers"]),
331
294
  name: z.string(),
332
295
  description: z.string().optional(),
333
296
  extras: z.record(z.any()).optional().describe("Typed fields keyed by column name (see list_entities output shape)"),
@@ -340,7 +303,7 @@ server.tool(
340
303
  "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.",
341
304
  {
342
305
  entity_id: z.string(),
343
- kind: z.enum(["products", "personas", "angles", "use_cases"]),
306
+ kind: z.enum(["products", "personas", "angles", "use_cases", "offers"]),
344
307
  name: z.string().optional(),
345
308
  description: z.string().optional(),
346
309
  extras: z.record(z.any()).optional(),
@@ -350,10 +313,10 @@ server.tool(
350
313
 
351
314
  server.tool(
352
315
  "delete_entity",
353
- "Delete a brand-DB entity by id. Its links to concepts are removed with it. Irreversible — confirm via list_entities first.",
316
+ "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.",
354
317
  {
355
318
  entity_id: z.string(),
356
- kind: z.enum(["products", "personas", "angles", "use_cases"]),
319
+ kind: z.enum(["products", "personas", "angles", "use_cases", "offers"]),
357
320
  },
358
321
  async (args) => ok(await rpc("delete_entity", args))
359
322
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lotux-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Local MCP server that lets Claude create LOTUX concepts (fetch brand-DB entities, create concepts, fill scripts + copy). Talks to the LOTUX app with a personal token; no business logic or secrets live here.",
6
6
  "bin": {