@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.
package/AGENTS.md ADDED
@@ -0,0 +1,448 @@
1
+ # AGENTS.md — Working on a Typeroll site
2
+
3
+ You are connected to a Typeroll site through `@typeroll/mcp-server`.
4
+ This file is your briefing: what the system is, what conventions matter,
5
+ what tools to reach for first.
6
+
7
+ If anything below conflicts with what you observe in the tools, trust the
8
+ tools — the platform may have moved since this was written.
9
+
10
+ ## What this is
11
+
12
+ Typeroll is a static-site CMS: content lives in a database, the user
13
+ edits it through an in-app editor, and a deploy step compiles everything
14
+ to a fast static site hosted on Cloudflare Pages. The in-app chat handles
15
+ single-page or single-block edits by the editor audience. You — through
16
+ this MCP — handle the work that doesn't fit there: site-wide redesigns,
17
+ bulk content updates, structural migrations, directory imports.
18
+
19
+ The MCP server is a thin wrapper around the public REST API. Each tool
20
+ maps to one HTTP endpoint; the actual logic runs in the customer's portal
21
+ (SaaS or self-hosted).
22
+
23
+ ## The data model in 90 seconds
24
+
25
+ - **Pages.** Title, slug, status (`draft | review | unlisted | published`),
26
+ body content + SEO fields. Two body shapes selectable per page via
27
+ `content_mode`:
28
+ - `blocks` (DEFAULT for new pages) — `blocks: Block[]` tree of typed
29
+ blocks (heading, prose, section, columns, image, button, plus any
30
+ user/third-party block types installed on the site). Use the
31
+ block-mutation tools (`add_block`, `update_block`, `move_block`,
32
+ `remove_block`) for structural changes.
33
+ - `html` — body lives in `html_content` as a single HTML string.
34
+ Useful when you have hand-written markup to drop in directly.
35
+
36
+ Slug can contain slashes, so `2024/01/foo-bar` is a valid slug →
37
+ `/2024/01/foo-bar` URL. Encode hierarchy in the slug; the renderer
38
+ doesn't use `parent` for routing.
39
+
40
+ - **Partials = global blocks.** Three kinds:
41
+ - `header` — auto-injected at the top of every page.
42
+ - `footer` — auto-injected at the bottom of every page.
43
+ - `free` — reusable HTML you drop into a page with
44
+ `<x-include name="block-id" />`. Free blocks are how you avoid
45
+ duplicating HTML across HTML-mode pages.
46
+
47
+ Partials themselves also support `content_mode='blocks'` — pass a
48
+ `blocks: Block[]` tree to `update_partial` and the renderer composes
49
+ it the same way as a page. Useful for header/footer authored with
50
+ block types.
51
+
52
+ - **Collections.** Repeatable content types (blog, team, events,
53
+ products, restaurants for a directory site, etc.). Each has a schema
54
+ (`fields[]`) and optional **per-item routing** via `route_template`
55
+ (e.g. `/restaurants/{slug}`). When set, every published item gets its
56
+ own static URL rendered through `item_template_html`. Set
57
+ `route_template=""` to opt out and keep the collection listing-only.
58
+
59
+ - **Settings.** Site name, tagline, logo, favicon, colors, fonts,
60
+ contact info, social links, SEO suffix, plus `scripts_head`,
61
+ `scripts_body_end`, `custom_css` (writable via the API — your bearer
62
+ token authorises shipping arbitrary CSS/JS to the live site, just
63
+ like editing a partial's HTML does).
64
+
65
+ - **Page templates.** A `PageTemplate` is a Block[] tree that wraps a
66
+ page's body. The template contains exactly one block of type
67
+ `template_content_slot` — at render time that block gets replaced by
68
+ the page's own `blocks`. Set `Page.template = "<template-id>"` to
69
+ apply a template to a page.
70
+
71
+ - **Block types.** A site has three sources of block types:
72
+ - **Core** (origin: 'core', ids like `core/section`) — shipped in
73
+ the platform, always available.
74
+ - **User** (origin: 'user') — created in the portal's block-types UI.
75
+ - **Third-party** (origin: 'third_party') — imported from .tcblocks
76
+ packages via `import_block_types`.
77
+
78
+ `list_block_types` returns ALL of them in one list, with each entry's
79
+ full schema (field names, types, defaults). Always call this FIRST
80
+ before working with blocks — never hardcode block ids or field names,
81
+ the available set is per-site.
82
+
83
+ - **Redirects.** `from_path → to_path` with status code 301 / 302.
84
+ Auto-created when you change a page's slug.
85
+
86
+ - **Versions / branches.** Copy-on-write. The "main" version is the
87
+ live one. Create a branch (`create_branch`) for multi-step work;
88
+ everything you write through `?version=<branch-id>` lives on the
89
+ branch until you `merge_branch` it back to main. Branches default
90
+ `robots_blocked: true` so a half-finished redesign can't be indexed,
91
+ and deploys land at a stable `{branch}.{project}.pages.dev` URL.
92
+
93
+ - **Deploys.** Customers see live changes only after a deploy. Preview
94
+ always sees drafts. `trigger_deploy` enqueues; `get_deploy_status`
95
+ reports `queued → running → succeeded | failed`.
96
+
97
+ - **Site URLs.** `get_site` returns a `urls` object with:
98
+ - `production` — the customer's real domain (or null)
99
+ - `fallback` — the auto `{slug}.typeroll.app`-style preview URL
100
+ - `preview_base` — the portal preview origin (for token URLs)
101
+ Use these in answers to "what's the URL?" — never invent.
102
+
103
+ ## Discovering this site
104
+
105
+ Don't hardcode assumptions about what's here. Every fact about the site
106
+ goes through the MCP:
107
+
108
+ 1. `get_site` — confirm the key works; learn the site name + URLs.
109
+ 2. `read_site_settings` — colors, fonts, contact info, SEO suffix,
110
+ content language (used by `suggest_alt_text_context`).
111
+ 3. `list_pages` — what pages exist, paginated.
112
+ 4. `list_partials` — what shared blocks already exist. **Defaults to
113
+ summary mode** (no html_content, just bytes count) — pass
114
+ `include_content: true` if you actually need the bodies inline.
115
+ 5. `list_collections` — what content types exist + their schemas +
116
+ `route_template` (so you know if items have URLs).
117
+ 6. `list_block_types` — every block type usable on this site: core
118
+ (always available, ids like `core/section`), custom (origin: 'user'),
119
+ and third-party (origin: 'third_party'). Each entry includes the
120
+ full schema so you know what `data.X` fields each block accepts.
121
+ 7. `list_page_templates` — PageTemplate docs that wrap pages.
122
+
123
+ You usually want at least #1 + #2 + a sampling from #3 before
124
+ proposing any design change, so you mirror the conventions in use.
125
+
126
+ ## Common operations
127
+
128
+ ### "Replace this string across the whole site"
129
+
130
+ ```
131
+ search_pages contains="299 kr" → matches + excerpts
132
+ bulk_replace_text dry_run=true ... → sample_diffs
133
+ # show the user, get confirmation
134
+ bulk_replace_text dry_run=false ... → write
135
+ trigger_deploy → ship
136
+ get_deploy_status job_id=… → poll until succeeded
137
+ ```
138
+
139
+ Writes go through the normal save pipeline (SEO transform + revision
140
+ snapshot) so changes are reversible from the in-app History tab.
141
+
142
+ ### "Audit / understand the site"
143
+
144
+ ```
145
+ list_pages limit=200 → inventory
146
+ batch_read_pages page_ids=[…] → bulk-load bodies
147
+ list_partials → shared blocks (summary)
148
+ find_pages_using_block partial_id=<id> → blast radius per block
149
+ list_collections → content types + routing
150
+ list_collection_items collection=<name> → items (richtext hidden)
151
+ ```
152
+
153
+ `find_pages_using_block` for the header or footer returns the full
154
+ page list (they're auto-injected on every page).
155
+
156
+ ### "Redesign the home page"
157
+
158
+ ```
159
+ get_site + read_site_settings
160
+ read_partial partial_id="header"
161
+ list_pages → batch_read_pages a few existing pages # learn conventions
162
+ # Propose redesign locally; ask user to confirm.
163
+ create_branch name="Home redesign" # ID is, say, "home-redesign"
164
+ update_page page_id=home patch={ html_content: "…" } version=home-redesign
165
+ get_preview_link page_id=home version=home-redesign # signed clickable URL
166
+ # Iterate. When approved:
167
+ merge_branch version_id=home-redesign
168
+ trigger_deploy
169
+ ```
170
+
171
+ The branch also has its own permanent deploy URL at
172
+ `https://home-redesign.<project>.pages.dev` after `trigger_deploy
173
+ version=home-redesign` — useful for "share with stakeholders without
174
+ showing them my preview token". `read_version version_id=home-redesign`
175
+ returns it as `deploy_url`.
176
+
177
+ ### "Build a reusable block"
178
+
179
+ If you see the same HTML on 3+ pages, propose a free block instead of
180
+ duplicating it:
181
+
182
+ ```
183
+ create_free_block id="newsletter-cta" html_content="<form>…</form>"
184
+ # Then on each page where it should appear (HTML-mode pages):
185
+ update_page page_id=… patch={ html_content: "<…><x-include name=\"newsletter-cta\" />" }
186
+ ```
187
+
188
+ Edits to the block update every page that includes it. Use
189
+ `find_pages_using_block` before changing it.
190
+
191
+ ### "Build a page using blocks (the default for new pages)"
192
+
193
+ New pages default to `content_mode='blocks'` with a seeded heading +
194
+ prose block. Discover-then-build:
195
+
196
+ ```
197
+ list_block_types
198
+ # → [{ id: "core/section", category: "layout", container: true, schema: [{ name: "width", type: "select", options: ["narrow","normal","wide","full"] }, …] },
199
+ # { id: "core/columns", container: "slots", slot_count: 2, slot_labels: ["Left","Right"], schema: [...] },
200
+ # { id: "hero_bold", origin: "user", schema: [...] }, ← any custom blocks on this site
201
+ # …]
202
+
203
+ get_page_blocks page_id=home
204
+ # → { content_mode: 'blocks', blocks: [...] }
205
+
206
+ add_block page_id=home block={ type: 'core/section', data: { width: 'wide' } }
207
+ # → { added_id: 'blk_xyz', blocks: [...] }
208
+ add_block page_id=home parent_id="blk_xyz" block={
209
+ type: 'core/heading', data: { text: 'Pricing', level: 'h2' }
210
+ }
211
+ add_block page_id=home parent_id="blk_xyz" block={
212
+ type: 'core/prose', data: { html: '<p>…</p>' }
213
+ }
214
+ ```
215
+
216
+ For an unfamiliar custom block, `read_block_type id="..."` gives the
217
+ full field list (types, defaults, required) so you don't ship invalid
218
+ `data`.
219
+
220
+ Updating, moving, removing blocks: `update_block`, `move_block`,
221
+ `remove_block` (all by `block_id`).
222
+
223
+ ### "Switch a page between blocks and HTML"
224
+
225
+ Use `set_page_mode` — it snapshots a revision before flipping, so the
226
+ previous state is restorable:
227
+
228
+ ```
229
+ # Convert an HTML-mode page to blocks with auto-heuristic conversion:
230
+ set_page_mode page_id=about to=blocks convert=true
231
+
232
+ # Or just switch the mode without converting (empty blocks):
233
+ set_page_mode page_id=about to=blocks
234
+
235
+ # Switch back to HTML (drops the block tree; revision retains it):
236
+ set_page_mode page_id=about to=html
237
+ ```
238
+
239
+ The heuristic converter recognises `<h1-4>` → heading, `<img>` → image,
240
+ `<a.btn>` → button, `grid-cols-2` → two-column, `<section>` / hero divs
241
+ → section. Anything it can't classify becomes a `core/prose` block,
242
+ which preserves the raw HTML losslessly. Run with `convert_page_to_blocks
243
+ dry_run=true` first if you want to inspect the proposal before
244
+ committing.
245
+
246
+ ### "Build a directory site / import structured data"
247
+
248
+ ```
249
+ create_collection
250
+ name="restaurants"
251
+ label_singular="Restaurant" label_plural="Restaurants"
252
+ fields=[ ...title, slug, address, phone, cuisine, body... ]
253
+ route_template="/restaurants/{slug}"
254
+ item_template_html="<article><h1>{{title}}</h1>… {{{body}}}</article>"
255
+
256
+ # For each row in your source data:
257
+ create_collection_item collection="restaurants" fields={…} status="published"
258
+
259
+ # Each published item now lives at /restaurants/{slug}, included in
260
+ # sitemap.xml. Preview a specific one:
261
+ get_preview_link collection_name="restaurants" item_id="<id>"
262
+
263
+ # Optional listing page:
264
+ list_collection_items collection="restaurants" limit=200
265
+ update_page page_id=restaurants patch={ html_content: "<hand-written listing>" }
266
+ ```
267
+
268
+ ### "Migrate a content type (e.g. WP custom post type)"
269
+
270
+ ```
271
+ list_collections # what exists today?
272
+ read_collection name=blog # what fields are writable?
273
+ batch_read_collection_items … # load items (richtext hidden)
274
+ # Transform locally; then:
275
+ update_collection_item … (or) create_collection_item …
276
+ ```
277
+
278
+ Fields outside the schema are silently dropped — call `read_collection`
279
+ first if you're unsure what's writable.
280
+
281
+ ### "Add images to a page"
282
+
283
+ ```
284
+ # Image lives on a URL somewhere (Unsplash, customer's existing CDN):
285
+ upload_media_from_url source_url="https://..." alt_text="Hero photo of …"
286
+ → returns { media_id, cdn_url, … }
287
+
288
+ # OR image lives in your memory (image-gen output):
289
+ upload_media_inline filename="hero.png" content_type="image/png"
290
+ data_base64="iVBORw0KGgo…"
291
+ → returns the same shape
292
+
293
+ # OPTIONAL but recommended: produce srcset variants for responsive <img>:
294
+ generate_image_variants media_id=<id>
295
+ → returns webp + avif variants at 320/640/1024/1920 widths
296
+ (skips upscales; sub-5s per image)
297
+
298
+ # Then embed in a page:
299
+ read_page page_id=...
300
+ # Build <picture> or <img srcset="..."> using variants[*].cdn_url
301
+ update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' .../>" }
302
+ ```
303
+
304
+ ### "Fill missing alt-text across the media library"
305
+
306
+ ```
307
+ list_media → find items where alt_text is empty
308
+ suggest_alt_text_context media_id=<id> → returns image_url + tuned prompt
309
+ + language + nearest-heading context
310
+ # Pass image_url + the returned suggested_prompt to YOUR OWN vision
311
+ # capability. The platform does NOT run vision for you.
312
+ update_media media_id=<id> alt_text="<what vision returned>"
313
+ ```
314
+
315
+ The prompt is tuned for SEO-grade output: 5-15 words, written in
316
+ `settings.language`, skips "image of" filler, decorative images return
317
+ empty string.
318
+
319
+ ### "Change a page's URL safely"
320
+
321
+ ```
322
+ update_page page_id=about patch={ slug: "om-oss" }
323
+ → response includes:
324
+ auto_redirects: [{ from_path: "/about", to_path: "/om-oss",
325
+ status_code: 301 }]
326
+ sanitization_warnings: []
327
+ ```
328
+
329
+ The 301 fires automatically — you don't have to remember.
330
+
331
+ ### "Change the site's fallback URL (slug)"
332
+
333
+ ```
334
+ update_site slug="acme"
335
+ → response includes:
336
+ urls.fallback: "https://acme.sites.typeroll.com"
337
+ dns_note: "New fallback URL … attached to CF Pages. SSL provisioning
338
+ takes 1–10 minutes after DNS propagates. …"
339
+ ```
340
+
341
+ The slug change triggers DNS + CF Pages reprovisioning behind the scenes.
342
+ **Always check the response for `dns_note` vs `dns_warning`:**
343
+
344
+ - `dns_note` present → the new fallback URL was wired up; warn the user it
345
+ may take 1–10 min for SSL to provision before the URL serves.
346
+ - `dns_warning` present → the slug was saved but DNS / CF attach failed.
347
+ The `urls.fallback` field is still returned (it's just `{slug}.{base}`
348
+ string formatting) but the URL will NOT resolve until the issue is
349
+ fixed. Surface the warning verbatim to the user — don't tell them the
350
+ URL is ready.
351
+ - Neither present → self-hosted portal without CF/SITES_BASE_DOMAIN
352
+ configured; URL behaviour is up to the operator.
353
+
354
+ The old fallback URL keeps working (bookmarks + SEO survive). Customer
355
+ can manually deprovision the old one via the portal.
356
+
357
+ ## Safety boundaries
358
+
359
+ - **HTML is sanitized at save.** No `<script>`, no `onclick`, no
360
+ `javascript:` URLs in page or partial bodies. `<style>` blocks DO
361
+ survive — multi-page sites need authored CSS for `@media` queries,
362
+ `:hover`, theming, etc. Inside `<style>` we strip a small list of
363
+ legacy code-execution constructs (`expression()`, `behavior:url`,
364
+ `@import`, `url(javascript:)`) but leave normal CSS alone.
365
+ - **Write responses include `sanitization_warnings: []` (strings) and
366
+ `sanitization_details: []`** (structured records `{ kind, label,
367
+ count, bytes? }`). Use the structured form to programmatically retry
368
+ with a fixed input.
369
+ - **scripts_head, scripts_body_end, custom_css** are now writable via
370
+ `update_site_settings` and readable via `read_site_settings`. Same
371
+ trust model as user-authored block-type JS: an API caller with a valid
372
+ bearer token takes responsibility for what they ship. The chat AI
373
+ inside the portal continues to NOT expose these fields, so a
374
+ conversation-driven assistant can't smuggle scripts in.
375
+ - **The API key is site-scoped.** Cross-site reach is impossible — a
376
+ key on the wrong site returns 401, indistinguishable from "bad token".
377
+ - **Audit log.** Every state-changing call (POST / PATCH / PUT /
378
+ DELETE) is logged. Reads aren't. The customer sees "Acme agency key
379
+ wrote to /pages/home at 14:32" in the portal.
380
+ - **Rate limits.** 600 reads/min, 60 writes/min per key. On 429 the
381
+ response carries `Retry-After`.
382
+
383
+ ## Preview-driven workflow
384
+
385
+ After any non-trivial change, call `get_preview_link` and ask the user
386
+ (or your own browser tool) to confirm the result before moving on. One
387
+ HTTP call vs. shipping a broken redesign — always worth it.
388
+
389
+ Preview shows DB state (drafts included). Live (`get_site → urls.production`)
390
+ shows the most recent deploy. Branch deploys live at
391
+ `get_version → deploy_url` (`{branch}.{project}.pages.dev`).
392
+
393
+ ## Branches
394
+
395
+ For multi-step work, create a branch:
396
+
397
+ ```
398
+ create_branch name="Pricing refresh"
399
+ ```
400
+
401
+ The response includes `id` — pass that as `version=<id>` on every
402
+ subsequent call. The branch is independent of main; writes don't affect
403
+ the live site until you `merge_branch`.
404
+
405
+ Branches default `robots_blocked: true`. Deploys to a branch land at a
406
+ stable, share-able URL (`{branch}.{project}.pages.dev`); use that for
407
+ stakeholder review.
408
+
409
+ ## When in doubt
410
+
411
+ - **Read before you write.** A `read_page` round-trip is cheap and
412
+ stops you overwriting unrelated changes.
413
+ - **Dry-run bulk operations.** `bulk_replace_text` accepts `dry_run:
414
+ true` and returns 3 sample diffs. Show them to the user before the
415
+ real run.
416
+ - **Watch the sanitization_warnings array.** If it's non-empty, the
417
+ stored HTML differs from what you sent. Read it back to confirm.
418
+ - **One small confirmation > one large undo.** The audit log makes it
419
+ obvious who did what, but a clean revert across many pages is still
420
+ more work than asking "ok to proceed?" first.
421
+ - **Match the site's design.** Read a partial or two before designing
422
+ new components. CSS variables (`var(--color-primary)`) are common
423
+ but not universal — mirror what's already in use.
424
+
425
+ ## Reference: tool families
426
+
427
+ | Family | Tools |
428
+ |---|---|
429
+ | **Discovery** | `get_site`, `update_site`, `list_versions`, `read_site_settings` |
430
+ | **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |
431
+ | **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |
432
+ | **Pages — blocks** | `get_page_blocks`, `add_block`, `update_block`, `move_block`, `remove_block`, `set_page_mode`, `convert_page_to_blocks` |
433
+ | **Pages — meta** | `get_page_preview` |
434
+ | **Global blocks (partials)** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |
435
+ | **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |
436
+ | **Collections** | `create_collection`, `update_collection_schema`, `delete_collection`, `list_collections`, `read_collection`, `list_collection_items` (richtext hidden by default), `read_collection_item`, `batch_read_collection_items`, `create_collection_item`, `update_collection_item`, `delete_collection_item`, `regenerate_collection_listing` |
437
+ | **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `generate_image_variants`, `suggest_alt_text_context` |
438
+ | **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
439
+ | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions` |
440
+ | **Settings** | `update_site_settings` (whitelist) |
441
+ | **Search + bulk** | `search_pages`, `bulk_replace_text` |
442
+ | **Branches** | `create_branch`, `read_version`, `delete_branch`, `merge_branch` |
443
+ | **Deploy** | `trigger_deploy`, `list_deploys`, `get_deploy_status` |
444
+ | **Preview** | `get_preview_link`, `get_page_preview` |
445
+
446
+ Every tool's input is validated server-side; the MCP server only does
447
+ auth + shape. If a tool returns `isError: true`, the body carries
448
+ `{ error, status, body }` from the underlying HTTP response.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @typeroll/mcp-server
2
+
3
+ Model Context Protocol server for the [Typeroll](https://typeroll.com)
4
+ public API. Lets Claude Code (or any MCP-compatible client) manage a
5
+ Typeroll site through the same tool surface a human agency would use:
6
+ read and write pages, partials, collections, media, redirects, versions;
7
+ trigger deploys; mint preview links.
8
+
9
+ The server is a **thin transport adapter** — every tool wraps one HTTP
10
+ endpoint of the Typeroll REST API. Auth happens at the API layer with
11
+ a site-scoped key; the MCP just carries the bearer through.
12
+
13
+ ## Quick start
14
+
15
+ 1. **Create an API key** in your Typeroll portal at
16
+ `/app/sites/{siteId}/settings/api-keys`. The key is shown once at
17
+ creation — copy it somewhere safe.
18
+
19
+ 2. **Add the server to Claude Code.** Drop this into `~/.claude.json`
20
+ (or your local `.claude/config.json`):
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "typeroll": {
26
+ "command": "npx",
27
+ "args": ["-y", "@typeroll/mcp-server"],
28
+ "env": {
29
+ "TYPEROLL_API_URL": "https://app.typeroll.com",
30
+ "TYPEROLL_API_KEY": "typeroll_live_REPLACE_WITH_YOUR_KEY"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ For a self-hosted portal, point `TYPEROLL_API_URL` at it (e.g.
38
+ `https://cms.example.com`).
39
+
40
+ 3. **Tell the agent what kind of work you want.** A good first message:
41
+
42
+ > "Connect to Typeroll and tell me what you find — site name,
43
+ > number of pages, what global blocks exist, what collections are
44
+ > defined. Then I'll give you a task."
45
+
46
+ Claude will call `get_site`, `list_pages`, `list_partials`,
47
+ `list_collections` in sequence and report back.
48
+
49
+ ## Environment variables
50
+
51
+ | Var | Required | Description |
52
+ |-----------------|----------|-------------|
53
+ | `TYPEROLL_API_URL` | yes | Base URL of your Typeroll portal. |
54
+ | `TYPEROLL_API_KEY` | yes | A `typeroll_live_…` bearer token from the API keys page. |
55
+ | `TYPEROLL_SITE_ID` | no | Pin the server to a specific site. Defaults to autodetect (calls `GET /v1/sites` — per-site keys return exactly one). |
56
+
57
+ ## What the agent should read first
58
+
59
+ The package ships [AGENTS.md](./AGENTS.md), a self-contained briefing
60
+ that explains Typeroll conventions, common operations, and the safety
61
+ boundaries an agent needs to respect. Point Claude at it (or include it
62
+ in your project's `CLAUDE.md` / `AGENTS.md`) so it knows when to use
63
+ which tool.
64
+
65
+ ## Tool surface
66
+
67
+ Around 50 tools across these families. See [AGENTS.md](./AGENTS.md) for
68
+ the full reference + concrete operation recipes.
69
+
70
+ - **Discovery** — `get_site`, `update_site` (name/slug/domain), `list_versions`,
71
+ `read_site_settings`, `update_site_settings`.
72
+ - **Pages** — list, read, batch-read, create, update (PATCH), replace
73
+ (PUT), batch-update, delete, clone, get-blocks, get-preview.
74
+ - **Global blocks (partials)** — list (summary mode by default), read,
75
+ create free block, update, replace, delete, find-pages-using-block.
76
+ - **Block types** — list, read, find-pages-using-block-type. Surface
77
+ for the future block editor.
78
+ - **Collections + items** — create/update/delete the collection schema
79
+ itself (incl. `route_template` for per-item URLs); list/read/batch-
80
+ read/create/update/delete items.
81
+ - **Media** — list, read, signed upload URLs, `upload_media_from_url`,
82
+ `upload_media_inline`, patch metadata, delete, `generate_image_variants`
83
+ (build-time srcset pipeline), `suggest_alt_text_context` (returns a
84
+ tuned prompt for your own vision model).
85
+ - **Redirects** — list, create, delete. Plus automatic 301 on slug change.
86
+ - **Forms** — list, read, create, update, delete, list submissions.
87
+ - **Settings** — read + patch (scripts and custom CSS not exposed).
88
+ - **Search** — `search_pages` with substring or regex.
89
+ - **Bulk** — `bulk_replace_text` with dry-run.
90
+ - **Branches** — create, read, delete, merge. Branch deploys get their
91
+ own URL at `{branch}.{project}.pages.dev`.
92
+ - **Deploy** — trigger, list, get status.
93
+ - **Preview** — `get_preview_link` (signed URL for browser navigation;
94
+ supports `page_id`, `slug`, or `collection_name + item_id`).
95
+
96
+ ## Direct REST API access
97
+
98
+ If you don't want the MCP wrapper, the same surface is reachable directly
99
+ with curl:
100
+
101
+ ```bash
102
+ curl -H "Authorization: Bearer typeroll_live_..." \
103
+ https://app.typeroll.com/api/v1/sites/<siteId>/pages
104
+ ```
105
+
106
+ The MCP server is purely an ergonomics layer on top of that.
107
+
108
+ ## Security model
109
+
110
+ - API keys are **site-scoped**. A key cannot read or write another site
111
+ in the same organization — server-side authoritative.
112
+ - All write calls (`POST`, `PUT`, `PATCH`, `DELETE`) are **audit-logged**
113
+ with the key prefix, IP, method, path, and status. Reads are not
114
+ logged (cost vs. value).
115
+ - **Rate limits**: 600 reads/min, 60 writes/min per key. 429 responses
116
+ carry `Retry-After` headers.
117
+ - **HTML sanitization** happens at save time on the server — `<script>`,
118
+ event handlers, and `javascript:` URLs are stripped. The customer's
119
+ `scripts_*` and `custom_css` settings are read-stripped and write-dropped
120
+ by the API.
121
+ - Keys can be **revoked** at any time from the portal. Revocation takes
122
+ effect on the next request (no in-flight requests get cancelled, but
123
+ the next one returns 401).
124
+
125
+ ## More
126
+
127
+ - Full end-to-end production setup recipe with troubleshooting:
128
+ [docs/claude-code-mcp-setup.md](../../docs/claude-code-mcp-setup.md)
129
+ - Agent operations briefing: [AGENTS.md](./AGENTS.md)
130
+ - Boilerplate skills (migration, content, images, directory, redesign):
131
+ [skills/](./skills/)
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](../../LICENSE).
package/dist/client.js ADDED
@@ -0,0 +1,103 @@
1
+ // Thin fetch client for the Typeroll REST API. The MCP server is just a
2
+ // transport adapter — every tool delegates to one of these methods.
3
+ //
4
+ // Surface kept deliberately tiny so it's easy to reason about: get/post/
5
+ // patch/put/del + a path helper. Errors come back as a structured
6
+ // ApiError with the status and the parsed body so the caller can shape
7
+ // useful tool-result messages.
8
+ export class ApiError extends Error {
9
+ status;
10
+ body;
11
+ constructor(status, body, message) {
12
+ super(message ?? `API request failed (${status})`);
13
+ this.status = status;
14
+ this.body = body;
15
+ this.name = 'ApiError';
16
+ }
17
+ }
18
+ export class TyperollClient {
19
+ baseUrl;
20
+ apiKey;
21
+ fetchImpl;
22
+ constructor(config) {
23
+ if (!config.baseUrl)
24
+ throw new Error('baseUrl is required');
25
+ if (!config.apiKey)
26
+ throw new Error('apiKey is required');
27
+ this.baseUrl = config.baseUrl.replace(/\/+$/, '');
28
+ this.apiKey = config.apiKey;
29
+ this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
30
+ }
31
+ /** Build the full URL for a path (relative to /api/v1/sites/{siteId}/...) */
32
+ url(siteId, path, query) {
33
+ const cleanPath = path.replace(/^\/+/, '');
34
+ const url = new URL(`${this.baseUrl}/api/v1/sites/${encodeURIComponent(siteId)}/${cleanPath}`);
35
+ if (query) {
36
+ for (const [k, v] of Object.entries(query)) {
37
+ if (v !== undefined && v !== null)
38
+ url.searchParams.set(k, String(v));
39
+ }
40
+ }
41
+ return url.toString();
42
+ }
43
+ /** Top-level URL (no siteId prefix) — used by GET /v1/sites only. */
44
+ rootUrl(path, query) {
45
+ const cleanPath = path.replace(/^\/+/, '');
46
+ const url = new URL(`${this.baseUrl}/api/v1/${cleanPath}`);
47
+ if (query) {
48
+ for (const [k, v] of Object.entries(query)) {
49
+ if (v !== undefined && v !== null)
50
+ url.searchParams.set(k, String(v));
51
+ }
52
+ }
53
+ return url.toString();
54
+ }
55
+ async request(method, url, body) {
56
+ const headers = {
57
+ authorization: `Bearer ${this.apiKey}`,
58
+ accept: 'application/json',
59
+ };
60
+ if (body !== undefined)
61
+ headers['content-type'] = 'application/json';
62
+ const res = await this.fetchImpl(url, {
63
+ method,
64
+ headers,
65
+ body: body !== undefined ? JSON.stringify(body) : undefined,
66
+ });
67
+ const text = await res.text();
68
+ let parsed = undefined;
69
+ if (text) {
70
+ try {
71
+ parsed = JSON.parse(text);
72
+ }
73
+ catch {
74
+ parsed = text;
75
+ }
76
+ }
77
+ if (!res.ok) {
78
+ const message = parsed?.error ?? `HTTP ${res.status}`;
79
+ throw new ApiError(res.status, parsed, message);
80
+ }
81
+ return parsed;
82
+ }
83
+ // ── Site-scoped helpers ────────────────────────────────────────────────
84
+ get(siteId, path, query) {
85
+ return this.request('GET', this.url(siteId, path, query));
86
+ }
87
+ post(siteId, path, body, query) {
88
+ return this.request('POST', this.url(siteId, path, query), body);
89
+ }
90
+ patch(siteId, path, body, query) {
91
+ return this.request('PATCH', this.url(siteId, path, query), body);
92
+ }
93
+ put(siteId, path, body, query) {
94
+ return this.request('PUT', this.url(siteId, path, query), body);
95
+ }
96
+ del(siteId, path, query) {
97
+ return this.request('DELETE', this.url(siteId, path, query));
98
+ }
99
+ // ── Root helpers ───────────────────────────────────────────────────────
100
+ rootGet(path) {
101
+ return this.request('GET', this.rootUrl(path));
102
+ }
103
+ }