frameworc-mcp 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -6,8 +6,11 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server that lets Cla
6
6
 
7
7
  - Composes 15 prebuilt FrameworC blocks (`Header`, `Section`, `Tiles`, `Slider`, `Tabs`, `Accordion`, `Form`, `Gallery`, `Downloads`, `Columns`, `Prefill`, `BlogList`, `MenuBlock`, `ImageStrip`, `InstaFeed`) into pages
8
8
  - Lists existing pages and their full builder JSON
9
- - Creates pages, adds / updates / removes / reorders individual blocks
10
- - Resolves referenced `Form` / `Menu` / `Prefill` entries via dedicated list tools
9
+ - Creates pages, adds / updates / removes / reorders individual blocks — on pages and on Prefill entries
10
+ - Full CRUD for `Form` entries (incl. their field rows), `Menu` entries (incl. the navigation tree) and `Prefill` entries (incl. their builder blocks)
11
+ - Extracts a repeated page section into a Prefill entry and replaces it with a reference (`extract_block_to_prefill`) — the intended FrameworC de-duplication workflow
12
+ - Reads and writes the per-site singles (Meta & SEO, Navigation, Footer) and the global FrameworC settings (navbar options, SCSS variables; integration secrets are not accessible by design)
13
+ - Multisite-aware: every content tool takes/pins a `site_id`; page and prefill translations are linked for the language switcher
11
14
  - Publishes the block catalogue as an MCP resource (`frameworc://blocks`) so the chat agent knows each block's fields, defaults, and when-to-use notes
12
15
  - Draft by default — created pages have `is_enabled = false`; the human flips the switch in the OctCMS backend after assigning images
13
16
 
@@ -23,10 +26,10 @@ node dist/index.js (local, spawned by the chat client)
23
26
  reads ~/.config/frameworc/sites.json (auto-created on first run; hot-reloaded)
24
27
  │ HTTPS + Authorization: Bearer <token resolved by URL>
25
28
  ▼
26
- any OctCMS install /api/mcp/v1/* (provided by the FrameworC plugin v1.10.0+)
29
+ any OctCMS install /api/mcp/v1/* (provided by the crscompany/frameworcmcp plugin v1.1.0+)
27
30
  ```
28
31
 
29
- No Docker, no ToolHive, no remote gateway, no second auth layer. Each OctCMS install has its own bearer token stored in its own `FrameworcSetting → Integration → MCP API token`, and your laptop keeps the URL → token map in one local file.
32
+ No Docker, no ToolHive, no remote gateway, no second auth layer. Each OctCMS install has its own bearer token stored in its backend under `Settings → FrameworC → MCP API`, and your laptop keeps the URL → token map in one local file.
30
33
 
31
34
  ## Install
32
35
 
@@ -93,8 +96,8 @@ If the file is missing or empty, `use_site` errors with a pointer to where to ad
93
96
 
94
97
  ### Adding a new OctCMS site
95
98
 
96
- 1. Spin up the new OctCMS install with the FrameworC plugin (v1.10.0 or newer).
97
- 2. Backend → Settings → FrameworC → Integration → **MCP API token** → paste a freshly generated random string (e.g. `openssl rand -hex 32`) → Save.
99
+ 1. Spin up the new OctCMS install with the FrameworC suite incl. the `crscompany/frameworcmcp` plugin (v1.1.0 or newer).
100
+ 2. Backend → Settings → FrameworC → **MCP API** → paste a freshly generated random string (e.g. `openssl rand -hex 32`) → Save.
98
101
  3. Edit `~/.config/frameworc/sites.json` on your laptop, add one entry: `{ "label": "New Client", "url": "https://newsite.test", "token": "<that string>" }`.
99
102
  4. In your next chat (no Claude Desktop restart needed): *"Use `https://newsite.test`."* → MCP hot-reloads the file, finds the token, sends it. Done.
100
103
 
@@ -106,25 +109,37 @@ No env var, no chat-client config edit, no ToolHive touch, no repo push. The who
106
109
  - A leak of the file compromises every site listed in it. Treat it like an SSH private key — back it up, rotate tokens periodically, never commit it to git.
107
110
  - One-off override: `use_site("https://X", "token-string")` lets you pass a token inline (without storing it) for the duration of the chat session. Useful for testing a token before saving it.
108
111
 
109
- ## Tools (v1)
112
+ ## Tools (0.4.0)
110
113
 
111
114
  | Tool | Description |
112
115
  |---|---|
113
116
  | `use_site(url, site_token?)` | Pin the target OctCMS site for the session. Token resolved from `~/.config/frameworc/sites.json` by URL, or supplied via the optional `site_token` arg. |
114
117
  | `list_sites` | List configured sites (label + URL only, no tokens) from `sites.json`. |
118
+ | `list_cms_sites` | List the multisite sites (languages) inside the pinned install. |
119
+ | `use_cms_site(site_id)` | Pin the multisite site for subsequent calls (null = primary). |
115
120
  | `list_pages` | List all pages on the pinned site. |
116
121
  | `get_page(id)` | Full page JSON (page meta + builder blocks + nested repeaters). |
117
122
  | `create_page(payload)` | Create a draft page. `payload = { page: {...}, builder: [...] }`. |
118
- | `update_page(id, payload)` | Edit page meta, or full-rebuild the builder array. |
123
+ | `update_page(id, payload)` | Edit page meta, or full-rebuild the builder array (prefer the per-block tools). |
119
124
  | `delete_page(id)` | Soft-delete a page. |
120
- | `add_block(page_id, block, position?)` | Append (or insert at `position`) a block. |
121
- | `update_block(page_id, block_id, block)` | Replace one block by row id. |
122
- | `remove_block(page_id, block_id)` | Remove one block by row id. |
123
- | `reorder_blocks(page_id, order)` | Reorder blocks (order = array of all row ids in new order). |
124
- | `list_forms` | Form Tailor entries for the `Form` block's `form_entry`. |
125
- | `list_menus` | Menu Tailor entries for the `MenuBlock`'s `content.menu_entry`. |
126
- | `list_prefills` | Prefill Tailor entries for the `Prefill` block's `content.prefill_entry`. |
127
- | `get_block_schema(name)` | Field schema + usage notes for one block. |
125
+ | `create_translation(id, target_site_id, prefill?)` | Linked sibling of a page (or Prefill with `prefill:true`) on another multisite site. |
126
+ | `add_block(page_id \| prefill_id, block, position?)` | Append (or insert at `position`) a block. |
127
+ | `update_block(page_id \| prefill_id, block_id, block)` | Replace one block by row id (new id returned; human-assigned media carried over unless the type changes). |
128
+ | `remove_block(page_id \| prefill_id, block_id)` | Remove one block by row id. |
129
+ | `reorder_blocks(page_id \| prefill_id, order)` | Reorder blocks (order = array of all row ids in new order). |
130
+ | `extract_block_to_prefill(page_id, block_id, title)` | Move a page block into a new Prefill entry (lossless, media survives) and reference it in place. |
131
+ | `list_forms` / `get_form(id)` | Form entries; `get_form` includes the `fwcFields` rows. |
132
+ | `get_form_schema` | Live field-group catalogue for authoring forms. |
133
+ | `create_form` / `update_form` / `delete_form` | Form CRUD. `fwcFields` rows: `{group, label, name, required, width, ...}`; delete guarded unless `force:true`. |
134
+ | `list_menus` / `get_menu(id)` | Menu entries; `get_menu` includes the navigation tree. |
135
+ | `create_menu` / `update_menu` / `delete_menu` | Menu CRUD. Tree items `{title, url \| {page_id}, anchor, blank, children}`, max 2 levels; delete guarded. |
136
+ | `list_prefills` / `get_prefill(id)` | Prefill entries; `get_prefill` includes the builder blocks. |
137
+ | `create_prefill` / `update_prefill` / `delete_prefill` | Prefill CRUD — same block shape as pages; delete guarded. |
138
+ | `get_page_meta(handle)` / `update_page_meta(handle, fields)` | Per-site singles: `Meta` (SEO), `Navigation` (navbar incl. `nav` menu link + buttons), `Footer` (incl. `socials` rows + `nav`). |
139
+ | `get_settings` / `update_settings(fields)` | Global FrameworC settings: navbar options + custom SCSS variables. Integration secrets are not exposed. |
140
+ | `get_block_schema(name)` | Field schema + usage notes for one block (live from the CMS). |
141
+
142
+ Form and Menu entries have no translation linking — create them per site by passing `site_id`.
128
143
 
129
144
  ## Resource
130
145
 
@@ -173,7 +188,7 @@ No env var, no chat-client config edit, no ToolHive touch, no repo push. The who
173
188
  {
174
189
  "content_group": "Form",
175
190
  "base": { "blockId": "formular", "headline": "<h2>Write us</h2>" },
176
- "form_entry": 7,
191
+ "form": 7,
177
192
  "content": { "variant": "default" }
178
193
  }
179
194
  ]
@@ -186,7 +201,7 @@ No env var, no chat-client config edit, no ToolHive touch, no repo push. The who
186
201
  - `switch` fields accept booleans; the API normalises to `"1"` / `"0"` for storage.
187
202
  - `customCssClass` + `responsiveHide` are arrays of strings.
188
203
  - Multi-`mediafinder` fields (`images` for `Gallery` / `ImageStrip`) are arrays of path strings — but must be empty `[]` per the rule above.
189
- - `entries` links are integers: `form_entry` (block level for `Form`), `content.menu_entry` (for `MenuBlock`), `content.prefill_entry` (for `Prefill`).
204
+ - `entries` links are integers (or `{id: n}`): `form` (block level for `Form`), `content.menu` (for `MenuBlock`), `content.block` (for `Prefill`). Reads return them as `{id, title}`.
190
205
  - `Slider` accepts an optional `content.breakpoints = { tablet: number, mobile: number }`.
191
206
  - `Columns` blocks have `content.columns = [{ blockId, builder: [...blocks] }]` — recursive: each column's `builder` follows the exact same shape as the top-level `builder`.
192
207
 
@@ -84,38 +84,103 @@ class ApiClient {
84
84
  }
85
85
  return this.request(`/pages/${id}/translations`, { method: "POST", body: JSON.stringify(body) });
86
86
  }
87
- // --- blocks (per-page) ------------------------------------------------
88
- addBlock(pageId, block, position, siteId) {
89
- return this.request(`/pages/${pageId}/blocks`, {
87
+ // --- blocks (per page or per prefill) ---------------------------------
88
+ blockBase(host) {
89
+ return host.type === "prefill" ? `/prefills/${host.id}` : `/pages/${host.id}`;
90
+ }
91
+ addBlock(host, block, position, siteId) {
92
+ return this.request(`${this.blockBase(host)}/blocks`, {
90
93
  method: "POST",
91
94
  body: this.b({ block, position }, siteId),
92
95
  });
93
96
  }
94
- updateBlock(pageId, blockId, block, siteId) {
95
- return this.request(`/pages/${pageId}/blocks/${blockId}`, {
97
+ updateBlock(host, blockId, block, siteId) {
98
+ return this.request(`${this.blockBase(host)}/blocks/${blockId}`, {
96
99
  method: "PATCH",
97
100
  body: this.b(block, siteId),
98
101
  });
99
102
  }
100
- removeBlock(pageId, blockId, siteId) {
101
- return this.request(this.q(`/pages/${pageId}/blocks/${blockId}`, siteId), { method: "DELETE" });
103
+ removeBlock(host, blockId, siteId) {
104
+ return this.request(this.q(`${this.blockBase(host)}/blocks/${blockId}`, siteId), { method: "DELETE" });
102
105
  }
103
- reorderBlocks(pageId, order, siteId) {
104
- return this.request(`/pages/${pageId}/blocks/order`, {
106
+ reorderBlocks(host, order, siteId) {
107
+ return this.request(`${this.blockBase(host)}/blocks/order`, {
105
108
  method: "POST",
106
109
  body: this.b({ order }, siteId),
107
110
  });
108
111
  }
109
- // --- referenced entries ----------------------------------------------
112
+ extractBlockToPrefill(pageId, blockId, title, siteId) {
113
+ return this.request(`/pages/${pageId}/blocks/${blockId}/extract-to-prefill`, {
114
+ method: "POST",
115
+ body: this.b({ title }, siteId),
116
+ });
117
+ }
118
+ // --- forms --------------------------------------------------------------
110
119
  listForms(siteId) {
111
120
  return this.request(this.q("/forms", siteId));
112
121
  }
122
+ getFormSchema() {
123
+ return this.request("/forms/schema");
124
+ }
125
+ getForm(id, siteId) {
126
+ return this.request(this.q(`/forms/${id}`, siteId));
127
+ }
128
+ createForm(payload, siteId) {
129
+ return this.request("/forms", { method: "POST", body: this.b(payload, siteId) });
130
+ }
131
+ updateForm(id, payload, siteId) {
132
+ return this.request(`/forms/${id}`, { method: "PATCH", body: this.b(payload, siteId) });
133
+ }
134
+ deleteForm(id, force, siteId) {
135
+ return this.request(this.q(`/forms/${id}${force ? "?force=1" : ""}`, siteId), { method: "DELETE" });
136
+ }
137
+ // --- menus --------------------------------------------------------------
113
138
  listMenus(siteId) {
114
139
  return this.request(this.q("/menus", siteId));
115
140
  }
141
+ getMenu(id, siteId) {
142
+ return this.request(this.q(`/menus/${id}`, siteId));
143
+ }
144
+ createMenu(payload, siteId) {
145
+ return this.request("/menus", { method: "POST", body: this.b(payload, siteId) });
146
+ }
147
+ updateMenu(id, payload, siteId) {
148
+ return this.request(`/menus/${id}`, { method: "PATCH", body: this.b(payload, siteId) });
149
+ }
150
+ deleteMenu(id, force, siteId) {
151
+ return this.request(this.q(`/menus/${id}${force ? "?force=1" : ""}`, siteId), { method: "DELETE" });
152
+ }
153
+ // --- prefills -----------------------------------------------------------
116
154
  listPrefills(siteId) {
117
155
  return this.request(this.q("/prefills", siteId));
118
156
  }
157
+ getPrefill(id, siteId) {
158
+ return this.request(this.q(`/prefills/${id}`, siteId));
159
+ }
160
+ createPrefill(payload, siteId) {
161
+ return this.request("/prefills", { method: "POST", body: this.b(payload, siteId) });
162
+ }
163
+ updatePrefill(id, payload, siteId) {
164
+ return this.request(`/prefills/${id}`, { method: "PATCH", body: this.b(payload, siteId) });
165
+ }
166
+ deletePrefill(id, force, siteId) {
167
+ return this.request(this.q(`/prefills/${id}${force ? "?force=1" : ""}`, siteId), { method: "DELETE" });
168
+ }
169
+ createPrefillTranslation(id, targetSiteId, payload, sourceSiteId) {
170
+ const body = { ...(payload ?? {}), site_id: targetSiteId };
171
+ const source = sourceSiteId ?? this.session.getSiteId();
172
+ if (source !== null && source !== undefined) {
173
+ body.source_site_id = source;
174
+ }
175
+ return this.request(`/prefills/${id}/translations`, { method: "POST", body: JSON.stringify(body) });
176
+ }
177
+ // --- FrameworC settings (global) ---------------------------------------
178
+ getSettings() {
179
+ return this.request("/settings");
180
+ }
181
+ updateSettings(fields) {
182
+ return this.request("/settings", { method: "PATCH", body: JSON.stringify({ fields }) });
183
+ }
119
184
  // --- singles ----------------------------------------------------------
120
185
  getSingle(handle, siteId) {
121
186
  return this.request(this.q(`/singles/${encodeURIComponent(handle)}`, siteId));
package/dist/catalogue.js CHANGED
@@ -83,10 +83,10 @@ exports.BLOCKS = [
83
83
  content_group: "Tiles",
84
84
  name: "Tiles (grid of cards)",
85
85
  description: "Grid of cards (icon + headline + rich text + button each).",
86
- whenToUse: "Feature lists, service grids, 'value proposition' card rows,ething lists of related items.",
86
+ whenToUse: "Feature lists, service grids, 'value proposition' card rows, or any list of related items.",
87
87
  base: baseBlock,
88
88
  content: [
89
- { name: "tiles", type: "taglist", comment: "Array of tile items, each {headline, image, text, buttonLabel1, buttonVariant1, buttonContrast1, buttonLink1, buttonIcon1, buttonBlank1, buttonLabel2, ...}. Use this to list the tiles." },
89
+ { name: "tiles", type: "repeater", comment: "Array of tile items, each {headline, image, text, buttonLabel1, buttonVariant1, buttonContrast1, buttonLink1, buttonIcon1, buttonBlank1, buttonLabel2, ...}. Use this to list the tiles." },
90
90
  { name: "tileLink", type: "switch", default: false, comment: "Whole tile acts as a link." },
91
91
  { name: "tileIcon", type: "switch", default: false, comment: "Display icon-only tiles." },
92
92
  { name: "contrastHover", type: "switch", default: false },
@@ -116,7 +116,7 @@ exports.BLOCKS = [
116
116
  whenToUse: "Image-heavy content that benefits from sequential browsing — testimonials, step-by-step walkthroughs, gallery-like rows.",
117
117
  base: baseBlock,
118
118
  content: [
119
- { name: "slides", type: "taglist", comment: "Array of slide items, each {image, headline, text, buttonLabel1, buttonVariant1, ...}." },
119
+ { name: "slides", type: "repeater", comment: "Array of slide items, each {image, headline, text, buttonLabel1, buttonVariant1, ...}." },
120
120
  { name: "gallery", type: "switch", default: false, comment: "Clicking a slide opens the gallery lightbox." },
121
121
  { name: "loop", type: "switch", default: false, comment: "Infinite loop playback." },
122
122
  { name: "autoWidth", type: "switch", default: false, comment: "Slide width matches image width." },
@@ -149,7 +149,7 @@ exports.BLOCKS = [
149
149
  whenToUse: "Long content split into topic tabs (e.g. 'What we do' / 'Who we are' / 'Contact'). Don't use if there are only 1–2 logical sections — use Section instead.",
150
150
  base: baseBlock,
151
151
  content: [
152
- { name: "slides", type: "taglist", comment: "Array of tab items, each {image, headline, text, buttonLabel1, ...}." },
152
+ { name: "slides", type: "repeater", comment: "Array of tab items, each {image, headline, text, buttonLabel1, ...}." },
153
153
  { name: "gallery", type: "switch", default: false },
154
154
  { name: "variant", type: "dropdown", enum: ["tabs-horizontal", "tabs-vertical"], default: "tabs-horizontal" },
155
155
  ],
@@ -173,7 +173,7 @@ exports.BLOCKS = [
173
173
  whenToUse: "FAQs, 'Common questions', or any content the user wants collapsed by default. Each item has a headline + rich text.",
174
174
  base: baseBlock,
175
175
  content: [
176
- { name: "items", type: "taglist", comment: "Array of items, each {headline, text}." },
176
+ { name: "items", type: "repeater", comment: "Array of items, each {headline, text}." },
177
177
  { name: "variant", type: "dropdown", enum: ["default", "outline", "shadow"], default: "default" },
178
178
  { name: "firstItemOpen", type: "switch", default: false, comment: "First item is expanded on load." },
179
179
  { name: "singleMode", type: "switch", default: false, comment: "Only one item open at a time." },
@@ -245,7 +245,7 @@ exports.BLOCKS = [
245
245
  description: "One repeater item per column. Each column has {blockId, builder: [...blocks]} where builder is a full Builder array.",
246
246
  items: [
247
247
  { name: "blockId", type: "text", comment: "Optional column anchor id (used for the column wrapper)." },
248
- { name: "builder", type: "taglist", comment: "Array of block objects (same shape as top-level 'builder' in a page). You can nest any block here, including another Columns (avoid deep nesting)." },
248
+ { name: "builder", type: "repeater", comment: "Array of block objects (same shape as top-level 'builder' in a page). You can nest any block here, including another Columns (avoid deep nesting)." },
249
249
  ],
250
250
  },
251
251
  ],
@@ -254,7 +254,7 @@ exports.BLOCKS = [
254
254
  content_group: "Prefill",
255
255
  name: "Prefill (reusable block template)",
256
256
  description: "References a Prefill Tailor entry whose builder content is shared across pages — edit once, updates everywhere it is used.",
257
- whenToUse: "For shared/global blocks like a newsletter CTA, a contact form snippet, or a footer-style band that should appear the same on many pages. The actual block content lives on the Prefill entry — get its id via `list_prefills`. Don't use this for one-off content.",
257
+ whenToUse: "For shared/global blocks like a newsletter CTA, a contact form snippet, or a footer-style band that should appear the same on many pages. The actual block content lives on the Prefill entry — get its id via `list_prefills`. Whenever the same section would appear on more than one page, do NOT copy it: keep it in one Prefill (create_prefill, or extract_block_to_prefill for an existing page block) and insert this block on every page that needs it. Don't use this for one-off content.",
258
258
  base: baseBlock,
259
259
  content: [],
260
260
  referencing: { kind: "prefill", location: "content", jsonKey: "block", column: "block_id" },
package/dist/index.js CHANGED
@@ -13,6 +13,17 @@ const CATALOGUE_URI = "frameworc://blocks";
13
13
  (0, sites_js_1.warnIfWorldReadable)();
14
14
  const session = new session_js_1.Session();
15
15
  const client = new api_client_js_1.ApiClient(session);
16
+ /** Resolves the block-op target from page_id / prefill_id args. */
17
+ function blockHost(args) {
18
+ const hasPage = args.page_id !== undefined && args.page_id !== null;
19
+ const hasPrefill = args.prefill_id !== undefined && args.prefill_id !== null;
20
+ if (hasPage === hasPrefill) {
21
+ throw new Error("Pass exactly one of page_id / prefill_id.");
22
+ }
23
+ return hasPrefill
24
+ ? { type: "prefill", id: Number(args.prefill_id) }
25
+ : { type: "page", id: Number(args.page_id) };
26
+ }
16
27
  const tools = [
17
28
  {
18
29
  name: "use_site",
@@ -130,86 +141,316 @@ const tools = [
130
141
  },
131
142
  {
132
143
  name: "add_block",
133
- description: "Append a block to a page's builder. Optional `position` (1-indexed) inserts at that position; otherwise appends at the end. The `block` arg has the shape { content_group, base, content, form? } per the catalogue — `form` sits at block level, while Menu and Prefill references live at `content.menu` and `content.block`.",
144
+ description: "Append a block to a page's builder — or a prefill's, by passing prefill_id instead of page_id. Optional `position` (1-indexed) inserts at that position; otherwise appends at the end. The `block` arg has the shape { content_group, base, content, form? } per the catalogue — `form` sits at block level, while Menu and Prefill references live at `content.menu` and `content.block`. If the section you are adding already exists on another page, do NOT copy it: reference its Prefill entry with a `Prefill` block (extract_block_to_prefill turns an existing page block into one).",
134
145
  inputSchema: {
135
146
  type: "object",
136
147
  properties: {
137
- page_id: { type: "integer" },
148
+ page_id: { type: "integer", description: "Page to edit. Pass exactly one of page_id / prefill_id." },
149
+ prefill_id: { type: "integer", description: "Prefill entry to edit instead of a page." },
138
150
  block: { type: "object" },
139
151
  position: { type: "integer" },
140
152
  site_url: { type: "string" },
141
153
  site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
142
154
  },
143
- required: ["page_id", "block"],
155
+ required: ["block"],
144
156
  },
145
157
  },
146
158
  {
147
159
  name: "update_block",
148
- description: "Replace a single block by id (the row id returned by get_page's builder[].id). The new `block` must include `content_group` (matching or changing the block type) plus `base` + `content`.",
160
+ description: "Replace a single block by id (the row id from get_page's builder[].id) on a page — or a prefill via prefill_id. The new `block` must include `content_group` plus `base` + `content`. The block is replaced, not patched: the returned row id is NEW. Media a human assigned in the backend is carried over automatically wherever your payload leaves the field empty — unless you change content_group. Other blocks are untouched.",
149
161
  inputSchema: {
150
162
  type: "object",
151
163
  properties: {
152
- page_id: { type: "integer" },
164
+ page_id: { type: "integer", description: "Page to edit. Pass exactly one of page_id / prefill_id." },
165
+ prefill_id: { type: "integer", description: "Prefill entry to edit instead of a page." },
153
166
  block_id: { type: "integer" },
154
167
  block: { type: "object" },
155
168
  site_url: { type: "string" },
156
169
  site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
157
170
  },
158
- required: ["page_id", "block_id", "block"],
171
+ required: ["block_id", "block"],
159
172
  },
160
173
  },
161
174
  {
162
175
  name: "remove_block",
163
- description: "Remove a block by its row id from the page's builder.",
176
+ description: "Remove a block by its row id from a page's builder (or a prefill's via prefill_id). Remaining blocks keep their ids; ordering closes the gap.",
164
177
  inputSchema: {
165
178
  type: "object",
166
179
  properties: {
167
- page_id: { type: "integer" },
180
+ page_id: { type: "integer", description: "Page to edit. Pass exactly one of page_id / prefill_id." },
181
+ prefill_id: { type: "integer", description: "Prefill entry to edit instead of a page." },
168
182
  block_id: { type: "integer" },
169
183
  site_url: { type: "string" },
170
184
  site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
171
185
  },
172
- required: ["page_id", "block_id"],
186
+ required: ["block_id"],
173
187
  },
174
188
  },
175
189
  {
176
190
  name: "reorder_blocks",
177
- description: "Reorder blocks. Send every block row id in the desired order (all existing block ids must be present, no extras).",
191
+ description: "Reorder blocks on a page (or a prefill via prefill_id). Send every block row id in the desired order (all existing block ids must be present, no extras).",
178
192
  inputSchema: {
179
193
  type: "object",
180
194
  properties: {
181
- page_id: { type: "integer" },
195
+ page_id: { type: "integer", description: "Page to edit. Pass exactly one of page_id / prefill_id." },
196
+ prefill_id: { type: "integer", description: "Prefill entry to edit instead of a page." },
182
197
  order: { type: "array", items: { type: "integer" } },
183
198
  site_url: { type: "string" },
184
199
  site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
185
200
  },
186
- required: ["page_id", "order"],
201
+ required: ["order"],
202
+ },
203
+ },
204
+ {
205
+ name: "extract_block_to_prefill",
206
+ description: "Move an existing page block into a NEW Prefill entry and replace it in place with a `Prefill` reference block. Lossless — media a human assigned survives the move. Use this whenever a section should appear on more than one page: extract it once, then add_block a `Prefill` block ({content_group:'Prefill', content:{block:<prefill id>}}) on the other pages. This is the intended FrameworC workflow; never copy the same section onto multiple pages.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ page_id: { type: "integer" },
211
+ block_id: { type: "integer", description: "Row id of the block to extract." },
212
+ title: { type: "string", description: "Title for the new Prefill entry, e.g. 'Newsletter CTA'." },
213
+ site_url: { type: "string" },
214
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
215
+ },
216
+ required: ["page_id", "block_id", "title"],
187
217
  },
188
218
  },
189
219
  {
190
220
  name: "list_forms",
191
- description: "List Form Tailor entries available for the `Form` block's block-level `form` key.",
221
+ description: "List Form entries (id, title, slug, field_count, ...). Use get_form for a form's fields, create_form / update_form / delete_form for CRUD, and the id for the `Form` block's block-level `form` key.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ site_url: { type: "string" },
226
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
227
+ },
228
+ },
229
+ },
230
+ {
231
+ name: "get_form_schema",
232
+ description: "Fetch the live field-group catalogue for Form entries from the CMS: every fwcFields group with its exact fields, enums and defaults, plus the form-level fields. Use before create_form / update_form when unsure of the shape.",
192
233
  inputSchema: {
193
234
  type: "object",
194
235
  properties: { site_url: { type: "string" } },
195
236
  },
196
237
  },
238
+ {
239
+ name: "get_form",
240
+ description: "Fetch one Form entry including its fwcFields rows (each {id, group, ...fields}).",
241
+ inputSchema: {
242
+ type: "object",
243
+ properties: {
244
+ id: { type: "integer" },
245
+ site_url: { type: "string" },
246
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
247
+ },
248
+ required: ["id"],
249
+ },
250
+ },
251
+ {
252
+ name: "create_form",
253
+ description: "Create a Form entry. Payload: {form: {title (required), headline?, recipients?: [emails], is_enabled?}, fwcFields?: [rows]}. Each row names its `group` — one of: section, text, number, email, phone, textarea, file, select, checkbox, radio, agreement — plus that group's fields: input groups take {label, placeholder?, name (required, unique), fieldId?, required?, width?: full|half|third|twoThirds}; select/checkbox/radio add options: [{value, label}]; agreement adds text (rich HTML); section takes only content (rich HTML, not an input). Call get_form_schema when unsure of a group's exact fields. Reference the created form from a page with a `Form` block ({content_group:'Form', form:<id>, content:{variant:...}}).",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ payload: {
258
+ type: "object",
259
+ properties: { form: { type: "object" }, fwcFields: { type: "array" } },
260
+ required: ["form"],
261
+ },
262
+ site_url: { type: "string" },
263
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
264
+ },
265
+ required: ["payload"],
266
+ },
267
+ },
268
+ {
269
+ name: "update_form",
270
+ description: "Update a Form entry. `form` merges scalar fields; if `fwcFields` is present the field rows are fully rebuilt from the array (send ALL rows, not a diff — they carry no media, so nothing is lost).",
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: {
274
+ id: { type: "integer" },
275
+ payload: { type: "object", properties: { form: { type: "object" }, fwcFields: { type: "array" } } },
276
+ site_url: { type: "string" },
277
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
278
+ },
279
+ required: ["id", "payload"],
280
+ },
281
+ },
282
+ {
283
+ name: "delete_form",
284
+ description: "Delete a Form entry. Refused with a 422 naming the referencing pages if any `Form` block still points at it; pass force:true to delete anyway.",
285
+ inputSchema: {
286
+ type: "object",
287
+ properties: {
288
+ id: { type: "integer" },
289
+ force: { type: "boolean" },
290
+ site_url: { type: "string" },
291
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
292
+ },
293
+ required: ["id"],
294
+ },
295
+ },
197
296
  {
198
297
  name: "list_menus",
199
- description: "List Menu Tailor entries available for the `MenuBlock`'s `content.menu` key.",
298
+ description: "List Menu entries (id, title, item_count, ...). Use get_menu / create_menu / update_menu / delete_menu for CRUD, and the id for `content.menu` on a MenuBlock or the `nav` field of the Navigation/Footer singles.",
200
299
  inputSchema: {
201
300
  type: "object",
202
- properties: { site_url: { type: "string" } },
301
+ properties: {
302
+ site_url: { type: "string" },
303
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
304
+ },
305
+ },
306
+ },
307
+ {
308
+ name: "get_menu",
309
+ description: "Fetch one Menu entry including its navigation tree (items {id, title, url, anchor, blank, children?}).",
310
+ inputSchema: {
311
+ type: "object",
312
+ properties: {
313
+ id: { type: "integer" },
314
+ site_url: { type: "string" },
315
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
316
+ },
317
+ required: ["id"],
318
+ },
319
+ },
320
+ {
321
+ name: "create_menu",
322
+ description: "Create a Menu entry. Payload: {menu: {title (required)}, navigation?: [items]}. Each item: {title (required), url, anchor?, blank?, children?: [items]}. `url` is a string ('/', '/kontakt#form', full URL) OR {page_id: n} to link a FrameworC page robustly. Max 2 levels — children may not have children.",
323
+ inputSchema: {
324
+ type: "object",
325
+ properties: {
326
+ payload: {
327
+ type: "object",
328
+ properties: { menu: { type: "object" }, navigation: { type: "array" } },
329
+ required: ["menu"],
330
+ },
331
+ site_url: { type: "string" },
332
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
333
+ },
334
+ required: ["payload"],
335
+ },
336
+ },
337
+ {
338
+ name: "update_menu",
339
+ description: "Update a Menu entry. `menu` merges title/slug/is_enabled; if `navigation` is present the whole tree is replaced from the array (send ALL items — they carry no media).",
340
+ inputSchema: {
341
+ type: "object",
342
+ properties: {
343
+ id: { type: "integer" },
344
+ payload: { type: "object", properties: { menu: { type: "object" }, navigation: { type: "array" } } },
345
+ site_url: { type: "string" },
346
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
347
+ },
348
+ required: ["id", "payload"],
349
+ },
350
+ },
351
+ {
352
+ name: "delete_menu",
353
+ description: "Delete a Menu entry. Refused with a 422 naming the referencing hosts (MenuBlocks, Navigation/Footer singles) if still referenced; pass force:true to delete anyway.",
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ id: { type: "integer" },
358
+ force: { type: "boolean" },
359
+ site_url: { type: "string" },
360
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
361
+ },
362
+ required: ["id"],
203
363
  },
204
364
  },
205
365
  {
206
366
  name: "list_prefills",
207
- description: "List Prefill Tailor entries available for the `Prefill` block's `content.block` key.",
367
+ description: "List Prefill entries (id, title, block_count, ...). Prefills hold sections shared across pages; reference one from a page with a `Prefill` block ({content_group:'Prefill', content:{block:<id>}}). Use get_prefill / create_prefill / update_prefill / delete_prefill for CRUD.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ site_url: { type: "string" },
372
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
373
+ },
374
+ },
375
+ },
376
+ {
377
+ name: "get_prefill",
378
+ description: "Fetch one Prefill entry including its builder blocks — same block shape as get_page.",
379
+ inputSchema: {
380
+ type: "object",
381
+ properties: {
382
+ id: { type: "integer" },
383
+ site_url: { type: "string" },
384
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
385
+ },
386
+ required: ["id"],
387
+ },
388
+ },
389
+ {
390
+ name: "create_prefill",
391
+ description: "Create a Prefill entry — a reusable section shared across pages. Payload: {prefill: {title (required), slug?}, builder?: [blocks]} with the SAME block shape as create_page. When several pages need the same section, create the Prefill FIRST and reference it from each page with a `Prefill` block instead of duplicating the section. (To factor out a section that already exists on a page, use extract_block_to_prefill instead.) Per-block edits work via add_block/update_block/remove_block/reorder_blocks with prefill_id.",
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ payload: {
396
+ type: "object",
397
+ properties: { prefill: { type: "object" }, builder: { type: "array" } },
398
+ required: ["prefill"],
399
+ },
400
+ site_url: { type: "string" },
401
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
402
+ },
403
+ required: ["payload"],
404
+ },
405
+ },
406
+ {
407
+ name: "update_prefill",
408
+ description: "Update a Prefill entry. `prefill` merges title/slug/is_enabled; a `builder` array fully rebuilds the blocks (prefer the per-block tools with prefill_id — a rebuild loses human-assigned media). Edits apply everywhere the prefill is referenced.",
409
+ inputSchema: {
410
+ type: "object",
411
+ properties: {
412
+ id: { type: "integer" },
413
+ payload: { type: "object", properties: { prefill: { type: "object" }, builder: { type: "array" } } },
414
+ site_url: { type: "string" },
415
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
416
+ },
417
+ required: ["id", "payload"],
418
+ },
419
+ },
420
+ {
421
+ name: "delete_prefill",
422
+ description: "Delete a Prefill entry. Refused with a 422 naming the referencing pages if any `Prefill` block still points at it; pass force:true to delete anyway (the referencing blocks would break).",
423
+ inputSchema: {
424
+ type: "object",
425
+ properties: {
426
+ id: { type: "integer" },
427
+ force: { type: "boolean" },
428
+ site_url: { type: "string" },
429
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
430
+ },
431
+ required: ["id"],
432
+ },
433
+ },
434
+ {
435
+ name: "get_settings",
436
+ description: "Read the FrameworC plugin settings exposed over the API: navbar layout options and the custom SCSS variables. Settings are GLOBAL (not per multisite site). Integration/secret settings are not accessible by design.",
208
437
  inputSchema: {
209
438
  type: "object",
210
439
  properties: { site_url: { type: "string" } },
211
440
  },
212
441
  },
442
+ {
443
+ name: "update_settings",
444
+ description: "Update FrameworC plugin settings. `fields` is a partial map; writable keys: navigation_width (full|container), navigation_align (left|center|right), navigation_mobile_extra_links (navbar|open), variable_variablesScss (SCSS overriding the design tokens). Settings are GLOBAL and affect the whole install — confirm with the user first. Integration/secret settings cannot be read or written through this API by design.",
445
+ inputSchema: {
446
+ type: "object",
447
+ properties: {
448
+ fields: { type: "object", description: "Field name => value map (partial)." },
449
+ site_url: { type: "string" },
450
+ },
451
+ required: ["fields"],
452
+ },
453
+ },
213
454
  {
214
455
  name: "get_block_schema",
215
456
  description: "Get the JSON schema + usage notes for one block by content_group name. Fetched live from the CMS, which derives it from the actual Tailor blueprints, so it is always current. Use this to recall field enums, defaults and conditional visibility.",
@@ -252,13 +493,14 @@ const tools = [
252
493
  },
253
494
  {
254
495
  name: "create_translation",
255
- description: "Create the sibling of an existing page on another multisite site, linked to the original so the language switcher connects them. Pass target_site_id, and optionally `page` (title/slug overrides) and `builder` (the translated blocks). Omitting builder copies nothing — the translation starts empty, ready for you to fill.",
496
+ description: "Create the sibling of an existing page — or Prefill entry, with prefill:true — on another multisite site, linked to the original so the language switcher connects them. Pass target_site_id, and optionally `page` (title/slug overrides) and `builder` (the translated blocks). Omitting builder copies nothing — the translation starts empty, ready for you to fill. Form and Menu entries have no translation linking; create them per site with site_id instead.",
256
497
  inputSchema: {
257
498
  type: "object",
258
499
  properties: {
259
- id: { type: "integer", description: "Source page id." },
500
+ id: { type: "integer", description: "Source page (or prefill) id." },
260
501
  target_site_id: { type: "integer", description: "Site to create the translation on." },
261
- source_site_id: { type: "integer", description: "Site the source page lives on. Defaults to the pinned CMS site." },
502
+ source_site_id: { type: "integer", description: "Site the source record lives on. Defaults to the pinned CMS site." },
503
+ prefill: { type: "boolean", description: "Set true when id is a Prefill entry, not a page." },
262
504
  page: { type: "object" },
263
505
  builder: { type: "array" },
264
506
  site_url: { type: "string" },
@@ -268,7 +510,7 @@ const tools = [
268
510
  },
269
511
  },
270
512
  ];
271
- const server = new index_js_1.Server({ name: "frameworc-mcp", version: "0.3.0" }, { capabilities: { tools: {}, resources: {} } });
513
+ const server = new index_js_1.Server({ name: "frameworc-mcp", version: "0.4.0" }, { capabilities: { tools: {}, resources: {} } });
272
514
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
273
515
  tools,
274
516
  }));
@@ -371,37 +613,105 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
371
613
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
372
614
  }
373
615
  case "create_translation": {
374
- const r = await client.createTranslation(Number(args.id), Number(args.target_site_id), { page: args.page ?? {}, builder: args.builder }, args.source_site_id !== undefined ? Number(args.source_site_id) : undefined);
616
+ const payload = { page: args.page ?? {}, builder: args.builder };
617
+ const source = args.source_site_id !== undefined ? Number(args.source_site_id) : undefined;
618
+ const r = args.prefill
619
+ ? await client.createPrefillTranslation(Number(args.id), Number(args.target_site_id), { prefill: args.page ?? {}, builder: args.builder }, source)
620
+ : await client.createTranslation(Number(args.id), Number(args.target_site_id), payload, source);
375
621
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
376
622
  }
377
623
  case "add_block": {
378
- const r = await client.addBlock(Number(args.page_id), args.block, args.position !== undefined ? Number(args.position) : undefined, site);
624
+ const r = await client.addBlock(blockHost(args), args.block, args.position !== undefined ? Number(args.position) : undefined, site);
379
625
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
380
626
  }
381
627
  case "update_block": {
382
- const r = await client.updateBlock(Number(args.page_id), Number(args.block_id), args.block, site);
628
+ const r = await client.updateBlock(blockHost(args), Number(args.block_id), args.block, site);
383
629
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
384
630
  }
385
631
  case "remove_block": {
386
- const r = await client.removeBlock(Number(args.page_id), Number(args.block_id), site);
632
+ const r = await client.removeBlock(blockHost(args), Number(args.block_id), site);
387
633
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
388
634
  }
389
635
  case "reorder_blocks": {
390
- const r = await client.reorderBlocks(Number(args.page_id), args.order, site);
636
+ const r = await client.reorderBlocks(blockHost(args), args.order, site);
637
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
638
+ }
639
+ case "extract_block_to_prefill": {
640
+ const r = await client.extractBlockToPrefill(Number(args.page_id), Number(args.block_id), args.title ?? "", site);
391
641
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
392
642
  }
393
643
  case "list_forms": {
394
644
  const r = await client.listForms(site);
395
645
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
396
646
  }
647
+ case "get_form_schema": {
648
+ const r = await client.getFormSchema();
649
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
650
+ }
651
+ case "get_form": {
652
+ const r = await client.getForm(Number(args.id), site);
653
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
654
+ }
655
+ case "create_form": {
656
+ const r = await client.createForm(args.payload, site);
657
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
658
+ }
659
+ case "update_form": {
660
+ const r = await client.updateForm(Number(args.id), args.payload, site);
661
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
662
+ }
663
+ case "delete_form": {
664
+ const r = await client.deleteForm(Number(args.id), Boolean(args.force), site);
665
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
666
+ }
397
667
  case "list_menus": {
398
668
  const r = await client.listMenus(site);
399
669
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
400
670
  }
671
+ case "get_menu": {
672
+ const r = await client.getMenu(Number(args.id), site);
673
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
674
+ }
675
+ case "create_menu": {
676
+ const r = await client.createMenu(args.payload, site);
677
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
678
+ }
679
+ case "update_menu": {
680
+ const r = await client.updateMenu(Number(args.id), args.payload, site);
681
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
682
+ }
683
+ case "delete_menu": {
684
+ const r = await client.deleteMenu(Number(args.id), Boolean(args.force), site);
685
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
686
+ }
401
687
  case "list_prefills": {
402
688
  const r = await client.listPrefills(site);
403
689
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
404
690
  }
691
+ case "get_prefill": {
692
+ const r = await client.getPrefill(Number(args.id), site);
693
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
694
+ }
695
+ case "create_prefill": {
696
+ const r = await client.createPrefill(args.payload, site);
697
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
698
+ }
699
+ case "update_prefill": {
700
+ const r = await client.updatePrefill(Number(args.id), args.payload, site);
701
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
702
+ }
703
+ case "delete_prefill": {
704
+ const r = await client.deletePrefill(Number(args.id), Boolean(args.force), site);
705
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
706
+ }
707
+ case "get_settings": {
708
+ const r = await client.getSettings();
709
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
710
+ }
711
+ case "update_settings": {
712
+ const r = await client.updateSettings(args.fields ?? {});
713
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
714
+ }
405
715
  case "get_page_meta": {
406
716
  const r = await client.getSingle(String(args.handle), site);
407
717
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frameworc-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for the FrameworC OctoberCMS plugin — programmatically create/edit FrameworC pages and blocks.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",