@typeroll/mcp-server 0.45.60 → 0.45.62

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/dist/server.js CHANGED
@@ -165,8 +165,11 @@ The full playbook ships with this server — use it:
165
165
  style through a block's style_id, and create_style for any look that could
166
166
  recur. Use one-off block values only for something clearly unique, never
167
167
  classed raw HTML. Text must meet WCAG AA contrast. A heading's small label
168
- is its eyebrow field. For what styles cannot express, use page or site
169
- custom_css on s-<style> or block custom_class selectors, not [data-block].
168
+ is its eyebrow field. A block's sub-elements (links, a hero's heading or
169
+ buttons) are parts (read_block_type): style them site-wide with a style's
170
+ parts or on one block with data.part_styles. For what styles cannot
171
+ express, use page or site custom_css on s-<style> or block custom_class
172
+ selectors, not [data-block].
170
173
  7. Reuse: content that must stay identical on many pages is a global block
171
174
  (make_block_global; core/global_block references it). A section people
172
175
  start from and then adapt is a block template (list_block_templates,
@@ -39,6 +39,7 @@ const AUTHORING_GUIDE = 'Two kinds of site block type. COMPOSED (preferred): `co
39
39
  'TEMPLATE (advanced, when existing blocks cannot express the markup): `template` is HTML with {{field}} (escaped), {{{field}}} (raw: richtext fields and derived …_svg / …_html only), {{#field}}…{{/field}} (shown when set), {{^field}}…{{/field}} (shown when empty), {{#each items}}…{{/each}} (per array item; names resolve against the item first; {{@index}}, {{@number}}, {{@first}}, {{@last}}), {{#link field class="…"}}…{{/link}} (wraps the body in a safe <a href rel target> when the link resolves), plus {{children}} / {{slot:NAME}} for containers. ' +
40
40
  'Fields: text, textarea, richtext, image, icon (derived {name}_svg), select (options), boolean, number, url, link ({ page_id?, url?, new_tab? } — prefer page_id for a page on the Site, it survives slug changes; resolves to .href, .target, .rel), array (a repeating group: fields, item_label, min_items, max_items; nests 3 levels), object, style … each with name, type, label and optional help, required, default. ' +
41
41
  '`styles` is scoped to the block when written: every selector applies inside [data-block="<name>"], :scope is the block element itself, body/html/:root are refused; use the site\'s var(--color-*) and spacing tokens. The response returns `styles_compiled`, the CSS that ships. ' +
42
+ '`parts` names elements of the markup (a title, a button, links) so site styles can restyle them without CSS against the block: [{ name, label, selector, target }]. ' +
42
43
  'Unknown properties are errors. Every problem has a JSON-pointer `path` (and a `line` for markup/CSS); a 400 lists them all in `problems`, warnings come back in `warnings`. Call validate_block_type / preview_block_type first, and list_block_type_starters for complete examples. Needs WRITE permission on the Site; editors can author types as well as place them on pages.';
43
44
  const definitionShape = {
44
45
  label: z.string().optional().describe('Display label in the block picker.'),
@@ -61,6 +62,18 @@ const definitionShape = {
61
62
  template: z.string().optional().describe('Markup of a template type (instead of composition); see the section syntax above.'),
62
63
  styles: z.string().optional().describe('CSS scoped to the block. Write selectors for the block\'s own markup; :scope is the block element.'),
63
64
  script: z.string().optional().describe("Client-side JS shipped with the block (runs on the published site's own origin). Accepted under your API key's authority — the write is audit-logged. Prefer a composed type without script."),
65
+ parts: z.array(z.object({
66
+ name: z.string().describe('Stable name: a-z, 0-9 and _ (max 32), starting with a letter. Never rename a part in use: part_styles and styles refer to it.'),
67
+ label: z.string().describe('Shown in the editor, e.g. "Primary button".'),
68
+ selector: z.string().describe('The element(s), relative to the block root: tags, .classes, [attributes], structural pseudo-classes (:first-child, :nth-child()) and combinators, up to four comma-separated. No braces, at-rules, pseudo-elements or :hover/:focus (hover comes from the style).'),
69
+ target: z.enum(['text', 'heading', 'list', 'button', 'container', 'link', 'image']).describe('The kind of style that fits the part.'),
70
+ style_field: z.string().optional().describe('A `style` field of this type that already chooses the part\'s style per instance; part_styles then skips the part.'),
71
+ color: z.string().optional().describe('Colour token of the part\'s text in the block\'s CSS, for contrast checks (default text).'),
72
+ background: z.string().optional().describe('Colour token behind the part, for contrast checks (default the site background).'),
73
+ background_field: z.string().optional().describe('A `color` field of this type holding the background behind the part.'),
74
+ decorative: z.boolean().optional().describe('The part holds no text (an overlay, a layout row): no text contrast check.'),
75
+ min_render_version: z.number().int().optional(),
76
+ }).passthrough()).optional().describe('Named sub-elements (at most 16) that site styles can reach: site-wide through a style\'s `parts` ("<type id>.<part>"), on one block through its `part_styles` data, a field added automatically when a part has no style_field. Mainly for template types; a warning names a selector class the template never writes. Composed types\' inner blocks keep their own parts. Replaces the whole list on update.'),
64
77
  };
65
78
  export const blockTypeTools = [
66
79
  {
@@ -128,7 +141,7 @@ export const blockTypeTools = [
128
141
  },
129
142
  {
130
143
  name: 'read_block_type',
131
- description: "Full definition of one block type: the field schema, and its composition (composed types) or template (template types), styles with `styles_compiled` (the scoped CSS that ships), and optional script. Use this when list_block_types' summary isn't enough — before add_block on an unfamiliar block, or before update_block_type.",
144
+ description: "Full definition of one block type: the field schema, its `parts` (named sub-elements such as core/rich_heading's links or core/hero's heading and buttons, each with a target; style one with a style's `parts` site-wide or the block's `data.part_styles`, or the part's own style_field), and its composition (composed types) or template (template types), styles with `styles_compiled` (the scoped CSS that ships), and optional script. Use this when list_block_types' summary isn't enough — before add_block on an unfamiliar block, or before update_block_type.",
132
145
  inputSchema: {
133
146
  type_id: z.string(),
134
147
  version: versionParam,
@@ -220,7 +233,7 @@ export const blockTypeTools = [
220
233
  },
221
234
  {
222
235
  name: 'update_block_type',
223
- description: "Change a site block type (core/* types are read-only). Needs WRITE permission. schema, composition, template and styles replace wholesale when sent; other properties merge. Changing a type in use: to RENAME a field, send the new schema with `renames` ({ \"title\": \"heading\", \"items.label\": \"text\" } — dotted paths of the OLD names, new name of the field) and the data moves in every block that uses the type (saved pages and drafts, page templates, header/footer/global blocks, block templates, other block types, repeater items), after a revision of each changed page; a composed type's own {{props.…}}/{{item.…}} bindings follow unless you send a composition. Removing or retyping a field that holds data answers 409 with the affected uses until you resend with confirm_data_loss: true (ask the user first). Returns { block_type, warnings, impact: { usages, renamed, removed, retyped } }. The same validation as create_block_type applies.",
236
+ description: "Change a site block type (core/* types are read-only). Needs WRITE permission. schema, composition, template, styles and parts replace wholesale when sent; other properties merge. Changing a type in use: to RENAME a field, send the new schema with `renames` ({ \"title\": \"heading\", \"items.label\": \"text\" } — dotted paths of the OLD names, new name of the field) and the data moves in every block that uses the type (saved pages and drafts, page templates, header/footer/global blocks, block templates, other block types, repeater items), after a revision of each changed page; a composed type's own {{props.…}}/{{item.…}} bindings follow unless you send a composition. Removing or retyping a field that holds data answers 409 with the affected uses until you resend with confirm_data_loss: true (ask the user first). Returns { block_type, warnings, impact: { usages, renamed, removed, retyped } }. The same validation as create_block_type applies.",
224
237
  inputSchema: {
225
238
  type_id: z.string().describe('Block type id (a site type). Cannot be a core/* block.'),
226
239
  ...definitionShape,
@@ -31,6 +31,20 @@ const targetSchema = z.object({
31
31
  function v(version) {
32
32
  return version ? { version } : undefined;
33
33
  }
34
+ /**
35
+ * Write tools answer compactly by default: the changed block, its parent id,
36
+ * slot index and position, has_unsaved_changes and any warnings, instead of
37
+ * the container's whole tree (tens of kB for an article). `response: "tree"`
38
+ * asks for the tree. Sent as a query parameter, which an older Core ignores
39
+ * and answers with the tree, where an unknown body key would be refused.
40
+ */
41
+ const responseParam = z.enum(['summary', 'tree']).optional().describe('Answer shape. "summary" (default): the changed block, parent_id, slot_index, position, has_unsaved_changes and warnings. "tree": the whole block tree of the container.');
42
+ function writeQuery(version, response) {
43
+ return { ...v(version), response: response ?? 'summary' };
44
+ }
45
+ /** Block parts: per-instance part styles in block data. */
46
+ const PART_STYLES = ' `data.part_styles` ({ "<part>": "<style id>" }) gives named parts of the block a style on this block only, replacing a site-wide part style; read_block_type lists the type\'s `parts` and their targets (parts with a style_field, such as core/heading eyebrow → eyebrow_style_id, use that field instead). The map is replaced whole on update; null removes it. A part the block cannot style is 400 `unknown_field` at data.part_styles.<part> with suggestions; a value that is not a style id is `field_type_mismatch`; a style the site lacks (`unknown_style`) or of the wrong kind (`style_target_mismatch`) is refused with the styles that fit, as is text that fails both APCA and WCAG 2 AA on the part\'s background (`part_style_contrast`; an APCA-only miss is a warning).';
47
+ const COMPACT_ANSWER = ' Answers with the changed block, `parent_id`, `slot_index`, `position` and `has_unsaved_changes` (the working copy differs from the saved content); pass `response: "tree"` for the whole tree, or call get_page_blocks.';
34
48
  /**
35
49
  * Resolve a (target | page_id) pair to a REST path prefix. Pages keep
36
50
  * routing through /pages/{id}/blocks for back-compat with older callers;
@@ -64,7 +78,7 @@ export const pageBlockTools = [
64
78
  },
65
79
  {
66
80
  name: 'add_block',
67
- description: 'Insert a block into a container (page, partial, or page template). BUFFER MODEL: block mutations on pages/partials edit the unsaved DRAFT — commit_working_copy is the explicit save. With no parent_id, inserts at the top level of the container. Otherwise inserts as a child (or into a slot for slot-containers). Position defaults to "end". Slot containers (core/columns, core/tabs — container: "slots"): slot_index picks the slot (0-based; defaults to 0) and works even if the parent instance has no slots array yet; a freshly added slot container gets its slots initialised to the type\'s arity. You can also pass the whole subtree inline via block.slots: [[...],[...]]. Pass `target: { kind: "partial", id: "header" }` to drop a block into the header on every page, etc. `page_id` is the legacy shorthand.',
81
+ description: 'Insert a block into a container (page, partial, or page template). BUFFER MODEL: block mutations on pages/partials edit the unsaved DRAFT — commit_working_copy is the explicit save. With no parent_id, inserts at the top level of the container. Otherwise inserts as a child (or into a slot for slot-containers). Position defaults to "end". Slot containers (core/columns, core/tabs — container: "slots"): slot_index picks the slot (0-based; defaults to 0) and works even if the parent instance has no slots array yet; a freshly added slot container gets its slots initialised to the type\'s arity. You can also pass the whole subtree inline via block.slots: [[...],[...]]. Pass `target: { kind: "partial", id: "header" }` to drop a block into the header on every page, etc. `page_id` is the legacy shorthand. A `data` key that no field of the block\'s type declares (read_block_type) is rejected with 400 `unknown_field`, for the block and every child; nothing is written, and `unknown_fields[].suggestions` says where it belongs (e.g. `item_overrides.<key>` for an item-block field on a repeater or its aliases, or the closest field names).' + PART_STYLES + COMPACT_ANSWER + ' `added_id` is the new block\'s id.',
68
82
  inputSchema: {
69
83
  target: targetSchema.optional(),
70
84
  page_id: z.string().optional(),
@@ -72,6 +86,7 @@ export const pageBlockTools = [
72
86
  parent_id: z.string().optional().nullable(),
73
87
  slot_index: z.number().int().min(0).optional(),
74
88
  position: z.number().int().min(0).optional(),
89
+ response: responseParam,
75
90
  version: versionParam,
76
91
  },
77
92
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -81,18 +96,18 @@ export const pageBlockTools = [
81
96
  parent_id: args.parent_id,
82
97
  slot_index: args.slot_index,
83
98
  position: args.position,
84
- }, v(args.version));
99
+ }, writeQuery(args.version, args.response));
85
100
  return ok(res);
86
101
  }),
87
102
  },
88
103
  {
89
104
  name: 'update_block',
90
- description: 'Update a block\'s data fields (shallow merge), style overrides, or responsive settings. Works on pages, partials, and page templates — use `target` to address non-page containers. Does not modify children or slots; use move/add/remove for structural changes. For appearance, prefer a named style (`data.style_id`, see list_styles) over custom_class or custom_css. From render version 2, custom_class lands on the heading or button link element itself instead of the block wrapper.',
105
+ description: 'Update a block\'s data fields (shallow merge), style overrides, or responsive settings. Works on pages, partials, and page templates — use `target` to address non-page containers. Does not modify children or slots; use move/add/remove for structural changes. For appearance, prefer a named style (`data.style_id`, see list_styles) over custom_class or custom_css. From render version 2, custom_class lands on the heading or button link element itself instead of the block wrapper. Send values in the field\'s type (numbers as numbers); a value the block will not render comes back in `warnings` (`field_type_mismatch`) with its path. A `null` value removes that key from data (the field renders its default); use it to clean up a key stored by mistake. A written key that no field of the block\'s type declares is rejected with 400 `unknown_field` and nothing is written; `unknown_fields[].suggestions` says where it belongs: on core/repeater and its aliases (core/testimonials, core/page_list, ...) a field of the item block goes in `item_overrides.<key>`, otherwise the closest field names.' + PART_STYLES + COMPACT_ANSWER,
91
106
  inputSchema: {
92
107
  target: targetSchema.optional(),
93
108
  page_id: z.string().optional(),
94
109
  block_id: z.string(),
95
- data: z.record(z.unknown()).optional(),
110
+ data: z.record(z.unknown()).optional().describe('Field values to merge into block.data. null removes the key.'),
96
111
  style_overrides: z.object({
97
112
  spacing_before: z.string().optional(),
98
113
  spacing_after: z.string().optional(),
@@ -100,6 +115,7 @@ export const pageBlockTools = [
100
115
  custom_class: z.string().optional(),
101
116
  html_id: z.string().optional(),
102
117
  }).optional(),
118
+ response: responseParam,
103
119
  version: versionParam,
104
120
  },
105
121
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -108,13 +124,13 @@ export const pageBlockTools = [
108
124
  block_id: args.block_id,
109
125
  data: args.data,
110
126
  style_overrides: args.style_overrides,
111
- }, v(args.version));
127
+ }, writeQuery(args.version, args.response));
112
128
  return ok(res);
113
129
  }),
114
130
  },
115
131
  {
116
132
  name: 'move_block',
117
- description: 'Reparent or reorder a block inside its container. 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. Works for pages, partials, and templates.',
133
+ description: 'Reparent or reorder a block inside its container. 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. Works for pages, partials, and templates.' + COMPACT_ANSWER + ' The location is where the block now sits.',
118
134
  inputSchema: {
119
135
  target: targetSchema.optional(),
120
136
  page_id: z.string().optional(),
@@ -122,6 +138,7 @@ export const pageBlockTools = [
122
138
  target_parent_id: z.string().optional().nullable(),
123
139
  target_slot_index: z.number().int().min(0).optional(),
124
140
  target_position: z.number().int().min(0).optional(),
141
+ response: responseParam,
125
142
  version: versionParam,
126
143
  },
127
144
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -131,61 +148,61 @@ export const pageBlockTools = [
131
148
  target_parent_id: args.target_parent_id,
132
149
  target_slot_index: args.target_slot_index,
133
150
  target_position: args.target_position,
134
- }, v(args.version));
151
+ }, writeQuery(args.version, args.response));
135
152
  return ok(res);
136
153
  }),
137
154
  },
138
155
  {
139
156
  name: 'remove_block',
140
- description: 'Remove a block and everything inside it from its container. Works for pages, partials, and templates.',
157
+ description: 'Remove a block and everything inside it from its container. Works for pages, partials, and templates. Answers with `removed_id`, the `parent_id`, `slot_index` and `position` it was removed from and `has_unsaved_changes`; pass `response: "tree"` for the whole remaining tree.',
141
158
  inputSchema: {
142
159
  target: targetSchema.optional(),
143
160
  page_id: z.string().optional(),
144
161
  block_id: z.string(),
162
+ response: responseParam,
145
163
  version: versionParam,
146
164
  },
147
165
  handler: withErrorBoundary(async (args, { client, siteId }) => {
148
166
  const path = pathFor(args.target, args.page_id);
149
- const query = { block_id: args.block_id };
150
- if (args.version)
151
- query.version = args.version;
152
- const res = await client.del(siteId, path, query);
167
+ const res = await client.del(siteId, path, { block_id: args.block_id, ...writeQuery(args.version, args.response) });
153
168
  return ok(res);
154
169
  }),
155
170
  },
156
171
  {
157
172
  name: 'duplicate_block',
158
- description: 'Clone a block (subtree included) and insert the copy directly after the original within the same container. Returns the duplicate\'s new id. Useful for "add another card to this feature grid" — duplicate the existing one and then `update_block` on the new id to change its data. Works for pages, partials, and templates.',
173
+ description: 'Clone a block (subtree included) and insert the copy directly after the original within the same container. Returns the duplicate\'s new id. Useful for "add another card to this feature grid" — duplicate the existing one and then `update_block` on the new id to change its data. Works for pages, partials, and templates.' + COMPACT_ANSWER + ' `duplicate_id` is the copy\'s id; `block` is the copy.',
159
174
  inputSchema: {
160
175
  target: targetSchema.optional(),
161
176
  page_id: z.string().optional(),
162
177
  block_id: z.string(),
178
+ response: responseParam,
163
179
  version: versionParam,
164
180
  },
165
181
  handler: withErrorBoundary(async (args, { client, siteId }) => {
166
182
  const path = pathFor(args.target, args.page_id);
167
183
  const res = await client.post(siteId, `${path}/duplicate`, {
168
184
  block_id: args.block_id,
169
- }, v(args.version));
185
+ }, writeQuery(args.version, args.response));
170
186
  return ok(res);
171
187
  }),
172
188
  },
173
189
  {
174
190
  name: 'set_block_responsive',
175
- description: 'Set a per-breakpoint value on one responsive field of a block (in any container — page, partial, or template). The renderer reads { mobile, tablet, laptop, desktop, wide } objects on any field marked responsive in the BlockType schema (use read_block_type to see which). Mobile-first inheritance: missing breakpoints inherit upward. Pass a scalar value to clear responsive back to a single value. Example: setting `cols` on a feature_grid to { mobile: 1, tablet: 2, desktop: 3 } renders 1-col on phones, 2-col on tablets, 3-col on desktop+.',
191
+ description: 'Set a per-breakpoint value on one responsive field of a block (in any container — page, partial, or template). The renderer reads { mobile, tablet, laptop, desktop, wide } objects on any field marked responsive in the BlockType schema (use read_block_type to see which). Mobile-first inheritance: missing breakpoints inherit upward. Pass a scalar value to clear responsive back to a single value. Example: setting `cols` on a feature_grid to { mobile: 1, tablet: 2, desktop: 3 } renders 1-col on phones, 2-col on tablets, 3-col on desktop+. A field the block type does not declare is rejected with 400 `unknown_field`.' + COMPACT_ANSWER,
176
192
  inputSchema: {
177
193
  target: targetSchema.optional(),
178
194
  page_id: z.string().optional(),
179
195
  block_id: z.string(),
180
196
  field: z.string().describe('Field name (must be marked responsive in the BlockType schema).'),
181
197
  value: z.any().describe('Scalar (applies to all BPs) or { mobile?, tablet?, laptop?, desktop?, wide? }.'),
198
+ response: responseParam,
182
199
  version: versionParam,
183
200
  },
184
201
  handler: withErrorBoundary(async (args, { client, siteId }) => {
185
202
  const path = pathFor(args.target, args.page_id);
186
203
  const res = await client.post(siteId, `${path}/responsive`, {
187
204
  block_id: args.block_id, field: args.field, value: args.value,
188
- }, v(args.version));
205
+ }, writeQuery(args.version, args.response));
189
206
  return ok(res);
190
207
  }),
191
208
  },
@@ -75,6 +75,10 @@ export const pageTools = [
75
75
  'and seeds the tree with a heading + prose block so the editor is never blank. ' +
76
76
  'Pass html_content (or content_mode="html") to opt into raw-HTML mode instead. ' +
77
77
  'Slug is derived from the title when omitted. Default status is "draft". ' +
78
+ 'Address conflicts: an explicit slug or path that another page already uses (drafts and unsaved drafts included) ' +
79
+ 'fails with 409 { code: "slug_in_use" | "path_in_use", details: { page_id, path } } — edit that page or choose another slug; ' +
80
+ 'the address is never changed silently. Only a title-derived slug is numbered (e.g. "tack" → "tack-2"), and the response then ' +
81
+ 'carries warnings: [{ code: "slug_adjusted", requested, slug, path, conflicting_page_id }]. ' +
78
82
  'Homepage convention: pass slug="" (empty string) or omit slug and set title to "Home"; ' +
79
83
  'the server stores it under id "home" with slug "". ' +
80
84
  'Slug must be a single path segment (no slashes). For nested URLs like ' +
@@ -88,7 +92,7 @@ export const pageTools = [
88
92
  title: z.string().min(1),
89
93
  content_type: z.string().optional().describe('Content type ID; defaults to page.'),
90
94
  fields: z.record(z.unknown()).optional().describe('Custom field values defined by the content type.'),
91
- slug: z.string().optional().describe('URL slug — single path segment, no slashes (e.g. "about", "kontakt"). Empty string "" = homepage. For nested URLs set the `path` field explicitly — `slug` stays a leaf id.'),
95
+ slug: z.string().optional().describe('URL slug — single path segment, no slashes (e.g. "about", "kontakt"). Empty string "" = homepage. For nested URLs set the `path` field explicitly — `slug` stays a leaf id. Already in use → 409 slug_in_use naming the existing page; omit it to derive one from the title (numbered with a slug_adjusted warning on conflict).'),
92
96
  path: z.string().optional().describe('Optional explicit URL path for nested pages (e.g. "/erbjudanden/sommar-2026"). When set, takes precedence over slug for routing. Must start with "/", lowercase a-z/0-9/-/_/ only, no "..", no "//", no trailing slash. Slug is still required as the leaf id but two pages CAN share a slug under different paths.'),
93
97
  content_mode: z.enum(['blocks', 'html']).optional().describe('Default "blocks". "blocks" stores a Block[] tree (the modern default — supports the full ~40-block library, page templates, and the responsive system). "html" stores raw markup in html_content (legacy path, still fully supported for imported content or hand-written HTML). Passing html_content without content_mode also opts into "html".'),
94
98
  html_content: z.string().optional().describe('Body HTML — only used when content_mode="html".'),
@@ -224,11 +228,11 @@ export const pageTools = [
224
228
  },
225
229
  {
226
230
  name: 'clone_page',
227
- 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.',
231
+ 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. Same address rule as create_page: a slug you pass that is already in use fails with 409 slug_in_use (details: { page_id, path }); a slug derived from the title is numbered and reported in warnings (code slug_adjusted).',
228
232
  inputSchema: {
229
233
  source_page_id: z.string(),
230
234
  title: z.string().min(1).describe('Title for the new page.'),
231
- slug: z.string().optional().describe('Slug for the new page. Omit to auto-derive from title.'),
235
+ slug: z.string().optional().describe('Slug for the new page. Omit to derive it from the title. A slug already in use is refused (409 slug_in_use), never changed.'),
232
236
  version: versionParam,
233
237
  },
234
238
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -268,7 +272,7 @@ export const pageTools = [
268
272
  image_sizes_default: src.image_sizes_default,
269
273
  status: 'draft',
270
274
  };
271
- if (args.slug)
275
+ if (args.slug !== undefined)
272
276
  body.slug = args.slug;
273
277
  if (typeof src.path === 'string' && src.path) {
274
278
  // Keep an explicit route's directory, never its old final segment.
@@ -282,7 +286,9 @@ export const pageTools = [
282
286
  segments.pop();
283
287
  body.path = '/' + [...segments, slug.replace(/^\/+|\/+$/g, '')].join('/');
284
288
  }
285
- // 3. Create. The API auto-uniques generated routes; explicit conflicts return 409.
289
+ // 3. Create. An explicit slug or path that is taken fails with 409
290
+ // (slug_in_use / path_in_use); a title-derived slug is numbered and
291
+ // reported in `warnings`.
286
292
  const created = await client.post(siteId, 'pages', body, v(args.version));
287
293
  return ok(created);
288
294
  }),
@@ -49,7 +49,7 @@ export const reusableBlockTools = [
49
49
  },
50
50
  {
51
51
  name: 'save_block_template',
52
- description: 'Save a block template: a section starter that editors and agents insert as an independent copy. Pass `from: { page_id, block_id }` to save a block from a page (its draft), or `blocks`. Prefer templates that use named styles (style_id) over one-off values, so copies stay consistent. Templates belong to the site, not a branch.',
52
+ description: 'Save a block template: a section starter that editors and agents insert as an independent copy. Pass `from: { page_id, block_id }` to save a block from a page (its draft), or `blocks`. Prefer templates that use named styles (style_id, part_styles) over one-off values, so copies stay consistent. Templates belong to the site, not a branch.',
53
53
  inputSchema: {
54
54
  name: z.string(),
55
55
  description: z.string().optional().describe('When to use it.'),
@@ -76,8 +76,8 @@ export const settingsTools = [
76
76
  }).strict().nullable().optional().describe('Organization schema.org JSON-LD emitted on every page. Replaces the stored value as a whole; null (or all-empty) clears it.'),
77
77
  staging_url: z.string().nullable().optional().describe('URL of a staging environment for this site, reported as urls.staging. Stored on the Site, not per version. Trailing slashes are dropped; empty string or null clears.'),
78
78
  render_version: z.number().int().min(1).optional().describe('Platform render version. Platform output changes ship as new versions so existing sites never change look on their own. Upgrade only after previewing (get_preview_link render_version) and with the user\'s approval; the tr-upgrade-rendering skill compares every page. Lowering it returns to an earlier version.'),
79
- colors: z.record(z.string()).optional(),
80
- fonts: z.record(z.unknown()).optional(),
79
+ colors: z.record(z.string().nullable()).optional().describe('Palette: primary, secondary, accent, background, surface, text, text_light (shallow-merged). Optional on_primary, on_secondary, on_accent set the text colour on that theme colour (buttons, badges, tabs, call-to-action sections, styles using the on_* tokens) as a hex colour such as "#ffffff"; null returns to the automatic black or white (chosen by APCA from render version 8, by the WCAG 2 ratio before). A chosen on_* colour applies on every render version. The response lists `on_colors` with each one\'s APCA Lc and WCAG 2 ratio; a choice below APCA Lc 60 (button text, 16px/600) is kept and returned as a warning. Prefer the automatic choice unless the brand specifies a colour.'),
80
+ fonts: z.record(z.unknown()).optional().describe('heading, body (Google Fonts family or "system"), size_base (px) and smoothing: "auto" (browser default) or "antialiased" (thinner text on macOS, for light text on dark backgrounds); null clears smoothing. Unset smoothing is auto from render version 6.'),
81
81
  contact: z
82
82
  .object({
83
83
  email: z.string().optional(),
@@ -1,13 +1,16 @@
1
1
  // Style library tools. A site's look is a library of NAMED styles
2
2
  // (SiteSettings.styles): element roles (body, h1–h6, link) style every
3
3
  // matching element, and every other style is chosen per block through the
4
- // block's `style_id` field. Reuse beats one-off values.
4
+ // block's `style_id` field. A style can also style a block PART (a named
5
+ // sub-element such as core/rich_heading's links): site-wide through the
6
+ // style's `parts`, on one block through its `part_styles` data. Reuse beats
7
+ // one-off values.
5
8
  import { z } from 'zod';
6
9
  import { ok, withErrorBoundary, versionParam } from './helpers.js';
7
10
  function v(version) {
8
11
  return version ? { version } : undefined;
9
12
  }
10
- const COLOR_HELP = 'A palette token (primary, secondary, accent, background, surface, text, text_light), a derived readable text colour for a palette background (on_primary, on_secondary, on_accent), or a hex/rgb/hsl colour. Prefer tokens so a palette change flows through.';
13
+ const COLOR_HELP = 'A palette token (primary, secondary, accent, background, surface, text, text_light), the text colour for a palette background (on_primary, on_secondary, on_accent: the site\'s colors.on_* when set, else black or white, whichever reads better), or a hex/rgb/hsl colour. Prefer tokens so a palette change flows through.';
11
14
  const length = z.string().describe('CSS length: px, rem, em, %, vw, ch, or clamp()/min()/max()');
12
15
  const color = z.string().describe(COLOR_HELP);
13
16
  const propsSchema = z.object({
@@ -33,6 +36,9 @@ const propsSchema = z.object({
33
36
  color: color.optional(),
34
37
  sides: z.enum(['all', 'top', 'right', 'bottom', 'left', 'block', 'inline']).optional(),
35
38
  }).optional(),
39
+ opacity: z.number().min(0).max(1).optional().describe('0 (invisible) to 1 (opaque)'),
40
+ shadow: z.enum(['none', 'sm', 'md', 'lg']).optional().describe('Shadow preset; none removes a shadow the block draws'),
41
+ gap: z.string().optional().describe('Space between the children of a flex or grid element: one CSS length, or row and column'),
36
42
  }).describe('Style properties. Mobile-first: `base` applies everywhere, `at.<breakpoint>` from that width up.');
37
43
  const atSchema = z.object({
38
44
  tablet: propsSchema.optional(),
@@ -44,24 +50,26 @@ const styleFields = {
44
50
  name: z.string().describe('Author-facing name, in the site\'s language, e.g. "Överrubrik" or "Hero title".'),
45
51
  role: z.enum(['body', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'link', 'lead', 'eyebrow', 'small', 'quote', 'button', 'button_secondary', 'section']).optional()
46
52
  .describe('Standard role. body/h1–h6/link style every matching element on the site; the others mark the standard style for that purpose. One style per role.'),
47
- targets: z.array(z.enum(['text', 'heading', 'list', 'button', 'container'])).optional()
48
- .describe('Block kinds that can select this style via style_id (text = core/prose, heading = core/heading and core/rich_heading, list, button, container = core/section and core/container).'),
53
+ targets: z.array(z.enum(['text', 'heading', 'list', 'button', 'container', 'link', 'image'])).optional()
54
+ .describe('Kinds of element that can select this style: through a block\'s style_id (text = core/prose, heading = core/heading and core/rich_heading, list, button, container = core/section and core/container) and through a block part with that target (link and image exist for parts, e.g. core/rich_heading.link, core/hero.media).'),
49
55
  description: z.string().optional().describe('When to use it; shown to editors and agents.'),
50
56
  base: propsSchema.optional(),
51
57
  at: atSchema.optional(),
52
58
  hover: z.object({ color: color.optional(), background: color.optional(), decoration: z.enum(['none', 'underline']).optional() }).optional(),
53
59
  css: z.string().optional().describe('Advanced CSS scoped to the style: bare declarations, or rules where `&` is the style selector (e.g. "&::before { content: \'—\' }"). Prefer structured properties.'),
60
+ parts: z.array(z.string()).optional()
61
+ .describe('Block parts this style applies to SITE-WIDE, on every instance, as "<block type>.<part>" (e.g. "core/rich_heading.link", "core/hero.primary_button"); read_block_type lists a block\'s `parts` with their targets. The style\'s targets must include each part\'s target; a part takes one site-wide style; not for element roles (body, h1–h6, link). Unknown parts are refused with the closest names. Precedence, lowest first: the block\'s own CSS and fields; this site-wide part style, replaced on one block by its `data.part_styles` choice; a style class on the element itself (a heading\'s eyebrow_style_id, a button\'s style_id).'),
54
62
  };
55
63
  export const styleTools = [
56
64
  {
57
65
  name: 'list_styles',
58
- description: 'List the site\'s named style library: element roles (body, h1–h6, link) that style every matching element, and the styles blocks select with `style_id`. Also returns missing_standard_roles and contrast/library problems. READ THIS BEFORE STYLING ANY PAGE: reuse an existing style for a recurring look (eyebrows, leads, card titles, buttons, section spacing) instead of per-block values, raw HTML classes or page CSS.',
66
+ description: 'List the site\'s named style library: element roles (body, h1–h6, link) that style every matching element, and the styles blocks select with `style_id`. Also returns missing_standard_roles, contrast/library problems and readability warnings (both APCA Lc and WCAG 2 ratio). READ THIS BEFORE STYLING ANY PAGE: reuse an existing style for a recurring look (eyebrows, leads, card titles, buttons, section spacing) instead of per-block values, raw HTML classes or page CSS.',
59
67
  inputSchema: { version: versionParam },
60
68
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'styles', v(args.version)))),
61
69
  },
62
70
  {
63
71
  name: 'create_style',
64
- description: 'Create a named style. Do this whenever a look could recur — an eyebrow, a lead paragraph, a card title, a quote, a button variant, a section rhythm — then apply it with the block\'s `style_id` field. Only a clearly unique one-off may use block settings instead. Set values for phones in `base` and grow them in `at.tablet`/`at.desktop`. Text must meet WCAG AA contrast (4.5:1, 3:1 for ≥24px or bold ≥18.7px) against its background; a failing style is refused. Never light grey on white.',
72
+ description: 'Create a named style. Do this whenever a look could recur — an eyebrow, a lead paragraph, a card title, a quote, a button variant, a section rhythm — then apply it with the block\'s `style_id` field. Only a clearly unique one-off may use block settings instead. Set values for phones in `base` and grow them in `at.tablet`/`at.desktop`. Text contrast against its background is measured with APCA by use, size and weight (body text Lc 75; secondary/small text Lc 60; headings, eyebrows and buttons Lc 60 from 16px at weight 600+, Lc 45 from 24px bold or 36px; smaller or lighter non-body text Lc 75) and with the WCAG 2 ratio. Text that fails both is refused; text below its APCA level that meets WCAG 2 AA (4.5:1, 3:1 for ≥24px or bold ≥18.7px) is saved and returned in `warnings`. Never light grey on white. To restyle a sub-element of a block (rich-heading links, a hero\'s buttons or image), list it in `parts` (site-wide) or choose the style in that block\'s `data.part_styles`.',
65
73
  inputSchema: {
66
74
  id: z.string().regex(/^[a-z][a-z0-9-]{0,47}$/).describe('Stable slug; the CSS class becomes s-<id>. Cannot change later.'),
67
75
  ...styleFields,
@@ -75,7 +83,7 @@ export const styleTools = [
75
83
  },
76
84
  {
77
85
  name: 'update_style',
78
- description: 'Update a style. `base`, `hover` and each breakpoint in `at` merge property by property (pass null to remove one); name, targets, description and css replace. Every page using the style changes at once — tell the user which pages that affects when it matters.',
86
+ description: 'Update a style. `base`, `hover` and each breakpoint in `at` merge property by property (pass null to remove one); name, targets, description, css and parts replace (parts: [] removes the style from every part). Every page using the style changes at once — tell the user which pages that affects when it matters.',
79
87
  inputSchema: {
80
88
  style_id: z.string(),
81
89
  name: styleFields.name.optional(),
@@ -86,6 +94,7 @@ export const styleTools = [
86
94
  at: z.record(z.unknown()).optional().describe('Per-breakpoint property patches; null removes a breakpoint or property.'),
87
95
  hover: z.record(z.unknown()).optional(),
88
96
  css: z.string().optional(),
97
+ parts: styleFields.parts,
89
98
  version: versionParam,
90
99
  },
91
100
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -101,7 +110,7 @@ export const styleTools = [
101
110
  },
102
111
  {
103
112
  name: 'apply_standard_styles',
104
- description: 'Add the standard styles the site is missing (body, H1–H6, link, lead, eyebrow, small text, quote, primary/secondary button, section), with considered sizes and spacing for phones, tablets and desktops. The natural first step when building a new site, before pages: then adjust them to the brand (fonts, colours, scale) with update_style. New sites already have them. overwrite:true resets the standard roles to the defaults and changes the site\'s look — ask first.',
113
+ description: 'Add the standard styles the site is missing (body, H1–H6, link, lead, eyebrow, small text, quote, primary/secondary button, section), with considered sizes and spacing for phones, tablets and desktops. The natural first step when building a new site, before pages: then adjust them to the brand (fonts, colours, scale) with update_style. New sites already have them. overwrite:true resets the standard roles to the defaults and changes the site\'s look — ask first. A standard style whose palette colour is unreadable on the background is added with the body text colour instead; `adjusted` lists those and `warnings` says what changed, with the measures — tell the user.',
105
114
  inputSchema: { overwrite: z.boolean().optional(), version: versionParam },
106
115
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'styles/standard', { overwrite: args.overwrite === true }, v(args.version)))),
107
116
  },
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.45.60';
11
+ export const VERSION = '0.45.62';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.60",
3
+ "version": "0.45.62",
4
4
  "description": "Typeroll CMS MCP server – connect AI agents to the REST API to manage content and publish static websites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,14 @@ A Typeroll site uses 7 color tokens:
51
51
  | `text` | Body copy. | ≥4.5:1 contrast ratio on `background`. |
52
52
  | `text_light` | Secondary labels, captions, placeholders. | ≥3:1 on `background`. |
53
53
 
54
+ Text on `primary`, `secondary` and `accent` (buttons, badges, tabs,
55
+ call-to-action sections, the `on_*` style tokens) is black or white
56
+ automatically, chosen by APCA from render version 8. When the brand
57
+ specifies it, set `colors.on_primary`, `colors.on_secondary` or
58
+ `colors.on_accent` (a hex colour; `null` returns to automatic). The response
59
+ lists `on_colors` with each one's APCA Lc and WCAG 2 ratio: button text needs
60
+ Lc 60, body text Lc 75. Do not compensate with custom CSS.
61
+
54
62
  **Palette recipes by mood:**
55
63
 
56
64
  *Nordic / minimal:*
@@ -87,6 +87,13 @@ When the markup cannot be built from blocks, use `template` instead of
87
87
  `{{@number}}`, `{{@first}}`, `{{@last}}`, and `{{#link field class="x"}}…{{/link}}`.
88
88
  A type has a composition or a template, never both.
89
89
 
90
+ Declare `parts` for the elements of the markup a site may want to restyle:
91
+ `parts: [{ name: "title", label: "Title", selector: ".icon-list-title", target: "heading" }]`.
92
+ Selectors are tags, classes, attributes and structural pseudo-classes, never
93
+ `:hover` or `::before`. Styles then reach the part site-wide
94
+ (`parts: ["icon_list.title"]` on the style) or on one block
95
+ (`data.part_styles: { title: "card-title" }`, a field added automatically).
96
+
90
97
  ## Changing a type in use
91
98
 
92
99
  - Rename a field with `renames: { "old": "new" }` (`"items.old": "new"` inside a
@@ -73,6 +73,9 @@ Then the ordinary preconditions:
73
73
  appearance unless a redesign is explicitly requested.
74
74
  - If the target already has content, you must NOT clobber it — always
75
75
  `list_pages` first and only write to slugs that don't already exist.
76
+ Pass the slug explicitly: `create_page` then refuses an address that is
77
+ already in use (409 `slug_in_use` naming the existing page) instead of
78
+ numbering it.
76
79
 
77
80
  ## Recipe
78
81
 
@@ -62,6 +62,41 @@ behavior or other semantics unsupported by the chosen block: record the gap
62
62
  and use a supported composition or stop for native support.
63
63
  Do not apply a template to hundreds of Pages until its prototype passes.
64
64
 
65
+ ## Report platform gaps, do not patch them
66
+
67
+ Matching the source sometimes needs something the platform does not offer.
68
+ Never close that with site or page `custom_css`, raw HTML or a generic custom
69
+ block: the next site hits the same gap and the workaround breaks on the next
70
+ Core change. Record it and report it instead. Check these five causes first,
71
+ because each has looked like a site problem:
72
+
73
+ 1. **A platform default no setting reaches.** Compare computed text rendering
74
+ and base styles on `body`, links and headings (`-webkit-font-smoothing`,
75
+ `line-height`, `text-decoration`) with the source. If the difference comes
76
+ from Typeroll's base CSS, check Site settings and the render versions first
77
+ (`read_site_settings` → `render.upgrades`).
78
+ 2. **A replaced control ignores the block's settings.** A capability can swap a
79
+ field for a provider element (address suggestions). Compare that control
80
+ with a plain field in the same block: border, radius, height, background,
81
+ text colour, font, placeholder, icon and colour scheme in light and dark mode.
82
+ 3. **A missing part.** The source has a part (icon, badge, second label) the
83
+ block has no field for. Check `read_block_type` for a field before
84
+ concluding it is missing.
85
+ 4. **A value that is stored and ignored.** Read every write response's
86
+ `warnings` (`field_type_mismatch`, `unknown_style_override`): a wrong type or
87
+ key is saved and never rendered. Measure the computed result, not the
88
+ read-back.
89
+ 5. **Preview differs from the published page.** A setting that shows in preview
90
+ but not in a fresh hosted build is a publication gap; report both URLs.
91
+
92
+ Report each gap with: site ID, Organization, Core version and render version;
93
+ source and target URL; viewport (actual `innerWidth`), browser and DPR; the
94
+ block ID and type or the setting; source and target computed values; what is
95
+ missing, ignored or wrong; the proposed setting or fix; and acceptance
96
+ criteria a reviewer can measure. State that no workaround CSS is in place.
97
+ Mark the affected recipe `waiting_for_native_support`, continue independent
98
+ work, and verify against the source again after the release.
99
+
65
100
  ## Separate files, addresses and business behavior
66
101
 
67
102
  Inventory both file bytes and every discovered historical asset URL, including
@@ -140,7 +140,9 @@ Set the site's look as named styles before building any page:
140
140
  `apply_standard_styles`.
141
141
  2. Adjust them to the brand with `update_style`: the font, the type scale and
142
142
  spacing for `base` (phones) and `at.tablet`/`at.desktop`, and colours from
143
- palette tokens. Keep text at WCAG AA contrast; failing styles are refused.
143
+ palette tokens. Keep text readable: contrast is checked with APCA and
144
+ WCAG 2; text that fails both is refused, and text below its APCA level
145
+ comes back as a warning.
144
146
  3. Add a named style for every look the design repeats: a card title, a price
145
147
  note, a highlighted section, a CTA button variant. Use the site's language
146
148
  for names.
@@ -148,6 +150,12 @@ Set the site's look as named styles before building any page:
148
150
  Pages then choose styles with each block's `style_id` field. A per-block
149
151
  font size, colour or class is only for something clearly unique.
150
152
 
153
+ To change a sub-element a block styles itself (the underline on formatted
154
+ heading links, a hero's heading or buttons), use its parts:
155
+ `read_block_type` lists them. A style with `parts: ["core/rich_heading.link"]`
156
+ applies to that part on every block; `data.part_styles` on one block replaces
157
+ it there.
158
+
151
159
  ### 3. Header + footer partials
152
160
 
153
161
  **Start from the native preset — don't hand-roll navigation.** Read