@typeroll/mcp-server 0.45.20 → 0.45.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // safety-critical when one connector can touch many sites.
10
10
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { z } from 'zod';
12
+ import { ownerReviewTools } from './tools/owner-review.js';
12
13
  import { pageTools } from './tools/pages.js';
13
14
  import { partialTools } from './tools/partials.js';
14
15
  import { contentTypeTools } from './tools/content-types.js';
@@ -126,6 +127,7 @@ export function buildServer(options) {
126
127
  ...skillTools,
127
128
  ...siteTools,
128
129
  ...pageTools,
130
+ ...ownerReviewTools,
129
131
  ...partialTools,
130
132
  ...blockTypeTools,
131
133
  ...pageBlockTools,
@@ -1,6 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  import { ok, withErrorBoundary } from './helpers.js';
3
3
  export const extensionTools = [
4
+ {
5
+ name: 'call_extension_admin',
6
+ description: 'Call an enabled app’s approved admin API using the current site administrator identity. Read that installed app’s guide first for supported paths and effects. GET reads; POST can change app settings. No deployment is queued by Core. Ordinary admin credentials only; installation credentials cannot delegate. Tokens stay server-side.',
7
+ inputSchema: {
8
+ installation_id: z.string().min(1), page_id: z.string().min(1),
9
+ path: z.string().describe('Relative operation below the approved native API base, from the installed app guide.'),
10
+ method: z.enum(['GET', 'POST']), query: z.record(z.string()).optional(), body: z.record(z.unknown()).optional(),
11
+ },
12
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
13
+ const { installation_id, ...operation } = args;
14
+ return ok(await client.post(siteId, `extensions/${encodeURIComponent(installation_id)}/admin-request`, operation));
15
+ }),
16
+ },
4
17
  {
5
18
  name: 'list_extension_installations',
6
19
  description: 'List the site\'s installed Extensions, including installation ids, manifests, config schemas, and masked current config. Read this before updating installation config. Admin permission required.',
@@ -9,7 +9,7 @@ const fieldSchema = z.object({
9
9
  name: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
10
10
  type: z.enum([
11
11
  'text', 'email', 'tel', 'url', 'number', 'textarea',
12
- 'select', 'checkbox', 'radio', 'hidden', 'gdpr_consent',
12
+ 'select', 'checkbox', 'boolean', 'radio', 'hidden', 'gdpr_consent',
13
13
  ]),
14
14
  label: z.string(),
15
15
  required: z.boolean().optional(),
@@ -30,6 +30,7 @@ const stepSchema = z.object({
30
30
  blocks: z.array(z.record(z.string(), z.unknown())).optional(),
31
31
  });
32
32
  const targetSchema = z.object({
33
+ context_params: z.array(z.string()).max(8).optional().describe('Explicit URL query keys forwarded to the app hydration endpoint; reserved security keys are rejected.'),
33
34
  installation_id: z.string(), path: z.string(), hydrate: z.boolean().optional(), session_param: z.string().optional(),
34
35
  }).optional().describe('Admin-only: bind to a declared POST route of an enabled app installation. Read its authenticated guide first.');
35
36
  export const formTools = [
@@ -52,7 +53,7 @@ export const formTools = [
52
53
  },
53
54
  {
54
55
  name: 'create_form',
55
- description: 'Create a form. Steps are the ONLY stored model — a Block[] tree of form/* field blocks per step. Simple forms: pass `fields`, each { name, type, label, required?, placeholder?, options? } (allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent) — the server converts them to a single static step; read_form returns the resulting steps. Multi-step funnels: pass `steps` directly (steps swap client-side, partial submissions persist per step). PLACING THE FORM: add a `core/form` block with data.form_id on block-mode pages, or `<x-form id="…" />` to HTML-mode page content. Both expand server-side to the same complete signed shell; never hand-write the form or add inline submit scripts.',
56
+ description: 'Create a form. Steps are the ONLY stored model — a Block[] tree of form/* field blocks per step. Simple forms: pass `fields`, each { name, type, label, required?, placeholder?, options? } (allowed types: text, email, tel, url, number, textarea, select, checkbox, boolean (Yes/No/unanswered), radio, hidden, gdpr_consent) — the server converts them to a single static step; read_form returns the resulting steps. Multi-step funnels: pass `steps` directly (steps swap client-side, partial submissions persist per step). PLACING THE FORM: add a `core/form` block with data.form_id on block-mode pages, or `<x-form id="…" />` to HTML-mode page content. Both expand server-side to the same complete signed shell; never hand-write the form or add inline submit scripts.',
56
57
  inputSchema: {
57
58
  id: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
58
59
  name: z.string().min(1),
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ const versionQuery = (version) => version ? { version } : undefined;
4
+ export const ownerReviewTools = [
5
+ {
6
+ name: 'read_owner_answers',
7
+ description: 'Site administrator: read typed owner-writable answers and their current revision. Returns safe source kinds/times, never private actor identifiers.',
8
+ inputSchema: { page_id: z.string(), version: versionParam },
9
+ handler: withErrorBoundary(async ({ version, ...input }, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { ...input, action: 'answers' }, versionQuery(version)))),
10
+ },
11
+ {
12
+ name: 'override_owner_answers',
13
+ description: 'Explicit administrator correction of protected answers. Supply the current revision from read_owner_answers, changed fields, and the factual reason. Does not publish. Never use this tool to bypass an import or scraping conflict without explicit authorization.',
14
+ inputSchema: { page_id: z.string(), base_revision: z.string(), fields: z.record(z.unknown()), reason: z.string().min(1), version: versionParam },
15
+ handler: withErrorBoundary(async ({ version, ...input }, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { ...input, action: 'override' }, versionQuery(version)))),
16
+ },
17
+ {
18
+ name: 'list_owner_proposals',
19
+ description: 'Site administrators: list isolated owner proposals and reviewer settings. Pending proposals are excluded from Pages, drafts and publication. Never exposes review tokens or owner identities.',
20
+ inputSchema: { cursor: z.string().optional(), version: versionParam },
21
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'owner-review', { cursor: args.cursor, ...versionQuery(args.version) }))),
22
+ },
23
+ {
24
+ name: 'read_owner_proposal',
25
+ description: 'Read the original before/after answers and review outcome. No changes or notifications are triggered.',
26
+ inputSchema: { proposal_id: z.string(), version: versionParam },
27
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { action: 'view', proposal_id: args.proposal_id }, versionQuery(args.version)))),
28
+ },
29
+ {
30
+ name: 'configure_owner_review',
31
+ description: 'Site administrators: configure the reviewer recipient, enabled state and token lifetime. A separately configured site email connector sends notifications. This action does not send email or publish.',
32
+ inputSchema: { recipient: z.string().email(), enabled: z.boolean(), link_ttl_hours: z.number().int().min(1).max(168), version: versionParam },
33
+ handler: withErrorBoundary(async ({ version, ...settings }, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { action: 'settings', settings }, versionQuery(version)))),
34
+ },
35
+ {
36
+ name: 'decide_owner_proposal',
37
+ description: 'Explicit site-administrator editorial decision: approve, reject, or approve with adjustments. Requires current content/schema and a stable request_id for retries. First decision wins. Approval updates accepted CMS content, never deploys; reviewer adjustments retain separate provenance.',
38
+ inputSchema: { proposal_id: z.string(), action: z.enum(['approve', 'reject']), request_id: z.string().min(16).max(100), adjustments: z.record(z.unknown()).optional(), version: versionParam },
39
+ handler: withErrorBoundary(async ({ version, ...input }, { client, siteId }) => ok(await client.post(siteId, 'owner-review', input, versionQuery(version)))),
40
+ },
41
+ {
42
+ name: 'retry_owner_review_notification',
43
+ description: 'Explicitly send or retry the proposal notification. Maximum three attempts. Never retries an ambiguous in-flight delivery; an administrator must first record a recovery reason. Requires authorization to send this transactional notification.',
44
+ inputSchema: { proposal_id: z.string(), version: versionParam },
45
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { action: 'notify', proposal_id: args.proposal_id }, versionQuery(args.version)))),
46
+ },
47
+ {
48
+ name: 'recover_owner_review_notification',
49
+ description: 'Administrator recovery after investigating an interrupted notification. Record why retry is safe. Does not send email and does not reset the three-attempt bound.',
50
+ inputSchema: { proposal_id: z.string(), reason: z.string().min(1), version: versionParam },
51
+ handler: withErrorBoundary(async ({ version, ...input }, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { ...input, action: 'recover-notification' }, versionQuery(version)))),
52
+ },
53
+ {
54
+ name: 'revoke_owner_proposal',
55
+ description: 'Revoke a pending proposal and its private review link. Accepted Page content remains unchanged.',
56
+ inputSchema: { proposal_id: z.string(), version: versionParam },
57
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'owner-review', { action: 'revoke', proposal_id: args.proposal_id }, versionQuery(args.version)))),
58
+ },
59
+ ];
@@ -1,6 +1,7 @@
1
1
  // Pages tools. Read + write + batch + blocks-view + preview.
2
2
  import { z } from 'zod';
3
3
  import { ok, withErrorBoundary, versionParam } from './helpers.js';
4
+ const answerSources = z.record(z.object({ source_url: z.string().url().optional(), import_run_id: z.string().max(200).optional() }).strict()).optional().describe('Evidence per schema leaf path. Actor and timestamp are always assigned by the server; unchanged answers are not confirmed.');
4
5
  function v(version) {
5
6
  return version ? { version } : undefined;
6
7
  }
@@ -164,11 +165,12 @@ export const pageTools = [
164
165
  template: z.string().nullable().optional().describe('Allowed template ID; null restores the content type default.'),
165
166
  })
166
167
  .passthrough(),
168
+ answer_sources: answerSources,
167
169
  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.'),
168
170
  version: versionParam,
169
171
  },
170
172
  handler: withErrorBoundary(async (args, { client, siteId }) => {
171
- const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.patch, ...(args.save ? { save: true } : {}) }, v(args.version));
173
+ const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.patch, ...(args.answer_sources ? { answer_sources: args.answer_sources } : {}), ...(args.save ? { save: true } : {}) }, v(args.version));
172
174
  return ok(res);
173
175
  }),
174
176
  },
@@ -178,11 +180,12 @@ export const pageTools = [
178
180
  inputSchema: {
179
181
  page_id: z.string(),
180
182
  page: z.object({ title: z.string().min(1) }).passthrough(),
183
+ answer_sources: answerSources,
181
184
  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.'),
182
185
  version: versionParam,
183
186
  },
184
187
  handler: withErrorBoundary(async (args, { client, siteId }) => {
185
- const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.page, ...(args.save ? { save: true } : {}) }, v(args.version));
188
+ const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}`, { ...args.page, ...(args.answer_sources ? { answer_sources: args.answer_sources } : {}), ...(args.save ? { save: true } : {}) }, v(args.version));
186
189
  return ok(res);
187
190
  }),
188
191
  },
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.20';
11
+ export const VERSION = '0.45.21';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.20",
3
+ "version": "0.45.21",
4
4
  "description": "Typeroll CMS MCP server \u2013 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
@@ -1,4 +1,4 @@
1
- # Typeroll skills for Claude Code
1
+ # Typeroll CMS skills for AI agents
2
2
 
3
3
  Boilerplate skills that pair with [`@typeroll/mcp-server`](../packages/mcp-server/README.md).
4
4
  Each one is a self-contained markdown file the agent reads when its
@@ -55,8 +55,9 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
55
55
 
56
56
  | File | When it triggers | What it does |
57
57
  |---|---|---|
58
- | `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. |
59
- | `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. |
58
+ | `tr-migration-evidence.md` | Before any bulk migration | Source baseline, supported-field round-trip, prototypes and separate technical/visual evidence. |
59
+ | `tr-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, preserves source design through verified native prototypes, transfers media, sets redirects, leaves everything as drafts for review. |
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, source-validated native recipes replicated, path preservation, hreflang clusters, and a parity check against the deployed site before DNS moves. |
60
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. |
61
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. |
62
63
 
@@ -5,6 +5,12 @@ description: Use when the user wants to import or migrate content from a non-Wor
5
5
 
6
6
  # Import content from a non-WordPress site
7
7
 
8
+ **Before bulk conversion:** read `tr-migration-evidence` with `read_skill`.
9
+ Complete its source baseline and one verified prototype per active template.
10
+ Read target content to protect edits, not to justify accidental target defaults.
11
+ Its shared evidence record is required for visual acceptance.
12
+
13
+
8
14
  > **The buffer model (draft writes).** Every content write in this recipe
9
15
  > (pages, blocks and partials) lands in an unsaved per-doc
10
16
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
@@ -45,9 +51,10 @@ Parse `<a href>` links to discover internal pages. Build a list:
45
51
  - Top-level pages (About, Services, Contact, etc.)
46
52
  - Any sub-pages that look important
47
53
 
48
- Avoid: pagination URLs, session URLs, `/wp-admin`, `/cdn-cgi/`, query strings.
54
+ Exclude private/session/admin URLs deliberately. Inventory public pagination and
55
+ query routes; decide preservation or retirement from their actual purpose.
49
56
 
50
- ### 2. Learn the target's design
57
+ ### 2. Measure the source and protect existing target work
51
58
 
52
59
  ```
53
60
  read_site_settings
@@ -55,8 +62,8 @@ read_partial partial_id="header"
55
62
  list_pages limit=5
56
63
  ```
57
64
 
58
- The goal is to understand what CSS variables, class names, and structural
59
- conventions the target site uses so the imported content looks native.
65
+ Read existing target data to avoid overwriting work. Establish source geometry
66
+ and validate native prototypes using `tr-migration-evidence` before mass writes.
60
67
 
61
68
  ### 3. Fetch and clean each source page
62
69
 
@@ -73,7 +80,9 @@ Can you provide the page source or an HTML export?"
73
80
 
74
81
  **b. Extract the main content.**
75
82
 
76
- Discard: nav, header, footer, cookie banners, chat widgets, scripts.
83
+ Inventory shared navigation/header/footer, consent and runtime features first;
84
+ map them to reusable partials or supported runtimes before excluding them from
85
+ individual Page bodies.
77
86
  Keep: `<main>`, `<article>`, the largest content region.
78
87
 
79
88
  Clean the HTML:
@@ -89,13 +98,13 @@ Clean the HTML:
89
98
  ```
90
99
  upload_media_from_url url="<source-img-url>" alt="..."
91
100
  ```
92
- Replace the src with the returned CDN URL. Skip tracking pixels
93
- (1×1 images), decorative SVGs that are just icons, and anything
94
- that 404s.
101
+ Replace the src with the returned CDN URL. Classify tracking pixels separately. Preserve meaningful decorative images and
102
+ icons. A required file that fails transfer blocks acceptance; do not silently
103
+ omit it. Inventory PDFs, download hrefs, srcsets and backgrounds as well.
95
104
 
96
105
  **d. Adapt to the target's design.**
97
- Replace source-specific CSS classes with target conventions.
98
- Use `var(--color-*)` for colors, `var(--font-*)` for type.
106
+ Use verified native fields from the source capability map. Preserve appearance
107
+ by default; do not substitute target defaults or corrective tenant CSS.
99
108
 
100
109
  ### 4. Create pages as drafts
101
110
 
@@ -5,6 +5,12 @@ description: Migrate an existing Astro site, including Astro Content Collections
5
5
 
6
6
  # Migrate an Astro site
7
7
 
8
+ **Before bulk conversion:** read `tr-migration-evidence` with `read_skill`.
9
+ Complete its source baseline and one verified prototype per active template.
10
+ Read target content to protect edits, not to justify accidental target defaults.
11
+ Its shared evidence record is required for visual acceptance.
12
+
13
+
8
14
  Astro Content Collections are source data. In Typeroll, each source record
9
15
  becomes a Page, and each source schema becomes a Content type. Static Astro
10
16
  pages use that same Page model. Shared Astro layouts become Page templates,
@@ -5,6 +5,12 @@ description: Use when migrating SEVERAL sites at once — a WordPress multisite
5
5
 
6
6
  # Migrate a multisite / multi-domain family to Typeroll
7
7
 
8
+ **Before bulk conversion:** read `tr-migration-evidence` with `read_skill`.
9
+ Complete its source baseline and one verified prototype per active template.
10
+ Read target content to protect edits, not to justify accidental target defaults.
11
+ Its shared evidence record is required for visual acceptance.
12
+
13
+
8
14
  > **The buffer model (draft writes).** Every content write in this recipe
9
15
  > (pages, blocks and partials) lands in an unsaved per-doc
10
16
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
@@ -63,8 +69,8 @@ Ask explicitly:
63
69
  - Are the sites **translations of each other** (same page structure) or
64
70
  independent? This decides whether hreflang clusters are mechanical or
65
71
  hand-mapped.
66
- - Any domains being **retired or merged**? Those need redirects at the DNS
67
- level, not just inside a site.
72
+ - Any domains being **retired or merged**? Those need HTTP redirects served by the old hostname; DNS itself is not
73
+ a redirect. Plan that hosting separately.
68
74
 
69
75
  ## Phase 1 — Inventory EVERY old site, before building anything
70
76
 
@@ -76,7 +82,7 @@ For each source site, create the target site first (the inventory lives on
76
82
  it):
77
83
 
78
84
  ```
79
- create_site name="Example DE" domain="example.de"
85
+ create_site name="Example DE"
80
86
  ```
81
87
 
82
88
  Then walk the source and post what you find:
@@ -128,12 +134,10 @@ N times as much.
128
134
 
129
135
  ## Phase 3 — Replicate the design to the other sites
130
136
 
131
- Design travels as a block-type package, not by hand:
132
-
133
- ```
134
- export_block_types site_id=<reference> # → .tcblocks JSON
135
- import_block_types site_id=<other> package=<that JSON>
136
- ```
137
+ Replicate only source-validated native recipes and brand settings. Confirm each
138
+ market's template overrides and business workflows independently. Export/import
139
+ custom block types only for specifically reviewed source-specific exceptions,
140
+ not generic corrective blocks or copied tenant CSS.
137
141
 
138
142
  Then per site:
139
143
  - `read_site_settings` on the reference → `update_site_settings` on the
@@ -149,71 +153,15 @@ alt-text generation:
149
153
  update_site site_id=<de-site> language="de"
150
154
  ```
151
155
 
152
- ## Phase 4 — Migrate content, preserving paths
153
-
154
- Per site, per URL, follow `tr-migrate-wp` §3. Two rules that matter more here
155
- than in a single-site migration:
156
-
157
- 1. **Preserve the path verbatim** unless there's a reason not to. Use
158
- `path` for anything nested: `create_page title="Über uns" slug="ueber-uns"
159
- path="/ueber-uns"`. A preserved path needs no redirect and loses nothing.
160
- 2. **Rewrite internal links to the NEW paths.** Imported HTML is full of
161
- absolute links to the old domain. Sweep them per site:
162
-
163
- ```
164
- bulk_replace_text site_id=<de-site> find="https://example.de/" replace="/" dry_run=true
165
- ```
166
-
167
- Check the dry-run count against what you expect before running it for real.
168
- Cross-domain links between sister sites stay absolute — only the site's own
169
- domain becomes relative.
170
-
171
- ### Media: per site, not shared
172
-
173
- Every Typeroll site has its own media library, so a shared asset (the group
174
- logo, a product shot used in all markets) is uploaded once **per site** and
175
- gets a different CDN URL in each. That's correct — the sites are independent
176
- and one market's deploy must not depend on another's assets — but it means:
177
-
178
- - Don't try to reuse a `cdn_url` from site A inside site B's HTML. It will
179
- render, and it will break the day site A is deleted or moved.
180
- - Do write alt text per market, in that market's language:
181
- `update_media media_id=… alt_text="…"`. The alt text is content, not
182
- metadata, and a Swedish alt on a German page is a real accessibility defect.
183
-
184
- Images referenced only from a stylesheet or from unrendered page-builder JSON
185
- are NOT found by an HTML scan. Spot-check the hero/background images of the
186
- top pages in the preview before you call a site done.
187
-
188
- ### Forms are NOT migrated — plan to rebuild them
189
-
190
- The HTML cleaner strips `<form>`, `<input>`, `<select>` and `<button>`
191
- entirely, on purpose: a Contact Form 7 / Gravity / Elementor form posts to
192
- WordPress endpoints that no longer exist, so importing the markup would give
193
- you a form that looks alive and silently drops every submission.
194
-
195
- So, per site:
196
-
197
- ```
198
- create_form name="Kontakt" fields=[…] # or steps=[…] for a funnel
199
- add_block target={kind:'page', id:'kontakt'} block={type:'core/form', data:{form_id:'<id>'}}
200
- ```
201
-
202
- Then, still per site:
203
-
204
- - **Recipient address per market** — the German enquiries rarely go to the
205
- Swedish inbox. Check this explicitly; it is the single most common thing
206
- to get wrong in a batch of ten.
207
- - **Email delivery is configured per site** by an admin in the portal
208
- (Settings → Integrations), not through this API. Flag it to the user as a
209
- manual step — a form that saves submissions but sends no notification looks
210
- fine in testing and loses leads in production.
211
- - **Submit a real test through every form** after deploy, and confirm both the
212
- stored submission and the notification email.
156
+ ## Phase 4 — Preserve each market's business functions
213
157
 
214
- Count the old site's forms during Phase 1 and put them in the plan table.
215
- Ten sites × three forms is thirty forms, and it is the part of the job that
216
- never shows up in a URL inventory.
158
+ Use the business-function inventory from `tr-migration-evidence`. Some markets
159
+ use native Forms, others an owned lead flow, affiliate destination or partner
160
+ iframe. Do not replace them all with a new contact form. Configure a Form only
161
+ where it is the approved equivalent. Verify each market's recipient, destination,
162
+ language and attribution. Use authorized synthetic tests with a safe recipient;
163
+ record delivery UNVERIFIED when it cannot be tested safely. Never create real
164
+ leads or send mail to third parties as a routine migration test.
217
165
 
218
166
  ## Phase 5 — Redirects for everything you didn't preserve
219
167
 
@@ -232,23 +180,10 @@ For each entry, one of three outcomes — no fourth option:
232
180
  Re-read the list. `unhandled` reaching zero is the exit condition for this
233
181
  phase.
234
182
 
235
- **Clear the URL families with one rule each**, per site — a WP network
236
- multiplies the same dead shapes across every market:
237
-
238
- ```
239
- create_redirect site_id=<de-site> from_path="/category/*" to_path="/blogg/:splat"
240
- create_redirect site_id=<de-site> from_path="/tag/*" to_path="/blogg"
241
- create_redirect site_id=<de-site> from_path="/2019/*" to_path="/blogg/:splat"
242
- ```
243
-
244
- Only a TRAILING `*` is supported (`:splat` replays the remainder); `:name`
245
- matches one segment. Pattern-covered inventory URLs count as `redirected`, so
246
- the work list actually empties. A pattern that would hide a live page is
247
- refused, naming the pages — narrow the prefix rather than working around it.
248
-
249
- Watch the per-market prefixes: the German site's archive base is `/kategorie/`,
250
- not `/category/`. Write the rules from each site's own inventory, never by
251
- copying the reference site's.
183
+ Pattern redirects require verified source-to-destination content mappings.
184
+ A category/tag/year shape alone is not proof of equivalent destinations. Check
185
+ expanded artifact size and provider limits, actual slash/query variants and
186
+ that no rule hides a preserved Page. A 200 at the wrong content fails.
252
187
 
253
188
  ## Phase 6 — Wire the hreflang cluster
254
189
 
@@ -301,8 +236,8 @@ is possible at all. Verdicts:
301
236
  | `error` | 5xx / timeout | inconclusive, re-run |
302
237
 
303
238
  `check_source=true` also requests the OLD site, so a URL that already 404s
304
- upstream shows up as noise in the inventory rather than as a migration
305
- failure — mark those `excluded`.
239
+ upstream needs a reviewed retention/retirement decision, including traffic and
240
+ backlinks. Never automatically mark source 404s `excluded`.
306
241
 
307
242
  Slash-equivalent source URLs are one inventory decision but remain separate
308
243
  verification requests through `observed_paths`. Therefore `checked` can exceed
@@ -316,7 +251,8 @@ parity, then record the exact dataset and result with
316
251
  `record_migration_seo_acceptance`.
317
252
 
318
253
  Iterate until `missing` and `broken_redirect` are both zero **on every site**.
319
- Then, per site:
254
+ Only after explicit cutover authorization, per site (`add_domain` prepares a
255
+ new canonical build and is not an inventory-only operation):
320
256
 
321
257
  1. `add_domain` with the intended apex/www canonical; leave `auto_deploy` on
322
258
  2. Wait for that canonical/sitemap deploy to succeed, then point DNS
@@ -338,9 +274,9 @@ Then, per site:
338
274
  - [ ] hreflang cluster written on both/all sides, absolute, final domains
339
275
  - [ ] `language` set on the site; `<html lang>` correct in the deployed HTML
340
276
  - [ ] Internal links rewritten (no lingering absolute links to the old domain)
341
- - [ ] Forms rebuilt, recipient address correct for THIS market, test submission sent and received
277
+ - [ ] Market-specific business functions mapped; authorized synthetic interaction/delivery verified or explicitly UNVERIFIED
342
278
  - [ ] Media uploaded to this site's own library (no cross-site `cdn_url`), alt text in this market's language
343
- - [ ] Domain declared, canonical deploy succeeded, DNS verified; sitemap submitted
279
+ - [ ] If cutover is authorized: domain declared, canonical deploy succeeded, DNS verified; sitemap submitted. Otherwise report pre-cutover complete separately.
344
280
 
345
281
  ## Pitfalls specific to this job
346
282
 
@@ -5,6 +5,12 @@ description: Use when the user asks to migrate a WordPress site to Typeroll, men
5
5
 
6
6
  # Migrate from WordPress to Typeroll
7
7
 
8
+ **Before bulk conversion:** read `tr-migration-evidence` with `read_skill`.
9
+ Complete its source baseline and one verified prototype per active template.
10
+ Read target content to protect edits, not to justify accidental target defaults.
11
+ Its shared evidence record is required for visual acceptance.
12
+
13
+
8
14
  > **The buffer model (draft writes).** Every content write in this recipe
9
15
  > (pages, blocks and partials) lands in an unsaved per-doc
10
16
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
@@ -58,10 +64,9 @@ Then the ordinary preconditions:
58
64
  - `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.
59
65
  - The source WP site has `/wp-json` reachable (Google for "wordpress
60
66
  REST API disabled" if not — common for hardened hosts).
61
- - The Typeroll target site exists **and already carries the design** —
62
- settings, header/footer, one or two example pages. Import preserves content; shared templates define presentation. Agree whether
63
- to preserve the original appearance or redesign it. Do not silently restyle
64
- or rewrite imported text.
67
+ - The target exists. Establish the source design baseline first, then prove
68
+ representative target templates using `tr-migration-evidence`. Preserve
69
+ appearance unless a redesign is explicitly requested.
65
70
  - If the target already has content, you must NOT clobber it — always
66
71
  `list_pages` first and only write to slugs that don't already exist.
67
72
 
@@ -78,7 +83,7 @@ Build a list of every URL you intend to migrate. WP custom post types
78
83
  need their REST endpoint (e.g. `/wp-json/wp/v2/news?per_page=100`),
79
84
  walking `X-WP-TotalPages` to paginate.
80
85
 
81
- ### 2. Learn the target's design
86
+ ### 2. Measure the source and protect existing target work
82
87
 
83
88
  ```
84
89
  get_site
@@ -169,19 +174,12 @@ path? If yes, no redirect. If renamed, `create_redirect`. If
169
174
  intentionally dropped, mark it `excluded` via `update_migration_url` (the
170
175
  customer should sign off on every dropped URL).
171
176
 
172
- **Use wildcards for WordPress's URL families.** A WP site's dead URLs come in
173
- shapes, not as individuals — and the inventory only knows the ones it found,
174
- while the old site had more (paginated archives, feeds, attachment pages). One
175
- pattern rule retires the whole family:
176
-
177
- | WordPress shape | Rule |
178
- |---|---|
179
- | Intentionally retired category archives | `from_path="/category/*"` → `to_path="/blogg/:splat"` (or a single landing page) |
180
- | Intentionally retired tag archives | `from_path="/tag/*"` → `to_path="/blogg"` |
181
- | Author archives | `from_path="/author/*"` → `to_path="/om-oss"` |
182
- | Date-based permalinks | `from_path="/2019/*"` → `to_path="/blogg/:splat"` — one rule per year |
183
- | Old post prefix → new | `from_path="/blog/:slug"` → `to_path="/artiklar/:slug"` |
184
- | Feeds | `from_path="/feed/*"` → `to_path="/blogg"` |
177
+ Use a pattern only after verifying every affected source-to-destination mapping.
178
+ For an explicitly approved prefix change, `/old-blog/:slug` → `/articles/:slug`
179
+ can preserve intent if each destination contains the corresponding article.
180
+ Do not blanket-redirect category/tag/author/feed/year families to a home or blog
181
+ page. Date paths need actual route mappings; stripping a year is not enough.
182
+ Review source 404s rather than automatically excluding them.
185
183
 
186
184
  Rules:
187
185
 
@@ -194,7 +192,9 @@ Rules:
194
192
  `/blogg/*` would make every real article under `/blogg/` unreachable. Narrow
195
193
  the prefix instead.
196
194
  - **Query-string URLs can't be matched.** WP's `/?p=123` has no path to key on;
197
- those need handling at the source (or accept the loss and mark them excluded).
195
+ these require an explicit query-routing solution on the future host or a
196
+ reviewed retirement decision. The old host cannot handle requests after its
197
+ hostname moves.
198
198
  - Rules are emitted most-specific-first, so a narrow rule always beats a broad
199
199
  one — you can safely have `/blogg/recept/*` alongside `/blogg/*`.
200
200