@typeroll/mcp-server 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ function v(version) {
4
+ return version ? { version } : undefined;
5
+ }
6
+ export const previewTools = [
7
+ {
8
+ name: 'get_preview_link',
9
+ description: 'Mint a short-lived signed URL the user (or your own browser tool) can open to SEE the rendered preview. Internal links inside the preview keep the same token attached, so the agent can navigate the whole site from one mint. Default TTL 15 min, max 24h. Target the preview at a page (page_id), a collection item (collection_name + item_id, resolves via route_template), or a raw slug. Omit all to land on the home page.',
10
+ inputSchema: {
11
+ page_id: z.string().optional(),
12
+ slug: z.string().optional(),
13
+ collection_name: z.string().optional(),
14
+ item_id: z.string().optional(),
15
+ ttl_seconds: z.number().int().min(60).max(86_400).optional(),
16
+ version: versionParam,
17
+ },
18
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
19
+ const { version, ...body } = args;
20
+ const res = await client.post(siteId, 'preview-link', body, v(version));
21
+ return ok(res);
22
+ }),
23
+ },
24
+ ];
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ function v(version) {
4
+ return version ? { version } : undefined;
5
+ }
6
+ export const redirectTools = [
7
+ {
8
+ name: 'list_redirects',
9
+ description: 'List redirect rules (from_path โ†’ to_path, status code).',
10
+ inputSchema: { version: versionParam },
11
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
12
+ const res = await client.get(siteId, 'redirects', v(args.version));
13
+ return ok(res);
14
+ }),
15
+ },
16
+ {
17
+ name: 'create_redirect',
18
+ description: 'Create a redirect rule. Defaults to 301; pass status_code=302 for a temporary redirect.',
19
+ inputSchema: {
20
+ from_path: z.string().describe('Old path, leading slash (e.g. "/old-about")'),
21
+ to_path: z.string().describe('Target path or absolute URL'),
22
+ status_code: z.union([z.literal(301), z.literal(302)]).optional(),
23
+ version: versionParam,
24
+ },
25
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
26
+ const { version, ...body } = args;
27
+ const res = await client.post(siteId, 'redirects', body, v(version));
28
+ return ok(res);
29
+ }),
30
+ },
31
+ {
32
+ name: 'delete_redirect',
33
+ description: 'Remove a redirect rule.',
34
+ inputSchema: { redirect_id: z.string(), version: versionParam },
35
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
36
+ const res = await client.del(siteId, `redirects/${encodeURIComponent(args.redirect_id)}`, v(args.version));
37
+ return ok(res);
38
+ }),
39
+ },
40
+ ];
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ function v(version) {
4
+ return version ? { version } : undefined;
5
+ }
6
+ export const searchTools = [
7
+ {
8
+ name: 'search_pages',
9
+ description: 'Search page bodies by literal substring or regex. Returns up to 500 matches each with an excerpt around the first hit. Use this to scope a redesign or a bulk replacement before running it.',
10
+ inputSchema: {
11
+ contains: z.string().optional().describe('Case-insensitive literal substring'),
12
+ regex: z.string().optional().describe('JS regex source (without slashes), case-insensitive'),
13
+ limit: z.number().int().min(1).max(500).optional(),
14
+ version: versionParam,
15
+ },
16
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
17
+ const { version, ...query } = args;
18
+ const res = await client.get(siteId, 'search', { ...query, ...v(version) });
19
+ return ok(res);
20
+ }),
21
+ },
22
+ ];
@@ -0,0 +1,64 @@
1
+ // Settings tools. scripts_head, scripts_body_end, and custom_css ARE
2
+ // writable via the public API (as of mcp-server 0.4.x) โ€” the v1 route
3
+ // accepts them and they round-trip through read_site_settings. Same
4
+ // trust model as user-authored block-type JS: a bearer-token caller
5
+ // takes responsibility for what they ship. The portal chat AI still
6
+ // doesn't expose these fields, so conversation-driven assistants can't
7
+ // smuggle scripts in.
8
+ import { z } from 'zod';
9
+ import { ok, withErrorBoundary } from './helpers.js';
10
+ export const settingsTools = [
11
+ {
12
+ name: 'read_site_settings',
13
+ description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, language, robots_txt, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css.",
14
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
15
+ const res = await client.get(siteId, 'settings');
16
+ return ok(res);
17
+ }),
18
+ },
19
+ {
20
+ name: 'update_site_settings',
21
+ description: 'Patch site settings. Only the fields you pass change. Nested objects (colors, fonts, contact, social) are shallow-merged. scripts_head / scripts_body_end / custom_css ARE writable here โ€” useful for a global stylesheet across all pages. Whatever you ship in those fields runs verbatim on the rendered site, so treat them as code, not data.',
22
+ inputSchema: {
23
+ site_name: z.string().optional(),
24
+ tagline: z.string().optional(),
25
+ logo: z.string().optional(),
26
+ favicon: z.string().optional(),
27
+ default_seo_suffix: z.string().optional(),
28
+ language: z.string().optional().describe('BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> on the rendered site.'),
29
+ robots_txt: z.string().optional(),
30
+ // Scriptable surfaces. Trusted because the caller has an API key.
31
+ scripts_head: z.string().optional().describe('Raw HTML injected into <head> on every page. Use for analytics, fonts, third-party CSS links.'),
32
+ scripts_body_end: z.string().optional().describe('Raw HTML injected just before </body> on every page. Use for chat widgets, deferred analytics.'),
33
+ custom_css: z.string().optional().describe('Global CSS shipped in <style> at the end of <head>. Lets you define site-wide design tokens (CSS variables, @media queries, :hover states) without inlining on every element.'),
34
+ colors: z.record(z.string()).optional(),
35
+ fonts: z.record(z.unknown()).optional(),
36
+ contact: z
37
+ .object({
38
+ email: z.string().optional(),
39
+ phone: z.string().optional(),
40
+ // address can be a plain string (legacy) OR a structured
41
+ // PostalAddress for rich Schema.org JSON-LD.
42
+ address: z
43
+ .union([
44
+ z.string(),
45
+ z.object({
46
+ street_address: z.string().optional(),
47
+ postal_code: z.string().optional(),
48
+ address_locality: z.string().optional(),
49
+ address_region: z.string().optional(),
50
+ address_country: z.string().optional(),
51
+ }).passthrough(),
52
+ ])
53
+ .optional(),
54
+ })
55
+ .passthrough()
56
+ .optional(),
57
+ social: z.record(z.string()).optional(),
58
+ },
59
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
60
+ const res = await client.patch(siteId, 'settings', args);
61
+ return ok(res);
62
+ }),
63
+ },
64
+ ];
@@ -0,0 +1,35 @@
1
+ // Sites tools โ€” discovery + Site-level identity edits.
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary } from './helpers.js';
4
+ export const siteTools = [
5
+ {
6
+ name: 'get_site',
7
+ description: 'Read this site\'s metadata (id, name, slug, domain, active version) + a urls object covering the production / fallback / preview_base URLs. Useful as a first call to confirm the key is wired up and to learn what URLs the site is reachable at.',
8
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
9
+ const res = await client.get(siteId, '');
10
+ return ok(res);
11
+ }),
12
+ },
13
+ {
14
+ name: 'get_site_capabilities',
15
+ 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.",
16
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
17
+ const res = await client.get(siteId, 'capabilities');
18
+ return ok(res);
19
+ }),
20
+ },
21
+ {
22
+ name: 'update_site',
23
+ description: 'Edit Site-level identity fields: name (display), slug (drives the {slug}.typeroll-fallback subdomain โ€” kebab-case, 3-48 chars, unique across the org), domain (the customer\'s real hostname; pass "" to clear). For colors / fonts / contact info / tagline use update_site_settings instead.',
24
+ inputSchema: {
25
+ name: z.string().min(1).optional(),
26
+ slug: z.string().optional().describe('Kebab-case identifier, 3-48 chars [a-z0-9-]. Empty string clears.'),
27
+ domain: z.string().optional().describe('Bare hostname e.g. "example.com". Empty string clears.'),
28
+ language: z.string().optional().describe('Default content language as a BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> and the default for alt-text generation. Empty string clears.'),
29
+ },
30
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
31
+ const res = await client.patch(siteId, '', args);
32
+ return ok(res);
33
+ }),
34
+ },
35
+ ];
@@ -0,0 +1,52 @@
1
+ // Version (branch) tools.
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary } from './helpers.js';
4
+ export const versionTools = [
5
+ {
6
+ name: 'list_versions',
7
+ description: 'List versions (main + branches).',
8
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
9
+ const res = await client.get(siteId, 'versions');
10
+ return ok(res);
11
+ }),
12
+ },
13
+ {
14
+ name: 'create_branch',
15
+ description: 'Create a copy-on-write branch from main (or the version passed in `base`). New branches default to robots_blocked:true. Pass the new branch\'s id as ?version= on subsequent calls to read/write against it.',
16
+ inputSchema: {
17
+ name: z.string().min(1),
18
+ base: z.string().optional().describe('Source version id; defaults to main.'),
19
+ },
20
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
21
+ const res = await client.post(siteId, 'versions', args);
22
+ return ok(res);
23
+ }),
24
+ },
25
+ {
26
+ name: 'read_version',
27
+ description: 'Read one version\'s metadata.',
28
+ inputSchema: { version_id: z.string() },
29
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
30
+ const res = await client.get(siteId, `versions/${encodeURIComponent(args.version_id)}`);
31
+ return ok(res);
32
+ }),
33
+ },
34
+ {
35
+ name: 'delete_branch',
36
+ description: 'Discard a branch (main cannot be deleted).',
37
+ inputSchema: { version_id: z.string() },
38
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
39
+ const res = await client.del(siteId, `versions/${encodeURIComponent(args.version_id)}`);
40
+ return ok(res);
41
+ }),
42
+ },
43
+ {
44
+ name: 'merge_branch',
45
+ description: 'Promote a branch\'s overrides + tombstones onto main. The branch is left in place so you can keep iterating; delete_branch removes it when you\'re done.',
46
+ inputSchema: { version_id: z.string() },
47
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
48
+ const res = await client.post(siteId, `versions/${encodeURIComponent(args.version_id)}/merge`);
49
+ return ok(res);
50
+ }),
51
+ },
52
+ ];
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@typeroll/mcp-server",
3
+ "version": "0.7.4",
4
+ "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/bootingbots/typeroll.git",
9
+ "directory": "packages/mcp-server"
10
+ },
11
+ "homepage": "https://github.com/bootingbots/typeroll/tree/main/packages/mcp-server#readme",
12
+ "bugs": "https://github.com/bootingbots/typeroll/issues",
13
+ "type": "module",
14
+ "bin": {
15
+ "typeroll-mcp": "./dist/index.js"
16
+ },
17
+ "main": "./dist/index.js",
18
+ "files": [
19
+ "dist",
20
+ "AGENTS.md",
21
+ "README.md",
22
+ "skills"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p .",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.0",
35
+ "zod": "^3.23.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.6.0",
39
+ "vitest": "^4.1.7"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "keywords": [
45
+ "mcp",
46
+ "typeroll",
47
+ "claude",
48
+ "model-context-protocol"
49
+ ]
50
+ }
@@ -0,0 +1,63 @@
1
+ # Typeroll skills for Claude Code
2
+
3
+ Boilerplate skills that pair with [`@typeroll/mcp-server`](../packages/mcp-server/README.md).
4
+ Each one is a self-contained markdown file the agent reads when its
5
+ description matches the user's request. Recipes call MCP tools; the agent
6
+ adapts them to the specific job.
7
+
8
+ ## Installation
9
+
10
+ Copy whichever skills you need into your Claude Code skills directory.
11
+ Either:
12
+
13
+ ```bash
14
+ # project-scoped (recommended)
15
+ mkdir -p .claude/skills
16
+ cp skills/*.md .claude/skills/
17
+
18
+ # or user-scoped (available in every project)
19
+ cp skills/*.md ~/.claude/skills/
20
+ ```
21
+
22
+ Symlinks work too, so you can stay in sync with the upstream:
23
+
24
+ ```bash
25
+ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
26
+ ```
27
+
28
+ ## The skills
29
+
30
+ | File | When it triggers | What it does |
31
+ |---|---|---|
32
+ | `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 |
33
+ | `tr-content-write.md` | "write a page aboutโ€ฆ", "draft copy forโ€ฆ" | Discovery first (settings + sample pages), then drafts in the site's voice, previews, iterates |
34
+ | `tr-images.md` | "make an image / hero / illustration", media uploads | Generates locally โ†’ signed upload URL โ†’ metadata patch โ†’ embed |
35
+ | `tr-directory.md` | Building a directory site, importing structured data | Schema โ†’ items โ†’ per-item URLs via `route_template` โ†’ listing page โ†’ preview โ†’ deploy |
36
+ | `tr-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved |
37
+
38
+ ## Prerequisites for every skill
39
+
40
+ 1. `@typeroll/mcp-server` configured in `.claude.json` with a valid
41
+ `TYPEROLL_API_KEY` and `TYPEROLL_API_URL`.
42
+ 2. The agent has read `AGENTS.md` (ships with the MCP package โ€” see
43
+ `node_modules/@typeroll/mcp-server/AGENTS.md` after install, or
44
+ reference it directly).
45
+
46
+ If those are missing, every skill will fail at the first MCP call with
47
+ "Missing bearer token" or "Invalid or revoked token".
48
+
49
+ ## Authoring more
50
+
51
+ Skills are markdown files that the agent loads on demand. Each one
52
+ should include:
53
+
54
+ - A `description:` frontmatter explaining when to load it (Claude uses
55
+ this to pick the right skill).
56
+ - A clear set of preconditions (what MCP tools must work, what state the
57
+ agent needs to know).
58
+ - A numbered recipe with concrete tool calls.
59
+ - A "Pitfalls" section that captures lessons learned across customer
60
+ jobs.
61
+
62
+ Keep them under ~150 lines so the agent reads them quickly. If a skill
63
+ balloons, split it.
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: tr-content-write
3
+ description: Use when the user asks to write, draft, or rewrite a page on a Typeroll site. Loads the site's design conventions before writing so the new content matches the existing voice and style.
4
+ ---
5
+
6
+ # Write a page that fits the site
7
+
8
+ The default failure mode for an AI writing a page is "good generic
9
+ HTML in the wrong voice." This skill makes the discovery step
10
+ non-optional.
11
+
12
+ ## Recipe
13
+
14
+ ### 1. Always discover first
15
+
16
+ ```
17
+ get_site # site name (use it in copy)
18
+ read_site_settings # tagline, contact info, brand colors
19
+ read_partial partial_id="header" # what other pages exist in the nav
20
+ list_pages limit=5
21
+ batch_read_pages page_ids=[<2-3 representative pages>]
22
+ ```
23
+
24
+ Read the actual HTML of an existing page. Note:
25
+ - Heading structure (single `<h1>` per page? subtitle pattern?)
26
+ - Whether the site uses CSS variables (`var(--color-primary)`) or
27
+ hardcoded values
28
+ - Tone (sober, playful, technical, marketing-y)
29
+ - Length conventions (do existing pages run 200 words or 2000?)
30
+ - Whether internal links use absolute or relative URLs
31
+
32
+ ### 2. Ask for the brief
33
+
34
+ If the user hasn't told you, ask:
35
+
36
+ - **Topic + purpose**: what's the page for, who's it for?
37
+ - **Key points**: must-include facts, calls to action
38
+ - **Target length**: short landing vs. long-form
39
+ - **Audience**: anything specific (existing customers, agencies,
40
+ developers)
41
+ - **Reference page**: is there an existing page to match in tone or
42
+ structure?
43
+
44
+ ### 3. Draft
45
+
46
+ Write in semantic HTML, matching the site's conventions you observed
47
+ in step 1:
48
+
49
+ - Use `<section>`, `<article>`, `<h1>`/`<h2>`, `<p>`, `<ul>` โ€” avoid
50
+ div soup.
51
+ - Match the existing site's class naming or CSS variable usage. Don't
52
+ introduce a new design system mid-page.
53
+ - Insert images via `<img src="https://cdn..." alt="...">` โ€” use
54
+ `list_media` to find existing images first; only generate new ones
55
+ if necessary (see `tr-images` skill).
56
+ - Default status: `draft`. Don't auto-publish unless the user said so.
57
+
58
+ ### 4. Create or update
59
+
60
+ ```
61
+ # New page:
62
+ create_page title="..." slug="..." html_content="<full body>"
63
+ status="draft" kind="page"
64
+ seo_title="..." seo_description="..."
65
+
66
+ # Or update an existing one:
67
+ update_page page_id=<id> patch={ html_content: "..." }
68
+ ```
69
+
70
+ For an existing page, `read_page` first and preserve the existing
71
+ structure โ€” replace one section at a time rather than rewriting the
72
+ whole body, unless the user explicitly asked for a full redo.
73
+
74
+ ### 5. Preview + iterate
75
+
76
+ ```
77
+ get_preview_link page_id=<id>
78
+ ```
79
+
80
+ Show the URL to the user. Iterate on feedback. Common rounds:
81
+ shortening, adding a CTA, tweaking SEO description.
82
+
83
+ ### 6. Status change is the user's call
84
+
85
+ Don't `update_page status:"published"` without an explicit "looks
86
+ good, publish it" from the user. Same for `trigger_deploy`.
87
+
88
+ ## SEO conventions worth knowing
89
+
90
+ - **`kind: "article"`** for blog posts and news. Switches to
91
+ `og:type=article` + emits Article JSON-LD. Set `author` too โ€” empty
92
+ author = no Person schema = no author rich-result eligibility.
93
+ - **SEO title** target 50-60 chars. Past 60 Google truncates.
94
+ - **Meta description** target 150-160 chars. Don't write fluff to
95
+ fill it; Google rewrites descriptions when they go off-topic.
96
+ - **OG image** per page matters for shareable content. For articles
97
+ especially.
98
+
99
+ ## Pitfalls
100
+
101
+ - Reading 0 pages and just inventing a design is the most common
102
+ failure. Always sample at least one existing page first.
103
+ - Skipping the brief and producing 1000 words of plausible filler when
104
+ the user wanted a 200-word landing. Ask up front.
105
+ - Auto-publishing. Don't.
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: tr-directory
3
+ description: Use when the user wants to build a directory site or import a structured dataset (restaurants, products, events, agencies, etc.) where each item should have its own URL. Covers collection schema creation, per-item URLs via route_template, listing page, deploy.
4
+ ---
5
+
6
+ # Build a directory site from external data
7
+
8
+ Typeroll collections support per-item URLs: every published item
9
+ in a collection with a `route_template` materialises as its own static
10
+ page at build time. This is the right pattern when you have hundreds
11
+ of similar entities (restaurants, products, listings, profiles).
12
+
13
+ ## Big-picture flow
14
+
15
+ 1. **Data source** โ†’ 2. **Collection schema** โ†’ 3. **Items** โ†’ 4. **Listing page**
16
+ โ†’ 5. **Preview** โ†’ 6. **Deploy**
17
+
18
+ You drive everything from Claude Code locally โ€” the scrape, the data
19
+ shaping, the writes. The MCP just receives the final shape.
20
+
21
+ ## Recipe
22
+
23
+ ### 1. Get the data
24
+
25
+ Whatever source the user has โ€” scraped CSV, public API, vendor feed,
26
+ manual research, another LLM's output. Normalise to a flat shape:
27
+ one object per item with stable, kebab-case field names.
28
+
29
+ ```jsonc
30
+ [
31
+ {
32
+ "title": "Joe's Pizza",
33
+ "slug": "joes-pizza",
34
+ "address": "123 Main St, Anytown",
35
+ "phone": "+1-555-0100",
36
+ "cuisine": "italian",
37
+ "rating": 4.5,
38
+ "image": "https://...", // optional: a hosted image URL
39
+ "excerpt": "Family-run since 1987...",
40
+ "body": "<p>Long-form description with HTML.</p>"
41
+ }
42
+ ]
43
+ ```
44
+
45
+ The `slug` field is what populates `route_template`. Make it
46
+ kebab-case, unique within the dataset. If the source doesn't have one,
47
+ derive from `title`: lowercase, replace non-alphanumeric with `-`,
48
+ collapse consecutive dashes.
49
+
50
+ ### 2. Decide the URL structure with the user
51
+
52
+ Common patterns:
53
+
54
+ - `/restaurants/{slug}` (default โ€” simple, predictable)
55
+ - `/r/{slug}` (compact)
56
+ - `/{cuisine}/{slug}` (categorised)
57
+ - `/dir/{slug}` (short prefix to avoid collisions with page slugs)
58
+
59
+ Pick one before creating the collection โ€” changing `route_template`
60
+ later renames every URL and requires redirect rules.
61
+
62
+ ### 3. Create the collection schema
63
+
64
+ ```
65
+ create_collection
66
+ name="restaurants"
67
+ label_singular="Restaurant"
68
+ label_plural="Restaurants"
69
+ icon="๐Ÿ•"
70
+ fields=[
71
+ {"name":"title","label":"Name","type":"text","required":true},
72
+ {"name":"slug","label":"Slug","type":"text","required":true},
73
+ {"name":"address","label":"Address","type":"text"},
74
+ {"name":"phone","label":"Phone","type":"text"},
75
+ {"name":"cuisine","label":"Cuisine","type":"text"},
76
+ {"name":"rating","label":"Rating","type":"number"},
77
+ {"name":"image","label":"Image URL","type":"text"},
78
+ {"name":"excerpt","label":"Excerpt","type":"textarea"},
79
+ {"name":"body","label":"Body","type":"richtext"}
80
+ ]
81
+ slug_field="slug"
82
+ sort_field="title"
83
+ sort_dir="asc"
84
+ route_template="/restaurants/{slug}"
85
+ item_template_html="<article class=\"directory-item\">
86
+ <header>
87
+ <h1>{{title}}</h1>
88
+ {{cuisine}} ยท โญ {{rating}}
89
+ </header>
90
+ <img src=\"{{image}}\" alt=\"{{title}}\" />
91
+ <address>{{address}} ยท <a href=\"tel:{{phone}}\">{{phone}}</a></address>
92
+ <section class=\"description\">{{{body}}}</section>
93
+ </article>"
94
+ ```
95
+
96
+ The `item_template_html` is what renders for each item. `{{field}}`
97
+ HTML-escapes; `{{{field}}}` leaves raw (use for richtext bodies that
98
+ intentionally carry HTML).
99
+
100
+ ### 4. Bulk-import items
101
+
102
+ Loop over your data array. For each item:
103
+
104
+ ```
105
+ create_collection_item
106
+ collection="restaurants"
107
+ fields={ title:"Joe's Pizza", slug:"joes-pizza", ... }
108
+ status="published"
109
+ ```
110
+
111
+ For larger datasets (1000+), batch outside the MCP โ€” spawn 5 parallel
112
+ `create_collection_item` calls at a time, watch the 60-writes/min rate
113
+ limit (you'll hit it on big imports, the API returns 429 with
114
+ `Retry-After`).
115
+
116
+ Set `status: "draft"` for items the user still needs to review; only
117
+ published items get static pages.
118
+
119
+ ### 5. Build a listing page
120
+
121
+ Items have per-item URLs but not a default index. Create one with a
122
+ marker pair that `regenerate_collection_listing` will keep up to date:
123
+
124
+ ```
125
+ create_page
126
+ title="Restaurants"
127
+ slug="restaurants"
128
+ status="published"
129
+ html_content="<h1>All restaurants</h1>
130
+ <!-- typeroll:listing:restaurants -->
131
+ <!-- /typeroll:listing:restaurants -->
132
+ "
133
+ ```
134
+
135
+ Then populate (and refresh whenever items change) with one call:
136
+
137
+ ```
138
+ regenerate_collection_listing
139
+ collection="restaurants"
140
+ page_id="restaurants"
141
+ item_template="<article class=\"directory-card\">
142
+ <h2><a href=\"{{url}}\">{{title}}</a></h2>
143
+ <p>{{cuisine}} ยท โญ {{rating}}</p>
144
+ <p>{{address}}</p>
145
+ </article>"
146
+ wrap_open="<div class=\"directory-grid\">"
147
+ wrap_close="</div>"
148
+ ```
149
+
150
+ `{{field}}` substitutes HTML-escaped, `{{{field}}}` raw (for richtext
151
+ fields), `{{url}}` resolves through the collection's `route_template`.
152
+ The tool replaces only what's between the marker pair โ€” anything before
153
+ or after the markers stays put.
154
+
155
+ When the customer adds a new restaurant later, the agent re-runs the
156
+ same `regenerate_collection_listing` call and the index updates. No
157
+ diff-the-HTML-by-hand, no stale listings.
158
+
159
+ (When the block editor lands, you'll be able to drop in a "collection
160
+ listing" block instead of hand-writing this. For now, raw HTML.)
161
+
162
+ ### 6. Preview an item
163
+
164
+ ```
165
+ get_preview_link collection_name="restaurants" item_id="<id>"
166
+ ```
167
+
168
+ Returns a URL the user can open. Internal links inside the preview
169
+ stay inside the preview surface, so navigating to another item works.
170
+
171
+ ### 7. Deploy
172
+
173
+ ```
174
+ trigger_deploy
175
+ get_deploy_status job_id=<id>
176
+ ```
177
+
178
+ Each published item gets its own URL in the static build, with
179
+ `sitemap.xml` automatically including them all.
180
+
181
+ ## Pitfalls
182
+
183
+ - **Slugs must be unique within the collection** โ€” duplicates cause
184
+ build failures (two pages claiming the same URL). De-dupe before
185
+ importing.
186
+ - **Don't reuse `slug` across collections without thinking.**
187
+ `/restaurants/joes` and `/products/joes` are fine; just avoid
188
+ `/joes` for both (collection items vs. pages don't collide because
189
+ pages always win, but two collections sharing a `slug_field=slug`
190
+ with the same `route_template` is a foot-gun).
191
+ - **Required fields.** `route_template="/restaurants/{slug}"` will
192
+ silently skip items where `slug` is missing. Check
193
+ `list_collection_items` after import โ€” if you imported 500 and the
194
+ listing only shows 480, look at the dropped 20's source data.
195
+ - **Template too clever.** Substitution is plain `{{field}}` โ€” no
196
+ loops, no conditionals. If your design needs more, prefer flat
197
+ fields (`star_html`, `rating_label`) prebuilt in the data step.
198
+ - **Field type changes drift data.** Adding a new field after import
199
+ is fine; renaming one orphans the old data on every item. Plan the
200
+ schema before import.
201
+
202
+ ## Mixing scraped + generated content
203
+
204
+ The whole point of the local-agent model: you can blend sources.
205
+
206
+ - Scrape addresses + phone from a yellow-pages site.
207
+ - Generate excerpt + body from a local Claude pass over the raw
208
+ scraped HTML.
209
+ - Generate hero images per item via `tr-images`.
210
+ - All three merged into one `create_collection_item` per record.
211
+
212
+ Keep a local manifest (`./directory-state.json`) of what's been
213
+ imported so a partial run is resumable. The MCP doesn't track that
214
+ state โ€” your local script does.