@timelesscms-com/mcp-server 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -23,16 +23,31 @@ maps to one HTTP endpoint; the actual logic runs in the customer's portal
23
23
  ## The data model in 90 seconds
24
24
 
25
25
  - **Pages.** Title, slug, status (`draft | review | unlisted | published`),
26
- HTML body (`html_content`), SEO fields. Slug can contain slashes, so
27
- `2024/01/foo-bar` is a valid slug → `/2024/01/foo-bar` URL. Encode
28
- hierarchy in the slug; the renderer doesn't use `parent` for routing.
26
+ body content + SEO fields. Two body shapes selectable per page via
27
+ `content_mode`:
28
+ - `blocks` (DEFAULT for new pages) `blocks: Block[]` tree of typed
29
+ blocks (heading, prose, section, columns, image, button, plus any
30
+ user/third-party block types installed on the site). Use the
31
+ block-mutation tools (`add_block`, `update_block`, `move_block`,
32
+ `remove_block`) for structural changes.
33
+ - `html` — body lives in `html_content` as a single HTML string.
34
+ Useful when you have hand-written markup to drop in directly.
35
+
36
+ Slug can contain slashes, so `2024/01/foo-bar` is a valid slug →
37
+ `/2024/01/foo-bar` URL. Encode hierarchy in the slug; the renderer
38
+ doesn't use `parent` for routing.
29
39
 
30
40
  - **Partials = global blocks.** Three kinds:
31
41
  - `header` — auto-injected at the top of every page.
32
42
  - `footer` — auto-injected at the bottom of every page.
33
43
  - `free` — reusable HTML you drop into a page with
34
44
  `<x-include name="block-id" />`. Free blocks are how you avoid
35
- duplicating HTML across pages.
45
+ duplicating HTML across HTML-mode pages.
46
+
47
+ Partials themselves also support `content_mode='blocks'` — pass a
48
+ `blocks: Block[]` tree to `update_partial` and the renderer composes
49
+ it the same way as a page. Useful for header/footer authored with
50
+ block types.
36
51
 
37
52
  - **Collections.** Repeatable content types (blog, team, events,
38
53
  products, restaurants for a directory site, etc.). Each has a schema
@@ -42,9 +57,28 @@ maps to one HTTP endpoint; the actual logic runs in the customer's portal
42
57
  `route_template=""` to opt out and keep the collection listing-only.
43
58
 
44
59
  - **Settings.** Site name, tagline, logo, favicon, colors, fonts,
45
- contact info, social links, SEO suffix. Scripts and custom CSS exist
46
- on the settings doc but the API does NOT expose them the customer
47
- edits those manually.
60
+ contact info, social links, SEO suffix, plus `scripts_head`,
61
+ `scripts_body_end`, `custom_css` (writable via the API — your bearer
62
+ token authorises shipping arbitrary CSS/JS to the live site, just
63
+ like editing a partial's HTML does).
64
+
65
+ - **Page templates.** A `PageTemplate` is a Block[] tree that wraps a
66
+ page's body. The template contains exactly one block of type
67
+ `template_content_slot` — at render time that block gets replaced by
68
+ the page's own `blocks`. Set `Page.template = "<template-id>"` to
69
+ apply a template to a page.
70
+
71
+ - **Block types.** A site has three sources of block types:
72
+ - **Core** (origin: 'core', ids like `core/section`) — shipped in
73
+ the platform, always available.
74
+ - **User** (origin: 'user') — created in the portal's block-types UI.
75
+ - **Third-party** (origin: 'third_party') — imported from .tcblocks
76
+ packages via `import_block_types`.
77
+
78
+ `list_block_types` returns ALL of them in one list, with each entry's
79
+ full schema (field names, types, defaults). Always call this FIRST
80
+ before working with blocks — never hardcode block ids or field names,
81
+ the available set is per-site.
48
82
 
49
83
  - **Redirects.** `from_path → to_path` with status code 301 / 302.
50
84
  Auto-created when you change a page's slug.
@@ -80,8 +114,11 @@ goes through the MCP:
80
114
  `include_content: true` if you actually need the bodies inline.
81
115
  5. `list_collections` — what content types exist + their schemas +
82
116
  `route_template` (so you know if items have URLs).
83
- 6. `list_block_types` — registered block types (often empty today;
84
- fills in once the block editor lands).
117
+ 6. `list_block_types` — every block type usable on this site: core
118
+ (always available, ids like `core/section`), custom (origin: 'user'),
119
+ and third-party (origin: 'third_party'). Each entry includes the
120
+ full schema so you know what `data.X` fields each block accepts.
121
+ 7. `list_page_templates` — PageTemplate docs that wrap pages.
85
122
 
86
123
  You usually want at least #1 + #2 + a sampling from #3 before
87
124
  proposing any design change, so you mirror the conventions in use.
@@ -144,13 +181,68 @@ duplicating it:
144
181
 
145
182
  ```
146
183
  create_free_block id="newsletter-cta" html_content="<form>…</form>"
147
- # Then on each page where it should appear:
184
+ # Then on each page where it should appear (HTML-mode pages):
148
185
  update_page page_id=… patch={ html_content: "<…><x-include name=\"newsletter-cta\" />" }
149
186
  ```
150
187
 
151
188
  Edits to the block update every page that includes it. Use
152
189
  `find_pages_using_block` before changing it.
153
190
 
191
+ ### "Build a page using blocks (the default for new pages)"
192
+
193
+ New pages default to `content_mode='blocks'` with a seeded heading +
194
+ prose block. Discover-then-build:
195
+
196
+ ```
197
+ list_block_types
198
+ # → [{ id: "core/section", category: "layout", container: true, schema: [{ name: "width", type: "select", options: ["narrow","normal","wide","full"] }, …] },
199
+ # { id: "core/columns", container: "slots", slot_count: 2, slot_labels: ["Left","Right"], schema: [...] },
200
+ # { id: "hero_bold", origin: "user", schema: [...] }, ← any custom blocks on this site
201
+ # …]
202
+
203
+ get_page_blocks page_id=home
204
+ # → { content_mode: 'blocks', blocks: [...] }
205
+
206
+ add_block page_id=home block={ type: 'core/section', data: { width: 'wide' } }
207
+ # → { added_id: 'blk_xyz', blocks: [...] }
208
+ add_block page_id=home parent_id="blk_xyz" block={
209
+ type: 'core/heading', data: { text: 'Pricing', level: 'h2' }
210
+ }
211
+ add_block page_id=home parent_id="blk_xyz" block={
212
+ type: 'core/prose', data: { html: '<p>…</p>' }
213
+ }
214
+ ```
215
+
216
+ For an unfamiliar custom block, `read_block_type id="..."` gives the
217
+ full field list (types, defaults, required) so you don't ship invalid
218
+ `data`.
219
+
220
+ Updating, moving, removing blocks: `update_block`, `move_block`,
221
+ `remove_block` (all by `block_id`).
222
+
223
+ ### "Switch a page between blocks and HTML"
224
+
225
+ Use `set_page_mode` — it snapshots a revision before flipping, so the
226
+ previous state is restorable:
227
+
228
+ ```
229
+ # Convert an HTML-mode page to blocks with auto-heuristic conversion:
230
+ set_page_mode page_id=about to=blocks convert=true
231
+
232
+ # Or just switch the mode without converting (empty blocks):
233
+ set_page_mode page_id=about to=blocks
234
+
235
+ # Switch back to HTML (drops the block tree; revision retains it):
236
+ set_page_mode page_id=about to=html
237
+ ```
238
+
239
+ The heuristic converter recognises `<h1-4>` → heading, `<img>` → image,
240
+ `<a.btn>` → button, `grid-cols-2` → two-column, `<section>` / hero divs
241
+ → section. Anything it can't classify becomes a `core/prose` block,
242
+ which preserves the raw HTML losslessly. Run with `convert_page_to_blocks
243
+ dry_run=true` first if you want to inspect the proposal before
244
+ committing.
245
+
154
246
  ### "Build a directory site / import structured data"
155
247
 
156
248
  ```
@@ -236,14 +328,50 @@ update_page page_id=about patch={ slug: "om-oss" }
236
328
 
237
329
  The 301 fires automatically — you don't have to remember.
238
330
 
331
+ ### "Change the site's fallback URL (slug)"
332
+
333
+ ```
334
+ update_site slug="acme"
335
+ → response includes:
336
+ urls.fallback: "https://acme.sites.timelesscms.com"
337
+ dns_note: "New fallback URL … attached to CF Pages. SSL provisioning
338
+ takes 1–10 minutes after DNS propagates. …"
339
+ ```
340
+
341
+ The slug change triggers DNS + CF Pages reprovisioning behind the scenes.
342
+ **Always check the response for `dns_note` vs `dns_warning`:**
343
+
344
+ - `dns_note` present → the new fallback URL was wired up; warn the user it
345
+ may take 1–10 min for SSL to provision before the URL serves.
346
+ - `dns_warning` present → the slug was saved but DNS / CF attach failed.
347
+ The `urls.fallback` field is still returned (it's just `{slug}.{base}`
348
+ string formatting) but the URL will NOT resolve until the issue is
349
+ fixed. Surface the warning verbatim to the user — don't tell them the
350
+ URL is ready.
351
+ - Neither present → self-hosted portal without CF/SITES_BASE_DOMAIN
352
+ configured; URL behaviour is up to the operator.
353
+
354
+ The old fallback URL keeps working (bookmarks + SEO survive). Customer
355
+ can manually deprovision the old one via the portal.
356
+
239
357
  ## Safety boundaries
240
358
 
241
359
  - **HTML is sanitized at save.** No `<script>`, no `onclick`, no
242
- `javascript:` URLs in page or partial bodies. Write responses include
243
- a `sanitization_warnings: []` array listing what got stripped, so you
244
- see when an input was modified.
245
- - **scripts_head, scripts_body_end, custom_css** are read-stripped on
246
- `read_site_settings` and silently dropped on `update_site_settings`.
360
+ `javascript:` URLs in page or partial bodies. `<style>` blocks DO
361
+ survive multi-page sites need authored CSS for `@media` queries,
362
+ `:hover`, theming, etc. Inside `<style>` we strip a small list of
363
+ legacy code-execution constructs (`expression()`, `behavior:url`,
364
+ `@import`, `url(javascript:)`) but leave normal CSS alone.
365
+ - **Write responses include `sanitization_warnings: []` (strings) and
366
+ `sanitization_details: []`** (structured records `{ kind, label,
367
+ count, bytes? }`). Use the structured form to programmatically retry
368
+ with a fixed input.
369
+ - **scripts_head, scripts_body_end, custom_css** are now writable via
370
+ `update_site_settings` and readable via `read_site_settings`. Same
371
+ trust model as user-authored block-type JS: an API caller with a valid
372
+ bearer token takes responsibility for what they ship. The chat AI
373
+ inside the portal continues to NOT expose these fields, so a
374
+ conversation-driven assistant can't smuggle scripts in.
247
375
  - **The API key is site-scoped.** Cross-site reach is impossible — a
248
376
  key on the wrong site returns 401, indistinguishable from "bad token".
249
377
  - **Audit log.** Every state-changing call (POST / PATCH / PUT /
@@ -301,9 +429,10 @@ stakeholder review.
301
429
  | **Discovery** | `get_site`, `update_site`, `list_versions`, `read_site_settings` |
302
430
  | **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |
303
431
  | **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |
304
- | **Pages — meta** | `get_page_blocks`, `get_page_preview` |
305
- | **Global blocks** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |
306
- | **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type` |
432
+ | **Pages — blocks** | `get_page_blocks`, `add_block`, `update_block`, `move_block`, `remove_block`, `set_page_mode`, `convert_page_to_blocks` |
433
+ | **Pages — meta** | `get_page_preview` |
434
+ | **Global blocks (partials)** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |
435
+ | **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |
307
436
  | **Collections** | `create_collection`, `update_collection_schema`, `delete_collection`, `list_collections`, `read_collection`, `list_collection_items` (richtext hidden by default), `read_collection_item`, `batch_read_collection_items`, `create_collection_item`, `update_collection_item`, `delete_collection_item`, `regenerate_collection_listing` |
308
437
  | **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `generate_image_variants`, `suggest_alt_text_context` |
309
438
  | **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ import { versionTools } from './tools/versions.js';
29
29
  import { deployTools } from './tools/deploy.js';
30
30
  import { previewTools } from './tools/preview.js';
31
31
  import { blockTypeTools } from './tools/block-types.js';
32
+ import { pageBlockTools } from './tools/page-blocks.js';
32
33
  import { settingsTools } from './tools/settings.js';
33
34
  import { siteTools } from './tools/sites.js';
34
35
  const VERSION = '0.1.0';
@@ -76,6 +77,7 @@ async function main() {
76
77
  ...pageTools,
77
78
  ...partialTools,
78
79
  ...blockTypeTools,
80
+ ...pageBlockTools,
79
81
  ...collectionTools,
80
82
  ...mediaTools,
81
83
  ...redirectTools,
@@ -6,18 +6,62 @@ function v(version) {
6
6
  return version ? { version } : undefined;
7
7
  }
8
8
  export const blockTypeTools = [
9
+ {
10
+ name: 'export_block_types',
11
+ description: 'Pack custom block types into a .tcblocks zip (base64). When `ids` is omitted, every user-created block type is bundled. Useful for moving blocks between sites or backing them up — the returned zip is a portable package the import tool reads back.',
12
+ inputSchema: {
13
+ ids: z.array(z.string()).optional().describe('Specific block-type ids to include. Omit to export every user-created block.'),
14
+ name: z.string().optional().describe('Package name (default: {site}-blocks).'),
15
+ version: z.string().optional().describe('Package version (default: 1.0.0).'),
16
+ version_branch: versionParam,
17
+ },
18
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
19
+ const query = {};
20
+ if (args.ids?.length)
21
+ query.ids = args.ids.join(',');
22
+ if (args.name)
23
+ query.name = args.name;
24
+ if (args.version)
25
+ query.version = args.version;
26
+ if (args.version_branch)
27
+ query.version = args.version_branch;
28
+ const res = await client.get(siteId, 'blocks/export', query);
29
+ return ok(res);
30
+ }),
31
+ },
32
+ {
33
+ name: 'import_block_types',
34
+ description: 'Install block types from a .tcblocks package. Provide the zip as base64. Re-importing the same package overwrites existing blocks with the same id (package_name/block_name). Each imported BlockType may include arbitrary CSS and JS — the caller is responsible for trusting the source. Returns counts of created vs updated docs.',
35
+ inputSchema: {
36
+ zip_base64: z.string().describe('Base64-encoded .tcblocks zip.'),
37
+ version: versionParam,
38
+ },
39
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
40
+ const v = args.version ? { version: args.version } : undefined;
41
+ const res = await client.post(siteId, 'blocks/import', { zip_base64: args.zip_base64 }, v);
42
+ return ok(res);
43
+ }),
44
+ },
9
45
  {
10
46
  name: 'list_block_types',
11
- description: 'List registered block types with their schemas. Most sites have none today; this fills in when the block editor ships.',
12
- inputSchema: { version: versionParam },
47
+ description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), custom blocks (origin: "user", created in the portal), and third-party blocks (origin: "third_party", imported from .tcblocks packages). Each entry includes id, label, category, container/slot info, origin, and the full schema (fields with name/type/label/required/options/default). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site.',
48
+ inputSchema: {
49
+ include_core: z.boolean().optional().describe('Default true. Set false to get only custom + third-party (rarely needed).'),
50
+ version: versionParam,
51
+ },
13
52
  handler: withErrorBoundary(async (args, { client, siteId }) => {
14
- const res = await client.get(siteId, 'block-types', v(args.version));
53
+ const query = {};
54
+ if (args.version)
55
+ query.version = args.version;
56
+ if (args.include_core === false)
57
+ query.include_core = 'false';
58
+ const res = await client.get(siteId, 'block-types', query);
15
59
  return ok(res);
16
60
  }),
17
61
  },
18
62
  {
19
63
  name: 'read_block_type',
20
- description: 'Read one block type\'s schema.',
64
+ description: "Full schema for one block type. Returns the BlockType doc with template, styles, optional script, and the field-definitions array. Use this when list_block_types' summary isn't enough — e.g. before add_block on a custom block whose data fields you haven't seen.",
21
65
  inputSchema: {
22
66
  type_id: z.string(),
23
67
  version: versionParam,
@@ -0,0 +1,155 @@
1
+ // Page-block mutation tools. Five primitives (get/add/update/move/remove)
2
+ // plus a one-shot HTML→blocks converter. All target the active version
3
+ // (override with `version`).
4
+ //
5
+ // The AI agent uses these to author block-mode pages directly. For
6
+ // HTML-mode pages, `convert_page_to_blocks` is the bridge — call with
7
+ // `dry_run: true` first to preview, then `dry_run: false, switch_mode:
8
+ // true` to commit.
9
+ import { z } from 'zod';
10
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
11
+ const blockSchema = z.object({
12
+ id: z.string().optional().describe('Stable id. Omit to let the server generate one.'),
13
+ type: z.string().describe('Block type id, e.g. "core/heading", "core/section", or a custom "user/banner".'),
14
+ data: z.record(z.unknown()).optional().describe('Field values matching the block-type schema.'),
15
+ children: z.array(z.any()).optional().describe('Nested blocks for container types.'),
16
+ slots: z.array(z.array(z.any())).optional().describe('Slot contents for slot-container types.'),
17
+ style_overrides: z.object({
18
+ spacing_before: z.string().optional(),
19
+ spacing_after: z.string().optional(),
20
+ custom_css: z.string().optional(),
21
+ custom_class: z.string().optional(),
22
+ html_id: z.string().optional(),
23
+ }).optional(),
24
+ }).passthrough();
25
+ function v(version) {
26
+ return version ? { version } : undefined;
27
+ }
28
+ export const pageBlockTools = [
29
+ {
30
+ name: 'get_page_blocks',
31
+ description: 'Read a page\'s block tree. Returns content_mode (html or blocks) and the blocks array. Empty array for HTML-mode pages — those store body content in html_content instead. Use this before any block mutation so you know what you\'re modifying.',
32
+ inputSchema: {
33
+ page_id: z.string(),
34
+ version: versionParam,
35
+ },
36
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
37
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, v(args.version));
38
+ return ok(res);
39
+ }),
40
+ },
41
+ {
42
+ name: 'add_block',
43
+ description: 'Insert a block into a page. With no parent_id, inserts at top level. Otherwise inserts as a child (or into the named slot for slot-containers). Position defaults to "end". For slot containers, slot_index picks the slot; for regular containers it\'s ignored.',
44
+ inputSchema: {
45
+ page_id: z.string(),
46
+ block: blockSchema,
47
+ parent_id: z.string().optional().nullable(),
48
+ slot_index: z.number().int().min(0).optional(),
49
+ position: z.number().int().min(0).optional(),
50
+ version: versionParam,
51
+ },
52
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
53
+ const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, {
54
+ block: args.block,
55
+ parent_id: args.parent_id,
56
+ slot_index: args.slot_index,
57
+ position: args.position,
58
+ }, v(args.version));
59
+ return ok(res);
60
+ }),
61
+ },
62
+ {
63
+ name: 'update_block',
64
+ description: 'Update a block\'s data fields (shallow merge), style overrides, or responsive settings. Does not modify children or slots — use move/add/remove for structural changes.',
65
+ inputSchema: {
66
+ page_id: z.string(),
67
+ block_id: z.string(),
68
+ data: z.record(z.unknown()).optional(),
69
+ style_overrides: z.object({
70
+ spacing_before: z.string().optional(),
71
+ spacing_after: z.string().optional(),
72
+ custom_css: z.string().optional(),
73
+ custom_class: z.string().optional(),
74
+ html_id: z.string().optional(),
75
+ }).optional(),
76
+ version: versionParam,
77
+ },
78
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
79
+ const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, {
80
+ block_id: args.block_id,
81
+ data: args.data,
82
+ style_overrides: args.style_overrides,
83
+ }, v(args.version));
84
+ return ok(res);
85
+ }),
86
+ },
87
+ {
88
+ name: 'move_block',
89
+ description: 'Reparent or reorder a block. target_parent_id=null moves to top level. For slot containers, target_slot_index picks the slot. Cycles (moving a block into its own descendant) are rejected.',
90
+ inputSchema: {
91
+ page_id: z.string(),
92
+ block_id: z.string(),
93
+ target_parent_id: z.string().optional().nullable(),
94
+ target_slot_index: z.number().int().min(0).optional(),
95
+ target_position: z.number().int().min(0).optional(),
96
+ version: versionParam,
97
+ },
98
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
99
+ const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, {
100
+ block_id: args.block_id,
101
+ target_parent_id: args.target_parent_id,
102
+ target_slot_index: args.target_slot_index,
103
+ target_position: args.target_position,
104
+ }, v(args.version));
105
+ return ok(res);
106
+ }),
107
+ },
108
+ {
109
+ name: 'remove_block',
110
+ description: 'Remove a block and everything inside it from a page.',
111
+ inputSchema: {
112
+ page_id: z.string(),
113
+ block_id: z.string(),
114
+ version: versionParam,
115
+ },
116
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
117
+ const query = { block_id: args.block_id };
118
+ if (args.version)
119
+ query.version = args.version;
120
+ const res = await client.del(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, query);
121
+ return ok(res);
122
+ }),
123
+ },
124
+ {
125
+ name: 'set_page_mode',
126
+ description: 'Safely switch a page\'s content_mode between "blocks" and "html". Always snapshots a revision before the flip so the previous state is restorable. HTML → blocks with `convert: true` also runs the heuristic converter so the page starts with a populated tree. Blocks → HTML drops the block tree (the revision retains it). Prefer this over `update_page patch={content_mode}` because that bypasses snapshotting.',
127
+ inputSchema: {
128
+ page_id: z.string(),
129
+ to: z.enum(['blocks', 'html']).describe('Target mode.'),
130
+ convert: z.boolean().optional().describe('When going to blocks: run the htmlToBlocks heuristic on html_content. Ignored for blocks → html.'),
131
+ version: versionParam,
132
+ },
133
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
134
+ const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/mode`, { to: args.to, convert: args.convert ?? false }, v(args.version));
135
+ return ok(res);
136
+ }),
137
+ },
138
+ {
139
+ name: 'convert_page_to_blocks',
140
+ description: 'Heuristically convert a page\'s html_content into a block tree. By default runs as dry_run and returns the proposed blocks without writing. Pass dry_run: false to apply, and switch_mode: true to flip content_mode to "blocks" so the renderer uses the new tree. For a safer one-call switch with revision snapshot, use `set_page_mode` with `convert: true` instead.',
141
+ inputSchema: {
142
+ page_id: z.string(),
143
+ dry_run: z.boolean().optional().describe('Default true. When true, returns the proposed tree without writing.'),
144
+ switch_mode: z.boolean().optional().describe('When applying (dry_run=false), flip content_mode to "blocks". Default false.'),
145
+ version: versionParam,
146
+ },
147
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
148
+ const res = await client.post(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks/convert`, {
149
+ dry_run: args.dry_run ?? true,
150
+ switch_mode: args.switch_mode ?? false,
151
+ }, v(args.version));
152
+ return ok(res);
153
+ }),
154
+ },
155
+ ];
@@ -50,17 +50,20 @@ export const pageTools = [
50
50
  },
51
51
  {
52
52
  name: 'create_page',
53
- description: 'Create a new HTML-mode page. Slug is derived from title if omitted; default status is "draft". Returns the created page.',
53
+ description: 'Create a new page. Defaults to blocks-mode (recommended) — the new page seeds with a heading + prose block built from the title. Pass content_mode="html" to opt out. Slug is derived from title when omitted; default status is "draft". Returns the created page.',
54
54
  inputSchema: {
55
55
  title: z.string().min(1),
56
56
  slug: z.string().optional(),
57
- html_content: z.string().optional(),
57
+ content_mode: z.enum(['blocks', 'html']).optional().describe('Default "blocks". "html" stores body in html_content; "blocks" stores a Block[] tree.'),
58
+ html_content: z.string().optional().describe('Body HTML — only used when content_mode="html".'),
59
+ blocks: z.array(z.any()).optional().describe('Block tree — only used when content_mode="blocks". Omit to get the default heading+prose seed.'),
58
60
  status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
59
61
  seo_title: z.string().optional(),
60
62
  seo_description: z.string().optional(),
61
63
  kind: z.enum(['page', 'article']).optional(),
62
64
  author: z.string().optional(),
63
65
  language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
66
+ template: z.string().optional().describe('Page template id (PageTemplate). Wraps the body in the template tree at render time.'),
64
67
  version: versionParam,
65
68
  },
66
69
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -71,14 +74,16 @@ export const pageTools = [
71
74
  },
72
75
  {
73
76
  name: 'update_page',
74
- description: 'Shallow-merge update on a page (only the fields you pass change). Returns the updated page. For "replace this page entirely", use replace_page instead.',
77
+ description: 'Shallow-merge update on a page (only the fields you pass change). Returns the updated page. For "replace this page entirely", use replace_page instead. To switch content_mode safely with a revision snapshot + optional auto-conversion, prefer `set_page_mode`.',
75
78
  inputSchema: {
76
79
  page_id: z.string(),
77
80
  patch: z
78
81
  .object({
79
82
  title: z.string().optional(),
80
83
  slug: z.string().optional(),
84
+ content_mode: z.enum(['blocks', 'html']).optional().describe('Changing this raw skips revision snapshotting — for safe switches use set_page_mode.'),
81
85
  html_content: z.string().optional(),
86
+ blocks: z.array(z.any()).optional().describe('Block tree, only used when content_mode="blocks".'),
82
87
  status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
83
88
  kind: z.enum(['page', 'article']).optional(),
84
89
  author: z.string().optional(),
@@ -179,18 +184,8 @@ export const pageTools = [
179
184
  return ok(res);
180
185
  }),
181
186
  },
182
- {
183
- name: 'get_page_blocks',
184
- description: 'Get the block hierarchy for a page. For today\'s HTML-mode pages returns { content_mode: "html", html_content }; once the block editor lands, block-mode pages return { content_mode: "blocks", blocks: [...] }.',
185
- inputSchema: {
186
- page_id: z.string(),
187
- version: versionParam,
188
- },
189
- handler: withErrorBoundary(async (args, { client, siteId }) => {
190
- const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, v(args.version));
191
- return ok(res);
192
- }),
193
- },
187
+ // get_page_blocks lives in tools/page-blocks.ts alongside the rest of
188
+ // the block-tree mutation tools (add/update/move/remove/convert).
194
189
  {
195
190
  name: 'get_page_preview',
196
191
  description: 'Get rendered HTML for one page (header-authed read). Returns { rendered_html, internal_links[] }. For a clickable preview URL use get_preview_link instead.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timelesscms-com/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Model Context Protocol server for the TimelessCMS public API. Use with Claude Code or any MCP-compatible client to manage a TimelessCMS site.",
5
5
  "license": "MIT",
6
6
  "type": "module",