frameworc-mcp 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server that lets Cla
9
9
  - Creates pages, adds / updates / removes / reorders individual blocks — on pages and on Prefill entries
10
10
  - Full CRUD for `Form` entries (incl. their field rows), `Menu` entries (incl. the navigation tree) and `Prefill` entries (incl. their builder blocks)
11
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)
12
+ - Reads and writes the per-site singles (Meta & SEO, Navigation, Footer) and the FrameworC settings (global navbar options and SCSS, per-site SCSS; integration secrets are not accessible by design)
13
13
  - Multisite-aware: every content tool takes/pins a `site_id`; page and prefill translations are linked for the language switcher
14
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
15
15
  - Draft by default — created pages have `is_enabled = false`; the human flips the switch in the OctCMS backend after assigning images
@@ -96,7 +96,7 @@ If the file is missing or empty, `use_site` errors with a pointer to where to ad
96
96
 
97
97
  ### Adding a new OctCMS site
98
98
 
99
- 1. Spin up the new OctCMS install with the FrameworC suite incl. the `crscompany/frameworcmcp` plugin (v1.1.0 or newer).
99
+ 1. Spin up the new OctCMS install with the FrameworC suite incl. the `crscompany/frameworcmcp` plugin (v1.3.0 or newer; media assignment needs v1.2.0, per-site settings need v1.3.0 with FrameworC v1.10.0).
100
100
  2. Backend → Settings → FrameworC → **MCP API** → paste a freshly generated random string (e.g. `openssl rand -hex 32`) → Save.
101
101
  3. Edit `~/.config/frameworc/sites.json` on your laptop, add one entry: `{ "label": "New Client", "url": "https://newsite.test", "token": "<that string>" }`.
102
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.
@@ -109,7 +109,7 @@ No env var, no chat-client config edit, no ToolHive touch, no repo push. The who
109
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.
110
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.
111
111
 
112
- ## Tools (0.4.0)
112
+ ## Tools (0.5.0)
113
113
 
114
114
  | Tool | Description |
115
115
  |---|---|
@@ -124,10 +124,10 @@ No env var, no chat-client config edit, no ToolHive touch, no repo push. The who
124
124
  | `delete_page(id)` | Soft-delete a page. |
125
125
  | `create_translation(id, target_site_id, prefill?)` | Linked sibling of a page (or Prefill with `prefill:true`) on another multisite site. |
126
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). |
127
+ | `update_block(page_id \| prefill_id, block_id, block)` | Replace one block by row id (new id returned). A media field you omit is inherited; one you send is written verbatim, so `""` clears it. |
128
128
  | `remove_block(page_id \| prefill_id, block_id)` | Remove one block by row id. |
129
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. |
130
+ | `extract_block_to_prefill(page_id, block_id, title)` | Move a page block into a new Prefill entry (lossless from plugin v1.2.0) and reference it in place. |
131
131
  | `list_forms` / `get_form(id)` | Form entries; `get_form` includes the `fwcFields` rows. |
132
132
  | `get_form_schema` | Live field-group catalogue for authoring forms. |
133
133
  | `create_form` / `update_form` / `delete_form` | Form CRUD. `fwcFields` rows: `{group, label, name, required, width, ...}`; delete guarded unless `force:true`. |
@@ -136,8 +136,11 @@ No env var, no chat-client config edit, no ToolHive touch, no repo push. The who
136
136
  | `list_prefills` / `get_prefill(id)` | Prefill entries; `get_prefill` includes the builder blocks. |
137
137
  | `create_prefill` / `update_prefill` / `delete_prefill` | Prefill CRUD — same block shape as pages; delete guarded. |
138
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. |
139
+ | `get_settings` / `update_settings(fields)` | Global FrameworC settings: navbar options + global custom SCSS (`styles_globalScss`). Integration secrets are not exposed. |
140
+ | `get_site_settings` / `update_site_settings(fields)` | Per-site FrameworC settings: custom SCSS for one site (`siteScss`), compiled after the global SCSS. |
140
141
  | `get_block_schema(name)` | Field schema + usage notes for one block (live from the CMS). |
142
+ | `list_media(folder?, type?, sort?, limit?, offset?)` | Browse the media library. Returns files with their `path` — write that into a mediafinder field. |
143
+ | `search_media(q, folder?, type?, ...)` | Find library files by name across all folders. Every whitespace-separated word must appear in the path. |
141
144
 
142
145
  Form and Menu entries have no translation linking — create them per site by passing `site_id`.
143
146
 
@@ -197,14 +200,23 @@ Form and Menu entries have no translation linking — create them per site by pa
197
200
 
198
201
  ### Storage encoding (what the MCP understands)
199
202
 
200
- - Media fields (`image`, `imageMobile`, `backgroundImage`, `buttonIcon1..4`, `ogImage`, `images`, `file`) **must be empty** in the JSON — they are rejected if non-empty. Fill them in the OctCMS backend.
203
+ - Media fields hold **media library paths**, root-relative with a leading slash (`/images/hero.jpg`). Assignment only: the API cannot upload, and a path that is not in the library is a 422. Single-item fields (`maxItems: 1`) are strings and read as `""` when unset; multi fields are arrays and read as `[]`. Find paths with `search_media` / `list_media`.
201
204
  - `switch` fields accept booleans; the API normalises to `"1"` / `"0"` for storage.
202
205
  - `customCssClass` + `responsiveHide` are arrays of strings.
203
- - Multi-`mediafinder` fields (`images` for `Gallery` / `ImageStrip`) are arrays of path strings — but must be empty `[]` per the rule above.
206
+ - Multi-`mediafinder` fields are arrays of path strings: `Gallery.images` and `ImageStrip.images`, where **every** item renders, plus `Section.content.image` and the `Footer` single's `logo`, which are arrays in storage but of which the theme renders **only the first item**.
204
207
  - `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}`.
205
208
  - `Slider` accepts an optional `content.breakpoints = { tablet: number, mobile: number }`.
206
209
  - `Columns` blocks have `content.columns = [{ blockId, builder: [...blocks] }]` — recursive: each column's `builder` follows the exact same shape as the top-level `builder`.
207
210
 
211
+ ### Working with media
212
+
213
+ `mode: image` on a blueprint field is only a hint for the backend widget; October never enforces it, and real FrameworC content relies on the slack. The API therefore accepts any image, SVG or video extension in a `mode: image` field and rejects the rest (a `.pdf` in a hero would break `resize()`), which means:
214
+
215
+ - The `Navigation` logo is an SVG and `Header.image` holds an `.mp4` when `isVideoBg` is on. Both are valid.
216
+ - `buttonIcon1..4` and `Downloads`' `file` declare no `mode` at all, so any file type is accepted there.
217
+ - Whether SVG reports as `file_type: image` or `document` depends on the install's `media.image_extensions` config, so treat `list_media`'s `type` filter as a convenience rather than a guarantee.
218
+ - Folders are not selectable, and filenames containing `+ # % !` are unreachable through the API — an October media library limitation that applies to its own backend manager too.
219
+
208
220
  ## Catalogue drift
209
221
 
210
222
  The hand-authored block catalogue lives in `src/catalogue.ts`. It mirrors `plugins/crscompany/frameworc/blueprints/Blocks/*.yaml` + `Mixins/Buttons.yaml` + `Mixins/Buttons2.yaml` + `BaseBlock.yaml`. If a block blueprint changes (new field, new enum value, removed field), update `src/catalogue.ts` to match and rebuild. No generator script — kept manual because blocks change rarely per the FrameworC convention.
@@ -1,6 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApiClient = void 0;
4
+ /** Renders the defined params as a leading-`?` query string, or "" when empty. */
5
+ function qs(params) {
6
+ const search = new URLSearchParams();
7
+ for (const [key, value] of Object.entries(params)) {
8
+ if (value !== undefined && value !== null && value !== "") {
9
+ search.set(key, String(value));
10
+ }
11
+ }
12
+ const rendered = search.toString();
13
+ return rendered ? `?${rendered}` : "";
14
+ }
4
15
  class ApiClient {
5
16
  session;
6
17
  constructor(session) {
@@ -60,6 +71,13 @@ class ApiClient {
60
71
  getBlockSchema(name) {
61
72
  return this.request(`/blocks/${encodeURIComponent(name)}`);
62
73
  }
74
+ // --- media library (global) -------------------------------------------
75
+ listMedia(params = {}, siteId) {
76
+ return this.request(this.q(`/media${qs(params)}`, siteId));
77
+ }
78
+ searchMedia(query, params = {}, siteId) {
79
+ return this.request(this.q(`/media/search${qs({ q: query, ...params })}`, siteId));
80
+ }
63
81
  // --- pages ------------------------------------------------------------
64
82
  listPages(siteId) {
65
83
  return this.request(this.q("/pages", siteId));
@@ -181,6 +199,13 @@ class ApiClient {
181
199
  updateSettings(fields) {
182
200
  return this.request("/settings", { method: "PATCH", body: JSON.stringify({ fields }) });
183
201
  }
202
+ // --- FrameworC settings (per site) -------------------------------------
203
+ getSiteSettings(siteId) {
204
+ return this.request(this.q("/site-settings", siteId));
205
+ }
206
+ updateSiteSettings(fields, siteId) {
207
+ return this.request("/site-settings", { method: "PATCH", body: this.b({ fields }, siteId) });
208
+ }
184
209
  // --- singles ----------------------------------------------------------
185
210
  getSingle(handle, siteId) {
186
211
  return this.request(this.q(`/singles/${encodeURIComponent(handle)}`, siteId));
package/dist/catalogue.js CHANGED
@@ -6,7 +6,7 @@ const buttonsMixin = [
6
6
  { name: "buttonVariant1", type: "dropdown", enum: ["default", "outline", "blurLight", "blurDark", "plain"], default: "default" },
7
7
  { name: "buttonContrast1", type: "switch", default: false },
8
8
  { name: "buttonLink1", type: "text", comment: "Pagefinder path or URL for button 1 (left blank = no link)" },
9
- { name: "buttonIcon1", type: "media-single", comment: "Must stay empty — human fills via backend" },
9
+ { name: "buttonIcon1", type: "media-single", comment: "Media library path, e.g. \"/icons/arrow.svg\". Find one with search_media. No mode restriction, so any file type is accepted." },
10
10
  { name: "buttonBlank1", type: "switch", default: false },
11
11
  { name: "buttonLabel2", type: "text", comment: "Text of button 2" },
12
12
  { name: "buttonVariant2", type: "dropdown", enum: ["default", "outline", "blurLight", "blurDark", "plain"], default: "default" },
@@ -35,14 +35,14 @@ const baseBlock = [
35
35
  { name: "elevated", type: "switch", default: false, comment: "Lifts this block up into the previous block visually." },
36
36
  { name: "containerWidth", type: "dropdown", enum: ["default", "narrow", "full", "pop"], default: "default" },
37
37
  { name: "backgroundColor", type: "dropdown", enum: ["default", "accent", "contrast", "image", "containerAccent", "containerContrast", "containerImage"], default: "default" },
38
- { name: "backgroundImage", type: "media-single", comment: "Must stay empty — leave for human to fill in backend." },
38
+ { name: "backgroundImage", type: "media-single", comment: "Media library path from list_media/search_media. Only renders when backgroundColor is 'image' or 'containerImage'." },
39
39
  { name: "backgroundImageMobile", type: "media-single" },
40
40
  { name: "customCssClass", type: "taglist", enum: ["animate", "noSpacing", "noSpacingTop", "noSpacingBottom", "narrowSpacing", "centerButtons"], comment: "Array of class keys to apply as Builder-container--<class>." },
41
41
  { name: "aliasOverride", type: "text", comment: "Custom component alias (theme partial hook). Leave blank unless specifically needed." },
42
42
  { name: "responsiveHide", type: "checkboxlist", enum: ["desktop", "tablet", "mobile"], comment: "Which devices to hide this block on." },
43
43
  ];
44
44
  const sectionVariants = [
45
- "halfAndHalf", "noText", "noImage", "textAndText", "img70", "text70",
45
+ "halfAndHalf", "imgBleed", "noText", "noImage", "textAndText", "img70", "text70",
46
46
  "embedHalfAndHalf", "embed70", "embed30", "embedOnly",
47
47
  ];
48
48
  exports.BLOCKS = [
@@ -53,9 +53,11 @@ exports.BLOCKS = [
53
53
  whenToUse: "Always first block on a page. Use H1/H2 here (other blocks use H2/H3). One per page.",
54
54
  base: baseBlock,
55
55
  content: [
56
- { name: "image", type: "media-single", comment: "Must stay empty for MCP; human fills via backend." },
56
+ { name: "image", type: "media-single", comment: "Media library path. Declared mode:image, but an .mp4 is valid here when isVideoBg is on." },
57
57
  { name: "imageMobile", type: "media-single" },
58
58
  { name: "isVideoBg", type: "switch", default: false, comment: "If true, 'image' is treated as a video file and 'imageMobile' as poster." },
59
+ { name: "fullVideo", type: "media-single", comment: "Only used when isVideoBg is on. Media library path to the full-length video; a play button opens it in a lightbox popup over the page." },
60
+ { name: "fullVideoLabel", type: "text", comment: "Only used when isVideoBg is on. Label of the button that opens fullVideo." },
59
61
  ...buttonsMixin,
60
62
  { name: "fullHeight", type: "switch", default: true },
61
63
  { name: "contrast", type: "switch", default: false, comment: "Use contrast (light-on-dark) text for the headline when the background image is dark." },
@@ -66,12 +68,13 @@ exports.BLOCKS = [
66
68
  content_group: "Section",
67
69
  name: "Section",
68
70
  description: "Text + image / embed in a configurable column layout. The most common content block.",
69
- whenToUse: "Default choice for any paragraph + image content. Pick a variant that matches the proportion of text to visual. Use 'textAndText' for two text columns (e.g. a feature comparison). Use an 'embed*' variant when you have a YouTube video, map iframe, etc.",
71
+ whenToUse: "Default choice for any paragraph + image content. Pick a variant that matches the proportion of text to visual. Use 'imgBleed' for an image running to the edge of the viewport next to half-width text. Use 'textAndText' for two text columns (e.g. a feature comparison). Use an 'embed*' variant when you have a YouTube video, map iframe, etc.",
70
72
  base: baseBlock,
71
73
  content: [
72
74
  { name: "variant", type: "dropdown", enum: sectionVariants, default: "halfAndHalf" },
73
75
  { name: "reverse", type: "switch", default: false, hiddenWhen: { field: "variant", equals: ["noText", "noImage", "embedOnly"] }, comment: "Swap sides (image left vs right)." },
74
- { name: "image", type: "media-single", hiddenWhen: { field: "variant", equals: ["noImage", "embedHalfAndHalf", "embedOnly", "embed30", "embed70", "textAndText"] } },
76
+ { name: "reverseMobile", type: "switch", default: false, hiddenWhen: { field: "variant", equals: ["noText", "noImage", "embedOnly"] }, comment: "On mobile the columns stack; this flips their stacking order." },
77
+ { name: "image", type: "media-multi", comment: "Stored as an ARRAY of paths (the blueprint sets no maxItems) but the theme renders only the FIRST item, so send [\"/photo.jpg\"].", hiddenWhen: { field: "variant", equals: ["noImage", "embedHalfAndHalf", "embedOnly", "embed30", "embed70", "textAndText"] } },
75
78
  { name: "embed", type: "codeeditor", shownWhen: { field: "variant", equals: ["embedHalfAndHalf", "embedOnly", "embed30", "embed70"] }, comment: "Raw embed HTML (iframe etc.)." },
76
79
  ...buttonsMixin,
77
80
  { name: "headline2", type: "richeditor", shownWhen: { field: "variant", equals: ["textAndText"] }, comment: "Second column text (only when variant is 'textAndText')." },
@@ -102,7 +105,7 @@ exports.BLOCKS = [
102
105
  description: "One repeater item per tile/card.",
103
106
  items: [
104
107
  { name: "headline", type: "text", required: true, comment: "Tile title." },
105
- { name: "image", type: "media-single", comment: "Tile image. Leave empty for text-only tiles." },
108
+ { name: "image", type: "media-single", comment: "Tile image: a media library path, or \"\" for a text-only tile." },
106
109
  { name: "text", type: "richeditor", comment: "Tile body text." },
107
110
  ...buttonsMixin,
108
111
  ],
@@ -205,10 +208,10 @@ exports.BLOCKS = [
205
208
  content_group: "Gallery",
206
209
  name: "Gallery (masonry)",
207
210
  description: "Masonry image gallery.",
208
- whenToUse: "Multiple images shown together in a grid. Lightbox on expand. Use 'images' array (must be empty in MCP — human fills via backend).",
211
+ whenToUse: "Multiple images shown together in a grid, lightbox on expand. 'images' is an array of media library paths and every item renders.",
209
212
  base: baseBlock,
210
213
  content: [
211
- { name: "images", type: "media-multi", comment: "Array of image paths; MUST be empty in MCP." },
214
+ { name: "images", type: "media-multi", comment: "Array of media library paths, e.g. [\"/a.jpg\",\"/b.jpg\"]. Each must already exist; find them with search_media." },
212
215
  { name: "columns", type: "number", default: 3, comment: "Number of columns (blank = 3)." },
213
216
  ],
214
217
  },
@@ -216,7 +219,7 @@ exports.BLOCKS = [
216
219
  content_group: "Downloads",
217
220
  name: "Downloads",
218
221
  description: "List of downloadable files (name + icon + description + file each).",
219
- whenToUse: "Document / spec / PDF lists. Each row has a name, an icon dropdown, an optional description, and a file (which must remain empty in MCP — human fills).",
222
+ whenToUse: "Document / spec / PDF lists. Each row has a name, an icon dropdown, an optional description, and a file taken from the media library.",
220
223
  base: baseBlock,
221
224
  content: [],
222
225
  nestedRepeaters: [
@@ -227,7 +230,7 @@ exports.BLOCKS = [
227
230
  { name: "name", type: "text", required: true },
228
231
  { name: "icon", type: "dropdown", enum: ["none", "file", "pdf", "dl"], default: "none" },
229
232
  { name: "description", type: "richeditor" },
230
- { name: "file", type: "media-single", comment: "Must stay empty — human fills via backend." },
233
+ { name: "file", type: "media-single", comment: "Media library path to the document, e.g. \"/docs/spec.pdf\". No mode restriction, so any file type is accepted." },
231
234
  ],
232
235
  },
233
236
  ],
@@ -288,7 +291,7 @@ exports.BLOCKS = [
288
291
  whenToUse: "Brand/logos row, partners strip, photo strip with horizontal scroll. Optional auto-scroll.",
289
292
  base: baseBlock,
290
293
  content: [
291
- { name: "images", type: "media-multi", comment: "Array of image paths; MUST be empty in MCP." },
294
+ { name: "images", type: "media-multi", comment: "Array of media library paths; every item renders, side by side." },
292
295
  { name: "centered", type: "switch", default: false, comment: "Center the strip horizontally." },
293
296
  { name: "autoScroll", type: "switch", default: false },
294
297
  { name: "stopOnHover", type: "switch", default: false },
@@ -308,7 +311,30 @@ exports.BLOCKS = [
308
311
  ],
309
312
  },
310
313
  ];
311
- exports.BLOCK_CATALOGUE_TEXT = exports.BLOCKS.map((b) => {
314
+ /**
315
+ * Lands once, ahead of every block, rather than being repeated per field.
316
+ * Media is the part of a block payload that models get wrong most often.
317
+ */
318
+ const MEDIA_PREAMBLE = [
319
+ "## Media fields",
320
+ "",
321
+ "`media-single` takes ONE path string; `media-multi` takes an ARRAY of path",
322
+ "strings. Paths are root-relative with a leading slash, exactly as stored:",
323
+ '"/hero.jpg", "/logos/brand.svg".',
324
+ "",
325
+ "The file must ALREADY EXIST in the library. Call search_media (by name) or",
326
+ "list_media (by folder) and copy the `path` from the result — never the `url`,",
327
+ "and never invent a filename: a path that does not resolve is rejected. This",
328
+ "server cannot upload. If what the user wants is not in the library, say so",
329
+ "and ask them to add it in Backend > Media.",
330
+ "",
331
+ 'To leave a field unset send "" (single) or [] (multi). On update_block a',
332
+ "media key you OMIT keeps whatever is stored, while a key you SEND is written",
333
+ 'as given — so sending "" is how you clear an image.',
334
+ "",
335
+ "",
336
+ ].join("\n");
337
+ exports.BLOCK_CATALOGUE_TEXT = MEDIA_PREAMBLE + exports.BLOCKS.map((b) => {
312
338
  const lines = [];
313
339
  lines.push(`### ${b.name} (content_group="${b.content_group}")`);
314
340
  lines.push(`When to use: ${b.whenToUse}`);
package/dist/index.js CHANGED
@@ -93,7 +93,7 @@ const tools = [
93
93
  },
94
94
  {
95
95
  name: "create_page",
96
- description: "Create a new FrameworC page. Send a `page` object (title, slug, fullslug, is_enabled, metaTitle, metaDescription, menuStyle, menuHide) and a `builder` array of block objects per the frameworc://blocks resource. Pages are created with is_enabled=false (draft) by default. Media fields must be left empty — the human fills them in the backend. After creating, surface the returned page id so the user can assign images and flip is_enabled to publish.",
96
+ description: "Create a new FrameworC page. Send a `page` object (title, slug, fullslug, is_enabled, metaTitle, metaDescription, menuStyle, menuHide) and a `builder` array of block objects per the frameworc://blocks resource. Pages are created with is_enabled=false (draft) by default. Media fields take a media library path such as \"/images/hero.jpg\" — find one with search_media or list_media; the file must already exist, as this server cannot upload. Leave a media field as \"\" (single) or [] (multi) to leave it unset. After creating, surface the returned page id so the user can review and flip is_enabled to publish.",
97
97
  inputSchema: {
98
98
  type: "object",
99
99
  properties: {
@@ -114,7 +114,7 @@ const tools = [
114
114
  },
115
115
  {
116
116
  name: "update_page",
117
- description: "Update an existing page. Partial updates: send only `page` (meta edits) and/or `builder` (full rebuild — all existing blocks are deleted and the new array is written). To only add/edit/remove one block, use add_block / update_block / remove_block instead.",
117
+ description: "Update an existing page. Partial updates: send only `page` (meta edits) and/or `builder` (full rebuild — all existing blocks are deleted and the new array is written). A rebuild also rewrites media, so send the builder array exactly as get_page returned it, paths included, or the existing images are cleared. To add, edit or remove one block, prefer add_block / update_block / remove_block. `page.ogImage` takes a media library path.",
118
118
  inputSchema: {
119
119
  type: "object",
120
120
  properties: {
@@ -157,7 +157,7 @@ const tools = [
157
157
  },
158
158
  {
159
159
  name: "update_block",
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.",
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. A media field you OMIT is inherited from the old block; one you SEND is written verbatim, so sending \"\" (or [] for a multi field) is how you clear an image. Keep each repeater row's `id` from get_page so media follows the right row. Inheritance is skipped when you change content_group. Other blocks are untouched.",
161
161
  inputSchema: {
162
162
  type: "object",
163
163
  properties: {
@@ -203,7 +203,7 @@ const tools = [
203
203
  },
204
204
  {
205
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.",
206
+ description: "Move an existing page block into a NEW Prefill entry and replace it in place with a `Prefill` reference block. Lossless: assigned media survives the move (requires the frameworcmcp plugin at v1.2.0 or newer; older versions silently dropped it). 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
207
  inputSchema: {
208
208
  type: "object",
209
209
  properties: {
@@ -405,7 +405,7 @@ const tools = [
405
405
  },
406
406
  {
407
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.",
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 rewrites media from your payload, so send the blocks as get_prefill returned them or the images are cleared). Edits apply everywhere the prefill is referenced.",
409
409
  inputSchema: {
410
410
  type: "object",
411
411
  properties: {
@@ -433,7 +433,7 @@ const tools = [
433
433
  },
434
434
  {
435
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.",
436
+ description: "Read the FrameworC plugin settings exposed over the API: navbar layout options and the global custom SCSS. Settings are GLOBAL (not per multisite site); per-site SCSS lives in get_site_settings. Integration/secret settings are not accessible by design.",
437
437
  inputSchema: {
438
438
  type: "object",
439
439
  properties: { site_url: { type: "string" } },
@@ -441,7 +441,7 @@ const tools = [
441
441
  },
442
442
  {
443
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.",
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), styles_globalScss (SCSS compiled into every site before that site's own SCSS, so its $variables and mixins are usable there; SCSS that does not compile is rejected). 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
445
  inputSchema: {
446
446
  type: "object",
447
447
  properties: {
@@ -451,6 +451,66 @@ const tools = [
451
451
  required: ["fields"],
452
452
  },
453
453
  },
454
+ {
455
+ name: "get_site_settings",
456
+ description: "Read the per-site FrameworC settings: siteScss, the custom SCSS for one multisite site. It is compiled after the global styles_globalScss from get_settings, so it can use that SCSS's $variables and mixins.",
457
+ inputSchema: {
458
+ type: "object",
459
+ properties: {
460
+ site_url: { type: "string" },
461
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
462
+ },
463
+ },
464
+ },
465
+ {
466
+ name: "update_site_settings",
467
+ description: "Update the per-site FrameworC settings. `fields` is a partial map; writable key: siteScss (SCSS for this site only, compiled after the global SCSS; SCSS that does not compile is rejected). Affects every page on that site, so confirm with the user first.",
468
+ inputSchema: {
469
+ type: "object",
470
+ properties: {
471
+ fields: { type: "object", description: "Field name => value map (partial)." },
472
+ site_url: { type: "string" },
473
+ site_id: { type: "integer", description: "Target multisite site id; defaults to the pinned CMS site." },
474
+ },
475
+ required: ["fields"],
476
+ },
477
+ },
478
+ {
479
+ name: "list_media",
480
+ description: "Browse the media library one folder at a time. Returns {folder, parent, folders:[{path,name,item_count}], files:[{path,name,file_type,extension,size,last_modified,url}], total_files, truncated}. To put a file on a page, write its `path` — the root-relative form with a leading slash, e.g. \"/logos/brand.svg\" — into a mediafinder field; never the `url`. This server CANNOT upload: if the file the user wants is not here, say so and ask them to add it in Backend > Media. Prefer search_media when you know part of the name. Note that `file_type` buckets follow the install's media config (SVG may report as image or as document), so treat `type` as a convenience filter rather than a guarantee. The library is global: site_id is accepted and ignored.",
481
+ inputSchema: {
482
+ type: "object",
483
+ properties: {
484
+ folder: { type: "string", description: "Folder to list, root-relative with a leading slash. Defaults to \"/\". A folder that does not exist is a 404." },
485
+ type: { type: "string", enum: ["image", "video", "audio", "document"], description: "Filter files by kind." },
486
+ sort: { type: "string", enum: ["title", "size", "modified"], description: "Defaults to title." },
487
+ direction: { type: "string", enum: ["asc", "desc"], description: "Defaults to asc." },
488
+ limit: { type: "integer", description: "Max files to return (default 100, max 500). Folders are never paged." },
489
+ offset: { type: "integer", description: "Files to skip, for paging." },
490
+ site_url: { type: "string" },
491
+ site_id: { type: "integer", description: "Accepted and ignored; the media library is global." },
492
+ },
493
+ },
494
+ },
495
+ {
496
+ name: "search_media",
497
+ description: "Find files anywhere in the media library by name. The query is lowercased and split on spaces, and EVERY word must appear somewhere in the file's full path — so \"logo dark\" matches \"/brand/logo-dark.svg\" and \"2024 pdf\" matches \"/reports/2024-annual.pdf\". It matches the whole path, not just the filename. Returns the same file shape as list_media; write the returned `path` into a mediafinder field. Prefer this over list_media whenever the user names a file. This server cannot upload files. Filenames containing + # % or ! are not addressable through this API (an October media library limit), so do not retry those.",
498
+ inputSchema: {
499
+ type: "object",
500
+ properties: {
501
+ q: { type: "string", description: "Search words; at least 2 characters." },
502
+ folder: { type: "string", description: "Optional: keep only results inside this folder subtree." },
503
+ type: { type: "string", enum: ["image", "video", "audio", "document"] },
504
+ sort: { type: "string", enum: ["title", "size", "modified"] },
505
+ direction: { type: "string", enum: ["asc", "desc"] },
506
+ limit: { type: "integer", description: "Max files (default 50, max 200)." },
507
+ offset: { type: "integer", description: "Files to skip, for paging." },
508
+ site_url: { type: "string" },
509
+ site_id: { type: "integer", description: "Accepted and ignored; the media library is global." },
510
+ },
511
+ required: ["q"],
512
+ },
513
+ },
454
514
  {
455
515
  name: "get_block_schema",
456
516
  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.",
@@ -479,7 +539,7 @@ const tools = [
479
539
  },
480
540
  {
481
541
  name: "update_page_meta",
482
- description: "Update a per-site single (Meta / Navigation / Footer). Send only the fields you want to change, e.g. {handle:'Meta', fields:{metaTitle:'...', description:'...'}}. Media fields must be left empty. Affects every page on that site, so confirm with the user first.",
542
+ description: "Update a per-site single (Meta / Navigation / Footer). Send only the fields you want to change, e.g. {handle:'Meta', fields:{metaTitle:'...', description:'...'}}. Media fields take a media library path from search_media: Meta.ogImage and Navigation.logo / logoDark are single paths, while Footer.logo is an ARRAY of paths of which only the first renders. Affects every page on that site, so confirm with the user first.",
483
543
  inputSchema: {
484
544
  type: "object",
485
545
  properties: {
@@ -510,7 +570,7 @@ const tools = [
510
570
  },
511
571
  },
512
572
  ];
513
- const server = new index_js_1.Server({ name: "frameworc-mcp", version: "0.4.0" }, { capabilities: { tools: {}, resources: {} } });
573
+ const server = new index_js_1.Server({ name: "frameworc-mcp", version: "0.5.0" }, { capabilities: { tools: {}, resources: {} } });
514
574
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
515
575
  tools,
516
576
  }));
@@ -704,6 +764,28 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
704
764
  const r = await client.deletePrefill(Number(args.id), Boolean(args.force), site);
705
765
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
706
766
  }
767
+ case "list_media": {
768
+ const r = await client.listMedia({
769
+ folder: args.folder,
770
+ type: args.type,
771
+ sort: args.sort,
772
+ direction: args.direction,
773
+ limit: args.limit,
774
+ offset: args.offset,
775
+ }, site);
776
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
777
+ }
778
+ case "search_media": {
779
+ const r = await client.searchMedia(String(args.q ?? ""), {
780
+ folder: args.folder,
781
+ type: args.type,
782
+ sort: args.sort,
783
+ direction: args.direction,
784
+ limit: args.limit,
785
+ offset: args.offset,
786
+ }, site);
787
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
788
+ }
707
789
  case "get_settings": {
708
790
  const r = await client.getSettings();
709
791
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
@@ -712,6 +794,14 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
712
794
  const r = await client.updateSettings(args.fields ?? {});
713
795
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
714
796
  }
797
+ case "get_site_settings": {
798
+ const r = await client.getSiteSettings(site);
799
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
800
+ }
801
+ case "update_site_settings": {
802
+ const r = await client.updateSiteSettings(args.fields ?? {}, site);
803
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
804
+ }
715
805
  case "get_page_meta": {
716
806
  const r = await client.getSingle(String(args.handle), site);
717
807
  return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
- {
2
- "name": "frameworc-mcp",
3
- "version": "0.4.0",
4
- "description": "MCP server for the FrameworC OctoberCMS plugin — programmatically create/edit FrameworC pages and blocks.",
5
- "license": "MIT",
6
- "type": "commonjs",
7
- "bin": {
8
- "frameworc-mcp": "dist/index.js"
9
- },
10
- "main": "dist/index.js",
11
- "files": [
12
- "dist",
13
- "README.md"
14
- ],
15
- "scripts": {
16
- "build": "tsc",
17
- "start": "node dist/index.js",
18
- "prepublishOnly": "npm run build"
19
- },
20
- "dependencies": {
21
- "@modelcontextprotocol/sdk": "^1.0.0"
22
- },
23
- "devDependencies": {
24
- "@types/node": "^20.0.0",
25
- "typescript": "^5.4.0"
26
- },
27
- "engines": {
28
- "node": ">=18.0.0"
29
- },
30
- "publishConfig": {
31
- "@yourorg:registry": "https://gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/packages/npm/"
32
- }
33
- }
1
+ {
2
+ "name": "frameworc-mcp",
3
+ "version": "0.6.1",
4
+ "description": "MCP server for the FrameworC OctoberCMS plugin — programmatically create/edit FrameworC pages and blocks.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "frameworc-mcp": "dist/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.0.0",
25
+ "typescript": "^5.4.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "@yourorg:registry": "https://gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/packages/npm/"
32
+ }
33
+ }