@typeroll/mcp-server 0.45.77 → 0.45.79

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.
Files changed (32) hide show
  1. package/dist/bundled-content.js +22 -4
  2. package/dist/tools/deploy.js +2 -1
  3. package/dist/tools/media.js +1 -1
  4. package/dist/tools/migration.js +4 -2
  5. package/dist/tools/pages.js +1 -1
  6. package/dist/tools/preview.js +1 -1
  7. package/dist/tools/working-copy.js +1 -1
  8. package/dist/version.js +1 -1
  9. package/dist/workspace.js +3 -4
  10. package/package.json +1 -1
  11. package/skills/tr-header-footer.md +95 -41
  12. package/skills/tr-migrate-wp.md +22 -6
  13. package/skills/tr-new-site.md +5 -7
  14. package/workspace-templates/qa/checklist.md +35 -0
  15. package/workspace-templates/qa/typeroll-platform-feedback.md +11 -0
  16. package/workspace-templates/scripts/README.md +85 -0
  17. package/workspace-templates/scripts/typeroll/browser.cjs +50 -0
  18. package/workspace-templates/scripts/typeroll/call.py +12 -0
  19. package/workspace-templates/scripts/typeroll/capture_text.cjs +17 -0
  20. package/workspace-templates/scripts/typeroll/card-stacking-check.cjs +27 -0
  21. package/workspace-templates/scripts/typeroll/check_assets.py +72 -0
  22. package/workspace-templates/scripts/typeroll/check_unsaved.py +13 -0
  23. package/workspace-templates/scripts/typeroll/check_url_parity.py +74 -0
  24. package/workspace-templates/scripts/typeroll/compare_text.py +59 -0
  25. package/workspace-templates/scripts/typeroll/contact_sheet.py +20 -0
  26. package/workspace-templates/scripts/typeroll/layout_trace.cjs +33 -0
  27. package/workspace-templates/scripts/typeroll/shoot_hosted.cjs +26 -0
  28. package/workspace-templates/scripts/typeroll/shootframe.cjs +16 -0
  29. package/workspace-templates/scripts/typeroll/sweep_blocks.example.py +23 -0
  30. package/workspace-templates/scripts/typeroll/trlib.py +212 -0
  31. package/workspace-templates/workflows/design.md +30 -0
  32. package/workspace-templates/workflows/migration.md +49 -0
@@ -0,0 +1,72 @@
1
+ """Check published page assets, including relative URLs and srcset. CSS backgrounds need visual QA."""
2
+ import argparse
3
+ import concurrent.futures
4
+ from html.parser import HTMLParser
5
+ import json
6
+ import urllib.parse
7
+ import trlib
8
+ from check_url_parity import origin, fetch, probe
9
+
10
+
11
+ class Assets(HTMLParser):
12
+ def __init__(self, base):
13
+ super().__init__(convert_charrefs=True)
14
+ self.base, self.urls = base, set()
15
+
16
+ def handle_starttag(self, tag, attrs):
17
+ data = dict(attrs)
18
+ values = []
19
+ if tag in ('img', 'source', 'video', 'audio', 'script'):
20
+ values.extend(data[k] for k in ('src', 'poster') if data.get(k))
21
+ if tag == 'link' and data.get('rel') == 'stylesheet':
22
+ values.append(data.get('href', ''))
23
+ href = data.get('href', '')
24
+ if tag == 'a' and ('download' in data or urllib.parse.urlsplit(href).path.lower().endswith(('.pdf', '.jpg', '.jpeg', '.png', '.webp', '.avif', '.svg', '.zip'))):
25
+ values.append(href)
26
+ if data.get('srcset') and not data['srcset'].strip().startswith('data:'):
27
+ values.extend(item.strip().split()[0] for item in data['srcset'].split(',') if item.strip())
28
+ for value in values:
29
+ url = urllib.parse.urljoin(self.base, value)
30
+ if urllib.parse.urlsplit(url).scheme in ('http', 'https'):
31
+ self.urls.add(url)
32
+
33
+
34
+ def check(url):
35
+ status, location, _ = fetch(url, 'HEAD')
36
+ # HEAD is not supported everywhere; redirects must resolve too.
37
+ if status in (405, 501) or location:
38
+ status = probe(url).get('status', 0)
39
+ return {'url': url, 'status': status, 'ok': status == 200}
40
+
41
+
42
+ def main():
43
+ parser = argparse.ArgumentParser(description=__doc__)
44
+ parser.add_argument('origin', type=origin)
45
+ args = parser.parse_args()
46
+ pages = [p for p in trlib.all_pages() if p.get('status') in ('published', 'unlisted')]
47
+ errors, assets = [], set()
48
+ for page in pages:
49
+ if not page.get('url'):
50
+ errors.append({'page_id': page['id'], 'error': 'missing_resolved_route'})
51
+ continue
52
+ route = urllib.parse.urlsplit(page['url'])
53
+ url = args.origin + (route.path or '/') + ('?' + route.query if route.query else '')
54
+ evidence = probe(url, expected_page=True)
55
+ if not evidence.get('ok'):
56
+ errors.append({'page_id': page['id'], **evidence})
57
+ continue
58
+ status, _, body = fetch(evidence['final_url'])
59
+ if status != 200:
60
+ errors.append({'page_id': page['id'], 'status': status})
61
+ continue
62
+ parser = Assets(evidence['final_url'])
63
+ parser.feed(body.decode('utf8', 'replace'))
64
+ assets.update(parser.urls)
65
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
66
+ results = list(pool.map(check, sorted(assets)))
67
+ print(json.dumps({'pages': len(pages), 'page_errors': errors, 'assets': results}, indent=2))
68
+ return 1 if not pages or errors or any(not item['ok'] for item in results) else 0
69
+
70
+
71
+ if __name__ == '__main__':
72
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """Pre-deploy gate: list every Page and partial with an unsaved working copy (Page templates save immediately).
2
+ Deploys build saved content only, and previews with include_working_copy hide the difference.
3
+ usage: python3 scripts/typeroll/check_unsaved.py (exit 1 when something is unsaved)"""
4
+ import os, sys
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+ import trlib
7
+ SITE = trlib.site_id()
8
+ bad = [t for t in trlib.containers() if t["kind"] != "template"
9
+ and trlib.call("read_working_copy", {"site_id": SITE, "target": t}).get("working_copy")]
10
+ for t in bad:
11
+ print("unsaved:", t["kind"], t["id"])
12
+ print("unsaved total:", len(bad))
13
+ sys.exit(1 if bad else 0)
@@ -0,0 +1,74 @@
1
+ """Read-only URL continuity check on an explicitly supplied deployed origin."""
2
+ import argparse
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ import trlib
8
+
9
+ UA = 'Typeroll-Starter-QA/1.0 (+https://typeroll.com/docs/)'
10
+
11
+
12
+ def fetch(url, method='GET'):
13
+ request = urllib.request.Request(url, method=method, headers={'User-Agent': UA})
14
+ for attempt in range(2):
15
+ try:
16
+ with urllib.request.build_opener(trlib.NoRedirect).open(request, timeout=30) as response:
17
+ return response.status, response.headers.get('Location'), response.read() if method == 'GET' else b''
18
+ except urllib.error.HTTPError as error:
19
+ if error.code in (502, 503, 504) and attempt == 0:
20
+ continue
21
+ return error.code, error.headers.get('Location'), b''
22
+ except (OSError, TimeoutError):
23
+ if attempt:
24
+ return 0, None, b''
25
+
26
+
27
+ def origin(value):
28
+ parsed = urllib.parse.urlsplit(value)
29
+ if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
30
+ raise ValueError('Supply a plain deployed HTTP(S) origin')
31
+ return value.rstrip('/')
32
+
33
+
34
+ def probe(url, expected_page=False):
35
+ initial, seen, hops = url, set(), []
36
+ for _ in range(6):
37
+ if url in seen:
38
+ return {'error': 'redirect_loop', 'hops': hops}
39
+ seen.add(url)
40
+ status, location, _ = fetch(url)
41
+ if status not in (301, 302, 303, 307, 308) or not location:
42
+ slash = bool(hops) and urllib.parse.urlsplit(initial)._replace(path=urllib.parse.urlsplit(initial).path.rstrip('/')) == urllib.parse.urlsplit(hops[0])._replace(path=urllib.parse.urlsplit(hops[0]).path.rstrip('/'))
43
+ maximum = 1 if expected_page else 2 if slash else 1
44
+ acceptable_page_hop = not expected_page or not hops or (len(hops) == 1 and slash)
45
+ ok = status == 200 and len(hops) <= maximum and acceptable_page_hop
46
+ return {'ok': ok, 'status': status, 'hops': hops, 'final_url': url}
47
+ url = urllib.parse.urljoin(url, location)
48
+ if urllib.parse.urlsplit(url).scheme not in ('http', 'https'):
49
+ return {'error': 'unsupported_redirect_scheme', 'hops': hops}
50
+ hops.append(url)
51
+ return {'error': 'too_many_redirects', 'hops': hops}
52
+
53
+
54
+ def main():
55
+ parser = argparse.ArgumentParser(description=__doc__)
56
+ parser.add_argument('origin', type=origin)
57
+ parser.add_argument('--show-excluded', action='store_true')
58
+ args = parser.parse_args()
59
+ report = []
60
+ for row in trlib.all_migration_urls():
61
+ if row.get('status') == 'excluded' and not args.show_excluded:
62
+ continue
63
+ for path in sorted(set([row['path']] + (row.get('observed_paths') or []))):
64
+ # Inventory spellings must be paths, not a silent change of tested host.
65
+ if not path.startswith('/') or path.startswith('//'):
66
+ raise ValueError('Inventory contains a non-local path')
67
+ url = args.origin + urllib.parse.quote(path, safe='/%?=&:+')
68
+ report.append({'path': path, **probe(url, row.get('status') == 'migrated')})
69
+ print(json.dumps(report, indent=2))
70
+ return 1 if not report or any(not row.get('ok') for row in report) else 0
71
+
72
+
73
+ if __name__ == '__main__':
74
+ raise SystemExit(main())
@@ -0,0 +1,59 @@
1
+ """Compare saved source HTML (or one WP REST record) to a captured target.
2
+ Template titles are included exactly once by explicit source title/target selector.
3
+ This reports differences, never rewrites content or approves a migration.
4
+ """
5
+ import argparse
6
+ import difflib
7
+ from html.parser import HTMLParser
8
+ import json
9
+ from pathlib import Path
10
+ import re
11
+
12
+
13
+ class Text(HTMLParser):
14
+ def __init__(self):
15
+ super().__init__(convert_charrefs=True)
16
+ self.words, self.skip = [], 0
17
+ def handle_starttag(self, tag, attrs):
18
+ if tag in ('script', 'style'):
19
+ self.skip += 1
20
+ elif tag in ('p', 'div', 'li', 'br', 'h1', 'h2', 'h3', 'h4'):
21
+ self.words.append(' ')
22
+ def handle_endtag(self, tag):
23
+ if tag in ('script', 'style'):
24
+ self.skip = max(0, self.skip - 1)
25
+ elif tag in ('p', 'div', 'li', 'h1', 'h2', 'h3', 'h4'):
26
+ self.words.append(' ')
27
+ def handle_data(self, value):
28
+ if not self.skip:
29
+ self.words.append(value)
30
+ # HTMLParser excludes comments. Do not strip tags with a regexp.
31
+
32
+
33
+ def tokens(value):
34
+ parser = Text()
35
+ parser.feed(value)
36
+ return re.findall(r'\w+|[^\w\s]', ''.join(parser.words))
37
+
38
+
39
+ def main():
40
+ args = argparse.ArgumentParser(description=__doc__)
41
+ args.add_argument('source', help='HTML fragment or one saved WP REST record (.json)')
42
+ args.add_argument('target', help='JSON from capture_text.cjs')
43
+ args.add_argument('--source-title', default='', help='For an HTML source whose title is template-owned')
44
+ opts = args.parse_args()
45
+ source = Path(opts.source).read_text()
46
+ if opts.source.endswith('.json'):
47
+ record = json.loads(source)
48
+ source = record['title']['rendered'] + ' ' + record['content']['rendered']
49
+ else:
50
+ source = opts.source_title + ' ' + source
51
+ target = json.loads(Path(opts.target).read_text())
52
+ before, after = tokens(source), re.findall(r'\w+|[^\w\s]', target['text'])
53
+ changes = [line for line in difflib.ndiff(before, after) if line.startswith(('+ ', '- '))]
54
+ print(json.dumps({'source_tokens': len(before), 'target_tokens': len(after), 'changes': changes}, ensure_ascii=False, indent=2))
55
+ return int(bool(changes))
56
+
57
+
58
+ if __name__ == '__main__':
59
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ """Tile screenshots into contact sheets for fast visual review of many pages.
2
+ usage: python3 contact_sheet.py <out-prefix> <cols> <tile-width> <png> [<png> ...]
3
+ Each tile shows the top of a screenshot (height = 3.2 x width) labelled with its file name."""
4
+ import sys, os
5
+ from PIL import Image, ImageDraw
6
+ out, cols, tw, files = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4:]
7
+ th = int(tw * 3.2)
8
+ per = cols * 2
9
+ for n, start in enumerate(range(0, len(files), per)):
10
+ chunk = files[start:start + per]
11
+ sheet = Image.new("RGB", (cols * tw, 2 * (th + 20)), "white")
12
+ d = ImageDraw.Draw(sheet)
13
+ for i, f in enumerate(chunk):
14
+ im = Image.open(f).convert("RGB")
15
+ im = im.resize((tw, int(im.height * tw / im.width))).crop((0, 0, tw, th))
16
+ x, y = (i % cols) * tw, (i // cols) * (th + 20)
17
+ sheet.paste(im, (x, y + 20))
18
+ d.text((x + 3, y + 3), os.path.basename(f)[:40], fill="red")
19
+ sheet.save(f"{out}-{n}.png")
20
+ print(f"{out}-{n}.png")
@@ -0,0 +1,33 @@
1
+ // Explain vertical spacing: computed margin/padding/gap of every ancestor of a selector and of the next
2
+ // section down to its first heading. Works on a signed preview link (reads the inner frame) or a public URL.
3
+ // usage: node layout_trace.js <url> [width=390] [selector=h1]
4
+ const qa = require('./browser.cjs');
5
+ const { chromium } = qa;
6
+ (async () => {
7
+ const [,, url, width = '390', sel = 'h1'] = process.argv;
8
+ const b = await chromium.launch();
9
+ const p = await b.newPage({viewport: {width: +width, height: 900}});
10
+ await qa.open(p, url);
11
+ const f = p.mainFrame();
12
+ try { console.log(await f.evaluate((sel) => {
13
+ const out = [];
14
+ const show = (lab, el) => {
15
+ if (!el) return;
16
+ const c = getComputedStyle(el), r = el.getBoundingClientRect();
17
+ out.push(`${lab} <${el.tagName.toLowerCase()} ${el.getAttribute('data-block') || String(el.className).slice(0, 40)}> top=${Math.round(r.top)} h=${Math.round(r.height)} margin=${c.marginTop}/${c.marginBottom} padding=${c.paddingTop}/${c.paddingBottom} gap=${c.rowGap} radius=${c.borderRadius}`);
18
+ };
19
+ const target = document.querySelector(sel);
20
+ if (!target) throw Error('Selector not found');
21
+ const sec = target.closest('[data-block="section"]') || target;
22
+ const chain = []; for (let a = target; a && a !== document.body; a = a.parentElement) chain.push(a);
23
+ chain.reverse().forEach((x, i) => show('ancestor' + i, x));
24
+ for (let s = sec.nextElementSibling; s; s = s.nextElementSibling) {
25
+ show('next', s);
26
+ const h = s.querySelector('h1,h2,h3');
27
+ if (h) { const ch = []; for (let q = h; q && q !== s; q = q.parentElement) ch.push(q); ch.reverse().forEach((x, i) => show('inside' + i, x)); break; }
28
+ }
29
+ show('header', document.querySelector('header'));
30
+ return out.join('\n');
31
+ }, sel));
32
+ } finally { await b.close(); }
33
+ })().catch(qa.fail);
@@ -0,0 +1,26 @@
1
+ const qa = require('./browser.cjs');
2
+ const [origin, out, paths, ...args] = process.argv.slice(2);
3
+ (async () => {
4
+ const browser = await qa.chromium.launch();
5
+ const report = [];
6
+ try {
7
+ for (const route of paths.split(',')) {
8
+ const url = new URL(route, origin);
9
+ if (url.origin !== new URL(origin).origin) throw Error('Paths must stay on the selected origin');
10
+ for (const width of qa.widths(args)) {
11
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
12
+ const failed = [];
13
+ page.on('requestfailed', r => failed.push({ host: new URL(r.url()).hostname, kind: 'network' }));
14
+ page.on('response', r => { if (r.status() >= 400) failed.push({ host: new URL(r.url()).hostname, status: r.status() }); });
15
+ await qa.open(page, url.href); await qa.settle(page);
16
+ const result = { path: route, width, ...await qa.inspect(page), failed };
17
+ const name = require('node:crypto').createHash('sha256').update(route).digest('hex').slice(0, 12);
18
+ await qa.screenshot(page, `${out}/${name}-${width}.png`);
19
+ report.push(result);
20
+ await page.close();
21
+ }
22
+ }
23
+ console.log(JSON.stringify(report, null, 2));
24
+ if (report.some(r => r.overflow || r.incompleteImages.length || r.h1 !== 1 || r.failed.length || !r.scrollCoverageComplete)) process.exitCode = 1;
25
+ } finally { await browser.close(); }
26
+ })().catch(qa.fail);
@@ -0,0 +1,16 @@
1
+ const qa = require('./browser.cjs');
2
+ const [out, url, ...args] = process.argv.slice(2);
3
+ (async () => {
4
+ const browser = await qa.chromium.launch();
5
+ try {
6
+ for (const width of qa.widths(args)) {
7
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
8
+ const hosts = new Set();
9
+ page.on('request', r => { try { hosts.add(new URL(r.url()).hostname); } catch {} });
10
+ await qa.open(page, url); await qa.settle(page);
11
+ await qa.screenshot(page, `${out}-${width}.png`);
12
+ console.log(JSON.stringify({ width, ...await qa.inspect(page), hosts: [...hosts] }));
13
+ await page.close();
14
+ }
15
+ } finally { await browser.close(); }
16
+ })().catch(qa.fail);
@@ -0,0 +1,23 @@
1
+ """Pattern: change one kind of block on every Page, Page template and partial, then save.
2
+ Example: set last_row "start" on every core/grid that centers its last row. Dry run lists; --apply writes.
3
+ Copy and edit MATCH/PATCH for other sweeps. Templates save immediately; Pages and partials must be committed. Refuses existing unsaved work."""
4
+ import os, sys
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+ import trlib
7
+ SITE = trlib.site_id()
8
+ MATCH = lambda b: b.get("type") == "core/grid" and (b.get("data") or {}).get("last_row") == "center"
9
+ PATCH = {"last_row": "start"}
10
+ apply = "--apply" in sys.argv
11
+ for tgt in trlib.containers():
12
+ tree = trlib.call("get_page_blocks", {"site_id": SITE, "target": tgt})
13
+ hits = [b for b in trlib.walk(tree.get("blocks", tree) if isinstance(tree, dict) else tree) if MATCH(b)]
14
+ if not hits:
15
+ continue
16
+ print(tgt["kind"], tgt["id"], len(hits))
17
+ if apply:
18
+ if tgt['kind'] != 'template' and trlib.call('read_working_copy', {'target': tgt}).get('working_copy'):
19
+ raise RuntimeError('Unsaved work exists; review it before this sweep')
20
+ for b in hits:
21
+ trlib.call("update_block", {"site_id": SITE, "target": tgt, "block_id": b["id"], "data": PATCH})
22
+ if tgt["kind"] in ("page", "partial"):
23
+ trlib.call("commit_working_copy", {"site_id": SITE, "target": tgt})
@@ -0,0 +1,212 @@
1
+ """Small, version-bound MCP helper. Credentials are injected, never persisted.
2
+ No automatic retries for mutations. Run from the workspace root.
3
+ """
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+
13
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
14
+ def redirect_request(self, *args, **kwargs):
15
+ return None # Never forward Authorization or signed PUTs to another URL.
16
+
17
+
18
+ def workspace():
19
+ for directory in (Path.cwd(), *Path.cwd().parents):
20
+ file = directory / 'typeroll.json'
21
+ if file.is_file():
22
+ value = json.loads(file.read_text())
23
+ if not all(value.get(k) for k in ('portal', 'organization_id', 'site_id', 'version')):
24
+ raise ValueError('Complete portal, organization_id, site_id and version in typeroll.json first')
25
+ origin = urllib.parse.urlsplit(value['portal'])
26
+ if origin.username or origin.password or origin.query or origin.fragment or origin.path not in ('', '/'):
27
+ raise ValueError('portal must be a plain origin')
28
+ if origin.scheme != 'https' and not (origin.scheme == 'http' and origin.hostname in ('localhost', '127.0.0.1', '::1')):
29
+ raise ValueError('portal requires HTTPS (HTTP is allowed only on loopback)')
30
+ return value
31
+ raise ValueError('Run from a workspace containing typeroll.json')
32
+
33
+
34
+ class Client:
35
+ def __init__(self, config=None):
36
+ self.config = config or workspace()
37
+ self.url = self.config['portal'].rstrip('/') + '/api/mcp?tools=full'
38
+ self.session = None
39
+ self.sequence = 0
40
+ self.tools = None
41
+ self.verified = False
42
+ self.opener = urllib.request.build_opener(NoRedirect)
43
+
44
+ def rpc(self, method, params=None, notify=False):
45
+ self.sequence += 1
46
+ body = {'jsonrpc': '2.0', 'method': method}
47
+ if params is not None:
48
+ body['params'] = params
49
+ if not notify:
50
+ body['id'] = self.sequence
51
+ key = os.environ.get('TYPEROLL_API_KEY')
52
+ if not key:
53
+ raise ValueError('Inject TYPEROLL_API_KEY privately before running')
54
+ headers = {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json',
55
+ 'Accept': 'application/json, text/event-stream'}
56
+ if self.session:
57
+ headers['Mcp-Session-Id'] = self.session
58
+ request = urllib.request.Request(self.url, data=json.dumps(body).encode(), headers=headers)
59
+ try:
60
+ with self.opener.open(request, timeout=180) as response:
61
+ self.session = response.headers.get('Mcp-Session-Id') or self.session
62
+ raw = response.read().decode()
63
+ except urllib.error.HTTPError as error:
64
+ raise RuntimeError(f'MCP HTTP {error.code}; no mutation was retried') from None
65
+ if notify:
66
+ return None
67
+ result = decode_rpc(raw, self.sequence)
68
+ if 'error' in result:
69
+ raise RuntimeError(f"MCP RPC error {result['error'].get('code')}")
70
+ return result['result']
71
+
72
+ def initialize(self):
73
+ self.rpc('initialize', {'protocolVersion': '2025-03-26', 'capabilities': {},
74
+ 'clientInfo': {'name': 'typeroll-workspace', 'version': '1'}})
75
+ self.rpc('notifications/initialized', notify=True)
76
+ self.tools = {}
77
+ cursor = None
78
+ seen = set()
79
+ while True:
80
+ result = self.rpc('tools/list', {'cursor': cursor} if cursor else {})
81
+ self.tools.update({t['name']: t for t in result['tools']})
82
+ cursor = result.get('nextCursor')
83
+ if not cursor:
84
+ break
85
+ if cursor in seen:
86
+ raise RuntimeError('tools/list repeated its cursor')
87
+ seen.add(cursor)
88
+
89
+ def call(self, tool, args=None):
90
+ if self.tools is None:
91
+ self.initialize()
92
+ if tool not in self.tools:
93
+ raise ValueError(f'Tool unavailable: {tool}')
94
+ if not self.verified:
95
+ info = self.invoke('get_site', {})
96
+ if info.get('organization_id') != self.config['organization_id'] or info.get('id') != self.config['site_id']:
97
+ raise ValueError('Connected Organization/Site does not match typeroll.json; nothing was written')
98
+ self.verified = True
99
+ return self.invoke(tool, args or {})
100
+
101
+ def invoke(self, tool, args):
102
+ props = self.tools[tool]['inputSchema'].get('properties', {})
103
+ data = dict(args)
104
+ for key, wanted in [('site_id', self.config['site_id']), ('version', self.config['version'])]:
105
+ if key in data and data[key] != wanted:
106
+ raise ValueError(f'{key} differs from typeroll.json; change the binding explicitly')
107
+ if key in props:
108
+ data[key] = wanted
109
+ if 'version' not in props and self.config['version'] != 'main' and tool != 'get_site':
110
+ raise ValueError(f'{tool} has no version parameter; refusing an implicit main-version operation')
111
+ result = self.rpc('tools/call', {'name': tool, 'arguments': data})
112
+ if result.get('isError'):
113
+ raise RuntimeError(f'{tool} failed; inspect through your authenticated MCP client (no retry)')
114
+ if 'structuredContent' in result:
115
+ return result['structuredContent']
116
+ text = ''.join(c.get('text', '') for c in result.get('content', []) if c.get('type') == 'text')
117
+ return json.loads(text)
118
+
119
+
120
+ def decode_rpc(raw, request_id):
121
+ if raw.lstrip().startswith('{'):
122
+ value = json.loads(raw)
123
+ if value.get('id') != request_id:
124
+ raise ValueError('MCP response did not contain the matching request ID')
125
+ return value
126
+ # Streamable HTTP may include notifications, multiline data, and keepalives.
127
+ for event in raw.replace('\r\n', '\n').split('\n\n'):
128
+ data = '\n'.join(line[5:].lstrip() for line in event.splitlines() if line.startswith('data:'))
129
+ if data:
130
+ value = json.loads(data)
131
+ if value.get('id') == request_id:
132
+ return value
133
+ raise ValueError('MCP response did not contain the matching request ID')
134
+
135
+
136
+ _client = None
137
+
138
+
139
+ def call(tool, args=None):
140
+ global _client
141
+ if _client is None:
142
+ _client = Client()
143
+ return _client.call(tool, args)
144
+
145
+
146
+ def site_id():
147
+ return workspace()['site_id']
148
+
149
+
150
+ def walk(blocks):
151
+ for block in blocks or []:
152
+ yield block
153
+ yield from walk(block.get('children'))
154
+ for slot in block.get('slots') or []:
155
+ yield from walk(slot)
156
+
157
+
158
+ def all_pages():
159
+ cursor, seen = None, set()
160
+ while True:
161
+ args = {'limit': 200, **({'cursor': cursor} if cursor else {})}
162
+ result = call('list_pages', args)
163
+ yield from result['pages']
164
+ cursor = result.get('next_cursor')
165
+ if not cursor:
166
+ return
167
+ if cursor in seen:
168
+ raise RuntimeError('list_pages repeated its cursor')
169
+ seen.add(cursor)
170
+
171
+
172
+ def all_migration_urls():
173
+ offset = 0
174
+ while True:
175
+ result = call('list_migration_urls', {'limit': 1000, 'offset': offset})
176
+ rows = result['urls']
177
+ yield from rows
178
+ offset += len(rows)
179
+ if not rows or offset >= result.get('total_matching', float('inf')):
180
+ return
181
+
182
+
183
+ def containers():
184
+ for page in all_pages():
185
+ yield {'kind': 'page', 'id': page['id']}
186
+ for kind, tool, key in [('template', 'list_page_templates', 'templates'), ('partial', 'list_partials', 'partials')]:
187
+ result = call(tool)
188
+ rows = result if isinstance(result, list) else result.get(key, result.get('page_templates'))
189
+ if not isinstance(rows, list):
190
+ raise ValueError(f'Unexpected response from {tool}')
191
+ for row in rows:
192
+ yield {'kind': kind, 'id': row['id']}
193
+
194
+
195
+ def upload(file, content_type, alt=''):
196
+ content = Path(file).read_bytes()
197
+ sha = hashlib.sha256(content).hexdigest()
198
+ mint = call('create_upload_url', {'filename': Path(file).name, 'content_type': content_type,
199
+ 'size': len(content), 'alt_text': alt})
200
+ if urllib.parse.urlsplit(mint['upload_url']).scheme != 'https':
201
+ raise ValueError('Signed upload must use HTTPS')
202
+ request = urllib.request.Request(mint['upload_url'], data=content, method='PUT', headers={'Content-Type': content_type})
203
+ try:
204
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as response:
205
+ response.read()
206
+ except Exception:
207
+ raise RuntimeError('Signed upload failed; URL withheld; no automatic retry') from None
208
+ result = call('finalize_media', {'media_id': mint['media_id'], 'expected_sha256': sha})
209
+ evidence = result.get('result', result)
210
+ if evidence.get('sha256') != sha:
211
+ raise ValueError('Finalize did not confirm the source SHA-256; do not reference this asset')
212
+ return {'media_id': mint['media_id'], 'cdn_url': mint['cdn_url'], 'sha256': sha}
@@ -0,0 +1,30 @@
1
+ # Layout defaults and owner decisions
2
+
3
+ Use current native composition starters and block schemas. These defaults are
4
+ starting points, not permission to overwrite an approved design.
5
+
6
+ - Keep incomplete grid rows left-aligned (`last_row: start`). Never enlarge one
7
+ peer card just to fill the gap. Center only on an explicit owner request.
8
+ - Prefer `core/navigation_menu` with `core/navigation_links` desktop/mobile slots
9
+ for a header drawer (below 1024 px). Read tr-header-footer; test 1023/1024 px.
10
+ - Separate dense footer about text from the link grid. Give it a readable width
11
+ and a larger gap; inspect 390/1024/1280/1536 px. Equal columns need equal density.
12
+ - Sections own gutters. Do not erase side padding with `padding: 3rem 0` when
13
+ only vertical spacing is intended. Wrap column-slot contents in a flow
14
+ container with article rhythm when they need vertical block spacing.
15
+ - Give cards one clear destination. Use whole_card_link without action_label
16
+ or download actions. Check excerpt, image, whitespace and keyboard activation.
17
+ - Do not fade body text with opacity. Verify actual text/background contrast,
18
+ current links on light/dark navigation, and all states.
19
+ - Preserve image subjects across widths. Choose contain/original sizing when
20
+ cropping or upscaling is not intended. Hero crop, text placement, plates,
21
+ corner treatment and category-page composition are project decisions in
22
+ brief/brand.md, not a universal design.
23
+ - After migration, check heading hierarchy, unchanged preserved text, HTML
24
+ comments, links with ../, and the home SEO title/suffix. Shared headings and
25
+ titles must not be duplicated in body content.
26
+
27
+ Record deliberate departures in decisions.md with scope and owner approval.
28
+ Use a QA round per review: method, pages/viewports, expected differences, results,
29
+ unverified items and remaining defects. Record platform findings in
30
+ qa/typeroll-platform-feedback.md; do not bury them in workaround CSS.
@@ -33,3 +33,52 @@ Track import, block reconstruction, separate text/design review, candidate
33
33
  publication and domain cutover as separate milestones. Import completion is not
34
34
  launch approval. All four text/design combinations use the same block hierarchy
35
35
  and URL continuity contract. Planning choices are not migration API fields.
36
+
37
+ ## Repeatable reconstruction and acceptance
38
+
39
+ Read workflows/design.md and scripts/README.md. First check whether public
40
+ WordPress REST + HTML covers the source; use tr-migrate-wp's access decision
41
+ table before asking for a helper plugin. Inventory both Pages and posts plus
42
+ internal links and known old slugs. A sitemap alone is not complete.
43
+
44
+ Discard HTML comments before extracting text, resolve relative links against
45
+ the source, and keep an explicit old URL map. Do not redirect deleted content
46
+ to the home page merely to get a 200. Preserve/renew text and design separately.
47
+ Use capture_text.cjs + compare_text.py for preserved text with explicit selectors.
48
+
49
+ Before every authorized test deployment run check_unsaved.py (Pages and partials).
50
+ Deploy saved content to the agreed protected test host, then run check_url_parity.py,
51
+ check_assets.py and shoot_hosted.cjs. Check whole-card targets with
52
+ card-stacking-check.cjs and inspect actual desktop/mobile screenshots. Record a
53
+ QA round and platform findings. Review on the owner's phone when possible.
54
+ Domain preparation/cutover and production indexing follow their own explicit
55
+ approval scope and the platform's current guided flow; do not bypass readiness.
56
+ Do not request a second approval for a step the owner has already authorized.
57
+
58
+
59
+ ## Native controls and publication checks
60
+
61
+ On Core 0.2.103 / MCP 0.45.79 or later, discover the actual block schemas first.
62
+ Use column `content_gap_px`, responsive section `padding_y`, heading-role
63
+ `space_before`/`space_after`, hero focal/fit/text-position fields and image
64
+ `never_upscale` instead of one-off CSS. Responsive block keys start at `mobile`,
65
+ not `base`. For vertical-only spacing, do not reset horizontal padding with CSS
66
+ shorthand. Optional `click_to_load` videos need an explicit poster if desired;
67
+ choose a first-party poster to avoid early provider requests.
68
+
69
+ `import_sitemap` can include public WordPress REST (`include_wordpress: true`)
70
+ and a bounded crawl (`crawl_pages: 10`). Record truncation and access warnings.
71
+ A `matched_draft` URL is accounted for in the CMS but not ready for launch.
72
+ Resolve `unpublished_targets`, redirect loops/chains and actual deployed parity.
73
+
74
+ A deploy with working copies returns `unsaved_content`; review the targets.
75
+ Save explicitly or obtain approval to publish saved content only using
76
+ `allow_unsaved: true`. Never silently save somebody else's work.
77
+
78
+ Keep the bound Organization/Site/Version explicit. A site API key cannot switch
79
+ to another Organization: use an authorized OAuth connection or a separate key.
80
+ Read the schema for target envelopes and pagination; do not guess aliases.
81
+ Page/partial block writes update working copies; check `saved` and use
82
+ `commit_working_copy` with `target: {kind: "page"|"partial", id: "..."}`.
83
+ Page templates save immediately and do not have working copies. REST read envelopes and
84
+ MCP tool text are different layers; `call.py` returns the decoded tool result.