@typeroll/mcp-server 0.44.74 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/init.js CHANGED
@@ -104,6 +104,12 @@ This project is wired to a Typeroll site through \`@typeroll/mcp-server\`.
104
104
  - **Connection** is configured in \`.mcp.json\`. Fill in \`TYPEROLL_API_KEY\`
105
105
  (create one in the portal under Settings → API keys) and
106
106
  \`TYPEROLL_SITE_ID\`.
107
+ - **One Page model (Core 0.2.0 / MCP 0.45.0).** Every article, checklist,
108
+ directory entry and ordinary page is a Page. A \`content_type\` supplies its
109
+ schema, URL pattern and default Page template; custom values live in \`fields\`.
110
+ Use \`create_page\`, \`list_pages content_type=...\` and Page IDs everywhere.
111
+ Use the same \`version\` for Pages, types, templates and previews. Existing
112
+ installations require the schema 2 migration before using this model.
107
113
  - **Discover before you write.** Call \`get_site\`, \`read_site_settings\`,
108
114
  \`list_pages\`, and \`list_block_types\` before proposing changes so you
109
115
  mirror the site's conventions.
package/dist/server.js CHANGED
@@ -11,7 +11,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { z } from 'zod';
12
12
  import { pageTools } from './tools/pages.js';
13
13
  import { partialTools } from './tools/partials.js';
14
- import { collectionTools } from './tools/collections.js';
14
+ import { contentTypeTools } from './tools/content-types.js';
15
+ import { pageTemplateTools } from './tools/page-templates.js';
15
16
  import { mediaTools } from './tools/media.js';
16
17
  import { redirectTools } from './tools/redirects.js';
17
18
  import { migrationTools } from './tools/migration.js';
@@ -44,6 +45,8 @@ const PERM_RANK = { read: 0, write: 1, admin: 2 };
44
45
  * mutates.
45
46
  */
46
47
  function effectFor(name) {
48
+ if (name === 'page_completeness')
49
+ return 'read';
47
50
  if (name === 'list_apps'
48
51
  || name === 'read_app'
49
52
  || name === 'update_app'
@@ -75,8 +78,11 @@ const DEFAULT_INFO = { name: 'typeroll', version: VERSION };
75
78
  */
76
79
  export const SERVER_INSTRUCTIONS = `
77
80
  Typeroll MCP — operating manual. You're managing a Typeroll site (a static-site
78
- CMS: database content compiles to a fast static site on a deploy). The full
79
- playbook ships with this server — use it:
81
+ CMS: database content compiles to a fast static site on a deploy). Every article,
82
+ checklist, directory entry and ordinary page is a Page. Content types supply
83
+ custom field schemas, URL patterns and default Page templates. Use Page IDs and
84
+ list_pages with content_type filters; use the same version for all operations.
85
+ The full playbook ships with this server — use it:
80
86
 
81
87
  0. For the complete operating context (data model, conventions, safety
82
88
  boundaries, tool-family reference), call read_guide once. list_skills +
@@ -90,12 +96,13 @@ playbook ships with this server — use it:
90
96
  or field names — they're per-site. For installed Extension config, call
91
97
  list_extension_installations then read_extension_installation before an
92
98
  update.
93
- 3. THE BUFFER MODEL: every content write (pages, blocks, partials,
94
- collection items) lands in an unsaved per-doc DRAFT — deploys and plain
99
+ 3. THE BUFFER MODEL: content edits to Pages, blocks and partials land in an
100
+ unsaved per-doc DRAFT — deploys and plain
95
101
  previews see saved content only. Iterate freely, view your drafts with
96
102
  include_working_copy on the preview tools, then SAVE explicitly:
97
103
  commit_working_copy (or save:true on the write call) when the user
98
- approves. Status changes and structural ops apply immediately.
104
+ approves. Status changes and structural ops apply immediately, including
105
+ Content type and Page template definitions and change_page_content_type.
99
106
  4. Branch first for anything larger than a small edit: create_branch, pass
100
107
  version=<id> on every subsequent call, merge_branch once approved. Nothing
101
108
  touches the live site until then.
@@ -126,7 +133,8 @@ export function buildServer(options) {
126
133
  ...blockTypeTools,
127
134
  ...pageBlockTools,
128
135
  ...workingCopyTools,
129
- ...collectionTools,
136
+ ...contentTypeTools,
137
+ ...pageTemplateTools,
130
138
  ...mediaTools,
131
139
  ...redirectTools,
132
140
  ...migrationTools,
@@ -6,7 +6,7 @@ function v(version) {
6
6
  export const bulkTools = [
7
7
  {
8
8
  name: 'check_internal_links',
9
- description: 'Check internal hrefs against the versioned database before deploy. Covers published pages, partials, collection templates/items, page templates, generated collection/facet routes, media URLs and redirect chains. Returns every broken source/href pair without requesting the live site.',
9
+ description: 'Check internal hrefs against the versioned database before deploy. Covers published Pages of every content type, partials, Page templates, generated facet routes, media URLs and redirect chains. Returns every broken source/href pair without requesting the live site.',
10
10
  inputSchema: { version: versionParam },
11
11
  handler: withErrorBoundary(async (args, { client, siteId }) => {
12
12
  return ok(await client.get(siteId, 'internal-links', v(args.version)));
@@ -14,15 +14,14 @@ export const bulkTools = [
14
14
  },
15
15
  {
16
16
  name: 'bulk_replace_text',
17
- description: 'Replace a literal substring or regex across pages, collection-item schema fields, and partials. ALWAYS run with dry_run=true first and show sample_diffs before the real call. Defaults to scope=pages for backwards compatibility. BUFFER MODEL: replacements land in unsaved DRAFTS; save=true commits each touched resource through the canonical save path.',
17
+ description: 'Replace a literal substring or regex across Page bodies, custom Page fields, and partials. ALWAYS run with dry_run=true first and show sample_diffs before the real call. Defaults to scope=pages. BUFFER MODEL: replacements land in unsaved DRAFTS; save=true commits each touched resource through the canonical save path.',
18
18
  inputSchema: {
19
19
  pattern: z.string().min(1).describe('Literal substring (default) or JS regex source if regex=true.'),
20
20
  replacement: z.string(),
21
21
  regex: z.boolean().optional().describe('Treat pattern as a regex source. Always case-insensitive + global.'),
22
- scope: z.enum(['pages', 'collection_items', 'partials', 'all']).optional().describe('Resource family to scan. Defaults to pages.'),
22
+ scope: z.enum(['pages', 'partials', 'all']).optional().describe('Resource family to scan. Defaults to pages.'),
23
23
  page_ids: z.array(z.string()).optional().describe('Restrict to these page ids. Omit to apply to every matching page.'),
24
- collection: z.string().optional().describe('Restrict collection-item replacement to this collection.'),
25
- item_ids: z.array(z.string()).optional().describe('Restrict to item ids. Requires collection.'),
24
+ content_type: z.string().optional().describe('Restrict replacement to Pages of this content type.'),
26
25
  partial_ids: z.array(z.string()).optional().describe('Restrict to partial ids.'),
27
26
  dry_run: z.boolean().optional(),
28
27
  save: z.boolean().optional().describe('Commit every touched page\'s draft in the same call — the usual choice after the user approved the dry-run diffs.'),
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ const schema = z.array(z.object({ name: z.string(), label: z.string(), type: z.string() }).passthrough());
4
+ const fields = {
5
+ label_singular: z.string().optional(), label_plural: z.string().optional(), fields: schema.optional(),
6
+ page_field_rules: z.record(z.object({ label: z.string().optional(), writable_by: z.array(z.enum(['portal', 'owner', 'agent', 'app', 'import'])) })).optional(),
7
+ allowed_templates: z.array(z.string()).nullable().optional().describe('Allowed Page template IDs, including the default. Null allows all compatible templates; an empty list permits no template.'),
8
+ route_template: z.string().optional(), template: z.string().optional(), schema_type: z.string().optional(),
9
+ schema_field_map: z.record(z.string()).optional(),
10
+ icon: z.string().optional(), sort_field: z.string().optional(), sort_dir: z.enum(['asc', 'desc']).optional(),
11
+ facets: z.array(z.record(z.unknown())).optional(), facet_combinations: z.array(z.record(z.unknown())).optional(),
12
+ };
13
+ export const contentTypeTools = [
14
+ { name: 'page_completeness', description: 'Report missing, unverified and stale fields for Pages of one content type, with per-field counts and worst-first Page IDs. Read-only; agent-writable fields only by default.', inputSchema: { content_type: z.string(), limit: z.number().int().min(1).max(200).optional(), stale_after_days: z.number().min(0).optional(), agent_writable_only: z.boolean().optional(), version: versionParam },
15
+ handler: withErrorBoundary(async (args, { client, siteId }) => { const { content_type, ...query } = args; return ok(await client.get(siteId, `content-types/${encodeURIComponent(content_type)}/completeness`, query)); }) },
16
+ { name: 'change_page_content_type', description: 'Change a saved Page to another content type. Preserves its identity, block body, status and existing URL. Save or discard its working copy first. If supplied, fields replaces the complete custom fields object; omitted fields are kept and must be valid for the new type. Snapshots a revision before changing.', inputSchema: { page_id: z.string(), content_type: z.string(), fields: z.record(z.unknown()).optional(), version: versionParam },
17
+ handler: withErrorBoundary(async (args, { client, siteId }) => { const { page_id, version, ...body } = args; return ok(await client.post(siteId, `pages/${encodeURIComponent(page_id)}/content-type`, body, { version })); }) },
18
+ { name: 'list_content_types', description: 'List the field schemas, URL patterns, sorting, allowed templates and default templates for Pages. All content is a Page; use list_pages with content_type to filter.', inputSchema: { version: versionParam },
19
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'content-types', { version: args.version }))) },
20
+ { name: 'read_content_type', description: 'Read one Page content type.', inputSchema: { name: z.string(), version: versionParam },
21
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `content-types/${encodeURIComponent(args.name)}`, { version: args.version }))) },
22
+ { name: 'create_content_type', description: 'Create a Page content type. Title, slug, block body, SEO and status are built in; fields defines custom fields only. An empty route_template means pages of this type have no public URL.', inputSchema: { name: z.string(), ...fields, label_singular: z.string(), label_plural: z.string(), fields: schema, route_template: z.string(), version: versionParam },
23
+ handler: withErrorBoundary(async (args, { client, siteId }) => { const { version, ...body } = args; return ok(await client.post(siteId, 'content-types', body, { version })); }) },
24
+ { name: 'update_content_type', description: 'Update a Page content type. Changes affect all its pages at the next publication; explicit per-page paths remain overrides. The default template must be allowed; removing templates used by saved Pages is rejected.', inputSchema: { name: z.string(), patch: z.object(fields), version: versionParam },
25
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.patch(siteId, `content-types/${encodeURIComponent(args.name)}`, args.patch, { version: args.version }))) },
26
+ { name: 'delete_content_type', description: 'Delete an unused content type. Reassign or delete its pages first; the standard Page type cannot be deleted.', inputSchema: { name: z.string(), version: versionParam },
27
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.del(siteId, `content-types/${encodeURIComponent(args.name)}`, { version: args.version }))) },
28
+ ];
@@ -12,7 +12,7 @@ export const deployTools = [
12
12
  },
13
13
  {
14
14
  name: 'trigger_deploy',
15
- description: "Enqueue a deploy (static-page build → hosting) of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. NOT for previewing iterative design/content edits — that's what get_preview_link is for (it renders live from the DB with no build). Reserve trigger_deploy for publishing, a stakeholder link to the compiled site, or pre-merge validation. Deploys build SAVED content only — unsaved drafts (working copies) are excluded, so commit_working_copy (or save:true on your writes) before deploying, or your changes won't ship. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new collection routes, schema bump, mode switch) without risking the live site. A dry_run job that succeeds proves the build is clean; one that fails returns the same astro stack-trace in get_deploy_status.error as a real deploy would.",
15
+ description: "Enqueue a deploy (static-page build → hosting) of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. NOT for previewing iterative design/content edits — that's what get_preview_link is for (it renders live from the DB with no build). Reserve trigger_deploy for publishing, a stakeholder link to the compiled site, or pre-merge validation. Deploys build SAVED content only — unsaved drafts (working copies) are excluded, so commit_working_copy (or save:true on your writes) before deploying, or your changes won't ship. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new content-type routes, schema bump, mode switch) without risking the live site. A dry_run job that succeeds proves the build is clean; one that fails returns the same astro stack-trace in get_deploy_status.error as a real deploy would.",
16
16
  inputSchema: {
17
17
  environment: z.enum(['production', 'staging']).optional(),
18
18
  dry_run: z.boolean().optional().describe('Run the build but skip the CDN upload. The job ends in succeeded/failed the same way a real deploy does.'),
@@ -63,7 +63,7 @@ export const migrationTools = [
63
63
  },
64
64
  {
65
65
  name: 'list_migration_urls',
66
- description: "The legacy site's URL inventory with LIVE coverage status. Every entry is classified on read against the site's current pages + redirects: `migrated` (a page/collection item answers at that path), `redirected` (a redirect rule covers it), `excluded` (signed off as an intentional 404), `unhandled` (nothing covers it — the work list). Returns a summary over the whole inventory plus a page of entries, sorted worst-first then by GSC clicks. Use `status: \"unhandled\"` to get exactly what's left to do before cutover. Coverage is computed, never stored, so it's current the moment you create a redirect.",
66
+ description: "The legacy site's URL inventory with LIVE coverage status. Every entry is classified on read against the site's current pages + redirects: `migrated` (a Page answers at that path), `redirected` (a redirect rule covers it), `excluded` (signed off as an intentional 404), `unhandled` (nothing covers it — the work list). Returns a summary over the whole inventory plus a page of entries, sorted worst-first then by GSC clicks. Use `status: \"unhandled\"` to get exactly what's left to do before cutover. Coverage is computed, never stored, so it's current the moment you create a redirect.",
67
67
  inputSchema: {
68
68
  status: STATUS.optional().describe('Only return entries with this coverage status.'),
69
69
  limit: z.number().int().positive().max(1000).optional().describe('Default 200.'),
@@ -154,11 +154,10 @@ export const migrationTools = [
154
154
  name: 'repair_migration_plain_text',
155
155
  description: 'Repair legacy WordPress entity encoding and HTML markup in fields whose contract is plain text. ONLY title, seo_title, seo_description and excerpt are eligible; rich content, slugs, paths and URLs are never changed. ALWAYS run the dry-run first (the default), show every returned before/after diff and conflict to the user, and obtain approval before calling again with dry_run:false. Existing working copies are skipped so another editor\'s draft cannot be overwritten or accidentally saved. save:false stages working copies for portal review; save:true commits through the normal revision and validation path.',
156
156
  inputSchema: {
157
- scope: z.enum(['pages', 'collection_items', 'all']).optional().describe('Defaults to all.'),
157
+ scope: z.enum(['pages', 'all']).optional().describe('Defaults to all.'),
158
158
  fields: z.array(PLAIN_TEXT_REPAIR_FIELD).min(1).max(4).optional().describe('Narrow the fixed plain-text allowlist.'),
159
159
  page_ids: z.array(z.string().min(1)).min(1).max(2000).optional(),
160
- collection: z.string().min(1).optional(),
161
- item_ids: z.array(z.string().min(1)).min(1).max(2000).optional().describe('Requires collection.'),
160
+ content_type: z.string().min(1).optional(),
162
161
  dry_run: z.boolean().optional().describe('Defaults to true. Set false only after the user reviews the dry-run.'),
163
162
  save: z.boolean().optional().describe('With dry_run:false, commit repaired working copies immediately. Defaults to false.'),
164
163
  diff_limit: z.number().int().min(1).max(2000).optional().describe('Exact field diffs returned; default 500.'),
@@ -28,9 +28,9 @@ const blockSchema = z.object({
28
28
  * `{ kind: 'page', id: page_id }`.
29
29
  */
30
30
  const targetSchema = z.object({
31
- kind: z.enum(['page', 'partial', 'template', 'item_template']),
31
+ kind: z.enum(['page', 'partial', 'template']),
32
32
  id: z.string(),
33
- }).describe('Address a container. kind=page|partial|template|item_template. Pages: id is the page id. Partials: id is "header"|"footer"|<free-block id>. Templates: id is the PageTemplate id. item_template: id is the COLLECTION NAME — the block tree is the collection\'s per-item layout (e.g. blog post body bound to {{item.title}}, {{item.body}}, etc.).');
33
+ }).describe('Address a page, partial or PageTemplate by its ID. All content uses kind=page; the same block editor applies to every content type.');
34
34
  function v(version) {
35
35
  return version ? { version } : undefined;
36
36
  }
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ const fields = {
4
+ label: z.string().optional(), icon: z.string().optional(), applies_to: z.string().optional(),
5
+ blocks: z.array(z.any()).optional(), status: z.enum(['draft', 'published']).optional(),
6
+ };
7
+ export const pageTemplateTools = [
8
+ { name: 'list_page_templates', description: 'List reusable Page templates in the selected version, including inherited templates.', inputSchema: { version: versionParam },
9
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'page-templates', { version: args.version }))) },
10
+ { name: 'read_page_template', description: 'Read one reusable Page template and its block tree.', inputSchema: { template_id: z.string(), version: versionParam },
11
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `page-templates/${encodeURIComponent(args.template_id)}`, { version: args.version }))) },
12
+ { name: 'create_page_template', description: 'Create a reusable template. Choose a starter or provide blocks, including template_content_slot for the Page body. Assign its ID to a content type’s template or a Page’s template.', inputSchema: { name: z.string(), ...fields, starter: z.enum(['blog', 'article', 'checklist', 'team', 'events', 'products', 'custom']).optional(), version: versionParam },
13
+ handler: withErrorBoundary(async (args, { client, siteId }) => { const { version, ...body } = args; return ok(await client.post(siteId, 'page-templates', body, { version })); }) },
14
+ { name: 'update_page_template', description: 'Update a reusable Page template. The change is saved immediately and affects its Pages on the next publication. A version isolates the edit from main.', inputSchema: { template_id: z.string(), patch: z.object(fields), version: versionParam },
15
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.patch(siteId, `page-templates/${encodeURIComponent(args.template_id)}`, args.patch, { version: args.version }))) },
16
+ { name: 'delete_page_template', description: 'Delete an unused Page template. Reassign any Pages or content types using it first.', inputSchema: { template_id: z.string(), version: versionParam },
17
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.del(siteId, `page-templates/${encodeURIComponent(args.template_id)}`, { version: args.version }))) },
18
+ ];
@@ -10,6 +10,9 @@ export const pageTools = [
10
10
  description: 'List pages on the active site. Returns id, title, slug, status, and SEO summary (no html_content by default — use read_page or batch_read_pages for body content). Supports filtering by status and forward-cursor pagination. Pass full=true to include html_content + blocks in the response.',
11
11
  inputSchema: {
12
12
  status: z.enum(['draft', 'review', 'unlisted', 'published', 'all']).optional(),
13
+ sort_by: z.string().optional().describe('Sort field; defaults to the selected content type’s order. Unfiltered lists use stable Page IDs.'),
14
+ sort_order: z.enum(['asc', 'desc']).optional(),
15
+ content_type: z.string().optional().describe('Filter Pages by content type ID.'),
13
16
  limit: z.number().int().min(1).max(200).optional(),
14
17
  cursor: z.string().optional(),
15
18
  full: z.boolean().optional().describe('Set true to include html_content + blocks. Default false (summary only) to avoid large payloads on sites with many pages.'),
@@ -18,6 +21,9 @@ export const pageTools = [
18
21
  handler: withErrorBoundary(async (args, { client, siteId }) => {
19
22
  const res = await client.get(siteId, 'pages', {
20
23
  status: args.status,
24
+ content_type: args.content_type,
25
+ sort_by: args.sort_by,
26
+ sort_order: args.sort_order,
21
27
  limit: args.limit,
22
28
  cursor: args.cursor,
23
29
  full: args.full ? 'true' : undefined,
@@ -69,13 +75,15 @@ export const pageTools = [
69
75
  'the server stores it under id "home" with slug "". ' +
70
76
  'Slug must be a single path segment (no slashes). For nested URLs like ' +
71
77
  '/erbjudanden/sommar or /tjanster/design, set the optional `path` field explicitly — ' +
72
- 'e.g. path="/erbjudanden/sommar". For many similar items use a collection with route_template ' +
78
+ 'e.g. path="/erbjudanden/sommar". For pages with the same schema use a content type with route_template ' +
73
79
  '(see tr-blog / tr-directory), but for a small group of bespoke pages sharing a URL prefix, ' +
74
80
  '`path` is the right primitive. ' +
75
81
  'Returns the created page (blocks for blocks-mode, html_content for html-mode) including ' +
76
82
  '`url` (the resolved live URL).',
77
83
  inputSchema: {
78
84
  title: z.string().min(1),
85
+ content_type: z.string().optional().describe('Content type ID; defaults to page.'),
86
+ fields: z.record(z.unknown()).optional().describe('Custom field values defined by the content type.'),
79
87
  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.'),
80
88
  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.'),
81
89
  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,7 +102,8 @@ export const pageTools = [
94
102
  kind: z.enum(['page', 'article']).optional(),
95
103
  author: z.string().optional(),
96
104
  language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
97
- template: z.string().optional().describe('Page template id (PageTemplate). Wraps the body in the template tree at render time.'),
105
+ sort_order: z.number().finite().optional().describe('Manual Page order; lower numbers come first in ascending lists. Content types and listings choose how to sort.'),
106
+ template: z.string().nullable().optional().describe('Allowed Page template ID; null uses the content type default.'),
98
107
  image_sizes_default: z.string().optional().describe('Per-page default `sizes` for responsive images (e.g. "(max-width: 640px) 360px, 560px"). Overrides the site setting; a per-<img> `sizes` attr still wins. Set when this page\'s images render narrower than the generic default so the browser stops over-fetching.'),
99
108
  custom_css: z.string().optional().describe('Per-page CSS, injected into <head> as a <style> AFTER the site-level custom_css (so it overrides site styling). This is the RIGHT home for page-specific styling — page metadata, not content. Put a page\'s <style> here instead of stuffing it into a core/html block (which is opaque and un-editable in the visual editor).'),
100
109
  version: versionParam,
@@ -113,6 +122,7 @@ export const pageTools = [
113
122
  patch: z
114
123
  .object({
115
124
  title: z.string().optional(),
125
+ fields: z.record(z.unknown()).optional(),
116
126
  slug: z.string().optional(),
117
127
  html_content: z.string().optional(),
118
128
  blocks: z.array(z.any()).optional().describe('Block tree, only used when content_mode="blocks".'),
@@ -149,7 +159,8 @@ export const pageTools = [
149
159
  url: z.string().optional(),
150
160
  })
151
161
  .optional(),
152
- template: z.string().optional(),
162
+ sort_order: z.number().finite().nullable().optional(),
163
+ template: z.string().nullable().optional().describe('Allowed template ID; null restores the content type default.'),
153
164
  })
154
165
  .passthrough(),
155
166
  save: z.boolean().optional().describe('Also SAVE (commit) the draft in the same call — use for pre-approved or batch changes. Without it, changes stay in the unsaved draft until commit_working_copy.'),
@@ -221,6 +232,8 @@ export const pageTools = [
221
232
  const srcMode = src.content_mode ?? 'html';
222
233
  const body = {
223
234
  title: args.title,
235
+ content_type: src.content_type,
236
+ fields: src.fields,
224
237
  content_mode: srcMode,
225
238
  html_content: srcMode === 'html' ? src.html_content : '',
226
239
  blocks: srcMode === 'blocks' ? (src.blocks ?? []) : undefined,
@@ -17,12 +17,10 @@ export const previewTools = [
17
17
  },
18
18
  {
19
19
  name: 'get_preview_link',
20
- description: 'Mint a signed URL the user (or your own browser tool) can open to SEE the rendered preview. Renders LIVE from the database with NO build — this is the PREFERRED way to preview design/content changes as you iterate; reach for this, not trigger_deploy, when previewing. BUFFER MODEL: your edits are unsaved drafts, so for the iteration loop mint the link with include_working_copy:true (drafts visible); a plain link shows SAVED content only — right for stakeholders reviewing what will deploy. Mint ONCE and REUSE that single URL across edits — internal links keep the token so it navigates the whole branch, and it stays valid until the TTL lapses (24h default and max). Target a page (page_id), a collection item (collection_name + item_id), or a raw slug; omit all for the home page. (For a permanent public link, complete Publishing setup and wait for a verified deployment on the organization or site domain.)',
20
+ description: 'Mint a signed URL the user (or your own browser tool) can open to SEE the rendered preview. Renders LIVE from the database with NO build — this is the PREFERRED way to preview design/content changes as you iterate; reach for this, not trigger_deploy, when previewing. BUFFER MODEL: your edits are unsaved drafts, so for the iteration loop mint the link with include_working_copy:true (drafts visible); a plain link shows SAVED content only — right for stakeholders reviewing what will deploy. Mint ONCE and REUSE that single URL across edits — internal links keep the token so it navigates the whole branch, and it stays valid until the TTL lapses (24h default and max). Target a page (page_id), or a raw slug; omit all for the home page. (For a permanent public link, complete Publishing setup and wait for a verified deployment on the organization or site domain.)',
21
21
  inputSchema: {
22
22
  page_id: z.string().optional(),
23
23
  slug: z.string().optional(),
24
- collection_name: z.string().optional(),
25
- item_id: z.string().optional(),
26
24
  ttl_seconds: z.number().int().min(60).max(86_400).optional(),
27
25
  include_working_copy: z
28
26
  .boolean()
@@ -28,7 +28,7 @@ export const siteTools = [
28
28
  },
29
29
  {
30
30
  name: 'get_site_capabilities',
31
- description: "Discover what the site-template renderer supports for this site. Returns a manifest of feature flags (blocks-mode, x-include, collection routes, custom block scripts, dry-run deploys, etc.) plus template_capabilities_version + the core block type ids. Call this when you're about to do something structural (set route_template on a collection, switch a page to blocks-mode, install a custom block type) and want to feature-detect rather than guess. The manifest is platform-wide today; per-site custom templates land later.",
31
+ description: "Discover what the site-template renderer supports for this site. Returns a manifest of feature flags (blocks-mode, x-include, content-type routes, custom block scripts, dry-run deploys, etc.) plus template_capabilities_version + the core block type ids. Call this when you're about to do something structural (set route_template on a content type, switch a page to blocks-mode, install a custom block type) and want to feature-detect rather than guess. The manifest is platform-wide today; per-site custom templates land later.",
32
32
  handler: withErrorBoundary(async (_args, { client, siteId }) => {
33
33
  const res = await client.get(siteId, 'capabilities');
34
34
  return ok(res);
@@ -17,16 +17,10 @@ function v(version) {
17
17
  return version ? { version } : undefined;
18
18
  }
19
19
  const wcTargetSchema = z.object({
20
- kind: z.enum(['page', 'partial', 'item']),
20
+ kind: z.enum(['page', 'partial']),
21
21
  id: z.string(),
22
- collection: z.string().optional().describe('Required when kind="item": the collection name.'),
23
- }).describe('Which doc\'s draft: page {id}, partial {id}, or collection item ({collection} + {id}). Templates/item templates have no drafts (their writes apply directly).');
22
+ }).describe('Which doc\'s draft: page {id} or partial {id}. Templates have no drafts (their writes apply directly).');
24
23
  function wcPath(t) {
25
- if (t.kind === 'item') {
26
- if (!t.collection)
27
- throw new Error('collection is required when kind="item"');
28
- return `working-copy/item/${encodeURIComponent(t.collection)}/${encodeURIComponent(t.id)}`;
29
- }
30
24
  return `working-copy/${t.kind}/${encodeURIComponent(t.id)}`;
31
25
  }
32
26
  export const workingCopyTools = [
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.44.74';
11
+ export const VERSION = '0.45.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.44.74",
3
+ "version": "0.45.0",
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
@@ -46,11 +46,10 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
46
46
 
47
47
  | File | When it triggers | What it does |
48
48
  |---|---|---|
49
- | `tr-blog.md` | "add a blog", "set up news", "article/podcast section" | Collection schema with `item_template_html` + `route_template` → seed items → listing page with marker block → deploy. **No per-article `create_page` needed** — items materialise their own URLs. |
49
+ | `tr-blog.md` | "add a blog", "set up news" | Articles are Pages of a content type, sharing a native template and Page listing. |
50
50
  | `tr-forms.md` | "contact form", "add a form", "booking form" | Form definition → embed HTML with signed token → inline JS feedback → deploy. |
51
- | `tr-directory.md` | Building a directory site, importing structured data | Schema → items → per-item URLs via `route_template` → listing page → preview → deploy. |
52
- | `tr-collection-template.md` | Rich per-item detail pages: audio players, chapter lists, guest cards, image galleries — anything needing loops/nested data | Pre-render HTML into `*_html` fields when Mustache's `{{field}}` / `{{#field}}` aren't enough. Concrete recipes per pattern. |
53
- | `tr-page-template.md` | Several pages share structure (category landings, service-detail variants) | Partials + `<x-include>` for HTML mode; formal `PageTemplate` via `set_page_template` for block mode. Refactor existing duplication. |
51
+ | `tr-directory.md` | Building a directory site, importing structured data | Content type → Pages → URLs via `route_template` → listing page → preview → deploy. |
52
+ | `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. |
54
53
  | `tr-seo.md` | "SEO", "meta descriptions", "structured data" | Audit → fix titles/descriptions → OG images → JSON-LD → robots.txt → deploy. |
55
54
 
56
55
  ### Importera innehåll
@@ -59,12 +58,12 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
59
58
  |---|---|---|
60
59
  | `tr-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, rebuilds each page in the target's design, transfers media, sets redirects, leaves everything as drafts for review. |
61
60
  | `tr-migrate-multisite.md` | "multisite", "our .se/.de/.co.uk sites", migrating several sites at once | One site per domain; per-site URL inventory, design replicated via `.tcblocks`, path preservation, hreflang clusters, and a parity check against the deployed site before DNS moves. |
62
- | `tr-migrate-astro.md` | "migrate an Astro site", "import from src/content" | Lifts Astro Content Collections (`src/content/*`) into Typeroll collections — zod schema → field list, frontmatter → field values, markdown body → richtext field. Translates standalone `src/pages/*` into Typeroll pages, maps `src/layouts` chunks into partials. |
61
+ | `tr-migrate-astro.md` | "migrate an Astro site", "import from src/content" | Lifts Astro Content Collections (`src/content/*`) into Typeroll content types — zod schema → field list, frontmatter → field values, Markdown body → Page blocks. Translates standalone `src/pages/*` into Typeroll pages, maps `src/layouts` chunks into partials. |
63
62
  | `tr-import-url.md` | "import from Squarespace/Wix/Webflow", any non-WP URL | Fetch → clean → adapt to target design → media transfer → draft pages → redirects → deploy. |
64
63
 
65
64
  ## Prerequisites for every skill
66
65
 
67
- 1. `@typeroll/mcp-server` configured in `.claude.json` with a valid
66
+ 1. `@typeroll/mcp-server` configured in the agent’s MCP settings with a valid
68
67
  `TYPEROLL_API_KEY` and `TYPEROLL_API_URL`.
69
68
  2. The agent has read `AGENTS.md` (ships with the MCP package — see
70
69
  `node_modules/@typeroll/mcp-server/AGENTS.md` after install, or
@@ -78,7 +77,7 @@ If those are missing, every skill will fail at the first MCP call with
78
77
  Skills are markdown files that the agent loads on demand. Each one
79
78
  should include:
80
79
 
81
- - A `description:` frontmatter explaining when to load it (Claude uses
80
+ - A `description:` frontmatter explaining when to load it (agents use
82
81
  this to pick the right skill).
83
82
  - A clear set of preconditions (what MCP tools must work, what state the
84
83
  agent needs to know).
package/skills/tr-blog.md CHANGED
@@ -1,145 +1,61 @@
1
1
  ---
2
2
  name: tr-blog
3
- description: Use when the user wants to set up a blog, news section, podcast feed, or any time-ordered article-style content on a Typeroll site. Triggers on "add a blog", "set up news", "article section", "create posts", "podcast", "inlägg", "nyheter", "avsnitt", or any feed-of-dated-entries pattern.
3
+ description: Set up a blog, news section or dated article series using Pages, a content type, shared templates and a dynamic listing.
4
4
  ---
5
5
 
6
- # Set up a blog / news section
6
+ # Build a blog with Pages
7
7
 
8
- > **The buffer model (draft writes).** Every content write in this recipe
9
- > (pages, blocks, partials, collection items) lands in an unsaved per-doc
10
- > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
11
- > build work, pass `save: true` on write calls (the work is pre-approved by
12
- > the task itself), or run `commit_working_copy` per doc before any
13
- > `trigger_deploy`. Preview your drafts with `include_working_copy: true`.
8
+ Every article is a Page. A Content type supplies its extra fields, URL pattern
9
+ and default template. Do not create a separate item store or generate listing
10
+ HTML by hand.
14
11
 
12
+ 1. Read `get_site_capabilities`, `list_block_types`, `list_content_types` and
13
+ `list_page_templates`. For a substantial change, create a site version and
14
+ pass its ID as `version` on every supported operation.
15
+ 2. Create an editable shared template:
15
16
 
16
- A blog in Typeroll is a **collection with `item_template_blocks` +
17
- `route_template`**. Every published item materialises as its own static page at
18
- build time — there is **no need to call `create_page` per article**. Start with
19
- the native `article` preset; the listing is a block-mode page with
20
- `core/collection_list` and updates at build time.
21
-
22
- If you find yourself about to create 20 pages for 20 articles, stop — you're using the old pattern. The recipe below is the right one.
23
-
24
- ## Preconditions
25
-
26
- - Site exists with working header/footer.
27
- - Collection name picked (`blog`, `news`, `artiklar`, `podcast`, `avsnitt`).
28
- - URL structure picked: `/blog/{slug}`, `/news/{slug}`, `/podd/{slug}`. Changing later renames every URL.
17
+ ```json
18
+ { "name": "article-layout", "label": "Article", "starter": "article", "status": "published" }
19
+ ```
29
20
 
30
- ## Recipe
21
+ Send it to `create_page_template`. Use block tools with
22
+ `target: { kind: "template", id: "article-layout" }` to refine the layout.
31
23
 
32
- ### 1. Create the collection with a native item composition
24
+ 3. Call `create_content_type`:
33
25
 
34
- ```
35
- create_collection {
36
- "name": "blog",
37
- "label_singular": "Artikel",
38
- "label_plural": "Artiklar",
39
- "icon": "📝",
40
- "slug_field": "slug",
41
- "sort_field": "published_at",
42
- "sort_dir": "desc",
43
- "route_template": "/blog/{slug}",
44
- "template_kind": "article",
26
+ ```json
27
+ {
28
+ "name": "articles", "label_singular": "Article", "label_plural": "Articles",
29
+ "route_template": "/articles/{slug}", "template": "article-layout",
30
+ "sort_field": "date_published", "sort_dir": "desc",
45
31
  "fields": [
46
- {"name": "title", "type": "text", "label": "Rubrik", "required": true},
47
- {"name": "slug", "type": "text", "label": "URL-slug", "required": true},
48
- {"name": "published_at", "type": "date", "label": "Datum", "required": true},
49
- {"name": "author", "type": "text", "label": "Författare"},
50
- {"name": "excerpt", "type": "textarea", "label": "Ingress"},
51
- {"name": "body", "type": "richtext", "label": "Brödtext"},
52
- {"name": "featured_image", "type": "image", "label": "Omslagsbild"}
32
+ { "name": "excerpt", "label": "Summary", "type": "textarea" },
33
+ { "name": "featured_image", "label": "Cover image", "type": "image" }
53
34
  ]
54
35
  }
55
36
  ```
56
37
 
57
- The preset writes an ordinary, editable `item_template_blocks` tree with
58
- breadcrumbs, title/date, body, and a server-rendered outline. Localize labels
59
- or change field mappings in the collection template editor.
60
-
61
- **Field name rule:** ASCII only, lowercase, `[a-z][a-z0-9_-]*`. `ä→a`, `ö→o`, `å→a` for the `name`; the `label` can be anything.
62
-
63
- ### 2. Seed with real content
64
-
65
- ```
66
- create_collection_item collection="blog" status="published" fields={
67
- "title": "Vår designfilosofi",
68
- "slug": "var-designfilosofi",
69
- "published_at": "2025-05-15",
70
- "author": "Anna Lindström",
71
- "excerpt": "Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.",
72
- "body": "<p>Lång brödtext här...</p><h2>En underrubrik</h2><p>Mer text...</p>",
73
- "featured_image": "https://cdn.typeroll.com/..."
74
- }
75
- ```
76
-
77
- If `featured_image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.
78
-
79
- Each published item with this collection's `route_template` automatically becomes `/blog/{slug}` at deploy time — you do **not** need to call `create_page`.
80
-
81
- ### 3. Build the native listing page once
82
-
83
- ```
84
- create_page title="Artiklar" slug="blog" status="published" content_mode="blocks" blocks=[
85
- {"id":"articles-heading","type":"core/heading","data":{"text":"Artiklar","level":"h1","size":"auto","align":"left"}},
86
- {"id":"articles-list","type":"core/collection_list","data":{"collection":"blog","sort_by":"published_at","sort_order":"desc","item_block":"core/post_card","layout":"grid","cols":3,"gap":"md"}}
87
- ]
88
- ```
89
-
90
- The list resolves from current collection data on every preview/build. No
91
- generated HTML marker or regeneration call is needed.
92
-
93
- ### 4. Update the header partial to link to the listing
94
-
95
- ```
96
- read_partial partial_id="header"
97
- replace_partial partial_id="header" html_content="<updated with /blog link>"
98
- ```
99
-
100
- ### 5. Preview a single article
101
-
102
- ```
103
- get_preview_link collection_name="blog" item_id="<id>"
104
- ```
105
-
106
- The returned URL renders the native item block tree from current database content.
107
-
108
- ### 6. Deploy
109
-
110
- ```
111
- trigger_deploy
112
- get_deploy_status job_id=<id>
113
- ```
114
-
115
- The build produces one HTML file per published article at `/blog/<slug>` plus the listing at `/blog`, and includes them all in `sitemap.xml`.
116
-
117
- ## Adding a new article later
118
-
119
- ```
120
- create_collection_item collection="blog" status="published" fields={ ... }
121
- trigger_deploy
122
- ```
123
-
124
- Two calls. No per-article `create_page`, listing regeneration, or HTML diffing.
125
-
126
- ## Pitfalls
127
-
128
- - **Don't fall back to "one page per article".** Collection item routes keep
129
- design, sitemap, preview, and nested URL handling centralized. `create_page`
130
- rejects slashes in `slug`; use the collection's `route_template`.
131
- - **Slugs must be unique within the collection.** An item whose route tokens
132
- are missing cannot receive a static detail URL.
133
- - **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.
134
- - **Template too clever.** First compose current native blocks and run the
135
- composition preflight. Use custom/HTML-backed behavior only when it is
136
- genuinely business-specific, not to repair a generic Core gap.
137
-
138
- ## When you want a page that ISN'T a collection item
139
-
140
- A normal `create_page` is still right for:
141
- - The blog's about/contact pages.
142
- - Editorial standalone features.
143
- - Anything that doesn't fit the "list of dated entries" mould.
144
-
145
- Just don't use `create_page` *for the entries themselves*.
38
+ Title, slug, author, date, SEO, status and body are built-in Page values. Keep
39
+ only custom values in `fields`.
40
+
41
+ 4. Call `create_page` for each article with its `title`, `slug`,
42
+ `content_type: "articles"`, `status`, `date_published`, `fields` and `blocks`.
43
+ Use native heading, prose, image, list and table blocks. Store its returned
44
+ Page ID; use it for every later operation.
45
+ 5. Create the archive as another Page with `core/page_list`, using
46
+ `data: { content_type: "articles", sort_by: "date_published", sort_order: "desc", paginate: 10 }`.
47
+ A homepage teaser can use `limit: 6` instead. Both resolve saved Pages at
48
+ render time; adding an article needs no listing regeneration.
49
+ 6. Link to the archive from navigation. Preview representative articles and
50
+ the archive on desktop and mobile with `get_preview_link page_id=...`.
51
+ 7. Save any working copies before publication. `update_page` and block edits
52
+ need `save: true` or `commit_working_copy`; new Pages and template definitions
53
+ are saved immediately. Deploy only within the user's authorized scope and
54
+ verify the returned job and public URL.
55
+
56
+ Use the organization's connected media storage before importing remote images.
57
+ Do not hardcode a Typeroll media hostname. Use the returned media URL.
58
+
59
+ A published Page with complete route tokens becomes one static detail page.
60
+ Draft/review Pages stay private. Unlisted Pages have a public URL but are omitted
61
+ from listings and search indexes. The selected version's robots policy applies.
@@ -6,7 +6,7 @@ description: Use when the user asks to create a brand identity, design system, o
6
6
  # Design a brand identity for a Typeroll site
7
7
 
8
8
  > **The buffer model (draft writes).** Every content write in this recipe
9
- > (pages, blocks, partials, collection items) lands in an unsaved per-doc
9
+ > (pages, blocks and partials) lands in an unsaved per-doc
10
10
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
11
11
  > build work, pass `save: true` on write calls (the work is pre-approved by
12
12
  > the task itself), or run `commit_working_copy` per doc before any
@@ -177,7 +177,7 @@ Then reference `var(--brand-gradient)` etc. in page HTML and partials.
177
177
 
178
178
  ## Step 4b — Section + layout design defaults
179
179
 
180
- These are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-collection-template`). Apply them on every page that has visible sections — they're battle-tested across real customer migrations.
180
+ These are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-page-template`). Apply them on every page that has visible sections — they're battle-tested across real customer migrations.
181
181
 
182
182
  ### One signal per section boundary
183
183