@timelesscms-com/mcp-server 0.1.0 → 0.3.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,22 @@ 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.** Custom blocks (`origin: 'user'`) and installed
72
+ third-party blocks (`origin: 'third_party'`) live in a per-site
73
+ collection. Use `list_block_types` to see what's available. The
74
+ `core/*` types (section, columns, prose, heading, image, button) are
75
+ always available and don't appear in that list.
48
76
 
49
77
  - **Redirects.** `from_path → to_path` with status code 301 / 302.
50
78
  Auto-created when you change a page's slug.
@@ -144,13 +172,57 @@ duplicating it:
144
172
 
145
173
  ```
146
174
  create_free_block id="newsletter-cta" html_content="<form>…</form>"
147
- # Then on each page where it should appear:
175
+ # Then on each page where it should appear (HTML-mode pages):
148
176
  update_page page_id=… patch={ html_content: "<…><x-include name=\"newsletter-cta\" />" }
149
177
  ```
150
178
 
151
179
  Edits to the block update every page that includes it. Use
152
180
  `find_pages_using_block` before changing it.
153
181
 
182
+ ### "Build a page using blocks (the default for new pages)"
183
+
184
+ New pages default to `content_mode='blocks'` with a seeded heading +
185
+ prose block. To add more:
186
+
187
+ ```
188
+ get_page_blocks page_id=home
189
+ # returns { content_mode: 'blocks', blocks: [...] }
190
+ add_block page_id=home block={ type: 'core/section', data: { width: 'wide' } }
191
+ # returns { added_id: 'blk_xyz', blocks: [...] }
192
+ add_block page_id=home parent_id="blk_xyz" block={
193
+ type: 'core/heading', data: { text: 'Pricing', level: 'h2' }
194
+ }
195
+ add_block page_id=home parent_id="blk_xyz" block={
196
+ type: 'core/prose', data: { html: '<p>…</p>' }
197
+ }
198
+ ```
199
+
200
+ Updating, moving, removing blocks: `update_block`, `move_block`,
201
+ `remove_block` (all by `block_id`).
202
+
203
+ ### "Switch a page between blocks and HTML"
204
+
205
+ Use `set_page_mode` — it snapshots a revision before flipping, so the
206
+ previous state is restorable:
207
+
208
+ ```
209
+ # Convert an HTML-mode page to blocks with auto-heuristic conversion:
210
+ set_page_mode page_id=about to=blocks convert=true
211
+
212
+ # Or just switch the mode without converting (empty blocks):
213
+ set_page_mode page_id=about to=blocks
214
+
215
+ # Switch back to HTML (drops the block tree; revision retains it):
216
+ set_page_mode page_id=about to=html
217
+ ```
218
+
219
+ The heuristic converter recognises `<h1-4>` → heading, `<img>` → image,
220
+ `<a.btn>` → button, `grid-cols-2` → two-column, `<section>` / hero divs
221
+ → section. Anything it can't classify becomes a `core/prose` block,
222
+ which preserves the raw HTML losslessly. Run with `convert_page_to_blocks
223
+ dry_run=true` first if you want to inspect the proposal before
224
+ committing.
225
+
154
226
  ### "Build a directory site / import structured data"
155
227
 
156
228
  ```
@@ -236,14 +308,50 @@ update_page page_id=about patch={ slug: "om-oss" }
236
308
 
237
309
  The 301 fires automatically — you don't have to remember.
238
310
 
311
+ ### "Change the site's fallback URL (slug)"
312
+
313
+ ```
314
+ update_site slug="acme"
315
+ → response includes:
316
+ urls.fallback: "https://acme.sites.timelesscms.com"
317
+ dns_note: "New fallback URL … attached to CF Pages. SSL provisioning
318
+ takes 1–10 minutes after DNS propagates. …"
319
+ ```
320
+
321
+ The slug change triggers DNS + CF Pages reprovisioning behind the scenes.
322
+ **Always check the response for `dns_note` vs `dns_warning`:**
323
+
324
+ - `dns_note` present → the new fallback URL was wired up; warn the user it
325
+ may take 1–10 min for SSL to provision before the URL serves.
326
+ - `dns_warning` present → the slug was saved but DNS / CF attach failed.
327
+ The `urls.fallback` field is still returned (it's just `{slug}.{base}`
328
+ string formatting) but the URL will NOT resolve until the issue is
329
+ fixed. Surface the warning verbatim to the user — don't tell them the
330
+ URL is ready.
331
+ - Neither present → self-hosted portal without CF/SITES_BASE_DOMAIN
332
+ configured; URL behaviour is up to the operator.
333
+
334
+ The old fallback URL keeps working (bookmarks + SEO survive). Customer
335
+ can manually deprovision the old one via the portal.
336
+
239
337
  ## Safety boundaries
240
338
 
241
339
  - **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`.
340
+ `javascript:` URLs in page or partial bodies. `<style>` blocks DO
341
+ survive multi-page sites need authored CSS for `@media` queries,
342
+ `:hover`, theming, etc. Inside `<style>` we strip a small list of
343
+ legacy code-execution constructs (`expression()`, `behavior:url`,
344
+ `@import`, `url(javascript:)`) but leave normal CSS alone.
345
+ - **Write responses include `sanitization_warnings: []` (strings) and
346
+ `sanitization_details: []`** (structured records `{ kind, label,
347
+ count, bytes? }`). Use the structured form to programmatically retry
348
+ with a fixed input.
349
+ - **scripts_head, scripts_body_end, custom_css** are now writable via
350
+ `update_site_settings` and readable via `read_site_settings`. Same
351
+ trust model as user-authored block-type JS: an API caller with a valid
352
+ bearer token takes responsibility for what they ship. The chat AI
353
+ inside the portal continues to NOT expose these fields, so a
354
+ conversation-driven assistant can't smuggle scripts in.
247
355
  - **The API key is site-scoped.** Cross-site reach is impossible — a
248
356
  key on the wrong site returns 401, indistinguishable from "bad token".
249
357
  - **Audit log.** Every state-changing call (POST / PATCH / PUT /
@@ -301,9 +409,10 @@ stakeholder review.
301
409
  | **Discovery** | `get_site`, `update_site`, `list_versions`, `read_site_settings` |
302
410
  | **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |
303
411
  | **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` |
412
+ | **Pages — blocks** | `get_page_blocks`, `add_block`, `update_block`, `move_block`, `remove_block`, `set_page_mode`, `convert_page_to_blocks` |
413
+ | **Pages — meta** | `get_page_preview` |
414
+ | **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` |
415
+ | **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |
307
416
  | **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
417
  | **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
418
  | **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,6 +6,42 @@ 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
47
  description: 'List registered block types with their schemas. Most sites have none today; this fills in when the block editor ships.',
@@ -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.3.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",