@typeroll/mcp-server 0.7.4

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.
@@ -0,0 +1,202 @@
1
+ // Media tools (list + signed upload URL + metadata patch).
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary } from './helpers.js';
4
+ // Best-effort content-type inference from the URL extension. The server's
5
+ // upload-URL endpoint requires content_type, so we have to pick *something*
6
+ // before the actual fetch — and a bad guess gets corrected by the HEAD
7
+ // response if the agent supplies an override.
8
+ function inferContentType(urlOrFilename) {
9
+ const lower = urlOrFilename.toLowerCase().split('?')[0];
10
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
11
+ return 'image/jpeg';
12
+ if (lower.endsWith('.png'))
13
+ return 'image/png';
14
+ if (lower.endsWith('.webp'))
15
+ return 'image/webp';
16
+ if (lower.endsWith('.gif'))
17
+ return 'image/gif';
18
+ if (lower.endsWith('.avif'))
19
+ return 'image/avif';
20
+ if (lower.endsWith('.svg'))
21
+ return 'image/svg+xml';
22
+ if (lower.endsWith('.pdf'))
23
+ return 'application/pdf';
24
+ return 'application/octet-stream';
25
+ }
26
+ function filenameFromUrl(url, fallback) {
27
+ if (fallback)
28
+ return fallback;
29
+ try {
30
+ const u = new URL(url);
31
+ const last = u.pathname.split('/').filter(Boolean).pop();
32
+ if (last)
33
+ return decodeURIComponent(last);
34
+ }
35
+ catch { /* fall through */ }
36
+ return `import-${Date.now()}`;
37
+ }
38
+ export const mediaTools = [
39
+ {
40
+ name: 'list_media',
41
+ description: 'List uploaded media items (CDN URLs, alt text, mime). Newest first, cursor-paginated.',
42
+ inputSchema: {
43
+ limit: z.number().int().min(1).max(200).optional(),
44
+ cursor: z.string().optional(),
45
+ },
46
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
47
+ const res = await client.get(siteId, 'media', { limit: args.limit, cursor: args.cursor });
48
+ return ok(res);
49
+ }),
50
+ },
51
+ {
52
+ name: 'read_media',
53
+ description: 'Read one media item\'s metadata by id.',
54
+ inputSchema: { media_id: z.string() },
55
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
56
+ const res = await client.get(siteId, `media/${encodeURIComponent(args.media_id)}`);
57
+ return ok(res);
58
+ }),
59
+ },
60
+ {
61
+ name: 'create_upload_url',
62
+ description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. The media doc is pre-created so a follow-up update_media can attach alt_text.',
63
+ inputSchema: {
64
+ filename: z.string().min(1),
65
+ content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
66
+ size: z.number().int().nonnegative().optional(),
67
+ alt_text: z.string().optional(),
68
+ },
69
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
70
+ const res = await client.post(siteId, 'media/upload-url', args);
71
+ return ok(res);
72
+ }),
73
+ },
74
+ {
75
+ name: 'upload_media_from_url',
76
+ description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The bytes pass through the agent\'s machine — they do NOT go through the Typeroll API — so this works whenever your agent can `fetch()` the source. Returns { media_id, cdn_url, filename }.',
77
+ inputSchema: {
78
+ source_url: z.string().url().describe('Public URL to download the image from.'),
79
+ filename: z.string().optional().describe('Override the filename used on R2. Defaults to the last path segment of source_url.'),
80
+ content_type: z.string().optional().describe('Override the inferred content type (e.g. when source_url has no extension).'),
81
+ alt_text: z.string().optional(),
82
+ },
83
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
84
+ const filename = filenameFromUrl(args.source_url, args.filename);
85
+ // 1. Fetch the source bytes on the agent's machine.
86
+ const sourceRes = await fetch(args.source_url);
87
+ if (!sourceRes.ok) {
88
+ throw new Error(`Failed to fetch source URL: ${sourceRes.status} ${sourceRes.statusText}`);
89
+ }
90
+ const buf = new Uint8Array(await sourceRes.arrayBuffer());
91
+ // Prefer the source's content-type header; fall back to extension
92
+ // inference; let the caller override the whole thing.
93
+ const sourceCt = sourceRes.headers.get('content-type')?.split(';')[0]?.trim();
94
+ const contentType = args.content_type ?? sourceCt ?? inferContentType(filename);
95
+ // 2. Mint a signed PUT URL through the Typeroll API.
96
+ const mint = await client.post(siteId, 'media/upload-url', {
97
+ filename,
98
+ content_type: contentType,
99
+ size: buf.byteLength,
100
+ alt_text: args.alt_text,
101
+ });
102
+ // 3. PUT the bytes straight to R2.
103
+ const putRes = await fetch(mint.upload_url, {
104
+ method: 'PUT',
105
+ headers: { 'Content-Type': contentType },
106
+ body: buf,
107
+ });
108
+ if (!putRes.ok) {
109
+ throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
110
+ }
111
+ return ok({
112
+ media_id: mint.media_id,
113
+ cdn_url: mint.cdn_url,
114
+ filename,
115
+ content_type: contentType,
116
+ size_bytes: buf.byteLength,
117
+ });
118
+ }),
119
+ },
120
+ {
121
+ name: 'upload_media_inline',
122
+ description: 'Upload an image (or PDF) directly from base64-encoded bytes — useful when you generated the image with an image-gen tool and have it in memory. Same two-step signed-PUT flow as upload_media_from_url, no temp file needed. Returns { media_id, cdn_url, filename }.',
123
+ inputSchema: {
124
+ filename: z.string().min(1),
125
+ content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
126
+ data_base64: z.string().min(1).describe('The file bytes, base64-encoded (without a data: URL prefix).'),
127
+ alt_text: z.string().optional(),
128
+ },
129
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
130
+ // Tolerate data: URL prefix in case the agent passed one through.
131
+ const raw = args.data_base64.startsWith('data:')
132
+ ? args.data_base64.slice(args.data_base64.indexOf(',') + 1)
133
+ : args.data_base64;
134
+ const buf = Uint8Array.from(Buffer.from(raw, 'base64'));
135
+ if (buf.byteLength === 0) {
136
+ throw new Error('data_base64 decoded to zero bytes — check the encoding.');
137
+ }
138
+ const mint = await client.post(siteId, 'media/upload-url', {
139
+ filename: args.filename,
140
+ content_type: args.content_type,
141
+ size: buf.byteLength,
142
+ alt_text: args.alt_text,
143
+ });
144
+ const putRes = await fetch(mint.upload_url, {
145
+ method: 'PUT',
146
+ headers: { 'Content-Type': args.content_type },
147
+ body: buf,
148
+ });
149
+ if (!putRes.ok) {
150
+ throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
151
+ }
152
+ return ok({
153
+ media_id: mint.media_id,
154
+ cdn_url: mint.cdn_url,
155
+ filename: args.filename,
156
+ content_type: args.content_type,
157
+ size_bytes: buf.byteLength,
158
+ });
159
+ }),
160
+ },
161
+ {
162
+ name: 'update_media',
163
+ description: 'Patch a media item\'s alt_text or filename. Other fields are immutable.',
164
+ inputSchema: {
165
+ media_id: z.string(),
166
+ alt_text: z.string().optional(),
167
+ filename: z.string().optional(),
168
+ },
169
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
170
+ const { media_id, ...body } = args;
171
+ const res = await client.patch(siteId, `media/${encodeURIComponent(media_id)}`, body);
172
+ return ok(res);
173
+ }),
174
+ },
175
+ {
176
+ name: 'generate_image_variants',
177
+ description: 'Run the build-time srcset pipeline for one image: produces webp + avif variants at 320 / 640 / 1024 / 1920 (skipping upscales), uploads them to R2 alongside the original, and patches the media doc with a variants[] array. Synchronous; takes ~1-5s per image. Skips PDFs and non-image media without erroring. Loop this over list_media to rebuild an existing library; call once per image right after upload to keep new media current.',
178
+ inputSchema: { media_id: z.string() },
179
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
180
+ const res = await client.post(siteId, `media/${encodeURIComponent(args.media_id)}/generate-variants`);
181
+ return ok(res);
182
+ }),
183
+ },
184
+ {
185
+ name: 'suggest_alt_text_context',
186
+ description: 'Get everything you need to generate good alt-text for a media item: the image URL, a tuned SEO/accessibility prompt, the site\'s content language, and the list of pages where the image is used (with the nearest heading per use site). YOU run the actual vision call with your own model — this endpoint does NOT generate text. After you decide on alt-text, save it with update_media.',
187
+ inputSchema: { media_id: z.string() },
188
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
189
+ const res = await client.get(siteId, `media/${encodeURIComponent(args.media_id)}/alt-text-context`);
190
+ return ok(res);
191
+ }),
192
+ },
193
+ {
194
+ name: 'delete_media',
195
+ description: 'Delete a media item\'s metadata. (R2 object cleanup is a separate ops task.)',
196
+ inputSchema: { media_id: z.string() },
197
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
198
+ const res = await client.del(siteId, `media/${encodeURIComponent(args.media_id)}`);
199
+ return ok(res);
200
+ }),
201
+ },
202
+ ];
@@ -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
+ ];
@@ -0,0 +1,209 @@
1
+ // Pages tools. Read + write + batch + blocks-view + preview.
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
4
+ function v(version) {
5
+ return version ? { version } : undefined;
6
+ }
7
+ export const pageTools = [
8
+ {
9
+ name: 'list_pages',
10
+ description: 'List pages on the active site. Returns id, title, slug, status, and SEO summary. Supports filtering by status and forward-cursor pagination.',
11
+ inputSchema: {
12
+ status: z.enum(['draft', 'review', 'unlisted', 'published', 'all']).optional(),
13
+ limit: z.number().int().min(1).max(200).optional(),
14
+ cursor: z.string().optional(),
15
+ version: versionParam,
16
+ },
17
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
18
+ const res = await client.get(siteId, 'pages', {
19
+ status: args.status,
20
+ limit: args.limit,
21
+ cursor: args.cursor,
22
+ ...v(args.version),
23
+ });
24
+ return ok(res);
25
+ }),
26
+ },
27
+ {
28
+ name: 'read_page',
29
+ description: 'Fetch one page in full — title, slug, status, html_content, SEO fields.',
30
+ inputSchema: {
31
+ page_id: z.string(),
32
+ version: versionParam,
33
+ },
34
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
35
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}`, v(args.version));
36
+ return ok(res);
37
+ }),
38
+ },
39
+ {
40
+ name: 'batch_read_pages',
41
+ description: 'Read up to 200 pages in a single call. Use to bulk-load context before a redesign sweep.',
42
+ inputSchema: {
43
+ page_ids: z.array(z.string()).min(1).max(200),
44
+ version: versionParam,
45
+ },
46
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
47
+ const res = await client.post(siteId, 'pages/batch-read', { page_ids: args.page_ids }, v(args.version));
48
+ return ok(res);
49
+ }),
50
+ },
51
+ {
52
+ name: 'create_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
+ inputSchema: {
55
+ title: z.string().min(1),
56
+ slug: 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.'),
60
+ status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
61
+ seo_title: z.string().optional(),
62
+ seo_description: z.string().optional(),
63
+ kind: z.enum(['page', 'article']).optional(),
64
+ author: z.string().optional(),
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.'),
67
+ version: versionParam,
68
+ },
69
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
70
+ const { version, ...body } = args;
71
+ const res = await client.post(siteId, 'pages', body, v(version));
72
+ return ok(res);
73
+ }),
74
+ },
75
+ {
76
+ name: 'update_page',
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`.',
78
+ inputSchema: {
79
+ page_id: z.string(),
80
+ patch: z
81
+ .object({
82
+ title: z.string().optional(),
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.'),
85
+ html_content: z.string().optional(),
86
+ blocks: z.array(z.any()).optional().describe('Block tree, only used when content_mode="blocks".'),
87
+ status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
88
+ kind: z.enum(['page', 'article']).optional(),
89
+ author: z.string().optional(),
90
+ language: z.string().optional(),
91
+ seo_title: z.string().optional(),
92
+ seo_description: z.string().optional(),
93
+ og_image: z.string().optional(),
94
+ canonical_url: z.string().optional(),
95
+ noindex: z.boolean().optional(),
96
+ json_ld: z.string().optional(),
97
+ template: z.string().optional(),
98
+ })
99
+ .passthrough(),
100
+ version: versionParam,
101
+ },
102
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
103
+ const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}`, args.patch, v(args.version));
104
+ return ok(res);
105
+ }),
106
+ },
107
+ {
108
+ name: 'replace_page',
109
+ description: 'Full replace of a page\'s writable fields (PUT). Omitted fields are reset to undefined; use update_page if you want shallow merge.',
110
+ inputSchema: {
111
+ page_id: z.string(),
112
+ page: z.object({ title: z.string().min(1) }).passthrough(),
113
+ version: versionParam,
114
+ },
115
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
116
+ const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}`, args.page, v(args.version));
117
+ return ok(res);
118
+ }),
119
+ },
120
+ {
121
+ name: 'batch_update_pages',
122
+ description: 'Apply per-page patches in one call (up to 200 entries). Each entry is { page_id, patch }; failures are reported per-row, the rest still apply.',
123
+ inputSchema: {
124
+ updates: z
125
+ .array(z.object({
126
+ page_id: z.string(),
127
+ patch: z.record(z.unknown()),
128
+ }))
129
+ .min(1)
130
+ .max(200),
131
+ version: versionParam,
132
+ },
133
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
134
+ const res = await client.post(siteId, 'pages/batch-write', args.updates, v(args.version));
135
+ return ok(res);
136
+ }),
137
+ },
138
+ {
139
+ name: 'clone_page',
140
+ description: 'Duplicate an existing page under a new title + slug. Use this when you want a near-copy of an existing page as the starting point (e.g. duplicate "Privatflytt" → "Privatflytt Härnösand"). The new page is created as a draft regardless of the source\'s status. Returns the created page.',
141
+ inputSchema: {
142
+ source_page_id: z.string(),
143
+ title: z.string().min(1).describe('Title for the new page.'),
144
+ slug: z.string().optional().describe('Slug for the new page. Omit to auto-derive from title.'),
145
+ version: versionParam,
146
+ },
147
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
148
+ // 1. Read the source — full body + SEO fields.
149
+ const sourceRes = await client.get(siteId, `pages/${encodeURIComponent(args.source_page_id)}`, v(args.version));
150
+ const src = sourceRes.page;
151
+ // 2. Build the new page body. Title + slug from args, everything else
152
+ // copied — except the new page always starts as a draft so cloning
153
+ // a published page doesn't accidentally publish a half-edited copy.
154
+ // Critical: preserve the source's content_mode exactly. Without
155
+ // this, create_page falls back to its blocks-default and seeds a
156
+ // fresh heading+prose tree, leaving the clone with TWO content
157
+ // sources (the copied html_content AND a default blocks[]). Bug
158
+ // surfaced in MCP-FEEDBACK-2.md from the Sundsvallsflytt demo build.
159
+ const srcMode = src.content_mode ?? 'html';
160
+ const body = {
161
+ title: args.title,
162
+ content_mode: srcMode,
163
+ html_content: srcMode === 'html' ? src.html_content : '',
164
+ blocks: srcMode === 'blocks' ? (src.blocks ?? []) : undefined,
165
+ seo_title: src.seo_title,
166
+ seo_description: src.seo_description,
167
+ og_image: src.og_image,
168
+ canonical_url: undefined, // intentionally NOT copied — point of canonical is to differ
169
+ noindex: src.noindex,
170
+ kind: src.kind,
171
+ author: src.author,
172
+ json_ld: undefined, // structured data is usually page-specific; force re-author
173
+ template: src.template,
174
+ status: 'draft',
175
+ };
176
+ if (args.slug)
177
+ body.slug = args.slug;
178
+ // 3. Create. The server auto-uniques the slug if it clashes.
179
+ const created = await client.post(siteId, 'pages', body, v(args.version));
180
+ return ok(created);
181
+ }),
182
+ },
183
+ {
184
+ name: 'delete_page',
185
+ description: 'Delete a page (tombstoned on branches, removed on main).',
186
+ inputSchema: {
187
+ page_id: z.string(),
188
+ version: versionParam,
189
+ },
190
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
191
+ const res = await client.del(siteId, `pages/${encodeURIComponent(args.page_id)}`, v(args.version));
192
+ return ok(res);
193
+ }),
194
+ },
195
+ // get_page_blocks lives in tools/page-blocks.ts alongside the rest of
196
+ // the block-tree mutation tools (add/update/move/remove/convert).
197
+ {
198
+ name: 'get_page_preview',
199
+ 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.',
200
+ inputSchema: {
201
+ page_id: z.string(),
202
+ version: versionParam,
203
+ },
204
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
205
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/preview`, v(args.version));
206
+ return ok(res);
207
+ }),
208
+ },
209
+ ];
@@ -0,0 +1,108 @@
1
+ // Partials (global blocks) tools.
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
4
+ function v(version) {
5
+ return version ? { version } : undefined;
6
+ }
7
+ export const partialTools = [
8
+ {
9
+ name: 'list_partials',
10
+ description: 'List global blocks (header, footer, free blocks). Defaults to summary mode (no html_content, just metadata + byte count) to keep agent context lean — pass include_content: true if you actually want the bodies inline, otherwise call read_partial on the one you care about.',
11
+ inputSchema: {
12
+ include_content: z.boolean().optional().describe('Include full html_content in the response. Default false.'),
13
+ version: versionParam,
14
+ },
15
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
16
+ const res = await client.get(siteId, 'partials', {
17
+ ...(args.include_content ? {} : { summary: 'true' }),
18
+ ...v(args.version),
19
+ });
20
+ return ok(res);
21
+ }),
22
+ },
23
+ {
24
+ name: 'read_partial',
25
+ description: 'Fetch one global block including its full HTML content.',
26
+ inputSchema: {
27
+ partial_id: z.string(),
28
+ version: versionParam,
29
+ },
30
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
31
+ const res = await client.get(siteId, `partials/${encodeURIComponent(args.partial_id)}`, v(args.version));
32
+ return ok(res);
33
+ }),
34
+ },
35
+ {
36
+ name: 'update_partial',
37
+ description: 'Shallow-merge update on a global block. Use partial_id "header" or "footer" for the auto-injected layout blocks, or a kebab-case id for a free block. HTML is sanitized server-side.',
38
+ inputSchema: {
39
+ partial_id: z.string(),
40
+ patch: z
41
+ .object({
42
+ name: z.string().optional(),
43
+ html_content: z.string().optional(),
44
+ status: z.enum(['draft', 'published']).optional(),
45
+ kind: z.enum(['header', 'footer', 'free']).optional(),
46
+ })
47
+ .passthrough(),
48
+ version: versionParam,
49
+ },
50
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
51
+ const res = await client.patch(siteId, `partials/${encodeURIComponent(args.partial_id)}`, args.patch, v(args.version));
52
+ return ok(res);
53
+ }),
54
+ },
55
+ {
56
+ name: 'replace_partial',
57
+ description: 'Full replace of a global block (PUT). Use this when you want to set every field including auto-inferring kind from id.',
58
+ inputSchema: {
59
+ partial_id: z.string(),
60
+ partial: z.object({ html_content: z.string() }).passthrough(),
61
+ version: versionParam,
62
+ },
63
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
64
+ const res = await client.put(siteId, `partials/${encodeURIComponent(args.partial_id)}`, args.partial, v(args.version));
65
+ return ok(res);
66
+ }),
67
+ },
68
+ {
69
+ name: 'create_free_block',
70
+ description: 'Create a new free (reusable) global block. Use a kebab-case id and embed it on pages with <x-include name="block-id" />.',
71
+ inputSchema: {
72
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/),
73
+ name: z.string().optional(),
74
+ html_content: z.string(),
75
+ status: z.enum(['draft', 'published']).optional(),
76
+ version: versionParam,
77
+ },
78
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
79
+ const { version, ...body } = args;
80
+ const res = await client.post(siteId, 'partials', body, v(version));
81
+ return ok(res);
82
+ }),
83
+ },
84
+ {
85
+ name: 'delete_partial',
86
+ description: 'Delete a free global block. header and footer cannot be deleted (they are layout blocks).',
87
+ inputSchema: {
88
+ partial_id: z.string(),
89
+ version: versionParam,
90
+ },
91
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
92
+ const res = await client.del(siteId, `partials/${encodeURIComponent(args.partial_id)}`, v(args.version));
93
+ return ok(res);
94
+ }),
95
+ },
96
+ {
97
+ name: 'find_pages_using_block',
98
+ description: 'Pages that embed a given block via <x-include name="…">. For partial_id "header" or "footer" returns the full page list (they are auto-injected on every page). Use this to know the blast radius before editing a shared block.',
99
+ inputSchema: {
100
+ partial_id: z.string(),
101
+ version: versionParam,
102
+ },
103
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
104
+ const res = await client.get(siteId, `partials/${encodeURIComponent(args.partial_id)}/usage`, v(args.version));
105
+ return ok(res);
106
+ }),
107
+ },
108
+ ];