@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 +125 -16
- package/dist/index.js +2 -0
- package/dist/tools/block-types.js +36 -0
- package/dist/tools/page-blocks.js +155 -0
- package/dist/tools/pages.js +10 -15
- package/package.json +1 -1
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
|
-
|
|
27
|
-
`
|
|
28
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
-
|
|
246
|
-
`
|
|
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 —
|
|
305
|
-
| **
|
|
306
|
-
| **
|
|
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
|
+
];
|
package/dist/tools/pages.js
CHANGED
|
@@ -50,17 +50,20 @@ export const pageTools = [
|
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
name: 'create_page',
|
|
53
|
-
description: 'Create a new
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|