@typeroll/mcp-server 0.45.45 → 0.45.47

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.
@@ -22,7 +22,7 @@ export function compactTools(catalog) {
22
22
  }),
23
23
  ...['read', 'write', 'admin'].map(effect => ({
24
24
  name: `call_${effect}_tool`, effect,
25
- description: effect === 'read' ? 'Call a discovered read-only tool with its exact arguments. Does not accept write/admin tools.' : `Call a discovered ${effect} tool. May ${effect === 'admin' ? 'change app or email configuration, send a test email or activate releases' : 'save, delete, publish or otherwise change state'}. Inspect describe_tool first and follow the user’s authorization.`,
25
+ description: effect === 'read' ? 'Call a discovered read-only tool with its exact arguments. Does not accept write/admin tools.' : `Call a discovered ${effect} tool. May ${effect === 'admin' ? 'change app, email or block type configuration, send a test email or activate releases' : 'save, delete, publish or otherwise change state'}. Inspect describe_tool first and follow the user’s authorization.`,
26
26
  schema: { name: z.string().max(100), arguments: z.record(z.unknown()).default({}) },
27
27
  invoke: async (args) => {
28
28
  const tool = byName.get(String(args.name));
package/dist/server.js CHANGED
@@ -50,7 +50,7 @@ const PERM_RANK = { read: 0, write: 1, admin: 2 };
50
50
  * gate then checks `effect <= sitePermission`.
51
51
  *
52
52
  * Naming convention is followed by the tool modules: read paths are
53
- * `list_*` / `read_*` / `get_*` / `preview_*` / `search_*` / `check_*`; everything else
53
+ * `list_*` / `read_*` / `get_*` / `preview_*` / `search_*` / `check_*` / `find_*`; everything else
54
54
  * mutates.
55
55
  */
56
56
  function effectFor(name) {
@@ -101,8 +101,17 @@ function effectFor(name) {
101
101
  || name === 'set_email_settings'
102
102
  || name === 'delete_email_settings'
103
103
  || name === 'send_test_email'
104
- || name === 'set_incoming_email_forwarding')
104
+ || name === 'set_incoming_email_forwarding'
105
+ // Block type authoring is site administration (the portal and the v1
106
+ // routes require admin); placing and editing blocks on pages stays write.
107
+ || name === 'create_block_type'
108
+ || name === 'update_block_type'
109
+ || name === 'delete_block_type'
110
+ || name === 'import_block_types')
105
111
  return 'admin';
112
+ // Compute an answer and write nothing, like the preview_* tools.
113
+ if (name === 'validate_block_type' || name.startsWith('find_'))
114
+ return 'read';
106
115
  if (name === 'diff_version')
107
116
  return 'read';
108
117
  if (name.startsWith('list_') ||
@@ -1,16 +1,18 @@
1
- // Block-type tools. Surface is wired in now so the future block editor
2
- // drops in without breaking integrations.
1
+ // Block-type tools: discovery, authoring (validate, preview, create, update,
2
+ // delete), usage, starters and .tcblocks packages. Every write goes through
3
+ // the portal's one block type write path (shared validator, version chain,
4
+ // rename migration), and authoring needs admin permission on the Site.
3
5
  import { z } from 'zod';
4
6
  import { ok, withErrorBoundary, versionParam } from './helpers.js';
5
7
  function v(version) {
6
8
  return version ? { version } : undefined;
7
9
  }
8
- // The per-block markup. These three fields dominate the payload size (a few
9
- // dozen blocks of template HTML + scoped CSS can exceed an agent's token
10
- // budget and get truncated), and an agent enumerating the library rarely
11
- // needs them — read_block_type fetches them for one block on demand. So
12
- // list_block_types omits them by default and returns a lightweight summary.
13
- const HEAVY_BLOCK_TYPE_FIELDS = ['template', 'styles', 'script'];
10
+ // The per-block markup. These fields dominate the payload size (a few dozen
11
+ // blocks of template HTML + scoped CSS can exceed an agent's token budget and
12
+ // get truncated), and an agent enumerating the library rarely needs them —
13
+ // read_block_type fetches them for one block on demand. So list_block_types
14
+ // omits them by default and returns a lightweight summary.
15
+ const HEAVY_BLOCK_TYPE_FIELDS = ['template', 'styles', 'styles_compiled', 'script', 'composition'];
14
16
  /** Strip the heavy markup fields from each block type in a `block-types`
15
17
  * response, preserving the field `schema` and all light metadata. Defensive:
16
18
  * if the shape isn't the expected `{ block_types: [...] }`, pass it through. */
@@ -25,18 +27,47 @@ function summariseBlockTypes(res) {
25
27
  if (!bt || typeof bt !== 'object')
26
28
  return bt;
27
29
  const copy = { ...bt };
30
+ if (Array.isArray(copy.composition) && copy.composition.length)
31
+ copy.composed = true;
28
32
  for (const field of HEAVY_BLOCK_TYPE_FIELDS)
29
33
  delete copy[field];
30
34
  return copy;
31
35
  });
32
36
  return { ...envelope, block_types: slim };
33
37
  }
38
+ const AUTHORING_GUIDE = 'Two kinds of site block type. COMPOSED (preferred): `composition` is a tree of existing blocks ({ id, type, data, children? } — core/heading, core/text, core/prose, core/image, core/icon, core/button, core/container, core/grid, core/repeater …) and `schema` declares the props people edit when they place the block. Inner blocks read props through exact bindings as the WHOLE value: a heading\'s text "{{props.title}}"; a core/repeater with items "{{props.items}}" (an array prop) renders its children once per item, where "{{item.title}}" reads the item and "{{item.link.href}}" a link sub-value. Everything the inner blocks do (named styles, responsive settings, images, safe links, render versions) carries over. ' +
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
+ '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
+ '`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
+ '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 ADMIN permission on the Site; editors (write) place block types on pages and edit their fields.';
43
+ const definitionShape = {
44
+ label: z.string().optional().describe('Display label in the block picker.'),
45
+ description: z.string().optional().describe('When to use this block type; shown to editors and agents (≤ 500 chars).'),
46
+ icon: z.string().optional().describe('Lucide icon name shown in the block picker.'),
47
+ category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
48
+ container: z.union([
49
+ z.literal(true), z.literal(false),
50
+ z.enum(['slots', 'repeater', 'conditional']),
51
+ ]).optional().describe('Template types only. true renders {{children}}, slots fixed named slots, repeater loops over items, conditional show-if. A composed type is never a container; put a container block inside its composition.'),
52
+ slot_count: z.number().optional().describe('Required when container="slots". 1-8.'),
53
+ slot_labels: z.array(z.string()).optional(),
54
+ item_compatible: z.boolean().optional().describe('Mark true if this block is designed to render as a repeater item (no outer padding).'),
55
+ expand_to: z.object({
56
+ target: z.string(),
57
+ defaults: z.record(z.unknown()),
58
+ }).optional().describe('Alias mechanism — render as another block type with these defaults merged under authored data.'),
59
+ schema: z.array(z.unknown()).optional().describe('FieldDefinition[]: { name, type, label, help?, required?, default?, options? (select), fields? (array/object), item_label?, min_items?, max_items? }. Replaces the whole schema on update.'),
60
+ composition: z.array(z.unknown()).optional().describe('Block[] a composed type is built from. Bind data to props with "{{props.name}}" and, inside a core/repeater over an array prop, to items with "{{item.name}}" — always as the whole value.'),
61
+ template: z.string().optional().describe('Markup of a template type (instead of composition); see the section syntax above.'),
62
+ styles: z.string().optional().describe('CSS scoped to the block. Write selectors for the block\'s own markup; :scope is the block element.'),
63
+ 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."),
64
+ };
34
65
  export const blockTypeTools = [
35
66
  {
36
67
  name: 'export_block_types',
37
- 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.',
68
+ description: 'Pack site block types into a .tcblocks zip (base64). When `ids` is omitted, every block type the Site has (made in the portal, by an agent, or imported) is bundled. The package keeps every property — composition, template, styles and CSS scope, script, field help and list settings — so it moves between any Sites, in any Organization or Typeroll instance. import_block_types reads it back.',
38
69
  inputSchema: {
39
- ids: z.array(z.string()).optional().describe('Specific block-type ids to include. Omit to export every user-created block.'),
70
+ ids: z.array(z.string()).optional().describe('Specific block-type ids to include. Omit to export every site block type.'),
40
71
  name: z.string().optional().describe('Package name (default: {site}-blocks).'),
41
72
  version: z.string().optional().describe('Package version written into the manifest (default: 1.0.0). This is the package semver, not a Site version — use version_branch for that.'),
42
73
  version_branch: versionParam,
@@ -60,26 +91,29 @@ export const blockTypeTools = [
60
91
  },
61
92
  {
62
93
  name: 'import_block_types',
63
- 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.',
94
+ description: 'Install block types from a .tcblocks package (zip as base64). Needs ADMIN permission on the Site. Each definition is checked by the same validator as create_block_type; a definition with errors is reported as failed and not written. On a name conflict `on_conflict` decides: skip (default) keeps the Site\'s type, rename imports it under a free name (references inside the package follow), replace overwrites it. Returns `results` per type (created, renamed, replaced, skipped, failed with problems) and counts. Imported types may carry CSS and JS — only import packages you trust.',
64
95
  inputSchema: {
65
96
  zip_base64: z.string().describe('Base64-encoded .tcblocks zip.'),
97
+ on_conflict: z.enum(['skip', 'rename', 'replace']).optional().describe('What to do when the Site already has a block type with the same name. Default skip.'),
66
98
  version: versionParam,
67
99
  },
68
100
  handler: withErrorBoundary(async (args, { client, siteId }) => {
69
- const v = args.version ? { version: args.version } : undefined;
70
- const res = await client.post(siteId, 'blocks/import', { zip_base64: args.zip_base64 }, v);
101
+ const res = await client.post(siteId, 'blocks/import', {
102
+ zip_base64: args.zip_base64,
103
+ ...(args.on_conflict ? { on_conflict: args.on_conflict } : {}),
104
+ }, v(args.version));
71
105
  return ok(res);
72
106
  }),
73
107
  },
74
108
  {
75
109
  name: 'list_block_types',
76
- description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), custom blocks (origin: "user", created in the portal), and third-party blocks (origin: "third_party", imported from .tcblocks packages). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site. By default this is a LIGHTWEIGHT SUMMARY: each entry has id, label, category, icon, container/slot info, origin, and the full field schema (name/type/label/required/options/default) — everything you need to pick a block and call add_block — but NOT the render-time markup. The per-block template HTML, scoped styles, and script are omitted so the list stays within token budget no matter how large the library grows; fetch them for a single block with read_block_type, or pass full:true to inline them for every block (can be very large).',
110
+ description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), site block types (origin "user" from the portal, "ai" from an agent) and third-party blocks (origin "third_party", imported from .tcblocks packages). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site. By default this is a LIGHTWEIGHT SUMMARY: id, label, description, category, icon, container/slot info, origin, `composed: true` for composed types, and the full field schema — everything you need to pick a block and call add_block — but NOT the template, composition, styles or script. Fetch those for one block with read_block_type, or pass full:true to inline them for every block (can be very large).',
77
111
  inputSchema: {
78
- include_core: z.boolean().optional().describe('Default true. Set false to get only custom + third-party (rarely needed).'),
112
+ include_core: z.boolean().optional().describe('Default true. Set false to get only the site\'s own and third-party types.'),
79
113
  full: z
80
114
  .boolean()
81
115
  .optional()
82
- .describe("Default false. When true, include each block type's template HTML, styles CSS, and script inline — the full doc. Large; prefer read_block_type for one block's markup."),
116
+ .describe("Default false. When true, include each block type's template, composition, styles and script inline — the full doc. Large; prefer read_block_type for one block."),
83
117
  version: versionParam,
84
118
  },
85
119
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -94,7 +128,7 @@ export const blockTypeTools = [
94
128
  },
95
129
  {
96
130
  name: 'read_block_type',
97
- description: "Full schema for one block type. Returns the BlockType doc with template, styles, optional script, and the field-definitions array. Use this when list_block_types' summary isn't enough — e.g. before add_block on a custom block whose data fields you haven't seen.",
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.",
98
132
  inputSchema: {
99
133
  type_id: z.string(),
100
134
  version: versionParam,
@@ -106,7 +140,7 @@ export const blockTypeTools = [
106
140
  },
107
141
  {
108
142
  name: 'find_pages_using_block_type',
109
- description: 'Pages whose block tree contains this block type. Empty for HTML-mode sites (the default today); fills in once block-mode pages exist.',
143
+ description: 'Everything that uses a block type, on every surface: `pages` (saved block pages and drafts — `sources` says which), `templates` (page templates), `partials` (header, footer, global blocks), `block_templates`, and `block_types` (other site block types built from it). Repeaters that render the type as their item_block count, and a use through another block type names it in `via`. `total` sums the lists; delete_block_type is refused while it is above zero. Call it before changing or removing fields of a type in use.',
110
144
  inputSchema: {
111
145
  type_id: z.string(),
112
146
  version: versionParam,
@@ -116,36 +150,62 @@ export const blockTypeTools = [
116
150
  return ok(res);
117
151
  }),
118
152
  },
153
+ {
154
+ name: 'list_block_type_starters',
155
+ description: 'Built-in starting points for a new block type: complete composed definitions (icon list, feature cards, numbered steps) with fields, composition and scoped CSS. Adapt one (rename it, change fields) and pass it to create_block_type — it is the quickest way to a valid composed type.',
156
+ inputSchema: {},
157
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
158
+ const res = await client.get(siteId, 'block-types/starters');
159
+ return ok(res);
160
+ }),
161
+ },
162
+ {
163
+ name: 'validate_block_type',
164
+ description: `Check a block type definition without saving it, with the exact validator create_block_type and update_block_type use. Pass the whole definition, or with type_id a patch to that stored type (renames are checked too). Returns { ok, problems, merged }: every error and warning with a JSON-pointer path (e.g. /schema/0/fields/2/name, /composition/0/children/1/data/text) and a line for markup and CSS, and the definition as it would be stored. Writes nothing. ${AUTHORING_GUIDE}`,
165
+ inputSchema: {
166
+ definition: z.record(z.unknown()).describe('The block type definition (as for create_block_type), or a patch when type_id is set.'),
167
+ type_id: z.string().optional().describe('Validate `definition` as a patch to this stored block type.'),
168
+ version: versionParam,
169
+ },
170
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
171
+ const query = {};
172
+ if (args.type_id)
173
+ query.type_id = args.type_id;
174
+ if (args.version)
175
+ query.version = args.version;
176
+ const res = await client.post(siteId, 'block-types/validate', args.definition, query);
177
+ return ok(res);
178
+ }),
179
+ },
180
+ {
181
+ name: 'preview_block_type',
182
+ description: 'Render a block type definition that need not be saved, through the same preview renderer as the portal (the Site\'s render version, theme CSS, named styles, custom CSS, the other site block types, and the sanitizer). Without `data` the block shows sample values derived from its fields. Returns { ok, html, css, document, data, problems }: `html` is the block\'s markup as a page carries it, `css` the scoped block CSS that ships, `document` a standalone HTML page. A definition with errors is not rendered (ok:false, problems listed). Block scripts never run in a preview. Writes nothing.',
183
+ inputSchema: {
184
+ definition: z.record(z.unknown()).optional().describe('The block type definition, or a patch when type_id is set. Omit with type_id to preview the stored type.'),
185
+ type_id: z.string().optional().describe('Preview `definition` as a patch to this stored block type.'),
186
+ data: z.record(z.unknown()).optional().describe('Field values for the previewed block. Omit for sample data.'),
187
+ render_version: z.number().int().optional().describe('Render with this platform render version instead of the Site\'s own.'),
188
+ version: versionParam,
189
+ },
190
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
191
+ const { version, ...body } = args;
192
+ const res = await client.post(siteId, 'block-types/preview', { ...body, definition: body.definition ?? {} }, v(version));
193
+ return ok(res);
194
+ }),
195
+ },
119
196
  // ─── BlockType authoring — CREATE / UPDATE / DELETE ──────────────────
120
- // `script` runs with full DOM access in every visitor's browser. Through
121
- // an API key it is ACCEPTED under the key holder's authority (same trust
122
- // level as scripts_head) — the write is audit-logged and the response
123
- // carries a notice naming the stored JS. Only the in-portal chat AI
124
- // remains gated on Site.ai_scripts_enabled. Origin is stamped 'ai' so
125
- // audit + UI distinguish agent-authored types.
197
+ // Admin permission on the Site, as in the portal. `script` runs with full
198
+ // DOM access in every visitor's browser. Through an API key it is ACCEPTED
199
+ // under the key holder's authority (same trust level as scripts_head) and
200
+ // the write is audit-logged. Only the in-portal chat never writes it.
201
+ // Origin is stamped 'ai' so audit + UI distinguish agent-authored types.
126
202
  {
127
203
  name: 'create_block_type',
128
- description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` is accepted under your API key's authority (audit-logged; the response carries a notice) — review before deploy. This is the supported way to ship per-page JS: `<script>` written into page or block MARKUP is stripped by the renderer's sanitizer no matter which credential wrote it, so package the behaviour as a block type instead. Site-wide tags belong in settings.scripts_head / scripts_body_end. Returns the created BlockType. Responsive fields: mark a schema field `responsive: true` and expose it as style=\"--{field}:{{field}}\" on the outermost element so it can vary per breakpoint. If the value is directly usable CSS, read var(--{field}); if it's a token that maps to CSS (e.g. layout 'icon-left' → flex-direction:row) add a `responsive_css` map on the field ({ 'icon-left': '--dir: row;' }), otherwise per-breakpoint overrides won't take effect.",
204
+ description: `Create a block type for this Site when no core block (or block template) fits a recurring shape. Origin is stamped 'ai'; id = name. The type is available to every page editor immediately and reaches the live site with the next deploy. ${AUTHORING_GUIDE} Returns { block_type, warnings }; 409 when the name exists. A block type is the supported way to ship per-block JS (\`script\`): <script> written into page or block markup is always stripped by the sanitizer. Site-wide tags belong in settings.scripts_head / scripts_body_end.`,
129
205
  inputSchema: {
130
- name: z.string().describe('Machine name (lowercase kebab/underscore, 1-64 chars). Becomes the id.'),
131
- label: z.string().optional().describe('Display label (defaults to name).'),
132
- icon: z.string().optional().describe('Lucide icon name shown in the block picker.'),
133
- category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
134
- container: z.union([
135
- z.literal(true), z.literal(false),
136
- z.enum(['slots', 'repeater', 'conditional']),
137
- ]).optional().describe('Container kind. Use slots for fixed named slots, repeater for looping over items, conditional for show-if blocks.'),
138
- slot_count: z.number().optional().describe('Required when container="slots". 1-8.'),
139
- slot_labels: z.array(z.string()).optional(),
140
- item_compatible: z.boolean().optional().describe('Mark true if this block is designed to render as a repeater item (no outer padding, kernel layout).'),
141
- expand_to: z.object({
142
- target: z.string(),
143
- defaults: z.record(z.unknown()),
144
- }).optional().describe('Alias mechanism — render as another block type with these defaults merged under authored data.'),
145
- schema: z.array(z.unknown()).optional().describe('FieldDefinition[]. Each field has name, type, label, optional required/default/options/responsive.'),
146
- template: z.string().optional().describe('HTML with {{field}}, {{{field}}}, {{=tag}}, {{children}}, {{slot:NAME}} substitutions.'),
147
- styles: z.string().optional().describe('Block-scoped CSS. Selectors should start with [data-block="<name>"].'),
148
- script: z.string().optional().describe("Client-side JS shipped with the block (runs on the published site). Accepted under your API key's authority — the write is audit-logged and the response carries a notice naming the stored JS. It runs on the published site's own origin, which shares nothing with the Typeroll portal."),
206
+ name: z.string().describe('Machine name (lowercase letters, digits, "-" and "_", 1-64 chars). Becomes the id.'),
207
+ ...definitionShape,
208
+ label: z.string().describe('Display label in the block picker.'),
149
209
  version: versionParam,
150
210
  },
151
211
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -160,24 +220,12 @@ export const blockTypeTools = [
160
220
  },
161
221
  {
162
222
  name: 'update_block_type',
163
- description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. The `script` field follows the same policy as create_block_type — accepted under your key's authority, audit-logged, notice in the response.",
223
+ description: "Change a site block type (core/* types are read-only). Needs ADMIN 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.",
164
224
  inputSchema: {
165
- type_id: z.string().describe('Block type id (custom or third_party). Cannot be a core/* block.'),
166
- label: z.string().optional(),
167
- icon: z.string().optional(),
168
- category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
169
- container: z.union([
170
- z.literal(true), z.literal(false),
171
- z.enum(['slots', 'repeater', 'conditional']),
172
- ]).optional(),
173
- slot_count: z.number().optional(),
174
- slot_labels: z.array(z.string()).optional(),
175
- item_compatible: z.boolean().optional(),
176
- expand_to: z.object({ target: z.string(), defaults: z.record(z.unknown()) }).optional(),
177
- schema: z.array(z.unknown()).optional(),
178
- template: z.string().optional(),
179
- styles: z.string().optional(),
180
- script: z.string().optional().describe("Accepted under your API key's authority; audit-logged, and the response carries a notice naming the stored JS."),
225
+ type_id: z.string().describe('Block type id (a site type). Cannot be a core/* block.'),
226
+ ...definitionShape,
227
+ renames: z.record(z.string()).optional().describe('Old field path → new field name, e.g. { "title": "heading", "items.label": "text" }. Send together with the new schema.'),
228
+ confirm_data_loss: z.boolean().optional().describe('Drop the data of removed or retyped fields that hold data. Only after the user agreed; the 409 lists what would be lost.'),
181
229
  version: versionParam,
182
230
  },
183
231
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -188,7 +236,7 @@ export const blockTypeTools = [
188
236
  },
189
237
  {
190
238
  name: 'delete_block_type',
191
- description: "Remove a custom block type. Core blocks (core/*) cannot be deleted. **Warning**: pages currently using this block type will render '<!-- unknown block type -->' comments instead. Call find_pages_using_block_type FIRST to see the blast radius; the AI should prompt the user before deleting if usage > 0.",
239
+ description: "Remove a site block type. Needs ADMIN permission; core blocks (core/*) cannot be deleted. Refused with 409 while anything uses the type — pages and drafts, page templates, header, footer, global blocks, block templates, other block types or repeaters — and the refusal lists the uses (as find_pages_using_block_type does). Remove those uses first and confirm with the user.",
192
240
  inputSchema: {
193
241
  type_id: z.string(),
194
242
  version: versionParam,
@@ -30,13 +30,13 @@ export const domainTools = [
30
30
  },
31
31
  {
32
32
  name: 'read_organization_publishing_connections', noSite: true,
33
- description: 'Read the Organization\'s GitHub and Cloudflare publishing connections: status, revision (needed to change or disconnect), account identity, media_ready, media migration, and connect_urls. Requires an organization API key; never returns credentials. GitHub sign-in/App installation and Cloudflare OAuth need a person in a browser: give the user the matching connect_urls link (an organization owner or admin completes it), then read again.',
33
+ description: 'Read the Organization\'s GitHub and Cloudflare publishing connections: status, revision (needed to change or disconnect), account identity, media_ready, media migration, github_setup (app_configured, encryption_available) and connect_urls. Requires an organization API key; never returns credentials. GitHub sign-in/App installation and Cloudflare OAuth need a person in a browser: give the user the matching connect_urls link (an organization owner or admin completes it), then read again. When GitHub is not connected, call diagnose_organization_github_connection to learn why and who must act.',
34
34
  inputSchema: {},
35
35
  handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('publishing/connections'))),
36
36
  },
37
37
  {
38
38
  name: 'disconnect_organization_publishing_provider', noSite: true,
39
- description: 'Disconnect the Organization\'s GitHub or Cloudflare publishing connection at the revision from read_organization_publishing_connections. Erases stored credentials; does not delete repositories, buckets, media, DNS or published sites, but publishing through that provider stops until reconnected. Requires an organization API key and the user\'s explicit go-ahead.',
39
+ description: 'Disconnect the Organization\'s GitHub or Cloudflare publishing connection at the revision from read_organization_publishing_connections. Erases stored credentials; does not delete repositories, buckets, media, DNS or published sites, but publishing through that provider stops until reconnected. After a GitHub disconnect, an owner or admin may connect a different GitHub account in the browser only after confirming which account it replaces; existing repositories are not moved. Requires an organization API key and the user\'s explicit go-ahead.',
40
40
  inputSchema: { provider: z.enum(['github', 'cloudflare']), revision: z.string(), hosting_group_id: z.string().optional().describe('Omit for the organization (Default) connection.') },
41
41
  handler: withErrorBoundary(async ({ provider, ...body }, { client }) => ok(await client.rootDelete(`publishing/connections/${provider}`, body))),
42
42
  },
@@ -63,10 +63,16 @@ export const domainTools = [
63
63
  },
64
64
  {
65
65
  name: 'check_organization_github_permissions', noSite: true,
66
- description: 'Check live GitHub build permissions without disconnecting or reconnecting. Requires an organization API key. Distinguishes an update the organization owner can approve from permissions Typeroll has not requested yet. Returns the existing installation approval URL only when actionable; does not enable a build engine.',
66
+ description: 'Check live GitHub build permissions (Actions and Workflows) of an already connected GitHub account without disconnecting or reconnecting. Requires an organization API key. Distinguishes an update the organization owner can approve from permissions Typeroll has not requested yet. Returns the existing installation approval URL only when actionable; does not enable a build engine. For a GitHub connection that is missing or failing, use diagnose_organization_github_connection.',
67
67
  inputSchema: {},
68
68
  handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('publishing/github-permissions'))),
69
69
  },
70
+ {
71
+ name: 'diagnose_organization_github_connection', noSite: true,
72
+ description: 'Explain why the Organization\'s GitHub publishing connection is or is not working and who must do what next. Returns the Organization\'s state: diagnosis.outcome (connected, needs_attention, choose, action_required, waiting_on_owner, sign_in_required, retryable_error or unavailable), blockers, and the saved installation once connected. A person\'s unfinished attempt and their GitHub accounts stay in their own browser session. Each blocker has a code, who must act (you = the person connecting, github_owner, publisher = operator of this Typeroll installation, typeroll_admin), a message and one action: a github.com or documentation link, or a browser step (sign_in, install, retry, confirm_account_change) completed at connect_url. Read only, except recheck: true re-checks a connected installation (suspension, repository access, permissions) with the App\'s own authority. An API key cannot sign in to GitHub; relay the message and link to the user. Requires an organization API key; never returns credentials.',
73
+ inputSchema: { recheck: z.boolean().optional().describe('Re-check the connected installation with the publisher App before answering.') },
74
+ handler: withErrorBoundary(async ({ recheck }, { client }) => ok(await client.rootGet(`publishing/github-diagnosis${recheck ? '?recheck=true' : ''}`))),
75
+ },
70
76
  {
71
77
  name: 'setup_organization_build_engine', noSite: true,
72
78
  description: 'Prepare or update the organization Cloudflare or GitHub build engine and start its isolated execution and artifact-transfer check. Use the selected provider’s engine revision. Setup does not change the organization’s default provider. Requires an organization API key and current revision. Keeps site repositories, branches and Hosting Groups separate. Read the engine status until verification finishes.',
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.45';
11
+ export const VERSION = '0.45.47';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.45",
3
+ "version": "0.45.47",
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": {
package/skills/README.md CHANGED
@@ -43,6 +43,7 @@ instructions. Read the relevant recipe when the task calls for it.
43
43
  |---|---|---|
44
44
  | `tr-blog.md` | "add a blog", "set up news" | Articles are Pages of a content type, sharing a native template and Page listing. |
45
45
  | `tr-forms.md` | "contact form", "add a form", "booking form" | Form definition → embed HTML with signed token → inline JS feedback → deploy. |
46
+ | `tr-custom-blocks.md` | "make a block for…", "icon list", a section shape the core blocks lack | Starter or composition from core blocks with fields and bindings → validate → preview → create → place. |
46
47
  | `tr-page-template.md` | Reusable Page templates: audio players, chapter lists, guest cards, image galleries — anything needing loops/nested data | Compose native blocks around `template_content_slot`; bind Page metadata, structured fields and references. |
47
48
  | `tr-seo.md` | "SEO", "meta descriptions", "structured data" | Audit → fix titles/descriptions → OG images → JSON-LD → robots.txt → deploy. |
48
49
 
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: tr-custom-blocks
3
+ description: Use when a Typeroll site needs a block the core library does not have (an icon list, cards, steps, a team strip), so the user wants a reusable block type with its own fields.
4
+ ---
5
+
6
+ # Build a site block type
7
+
8
+ A site block type is reusable: editors place it and fill in its fields, and
9
+ every instance updates when the type changes. Build one when the same section
10
+ shape recurs, or when editors should fill in fields rather than arrange
11
+ blocks. For a one-off section use plain blocks; for a section copied and then
12
+ adapted per page use a block template; for content that must be identical
13
+ everywhere use a global block.
14
+
15
+ Creating, changing and deleting block types needs admin permission. A changed
16
+ block type reaches the live site with the next deploy.
17
+
18
+ ## 1. Look before you build
19
+
20
+ ```
21
+ list_block_types # core and site types: maybe it exists
22
+ list_block_type_starters # icon_list, feature_cards, steps
23
+ list_styles # the site's named styles to use inside
24
+ ```
25
+
26
+ Prefer a starter or a core block with the right settings over a new type.
27
+
28
+ ## 2. Compose it from existing blocks
29
+
30
+ Composed is the default. Declare the editable fields in `schema` and build the
31
+ markup from core blocks in `composition`:
32
+
33
+ - An inner block field set to exactly `"{{props.name}}"` takes the block's
34
+ field value. Bindings keep their type (lists, links, booleans).
35
+ - For a repeating group, add a `core/repeater` with
36
+ `items: "{{props.items}}"`, `source_type: "static"` and no `item_block`. Its
37
+ children render once per item and read the item with `"{{item.title}}"`.
38
+ - Make a whole item a link with a `core/container`
39
+ `{ tag: "a", href: "{{item.link.href}}", new_tab: "{{item.link.new_tab}}" }`.
40
+ Items without a link render as a plain container.
41
+ - Use `core/text` for values from text fields (always escaped), `core/heading`
42
+ for headings, `core/icon`, `core/image`, `core/button`.
43
+ - Give inner blocks named styles (`style_id`) instead of CSS where you can.
44
+
45
+ Field types: `text`, `textarea`, `richtext`, `image`, `icon` (`{name}_svg`),
46
+ `link` (`{ page_id }` or `{ url }` plus `new_tab`; resolves to `href`, `target`,
47
+ `rel` and follows page renames), `select` with `options`, `boolean`, `number`,
48
+ `array` (`fields`, `item_label`, `min_items`, `max_items`), `object`.
49
+
50
+ ## 3. Check and preview before saving
51
+
52
+ ```
53
+ validate_block_type definition={…} # problems with paths, e.g. /schema/0/fields/1/name
54
+ preview_block_type definition={…} # html + css with sample data, as published
55
+ ```
56
+
57
+ Fix every error. Read warnings: a binding inside other text
58
+ (`"Call {{props.phone}}"`) is shown as typed; only whole-value bindings resolve.
59
+
60
+ ## 4. CSS
61
+
62
+ `styles` apply only inside the block: selectors are prefixed automatically,
63
+ `:scope` is the block element, and `html`, `body` and `:root` are refused. Use
64
+ `var(--color-primary)` and the site's spacing, not fixed colours, and keep text
65
+ at WCAG AA contrast.
66
+
67
+ ## 5. Create, place, deploy
68
+
69
+ ```
70
+ create_block_type name="icon_list" label="Icon list" category="content" schema=[…] composition=[…] styles="…"
71
+ add_block page_id=<id> block={ type: "icon_list", data: { items: [ … ] } }
72
+ get_preview_link page_id=<id>
73
+ ```
74
+
75
+ Show the preview to the user. Deploy only when they approve.
76
+
77
+ ## Template block types (advanced)
78
+
79
+ When the markup cannot be built from blocks, use `template` instead of
80
+ `composition`: `{{field}}` (escaped), `{{{richtext_field}}}`, `{{#field}}…{{/field}}`,
81
+ `{{^field}}…{{/field}}` (empty), `{{#each items}}…{{/each}}` with `{{@index}}`,
82
+ `{{@number}}`, `{{@first}}`, `{{@last}}`, and `{{#link field class="x"}}…{{/link}}`.
83
+ A type has a composition or a template, never both.
84
+
85
+ ## Changing a type in use
86
+
87
+ - Rename a field with `renames: { "old": "new" }` (`"items.old": "new"` inside a
88
+ group): Typeroll moves the data everywhere the type is used.
89
+ - Removing or retyping a field that holds data is refused with the list of
90
+ usages unless you send `confirm_data_loss: true`. Ask the user first.
91
+ - `find_pages_using_block_type` before deleting; a type in use cannot be
92
+ deleted.
93
+
94
+ ## Pitfalls
95
+
96
+ - Pasting HTML into a `core/html` block inside a composition: editors cannot
97
+ edit it and it skips the site's styles. Compose from real blocks.
98
+ - Binding a text field into `core/prose`: the value becomes HTML. Use `core/text`.
99
+ - Nesting links: an icon or button with its own link inside a linked container.
100
+ - Linked containers on a site below render version 4: every icon sits in a
101
+ link of its own, so the icon list starter nests links, and an item without
102
+ an address is still a link. The write warns; upgrade the site's rendering
103
+ (`tr-upgrade-rendering`) first.
104
+ - Writing `script` without the user's explicit request.