frameworc-mcp 0.2.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 ADDED
@@ -0,0 +1,216 @@
1
+ # @yourorg/frameworc-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that lets Claude / Cursor / opencode / other AI chat clients create and edit pages built with the **FrameworC** OctoberCMS plugin. One MCP process serves any number of OctCMS installs — you tell the chat which site to work on by calling `use_site`.
4
+
5
+ ## What it does
6
+
7
+ - Composes 15 prebuilt FrameworC blocks (`Header`, `Section`, `Tiles`, `Slider`, `Tabs`, `Accordion`, `Form`, `Gallery`, `Downloads`, `Columns`, `Prefill`, `BlogList`, `MenuBlock`, `ImageStrip`, `InstaFeed`) into pages
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
11
+ - 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
+ - Draft by default — created pages have `is_enabled = false`; the human flips the switch in the OctCMS backend after assigning images
13
+
14
+ The chat-side flow (text → blocks) is **LLM-native**: you paste a markdown page (or upload `.docx` / `.pdf` and instruct Claude to convert it to markdown), Claude reads the `frameworc://blocks` resource once, segments the content into blocks, calls `list_forms` / `list_menus` / `list_prefills` as needed, then calls `create_page` with the assembled JSON. The MCP simply forwards authenticated HTTPS calls to the OctCMS install.
15
+
16
+ ## Architecture
17
+
18
+ ```
19
+ chat client (Claude Desktop / opencode / Cursor / Cline / Continue / ...)
20
+ │ stdio JSON-RPC
21
+ ▼
22
+ node dist/index.js (local, spawned by the chat client)
23
+ reads ~/.config/frameworc/sites.json (auto-created on first run; hot-reloaded)
24
+ │ HTTPS + Authorization: Bearer <token resolved by URL>
25
+ ▼
26
+ any OctCMS install /api/mcp/v1/* (provided by the FrameworC plugin v1.10.0+)
27
+ ```
28
+
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.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ git clone <your-gitlab-url>/frameworc-mcp.git
35
+ cd frameworc-mcp
36
+ npm install
37
+ npm run build
38
+ ```
39
+
40
+ (Optional) publish to your GitLab group's npm registry so colleagues can `npx` it without a clone — see [Publishing](#publishing) below.
41
+
42
+ ## Claude Desktop config
43
+
44
+ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`; Linux: `~/.config/Claude/`):
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "frameworc": {
50
+ "command": "npx",
51
+ "args": ["-y", "@yourorg/frameworc-mcp"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ If you cloned instead of `npx`-installed, point `args` to your local `dist/index.js`:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "frameworc": {
63
+ "command": "node",
64
+ "args": ["/absolute/path/to/frameworc-mcp/dist/index.js"]
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ No env vars. Restart Claude → the `frameworc` tools appear. On first start the MCP creates `~/.config/frameworc/sites.json` with a template; edit it with your real sites (see below).
71
+
72
+ ## Per-site token file — `~/.config/frameworc/sites.json`
73
+
74
+ The MCP stores one bearer token per OctCMS install in this file on your laptop. It's the only credential store. Format:
75
+
76
+ ```json
77
+ {
78
+ "sites": [
79
+ { "label": "Client A prod", "url": "https://clienta.test", "token": "AAA..." },
80
+ { "label": "Client A staging", "url": "https://staging.clienta.test", "token": "BBB..." },
81
+ { "label": "Myco", "url": "https://myco.example", "token": "CCC..." }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ - `label` is optional, shown by the `list_sites` tool so the chat can pick a site by name when you say "use the myco site".
87
+ - `url` is matched after normalising trailing slashes — `https://x.test/` and `https://x.test` are the same.
88
+ - `token` is sent as `Authorization: Bearer <token>` on every HTTP call to that site. Never committed to git.
89
+
90
+ The file is created automatically on first MCP startup (with a template and `0600` perms). Edits are picked up via stat-on-every-tool-call hot-reload — no MCP or chat-client restart needed.
91
+
92
+ If the file is missing or empty, `use_site` errors with a pointer to where to add it. The `list_sites` tool enumerates configured sites without exposing tokens.
93
+
94
+ ### Adding a new OctCMS site
95
+
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.
98
+ 3. Edit `~/.config/frameworc/sites.json` on your laptop, add one entry: `{ "label": "New Client", "url": "https://newsite.test", "token": "<that string>" }`.
99
+ 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
+
101
+ No env var, no chat-client config edit, no ToolHive touch, no repo push. The whole token map lives in one file on your machine.
102
+
103
+ ### Security
104
+
105
+ - The file should be `0600` (auto-set on first creation). If you `chmod` it looser, the MCP prints a stderr warning on startup.
106
+ - 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
+ - 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
+
109
+ ## Tools (v1)
110
+
111
+ | Tool | Description |
112
+ |---|---|
113
+ | `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
+ | `list_sites` | List configured sites (label + URL only, no tokens) from `sites.json`. |
115
+ | `list_pages` | List all pages on the pinned site. |
116
+ | `get_page(id)` | Full page JSON (page meta + builder blocks + nested repeaters). |
117
+ | `create_page(payload)` | Create a draft page. `payload = { page: {...}, builder: [...] }`. |
118
+ | `update_page(id, payload)` | Edit page meta, or full-rebuild the builder array. |
119
+ | `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. |
128
+
129
+ ## Resource
130
+
131
+ - `frameworc://blocks` — reference for all 15 block types (fields, defaults, enums, conditional visibility, nested repeaters, reference fields). The chat reads this once per session; you don't invoke it manually.
132
+
133
+ ## Page-content JSON shape
134
+
135
+ `create_page` / `update_page` `payload`:
136
+
137
+ ```json
138
+ {
139
+ "page": {
140
+ "title": "Contact",
141
+ "slug": "contact",
142
+ "fullslug": "contact",
143
+ "is_enabled": false,
144
+ "metaTitle": "",
145
+ "metaDescription": "",
146
+ "menuStyle": "solid",
147
+ "menuHide": "no"
148
+ },
149
+ "builder": [
150
+ {
151
+ "content_group": "Header",
152
+ "base": {
153
+ "blockId": "kontakt",
154
+ "headline": "<h1>Contact us</h1>",
155
+ "elevated": false,
156
+ "containerWidth": "default",
157
+ "backgroundColor": "default",
158
+ "customCssClass": [],
159
+ "responsiveHide": []
160
+ },
161
+ "content": {
162
+ "image": "",
163
+ "imageMobile": "",
164
+ "isVideoBg": false,
165
+ "buttonLabel1": "",
166
+ "buttonLink1": "",
167
+ "buttonBlank1": false,
168
+ "fullHeight": true,
169
+ "contrast": false,
170
+ "overlay": false
171
+ }
172
+ },
173
+ {
174
+ "content_group": "Form",
175
+ "base": { "blockId": "formular", "headline": "<h2>Write us</h2>" },
176
+ "form_entry": 7,
177
+ "content": { "variant": "default" }
178
+ }
179
+ ]
180
+ }
181
+ ```
182
+
183
+ ### Storage encoding (what the MCP understands)
184
+
185
+ - 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.
186
+ - `switch` fields accept booleans; the API normalises to `"1"` / `"0"` for storage.
187
+ - `customCssClass` + `responsiveHide` are arrays of strings.
188
+ - 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`).
190
+ - `Slider` accepts an optional `content.breakpoints = { tablet: number, mobile: number }`.
191
+ - `Columns` blocks have `content.columns = [{ blockId, builder: [...blocks] }]` — recursive: each column's `builder` follows the exact same shape as the top-level `builder`.
192
+
193
+ ## Catalogue drift
194
+
195
+ 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.
196
+
197
+ ## Publishing
198
+
199
+ To publish to your GitLab group's npm registry:
200
+
201
+ 1. Edit `package.json`:
202
+ - Replace `@yourorg` in `name` and `publishConfig["@yourorg:registry"]` with your actual GitLab scope/group.
203
+ - Replace `<YOUR-PROJECT-ID>` in `publishConfig["@yourorg:registry"]` with the numeric project id of the `frameworc-mcp` GitLab repo.
204
+ 2. Create a GitLab deploy token / project access token with `api` + `write_registry` scope. Add a `.npmrc` at the registry host:
205
+
206
+ ```
207
+ @yourorg:registry=https://gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/packages/npm/
208
+ //gitlab.com/api/v4/projects/<YOUR-PROJECT-ID>/packages/npm/:_authToken=<TOKEN>
209
+ ```
210
+ 3. `npm publish`.
211
+
212
+ Colleagues then use `npx -y @yourorg/frameworc-mcp` in their `claude_desktop_config.json` (no clone needed).
213
+
214
+ ## License
215
+
216
+ MIT.
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiClient = void 0;
4
+ class ApiClient {
5
+ session;
6
+ constructor(session) {
7
+ this.session = session;
8
+ }
9
+ async request(path, init = {}) {
10
+ const base = this.session.getUrl();
11
+ const url = `${base}/api/mcp/v1${path}`;
12
+ const headers = {
13
+ "Authorization": `Bearer ${this.session.getToken()}`,
14
+ "Accept": "application/json",
15
+ ...(init.headers ?? {}),
16
+ };
17
+ if (init.body && !headers["Content-Type"]) {
18
+ headers["Content-Type"] = "application/json";
19
+ }
20
+ const res = await fetch(url, { ...init, headers });
21
+ const text = await res.text();
22
+ let json = null;
23
+ try {
24
+ json = text ? JSON.parse(text) : null;
25
+ }
26
+ catch {
27
+ json = text;
28
+ }
29
+ if (!res.ok) {
30
+ const msg = json && typeof json === "object" && json.errors
31
+ ? `HTTP ${res.status}: ${JSON.stringify(json.errors)}`
32
+ : `HTTP ${res.status}: ${typeof json === "string" ? json : JSON.stringify(json)}`;
33
+ throw new Error(msg);
34
+ }
35
+ return json;
36
+ }
37
+ listPages() {
38
+ return this.request("/pages");
39
+ }
40
+ getPage(id) {
41
+ return this.request(`/pages/${id}`);
42
+ }
43
+ createPage(payload) {
44
+ return this.request("/pages", { method: "POST", body: JSON.stringify(payload) });
45
+ }
46
+ updatePage(id, payload) {
47
+ return this.request(`/pages/${id}`, { method: "PATCH", body: JSON.stringify(payload) });
48
+ }
49
+ deletePage(id) {
50
+ return this.request(`/pages/${id}`, { method: "DELETE" });
51
+ }
52
+ addBlock(pageId, block, position) {
53
+ return this.request(`/pages/${pageId}/blocks`, {
54
+ method: "POST",
55
+ body: JSON.stringify({ block, position }),
56
+ });
57
+ }
58
+ updateBlock(pageId, blockId, block) {
59
+ return this.request(`/pages/${pageId}/blocks/${blockId}`, {
60
+ method: "PATCH",
61
+ body: JSON.stringify(block),
62
+ });
63
+ }
64
+ removeBlock(pageId, blockId) {
65
+ return this.request(`/pages/${pageId}/blocks/${blockId}`, { method: "DELETE" });
66
+ }
67
+ reorderBlocks(pageId, order) {
68
+ return this.request(`/pages/${pageId}/blocks/order`, {
69
+ method: "POST",
70
+ body: JSON.stringify({ order }),
71
+ });
72
+ }
73
+ listForms() {
74
+ return this.request("/forms");
75
+ }
76
+ listMenus() {
77
+ return this.request("/menus");
78
+ }
79
+ listPrefills() {
80
+ return this.request("/prefills");
81
+ }
82
+ }
83
+ exports.ApiClient = ApiClient;
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BLOCK_CATALOGUE_TEXT = exports.BLOCKS = void 0;
4
+ const buttonsMixin = [
5
+ { name: "buttonLabel1", type: "text", comment: "Text of button 1" },
6
+ { name: "buttonVariant1", type: "dropdown", enum: ["default", "outline", "blurLight", "blurDark", "plain"], default: "default" },
7
+ { name: "buttonContrast1", type: "switch", default: false },
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" },
10
+ { name: "buttonBlank1", type: "switch", default: false },
11
+ { name: "buttonLabel2", type: "text", comment: "Text of button 2" },
12
+ { name: "buttonVariant2", type: "dropdown", enum: ["default", "outline", "blurLight", "blurDark", "plain"], default: "default" },
13
+ { name: "buttonContrast2", type: "switch", default: false },
14
+ { name: "buttonLink2", type: "text", comment: "Pagefinder path or URL for button 2" },
15
+ { name: "buttonIcon2", type: "media-single" },
16
+ { name: "buttonBlank2", type: "switch", default: false },
17
+ ];
18
+ const buttons2Mixin = [
19
+ { name: "buttonLabel3", type: "text" },
20
+ { name: "buttonVariant3", type: "dropdown", enum: ["default", "outline", "blurLight", "blurDark", "plain"], default: "default" },
21
+ { name: "buttonContrast3", type: "switch", default: false },
22
+ { name: "buttonLink3", type: "text" },
23
+ { name: "buttonIcon3", type: "media-single" },
24
+ { name: "buttonBlank3", type: "switch", default: false },
25
+ { name: "buttonLabel4", type: "text" },
26
+ { name: "buttonVariant4", type: "dropdown", enum: ["default", "outline", "blurLight", "blurDark", "plain"], default: "default" },
27
+ { name: "buttonContrast4", type: "switch", default: false },
28
+ { name: "buttonLink4", type: "text" },
29
+ { name: "buttonIcon4", type: "media-single" },
30
+ { name: "buttonBlank4", type: "switch", default: false },
31
+ ];
32
+ const baseBlock = [
33
+ { name: "blockId", type: "text", comment: "Unique anchor id, slug-safe (e.g. 'o-nas' or 'kontakt')" },
34
+ { name: "headline", type: "richeditor", comment: "Block headline. Use H2/H3 (Header block uses H1/H2). Wrap text in <p> or <h2> tags as appropriate." },
35
+ { name: "elevated", type: "switch", default: false, comment: "Lifts this block up into the previous block visually." },
36
+ { name: "containerWidth", type: "dropdown", enum: ["default", "narrow", "full", "pop"], default: "default" },
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." },
39
+ { name: "backgroundImageMobile", type: "media-single" },
40
+ { name: "customCssClass", type: "taglist", enum: ["animate", "noSpacing", "noSpacingTop", "noSpacingBottom", "narrowSpacing", "centerButtons"], comment: "Array of class keys to apply as Builder-container--<class>." },
41
+ { name: "aliasOverride", type: "text", comment: "Custom component alias (theme partial hook). Leave blank unless specifically needed." },
42
+ { name: "responsiveHide", type: "checkboxlist", enum: ["desktop", "tablet", "mobile"], comment: "Which devices to hide this block on." },
43
+ ];
44
+ const sectionVariants = [
45
+ "halfAndHalf", "noText", "noImage", "textAndText", "img70", "text70",
46
+ "embedHalfAndHalf", "embed70", "embed30", "embedOnly",
47
+ ];
48
+ exports.BLOCKS = [
49
+ {
50
+ content_group: "Header",
51
+ name: "Header (hero)",
52
+ description: "Page hero / headline banner at the top of a page.",
53
+ whenToUse: "Always first block on a page. Use H1/H2 here (other blocks use H2/H3). One per page.",
54
+ base: baseBlock,
55
+ content: [
56
+ { name: "image", type: "media-single", comment: "Must stay empty for MCP; human fills via backend." },
57
+ { name: "imageMobile", type: "media-single" },
58
+ { name: "isVideoBg", type: "switch", default: false, comment: "If true, 'image' is treated as a video file and 'imageMobile' as poster." },
59
+ ...buttonsMixin,
60
+ { name: "fullHeight", type: "switch", default: true },
61
+ { name: "contrast", type: "switch", default: false, comment: "Use contrast (light-on-dark) text for the headline when the background image is dark." },
62
+ { name: "overlay", type: "switch", default: false, comment: "Darken the background image." },
63
+ ],
64
+ },
65
+ {
66
+ content_group: "Section",
67
+ name: "Section",
68
+ 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.",
70
+ base: baseBlock,
71
+ content: [
72
+ { name: "variant", type: "dropdown", enum: sectionVariants, default: "halfAndHalf" },
73
+ { 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"] } },
75
+ { name: "embed", type: "codeeditor", shownWhen: { field: "variant", equals: ["embedHalfAndHalf", "embedOnly", "embed30", "embed70"] }, comment: "Raw embed HTML (iframe etc.)." },
76
+ ...buttonsMixin,
77
+ { name: "headline2", type: "richeditor", shownWhen: { field: "variant", equals: ["textAndText"] }, comment: "Second column text (only when variant is 'textAndText')." },
78
+ ...buttons2Mixin.map((f) => ({ ...f, shownWhen: { field: "variant", equals: ["textAndText"] } })),
79
+ { name: "doubleText", type: "switch", default: false, shownWhen: { field: "variant", equals: ["halfAndHalf", "img70", "text70"] }, comment: "Split the text column into two text columns (no image)." },
80
+ ],
81
+ },
82
+ {
83
+ content_group: "Tiles",
84
+ name: "Tiles (grid of cards)",
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.",
87
+ base: baseBlock,
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." },
90
+ { name: "tileLink", type: "switch", default: false, comment: "Whole tile acts as a link." },
91
+ { name: "tileIcon", type: "switch", default: false, comment: "Display icon-only tiles." },
92
+ { name: "contrastHover", type: "switch", default: false },
93
+ { name: "contrastBorderHover", type: "switch", default: false },
94
+ { name: "outline", type: "switch", default: false, comment: "Cards have outline border." },
95
+ { name: "borderless", type: "switch", default: false, comment: "No border / no shadow." },
96
+ { name: "buttonStyle", type: "dropdown", enum: ["Button--contrast", "Button--outline"], default: "Button--contrast" },
97
+ { name: "count", type: "dropdown", enum: ["2", "3", "4"], default: "3", comment: "Tiles per row." },
98
+ ],
99
+ nestedRepeaters: [
100
+ {
101
+ name: "tiles",
102
+ description: "One repeater item per tile/card.",
103
+ items: [
104
+ { name: "headline", type: "text", required: true, comment: "Tile title." },
105
+ { name: "image", type: "media-single", comment: "Tile image. Leave empty for text-only tiles." },
106
+ { name: "text", type: "richeditor", comment: "Tile body text." },
107
+ ...buttonsMixin,
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ {
113
+ content_group: "Slider",
114
+ name: "Slider",
115
+ description: "Splide carousel of slides (image + headline + text + buttons each). Can render as tabs.",
116
+ whenToUse: "Image-heavy content that benefits from sequential browsing — testimonials, step-by-step walkthroughs, gallery-like rows.",
117
+ base: baseBlock,
118
+ content: [
119
+ { name: "slides", type: "taglist", comment: "Array of slide items, each {image, headline, text, buttonLabel1, buttonVariant1, ...}." },
120
+ { name: "gallery", type: "switch", default: false, comment: "Clicking a slide opens the gallery lightbox." },
121
+ { name: "loop", type: "switch", default: false, comment: "Infinite loop playback." },
122
+ { name: "autoWidth", type: "switch", default: false, comment: "Slide width matches image width." },
123
+ { name: "variant", type: "dropdown", enum: ["basic", "double", "tabs-horizontal", "tabs-vertical"], default: "basic" },
124
+ { name: "perView", type: "number", default: 1, comment: "Visible slides at once (1+)." },
125
+ { name: "gap", type: "number", default: 30, comment: "Pixels between slides." },
126
+ { name: "focusedSlidePosition", type: "dropdown", enum: ["left", "center", "right"] },
127
+ { name: "startIndex", type: "number", default: 0, comment: "Default active slide (0-based; must be less than total slides)." },
128
+ { name: "syncWith", type: "text", comment: "ID of another slider to synchronize with." },
129
+ { name: "autoplay", type: "number", comment: "Autoplay interval in ms. 0 or blank = off." },
130
+ ],
131
+ nestedRepeaters: [
132
+ {
133
+ name: "slides",
134
+ description: "One repeater item per slide.",
135
+ items: [
136
+ { name: "image", type: "media-single" },
137
+ { name: "headline", type: "text" },
138
+ { name: "text", type: "richeditor" },
139
+ ...buttonsMixin,
140
+ ],
141
+ },
142
+ ],
143
+ hasBreakpoints: true,
144
+ },
145
+ {
146
+ content_group: "Tabs",
147
+ name: "Tabs",
148
+ description: "Tabbed content panels (image + headline + text + button each).",
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
+ base: baseBlock,
151
+ content: [
152
+ { name: "slides", type: "taglist", comment: "Array of tab items, each {image, headline, text, buttonLabel1, ...}." },
153
+ { name: "gallery", type: "switch", default: false },
154
+ { name: "variant", type: "dropdown", enum: ["tabs-horizontal", "tabs-vertical"], default: "tabs-horizontal" },
155
+ ],
156
+ nestedRepeaters: [
157
+ {
158
+ name: "slides",
159
+ description: "One repeater item per tab.",
160
+ items: [
161
+ { name: "image", type: "media-single" },
162
+ { name: "headline", type: "text", required: true },
163
+ { name: "text", type: "richeditor" },
164
+ ...buttonsMixin,
165
+ ],
166
+ },
167
+ ],
168
+ },
169
+ {
170
+ content_group: "Accordion",
171
+ name: "Accordion",
172
+ description: "Expandable Q&A / collapsible sections.",
173
+ whenToUse: "FAQs, 'Common questions', or any content the user wants collapsed by default. Each item has a headline + rich text.",
174
+ base: baseBlock,
175
+ content: [
176
+ { name: "items", type: "taglist", comment: "Array of items, each {headline, text}." },
177
+ { name: "variant", type: "dropdown", enum: ["default", "outline", "shadow"], default: "default" },
178
+ { name: "firstItemOpen", type: "switch", default: false, comment: "First item is expanded on load." },
179
+ { name: "singleMode", type: "switch", default: false, comment: "Only one item open at a time." },
180
+ { name: "icon", type: "codeeditor", comment: "Custom SVG icon (raw SVG markup). Leave blank for default +/- icon." },
181
+ ],
182
+ nestedRepeaters: [
183
+ {
184
+ name: "items",
185
+ description: "One repeater item per accordion row.",
186
+ items: [
187
+ { name: "headline", type: "text", required: true, comment: "Question / collapsed heading." },
188
+ { name: "text", type: "richeditor", comment: "Expanded answer / body." },
189
+ ],
190
+ },
191
+ ],
192
+ },
193
+ {
194
+ content_group: "Form",
195
+ name: "Form",
196
+ description: "Contact / inquiry form wired to a separately-defined Form Tailor entry.",
197
+ whenToUse: "Only when a contact form is needed AND the referenced Form entry already exists. Get the form_entry id via the `list_forms` tool. The actual form fields live on the linked Form entry, not on this block.",
198
+ base: baseBlock,
199
+ content: [
200
+ { name: "variant", type: "dropdown", enum: ["default", "outline", "card"], default: "default" },
201
+ ],
202
+ referencing: { kind: "form", location: "block", jsonKey: "form_entry", column: "form_id" },
203
+ },
204
+ {
205
+ content_group: "Gallery",
206
+ name: "Gallery (masonry)",
207
+ 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).",
209
+ base: baseBlock,
210
+ content: [
211
+ { name: "images", type: "media-multi", comment: "Array of image paths; MUST be empty in MCP." },
212
+ { name: "columns", type: "number", default: 3, comment: "Number of columns (blank = 3)." },
213
+ ],
214
+ },
215
+ {
216
+ content_group: "Downloads",
217
+ name: "Downloads",
218
+ 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).",
220
+ base: baseBlock,
221
+ content: [],
222
+ nestedRepeaters: [
223
+ {
224
+ name: "files",
225
+ description: "One repeater item per downloadable file.",
226
+ items: [
227
+ { name: "name", type: "text", required: true },
228
+ { name: "icon", type: "dropdown", enum: ["none", "file", "pdf", "dl"], default: "none" },
229
+ { name: "description", type: "richeditor" },
230
+ { name: "file", type: "media-single", comment: "Must stay empty — human fills via backend." },
231
+ ],
232
+ },
233
+ ],
234
+ },
235
+ {
236
+ content_group: "Columns",
237
+ name: "Columns (recursive Builder)",
238
+ description: "Multi-column layout where each column contains its own full Builder (any blocks).",
239
+ whenToUse: "When you need to present 2–4 distinct blocks side-by-side at desktop widths. Each column individually can host any of the other block types (Header doesn't fit here — use Section/Tiles/etc). Recursion: each column has a 'builder' array of blocks.",
240
+ base: baseBlock,
241
+ content: [],
242
+ nestedRepeaters: [
243
+ {
244
+ name: "columns",
245
+ description: "One repeater item per column. Each column has {blockId, builder: [...blocks]} where builder is a full Builder array.",
246
+ items: [
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)." },
249
+ ],
250
+ },
251
+ ],
252
+ },
253
+ {
254
+ content_group: "Prefill",
255
+ name: "Prefill (reusable block template)",
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.",
258
+ base: baseBlock,
259
+ content: [],
260
+ referencing: { kind: "prefill", location: "content", jsonKey: "prefill_entry", column: "block_id" },
261
+ },
262
+ {
263
+ content_group: "BlogList",
264
+ name: "Blog list",
265
+ description: "Lists BlogPost stream entries, optional pagination + category switcher.",
266
+ whenToUse: "Blog index page or category listing. Pulls BlogPost entries automatically — no content of its own beyond pagination/layout options.",
267
+ base: baseBlock,
268
+ content: [
269
+ { name: "count", type: "dropdown", enum: ["1", "2", "3", "4"], default: "3", comment: "Posts per row." },
270
+ { name: "perPage", type: "number", comment: "Posts per page before pagination kicks in. Blank = all." },
271
+ { name: "pagination", type: "switch", default: false, comment: "Show 'load more'." },
272
+ { name: "categorySwitcher", type: "switch", default: false, comment: "Show tag/category filter row." },
273
+ ],
274
+ },
275
+ {
276
+ content_group: "MenuBlock",
277
+ name: "Menu block",
278
+ description: "Renders an existing Menu Tailor entry inline in the page body (flattened to 2 levels).",
279
+ whenToUse: "When you need to embed navigation as content (e.g. an on-page section that lists links). Get the menu_entry id via `list_menus`.",
280
+ base: baseBlock,
281
+ content: [],
282
+ referencing: { kind: "menu", location: "content", jsonKey: "menu_entry", column: "menu_id" },
283
+ },
284
+ {
285
+ content_group: "ImageStrip",
286
+ name: "Image strip (marquee)",
287
+ description: "Horizontal scrolling strip/marquee of images.",
288
+ whenToUse: "Brand/logos row, partners strip, photo strip with horizontal scroll. Optional auto-scroll.",
289
+ base: baseBlock,
290
+ content: [
291
+ { name: "images", type: "media-multi", comment: "Array of image paths; MUST be empty in MCP." },
292
+ { name: "centered", type: "switch", default: false, comment: "Center the strip horizontally." },
293
+ { name: "autoScroll", type: "switch", default: false },
294
+ { name: "stopOnHover", type: "switch", default: false },
295
+ { name: "intervalDelay", type: "number", default: 20, comment: "Scroll interval delay. Lower = faster. 20 is sensible default." },
296
+ ],
297
+ },
298
+ {
299
+ content_group: "InstaFeed",
300
+ name: "Instagram feed",
301
+ description: "Instagram feed grid (uses Yizack\InstagramFeed module).",
302
+ whenToUse: "Showing a live Instagram feed inline. Requires the Instagram token — set in backend Blocks/InstaFeed settings; the MCP passes the same token through.",
303
+ base: baseBlock,
304
+ content: [
305
+ { name: "token", type: "text", comment: "Instagram API access token (sensitive; the API accepts but typically you should set these via backend)." },
306
+ { name: "columnCount", type: "number", default: 3, comment: "Grid columns." },
307
+ { name: "imageCount", type: "number", default: 6, comment: "Total images shown. Should divide evenly by columnCount." },
308
+ ],
309
+ },
310
+ ];
311
+ exports.BLOCK_CATALOGUE_TEXT = exports.BLOCKS.map((b) => {
312
+ const lines = [];
313
+ lines.push(`### ${b.name} (content_group="${b.content_group}")`);
314
+ lines.push(`When to use: ${b.whenToUse}`);
315
+ lines.push("");
316
+ lines.push("base fields (shared BaseBlock mixin):");
317
+ for (const f of b.base) {
318
+ lines.push(` - ${f.name}: ${f.type}${f.enum ? ` (one of: ${f.enum.join(", ")})` : ""}${f.default !== undefined ? ` [default=${JSON.stringify(f.default)}]` : ""}${f.comment ? ` — ${f.comment}` : ""}`);
319
+ }
320
+ if (b.content.length > 0) {
321
+ lines.push("");
322
+ lines.push("content fields:");
323
+ for (const f of b.content) {
324
+ lines.push(` - ${f.name}: ${f.type}${f.enum ? ` (one of: ${f.enum.join(", ")})` : ""}${f.default !== undefined ? ` [default=${JSON.stringify(f.default)}]` : ""}${f.hiddenWhen ? ` (hidden when ${f.hiddenWhen.field} in [${f.hiddenWhen.equals.join("|")}])` : ""}${f.shownWhen ? ` (only when ${f.shownWhen.field} in [${f.shownWhen.equals.join("|")}])` : ""}${f.comment ? ` — ${f.comment}` : ""}`);
325
+ }
326
+ }
327
+ if (b.referencing) {
328
+ lines.push("");
329
+ lines.push(`references: ${b.referencing.kind} (set in JSON ${b.referencing.location === "block" ? "at block level" : "inside content"} via key "${b.referencing.jsonKey}"); stored on the "${b.referencing.column}" column.`);
330
+ }
331
+ if (b.nestedRepeaters && b.nestedRepeaters.length > 0) {
332
+ for (const nr of b.nestedRepeaters) {
333
+ lines.push("");
334
+ lines.push(`nested repeater "${nr.name}": ${nr.description}`);
335
+ for (const f of nr.items) {
336
+ lines.push(` - ${f.name}: ${f.type}${f.enum ? ` (one of: ${f.enum.join(", ")})` : ""}${f.default !== undefined ? ` [default=${JSON.stringify(f.default)}]` : ""}${f.comment ? ` — ${f.comment}` : ""}`);
337
+ }
338
+ }
339
+ }
340
+ if (b.hasBreakpoints) {
341
+ lines.push("");
342
+ lines.push("optional nestedform 'breakpoints': { tablet: number, mobile: number }");
343
+ }
344
+ lines.push("");
345
+ return lines.join("\n");
346
+ }).join("\n");
package/dist/index.js ADDED
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
7
+ const api_client_js_1 = require("./api-client.js");
8
+ const session_js_1 = require("./session.js");
9
+ const catalogue_js_1 = require("./catalogue.js");
10
+ const sites_js_1 = require("./sites.js");
11
+ const CATALOGUE_URI = "frameworc://blocks";
12
+ (0, sites_js_1.ensureFile)();
13
+ (0, sites_js_1.warnIfWorldReadable)();
14
+ const session = new session_js_1.Session();
15
+ const client = new api_client_js_1.ApiClient(session);
16
+ const tools = [
17
+ {
18
+ name: "use_site",
19
+ description: "Pin the OctCMS site for all subsequent tool calls in this session. Required before any page/block tool. The token is resolved from the local file ~/.config/frameworc/sites.json by URL; pass the optional `site_token` arg only if you want to override that lookup for this session. If neither resolves a token, the call errors with a helpful message about editing sites.json.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ url: { type: "string", description: "Base HTTPS URL of the OctCMS site, e.g. https://example.com" },
24
+ site_token: {
25
+ type: "string",
26
+ description: "Optional override bearer token for this site. If omitted, the MCP looks up the token by URL in ~/.config/frameworc/sites.json.",
27
+ },
28
+ },
29
+ required: ["url"],
30
+ },
31
+ },
32
+ {
33
+ name: "list_sites",
34
+ description: "List the OctCMS sites configured in the local file ~/.config/frameworc/sites.json. Returns [{label, url}] WITHOUT tokens. Use this when the user asks 'which sites are available' or wants to switch but only remembers the label.",
35
+ inputSchema: {
36
+ type: "object",
37
+ properties: {},
38
+ },
39
+ },
40
+ {
41
+ name: "list_pages",
42
+ description: "List all FrameworC pages on the pinned site.",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: { site_url: { type: "string", description: "Override the pinned site URL for this call." } },
46
+ },
47
+ },
48
+ {
49
+ name: "get_page",
50
+ description: "Fetch a full page including its builder blocks (base + content + nested repeaters).",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ id: { type: "integer", description: "Page id." },
55
+ site_url: { type: "string" },
56
+ },
57
+ required: ["id"],
58
+ },
59
+ },
60
+ {
61
+ name: "create_page",
62
+ 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.",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: {
66
+ payload: {
67
+ type: "object",
68
+ description: "Page + builder payload per the schema described in the frameworc://blocks resource.",
69
+ properties: {
70
+ page: { type: "object" },
71
+ builder: { type: "array" },
72
+ },
73
+ required: ["page"],
74
+ },
75
+ site_url: { type: "string" },
76
+ },
77
+ required: ["payload"],
78
+ },
79
+ },
80
+ {
81
+ name: "update_page",
82
+ 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.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ id: { type: "integer" },
87
+ payload: { type: "object" },
88
+ site_url: { type: "string" },
89
+ },
90
+ required: ["id", "payload"],
91
+ },
92
+ },
93
+ {
94
+ name: "delete_page",
95
+ description: "Soft-delete a page (sets deleted_at).",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ id: { type: "integer" },
100
+ site_url: { type: "string" },
101
+ },
102
+ required: ["id"],
103
+ },
104
+ },
105
+ {
106
+ name: "add_block",
107
+ 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_entry? / menu_entry? / prefill_entry? } per the catalogue.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ page_id: { type: "integer" },
112
+ block: { type: "object" },
113
+ position: { type: "integer" },
114
+ site_url: { type: "string" },
115
+ },
116
+ required: ["page_id", "block"],
117
+ },
118
+ },
119
+ {
120
+ name: "update_block",
121
+ 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`.",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {
125
+ page_id: { type: "integer" },
126
+ block_id: { type: "integer" },
127
+ block: { type: "object" },
128
+ site_url: { type: "string" },
129
+ },
130
+ required: ["page_id", "block_id", "block"],
131
+ },
132
+ },
133
+ {
134
+ name: "remove_block",
135
+ description: "Remove a block by its row id from the page's builder.",
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ page_id: { type: "integer" },
140
+ block_id: { type: "integer" },
141
+ site_url: { type: "string" },
142
+ },
143
+ required: ["page_id", "block_id"],
144
+ },
145
+ },
146
+ {
147
+ name: "reorder_blocks",
148
+ description: "Reorder blocks. Send every block row id in the desired order (all existing block ids must be present, no extras).",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ page_id: { type: "integer" },
153
+ order: { type: "array", items: { type: "integer" } },
154
+ site_url: { type: "string" },
155
+ },
156
+ required: ["page_id", "order"],
157
+ },
158
+ },
159
+ {
160
+ name: "list_forms",
161
+ description: "List Form Tailor entries available for the `Form` block's `form_entry` argument.",
162
+ inputSchema: {
163
+ type: "object",
164
+ properties: { site_url: { type: "string" } },
165
+ },
166
+ },
167
+ {
168
+ name: "list_menus",
169
+ description: "List Menu Tailor entries available for the `MenuBlock`'s `content.menu_entry` argument.",
170
+ inputSchema: {
171
+ type: "object",
172
+ properties: { site_url: { type: "string" } },
173
+ },
174
+ },
175
+ {
176
+ name: "list_prefills",
177
+ description: "List Prefill Tailor entries available for the `Prefill` block's `content.prefill_entry` argument.",
178
+ inputSchema: {
179
+ type: "object",
180
+ properties: { site_url: { type: "string" } },
181
+ },
182
+ },
183
+ {
184
+ name: "get_block_schema",
185
+ description: "Get the JSON schema + usage notes for a single block by content_group name. Use this when you need to recall field enums / required fields / conditional visibility for one block.",
186
+ inputSchema: {
187
+ type: "object",
188
+ properties: {
189
+ name: { type: "string", enum: catalogue_js_1.BLOCKS.map((b) => b.content_group) },
190
+ },
191
+ required: ["name"],
192
+ },
193
+ },
194
+ ];
195
+ const server = new index_js_1.Server({ name: "frameworc-mcp", version: "0.2.0" }, { capabilities: { tools: {}, resources: {} } });
196
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
197
+ tools,
198
+ }));
199
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
200
+ const name = req.params.name;
201
+ const args = (req.params.arguments ?? {});
202
+ (0, sites_js_1.refreshIfChanged)();
203
+ if (name !== "use_site" && name !== "list_sites" && !args.site_url && !session.getUrl()) {
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: "Error: no site pinned. Call use_site(url) first, or pass site_url in this call.",
209
+ },
210
+ ],
211
+ isError: true,
212
+ };
213
+ }
214
+ try {
215
+ if (args.site_url && name !== "use_site") {
216
+ session.pin(args.site_url);
217
+ }
218
+ switch (name) {
219
+ case "use_site": {
220
+ session.pin(args.url, args.site_token);
221
+ let resolvedVia;
222
+ if (args.site_token) {
223
+ resolvedVia = "explicit site_token arg";
224
+ }
225
+ else {
226
+ try {
227
+ session.getToken();
228
+ resolvedVia = "sites.json lookup";
229
+ }
230
+ catch (err) {
231
+ return {
232
+ content: [
233
+ {
234
+ type: "text",
235
+ text: `Pinned ${args.url}, but no token resolved. ${err?.message ?? "Add an entry to ~/.config/frameworc/sites.json or pass site_token explicitly."}`,
236
+ },
237
+ ],
238
+ isError: true,
239
+ };
240
+ }
241
+ }
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: `Pinned site ${args.url} (token via ${resolvedVia})`,
247
+ },
248
+ ],
249
+ };
250
+ }
251
+ case "list_sites": {
252
+ const sites = (0, sites_js_1.listSites)();
253
+ return {
254
+ content: [{ type: "text", text: JSON.stringify({ sites }, null, 2) }],
255
+ };
256
+ }
257
+ case "list_pages": {
258
+ const r = await client.listPages();
259
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
260
+ }
261
+ case "get_page": {
262
+ const r = await client.getPage(Number(args.id));
263
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
264
+ }
265
+ case "create_page": {
266
+ const r = await client.createPage(args.payload);
267
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
268
+ }
269
+ case "update_page": {
270
+ const r = await client.updatePage(Number(args.id), args.payload);
271
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
272
+ }
273
+ case "delete_page": {
274
+ const r = await client.deletePage(Number(args.id));
275
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
276
+ }
277
+ case "add_block": {
278
+ const r = await client.addBlock(Number(args.page_id), args.block, args.position !== undefined ? Number(args.position) : undefined);
279
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
280
+ }
281
+ case "update_block": {
282
+ const r = await client.updateBlock(Number(args.page_id), Number(args.block_id), args.block);
283
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
284
+ }
285
+ case "remove_block": {
286
+ const r = await client.removeBlock(Number(args.page_id), Number(args.block_id));
287
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
288
+ }
289
+ case "reorder_blocks": {
290
+ const r = await client.reorderBlocks(Number(args.page_id), args.order);
291
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
292
+ }
293
+ case "list_forms": {
294
+ const r = await client.listForms();
295
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
296
+ }
297
+ case "list_menus": {
298
+ const r = await client.listMenus();
299
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
300
+ }
301
+ case "list_prefills": {
302
+ const r = await client.listPrefills();
303
+ return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
304
+ }
305
+ case "get_block_schema": {
306
+ const block = catalogue_js_1.BLOCKS.find((b) => b.content_group === args.name);
307
+ if (!block) {
308
+ return {
309
+ content: [
310
+ {
311
+ type: "text",
312
+ text: `Unknown block "${args.name}". Available: ${catalogue_js_1.BLOCKS.map((b) => b.content_group).join(", ")}`,
313
+ },
314
+ ],
315
+ isError: true,
316
+ };
317
+ }
318
+ return { content: [{ type: "text", text: JSON.stringify(block, null, 2) }] };
319
+ }
320
+ default: {
321
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
322
+ }
323
+ }
324
+ }
325
+ catch (err) {
326
+ return {
327
+ content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
328
+ isError: true,
329
+ };
330
+ }
331
+ });
332
+ server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => ({
333
+ resources: [
334
+ {
335
+ uri: CATALOGUE_URI,
336
+ name: "FrameworC block catalogue",
337
+ description: "Reference for all 15 FrameworC builder block types: when-to-use, base fields, content fields (with enums / defaults / conditional visibility), nested repeaters, and reference fields (Form/Menu/Prefill). READ THIS FIRST before composing a page.",
338
+ mimeType: "text/plain",
339
+ },
340
+ ],
341
+ }));
342
+ server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (req) => {
343
+ const uri = String(req.params.uri);
344
+ if (uri !== CATALOGUE_URI) {
345
+ throw new Error(`Unknown resource: ${uri}`);
346
+ }
347
+ return {
348
+ contents: [
349
+ {
350
+ uri: CATALOGUE_URI,
351
+ mimeType: "text/plain",
352
+ text: catalogue_js_1.BLOCK_CATALOGUE_TEXT,
353
+ },
354
+ ],
355
+ };
356
+ });
357
+ const transport = new stdio_js_1.StdioServerTransport();
358
+ server
359
+ .connect(transport)
360
+ .then(() => {
361
+ process.stderr.write("frameworc-mcp ready (stdio).\n");
362
+ })
363
+ .catch((err) => {
364
+ process.stderr.write(`frameworc-mcp failed to start: ${err?.message ?? err}\n`);
365
+ process.exit(1);
366
+ });
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Session = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const sites_js_1 = require("./sites.js");
6
+ class Session {
7
+ url = null;
8
+ tokenOverride = null;
9
+ id;
10
+ constructor() {
11
+ this.id = (0, node_crypto_1.randomUUID)();
12
+ }
13
+ pin(url, token) {
14
+ this.url = url.replace(/\/+$/, "");
15
+ if (token) {
16
+ this.tokenOverride = token;
17
+ }
18
+ else {
19
+ this.tokenOverride = null;
20
+ }
21
+ }
22
+ getToken() {
23
+ if (!this.url) {
24
+ throw new Error("No site pinned. Call use_site(url) first, or pass site_url explicitly.");
25
+ }
26
+ if (this.tokenOverride) {
27
+ return this.tokenOverride;
28
+ }
29
+ const fromStore = (0, sites_js_1.findToken)(this.url);
30
+ if (fromStore) {
31
+ return fromStore;
32
+ }
33
+ throw new Error(`No token for ${this.url}. Add an entry to ~/.config/frameworc/sites.json, or pass site_token explicitly in use_site().`);
34
+ }
35
+ getUrl() {
36
+ if (!this.url) {
37
+ throw new Error("No site pinned. Call use_site(url) first, or pass site_url explicitly.");
38
+ }
39
+ return this.url;
40
+ }
41
+ clear() {
42
+ this.url = null;
43
+ this.tokenOverride = null;
44
+ }
45
+ }
46
+ exports.Session = Session;
package/dist/sites.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SITES_FILE = void 0;
4
+ exports.ensureFile = ensureFile;
5
+ exports.reload = reload;
6
+ exports.findToken = findToken;
7
+ exports.listSites = listSites;
8
+ exports.refreshIfChanged = refreshIfChanged;
9
+ exports.warnIfWorldReadable = warnIfWorldReadable;
10
+ const node_fs_1 = require("node:fs");
11
+ const node_os_1 = require("node:os");
12
+ const node_path_1 = require("node:path");
13
+ const SITES_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "frameworc");
14
+ exports.SITES_FILE = (0, node_path_1.join)(SITES_DIR, "sites.json");
15
+ const TEMPLATE = {
16
+ sites: [
17
+ {
18
+ label: "Example site",
19
+ url: "https://example.test",
20
+ token: "paste-your-token-here",
21
+ },
22
+ ],
23
+ };
24
+ function normalizeUrl(url) {
25
+ return String(url).replace(/\/+$/, "");
26
+ }
27
+ let lastMtime = 0;
28
+ let cached = { sites: [] };
29
+ function ensureFile() {
30
+ if ((0, node_fs_1.existsSync)(exports.SITES_FILE)) {
31
+ return;
32
+ }
33
+ (0, node_fs_1.mkdirSync)(SITES_DIR, { recursive: true, mode: 0o700 });
34
+ (0, node_fs_1.writeFileSync)(exports.SITES_FILE, JSON.stringify(TEMPLATE, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
35
+ try {
36
+ (0, node_fs_1.chmodSync)(exports.SITES_FILE, 0o600);
37
+ }
38
+ catch {
39
+ // best effort
40
+ }
41
+ process.stderr.write(`frameworc-mcp: created ${exports.SITES_FILE} with a template — edit it with your real site URLs and tokens, then retry.\n`);
42
+ }
43
+ function reload() {
44
+ try {
45
+ const st = (0, node_fs_1.statSync)(exports.SITES_FILE);
46
+ const mtime = st.mtimeMs;
47
+ if (mtime === lastMtime && cached.sites.length > 0) {
48
+ return cached;
49
+ }
50
+ const raw = (0, node_fs_1.readFileSync)(exports.SITES_FILE, "utf8");
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ }
55
+ catch (err) {
56
+ process.stderr.write(`frameworc-mcp: ${exports.SITES_FILE} is not valid JSON (${err?.message ?? err}). Site tokens are unavailable — please fix the file. Using empty site list.\n`);
57
+ cached = { sites: [] };
58
+ lastMtime = mtime;
59
+ return cached;
60
+ }
61
+ if (!parsed || !Array.isArray(parsed.sites)) {
62
+ process.stderr.write(`frameworc-mcp: ${exports.SITES_FILE} is missing the "sites" array. Treating as empty.\n`);
63
+ cached = { sites: [] };
64
+ }
65
+ else {
66
+ cached = { sites: parsed.sites.filter((s) => s && typeof s.url === "string" && typeof s.token === "string") };
67
+ }
68
+ lastMtime = mtime;
69
+ return cached;
70
+ }
71
+ catch {
72
+ cached = { sites: [] };
73
+ return cached;
74
+ }
75
+ }
76
+ function findToken(url) {
77
+ const store = reload();
78
+ const target = normalizeUrl(url);
79
+ for (const entry of store.sites) {
80
+ if (normalizeUrl(entry.url) === target) {
81
+ return entry.token || null;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ function listSites() {
87
+ const store = reload();
88
+ return store.sites.map((s) => ({ label: s.label, url: normalizeUrl(s.url) }));
89
+ }
90
+ function refreshIfChanged() {
91
+ try {
92
+ const st = (0, node_fs_1.statSync)(exports.SITES_FILE);
93
+ if (st.mtimeMs !== lastMtime) {
94
+ reload();
95
+ }
96
+ }
97
+ catch {
98
+ // file may have been deleted mid-session; reload() handles farther down
99
+ reload();
100
+ }
101
+ }
102
+ function warnIfWorldReadable() {
103
+ try {
104
+ const st = (0, node_fs_1.statSync)(exports.SITES_FILE);
105
+ const mode = st.mode & 0o777;
106
+ if (mode & 0o077) {
107
+ process.stderr.write(`frameworc-mcp: warning: ${exports.SITES_FILE} is readable by group/others (mode ${mode.toString(8)}).Recommend \`chmod 600 ${exports.SITES_FILE}\` to protect tokens.\n`);
108
+ }
109
+ }
110
+ catch {
111
+ // ignore
112
+ }
113
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "frameworc-mcp",
3
+ "version": "0.2.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
+ }