@typeroll/mcp-server 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -305,6 +305,32 @@ read_page page_id=...
305
305
  update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' alt='…' /></...>" }
306
306
  ```
307
307
 
308
+ ### "Stop an image over-fetching a too-large variant"
309
+
310
+ When an image renders much narrower than the viewport (a container-constrained
311
+ hero, a sidebar thumbnail), the default `<picture sizes>` of
312
+ `(max-width: 768px) 100vw, 800px` makes the browser pull a wider srcset variant
313
+ than it needs — Lighthouse flags it as wasted bytes. Three levers, narrowest
314
+ wins:
315
+
316
+ ```
317
+ # 1. Per-image: put a real `sizes` on the <img>. Survives the transform verbatim.
318
+ update_page page_id=... patch={ html_content:
319
+ "<img src='{cdn_url}' alt='…' sizes='(max-width: 640px) 360px, 560px' />" }
320
+
321
+ # 2. Per-page default (applies to every image on the page that has no own sizes):
322
+ update_page page_id=... patch={ image_sizes_default: "(max-width: 640px) 360px, 560px" }
323
+
324
+ # 3. Site-wide default (fallback under the page default):
325
+ update_site_settings image_sizes_default="(max-width: 640px) 360px, 560px"
326
+ ```
327
+
328
+ Precedence: per-image `sizes` > page `image_sizes_default` >
329
+ site `image_sizes_default` > the generic built-in. To opt a single image out of
330
+ the platform's auto-`<picture>` entirely, hand-write your own `<picture>` with
331
+ custom `<source media=…>` — the transform leaves an existing `<picture>`
332
+ untouched (it no longer re-wraps the inner `<img>`).
333
+
308
334
  ### "Fill missing alt-text across the media library"
309
335
 
310
336
  ```
@@ -440,7 +466,7 @@ stakeholder review.
440
466
  | **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` |
441
467
  | **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `finalize_media`, `finalize_all_media`, `generate_image_variants`, `suggest_alt_text_context` |
442
468
  | **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
443
- | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions` |
469
+ | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions`. read/create return `submit_token` + `submit_url` — embed as a plain `<form method="POST">` with a hidden `_token` input + empty honeypot `_hp`; no client JS (the sanitizer strips inline `<script>`; the endpoint answers form posts with an HTML confirmation page) |
444
470
  | **Settings** | `update_site_settings` (whitelist) |
445
471
  | **Search + bulk** | `search_pages`, `bulk_replace_text` |
446
472
  | **Branches** | `create_branch`, `read_version`, `delete_branch`, `merge_branch` |
@@ -1,8 +1,10 @@
1
1
  // Forms — agents can create/update/delete forms, and read submissions.
2
2
  // The customer-facing submit endpoint is HMAC-signed and lives at
3
- // /api/forms/submit; the in-portal chat's `get_form_embed` tool produces
4
- // the snippet customers paste into their pages. This file is the
5
- // management surface.
3
+ // /api/forms/submit. read_form/create_form return `submit_token` +
4
+ // `submit_url`, which is everything an agent needs to embed a working
5
+ // plain-POST form (no client JS — the page sanitizer strips inline
6
+ // <script>, and the endpoint answers form-encoded posts with an HTML
7
+ // confirmation page).
6
8
  import { z } from 'zod';
7
9
  import { ok, withErrorBoundary } from './helpers.js';
8
10
  const fieldSchema = z.object({
@@ -28,7 +30,7 @@ export const formTools = [
28
30
  },
29
31
  {
30
32
  name: 'read_form',
31
- description: 'Read one form by id, including fields, submit_text, and success_message.',
33
+ description: 'Read one form by id, including fields, submit_text, success_message — plus submit_token and submit_url for embedding: a plain <form method="POST" action={submit_url}> with a hidden _token input and an empty honeypot _hp is a fully working no-JS embed.',
32
34
  inputSchema: { form_id: z.string() },
33
35
  handler: withErrorBoundary(async (args, { client, siteId }) => {
34
36
  const res = await client.get(siteId, `forms/${encodeURIComponent(args.form_id)}`);
@@ -37,7 +39,7 @@ export const formTools = [
37
39
  },
38
40
  {
39
41
  name: 'create_form',
40
- description: 'Create a form. Each field has { name, type, label, required?, placeholder?, options? }. Allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent. The form can then be embedded on any page use get_form_embed (in-portal chat) or render the form yourself by reading the schema.',
42
+ description: 'Create a form. Each field has { name, type, label, required?, placeholder?, options? }. Allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent. The response includes submit_token + submit_url — embed with a plain <form method="POST" action={submit_url}>, a hidden _token input, and an empty honeypot _hp. Do NOT add inline <script> (the page sanitizer strips it); the endpoint answers plain form posts with an HTML confirmation page.',
41
43
  inputSchema: {
42
44
  id: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
43
45
  name: z.string().min(1),
@@ -90,6 +90,7 @@ export const pageTools = [
90
90
  author: z.string().optional(),
91
91
  language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
92
92
  template: z.string().optional().describe('Page template id (PageTemplate). Wraps the body in the template tree at render time.'),
93
+ image_sizes_default: z.string().optional().describe('Per-page default `sizes` for responsive images (e.g. "(max-width: 640px) 360px, 560px"). Overrides the site setting; a per-<img> `sizes` attr still wins. Set when this page\'s images render narrower than the generic default so the browser stops over-fetching.'),
93
94
  version: versionParam,
94
95
  },
95
96
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -121,6 +122,7 @@ export const pageTools = [
121
122
  canonical_url: z.string().optional(),
122
123
  noindex: z.boolean().optional(),
123
124
  lastmod_override: z.string().optional().describe('Override the sitemap <lastmod>. Empty string suppresses lastmod for this page entirely.'),
125
+ image_sizes_default: z.string().optional().describe('Per-page default `sizes` for responsive images (e.g. "(max-width: 640px) 360px, 560px"). Overrides the site setting; a per-<img> `sizes` attr still wins. Set when this page\'s images render narrower than the generic default so the browser stops over-fetching the larger variant.'),
124
126
  json_ld: z.string().optional(),
125
127
  schema_type: z.string().optional().describe('Free-form Schema.org type ("Service", "Course", "Product", …) used for auto JSON-LD emission. Use service.* for Service-specific fields.'),
126
128
  service: z
@@ -10,7 +10,7 @@ import { ok, withErrorBoundary } from './helpers.js';
10
10
  export const settingsTools = [
11
11
  {
12
12
  name: 'read_site_settings',
13
- description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, language, robots_txt, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css.",
13
+ description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, language, robots_txt, image_sizes_default, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css.",
14
14
  handler: withErrorBoundary(async (_args, { client, siteId }) => {
15
15
  const res = await client.get(siteId, 'settings');
16
16
  return ok(res);
@@ -32,6 +32,7 @@ export const settingsTools = [
32
32
  default_seo_suffix: z.string().optional(),
33
33
  language: z.string().optional().describe('BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> on the rendered site.'),
34
34
  robots_txt: z.string().optional(),
35
+ image_sizes_default: z.string().optional().describe('Site-wide default `sizes` attribute for responsive-image <picture> output, e.g. "(max-width: 640px) 360px, 560px". Tells the browser how wide images actually render so it stops over-fetching the larger srcset variant. A page can override via its own image_sizes_default; a per-<img> `sizes` attribute wins over both. Leave unset for the generic "(max-width: 768px) 100vw, 800px".'),
35
36
  // Scriptable surfaces. Trusted because the caller has an API key.
36
37
  scripts_head: z.string().optional().describe('Raw HTML injected into <head> on every page. Use for analytics, fonts, third-party CSS links.'),
37
38
  scripts_body_end: z.string().optional().describe('Raw HTML injected just before </body> on every page. Use for chat widgets, deferred analytics.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,11 +31,15 @@ create_form {
31
31
  {"name":"subject", "label":"Ämne", "type":"select",
32
32
  "options":["Prisförfrågan","Samarbete","Övrigt"]}
33
33
  ],
34
- "success_message": "Tack! Vi återkommer inom 24 timmar.",
35
- "recipient_email": "hej@acme.se"
34
+ "success_message": "Tack! Vi återkommer inom 24 timmar."
36
35
  }
37
36
  ```
38
37
 
38
+ There is no `recipient_email` field — submissions land in the portal's
39
+ Submissions inbox (`/app/sites/{siteId}/forms/{formId}/submissions`).
40
+ Email notification is a form *action* (not yet wired platform-side);
41
+ tell the customer to check the inbox.
42
+
39
43
  **Field types:** `text`, `email`, `tel`, `url`, `number`, `textarea`,
40
44
  `select`, `radio`, `checkbox`.
41
45
 
@@ -46,22 +50,28 @@ create_form {
46
50
 
47
51
  ### 2. Get the signed embed token
48
52
 
49
- The form's submit button needs a signed token that proves it was
50
- generated by the platform. Fetch it:
53
+ The submit endpoint only accepts requests carrying a platform-signed
54
+ HMAC token. `create_form` returns it directly; you can also fetch it any
55
+ time with:
51
56
 
52
57
  ```
53
58
  read_form form_id="kontakt"
54
59
  ```
55
60
 
56
- The response includes `submit_token` (a short-lived HMAC). Use this in
57
- the HTML.
58
-
59
- Actually, the embed HTML is best generated by reading the form and
60
- building it manually the form's `id` is all you need for the action URL.
61
+ The response includes `submit_token` (put it in the hidden `_token`
62
+ input) and `submit_url` (use it as the form's `action`). The token is
63
+ stable — it only stops working if the platform rotates its signing
64
+ secret so baking it into static page HTML is correct. If
65
+ `submit_token` comes back `null`, the server has no signing secret
66
+ configured (dev setups); the form cannot accept submissions until that's
67
+ fixed — tell the user instead of embedding a broken form.
61
68
 
62
69
  ### 3. Embed the form on a page
63
70
 
64
- Typeroll forms submit to the platform's endpoint. The HTML:
71
+ A plain HTML form POST is the default and needs **no JavaScript**: the
72
+ endpoint answers a normal form-encoded POST with a small confirmation
73
+ page (the form's `success_message` + a link back to the page the
74
+ visitor came from). Validation errors get the same treatment. The HTML:
65
75
 
66
76
  ```html
67
77
  <section class="contact-section">
@@ -70,11 +80,10 @@ Typeroll forms submit to the platform's endpoint. The HTML:
70
80
  <p>Fyll i formuläret så återkommer vi inom 24 timmar.</p>
71
81
 
72
82
  <form class="contact-form"
73
- action="https://app.typeroll.com/api/forms/submit"
83
+ action="SUBMIT_URL"
74
84
  method="POST">
75
- <!-- Hidden fields required by the platform -->
76
- <input type="hidden" name="_form_id" value="kontakt">
77
- <input type="hidden" name="_site_id" value="YOUR_SITE_ID">
85
+ <!-- The signed token is the only hidden field the platform needs;
86
+ it encodes org + site + form identity. -->
78
87
  <input type="hidden" name="_token" value="SIGNED_TOKEN">
79
88
  <!-- Honeypot — must stay empty, bots fill it -->
80
89
  <input type="text" name="_hp" style="display:none" tabindex="-1" autocomplete="off">
@@ -129,32 +138,50 @@ Typeroll forms submit to the platform's endpoint. The HTML:
129
138
  ```
130
139
 
131
140
  **Replace:**
132
- - `YOUR_SITE_ID` → the site's actual id (from `get_site`)
133
- - `SIGNED_TOKEN` → fetch via `read_form form_id="kontakt"` → `submit_token`
141
+ - `SUBMIT_URL` → `submit_url` from `read_form` / `create_form`
142
+ - `SIGNED_TOKEN` → `submit_token` from the same response
143
+
144
+ ### 4. Inline feedback (optional — requires user action)
134
145
 
135
- ### 4. Add success/error handling (optional JS)
146
+ **The page sanitizer strips inline `<script>` from page and partial
147
+ HTML**, so you cannot ship fetch-based submit handling yourself — a
148
+ `<script>` you put in `html_content` is silently removed. The plain
149
+ POST above is the reliable path; use it.
136
150
 
137
- To show inline feedback without a page reload:
151
+ If the customer wants inline feedback without a page reload, the script
152
+ must go into the site's `scripts_body_end` setting, which only a human
153
+ can edit (Settings → Custom code — it's deliberately excluded from the
154
+ AI tool surface). Hand them this snippet for that box; it intercepts the
155
+ form and POSTs the JSON shape `{ token, data }`:
138
156
 
139
157
  ```html
140
158
  <script>
141
159
  (function(){
142
160
  const form = document.querySelector('.contact-form');
143
161
  if(!form) return;
144
- form.addEventListener('submit', async(e) => {
162
+ form.addEventListener('submit', async (e) => {
145
163
  e.preventDefault();
146
164
  const btn = form.querySelector('[type=submit]');
147
165
  btn.disabled = true;
148
166
  btn.textContent = 'Skickar…';
167
+ // The endpoint's fetch contract is JSON: { token, data }.
168
+ const fd = new FormData(form);
169
+ const token = fd.get('_token');
170
+ const data = {};
171
+ fd.forEach((v, k) => { if (k !== '_token') data[k] = v; });
149
172
  try {
150
- const res = await fetch(form.action, {method:'POST', body: new FormData(form)});
151
- const data = await res.json();
152
- if(data.ok) {
153
- form.innerHTML = '<p class="form-success">Tack! Vi återkommer inom 24 timmar.</p>';
173
+ const res = await fetch(form.action, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({ token, data }),
177
+ });
178
+ const json = await res.json();
179
+ if (res.ok && json.success) {
180
+ form.innerHTML = '<p class="form-success">' + (json.message || 'Tack!') + '</p>';
154
181
  } else {
155
182
  btn.disabled = false;
156
183
  btn.textContent = 'Skicka meddelande';
157
- alert('Något gick fel: ' + (data.error || 'okänt fel'));
184
+ alert('Något gick fel: ' + ((json.errors && json.errors.join(', ')) || json.error || 'okänt fel'));
158
185
  }
159
186
  } catch {
160
187
  btn.disabled = false;
@@ -226,18 +253,19 @@ portal at `/app/sites/{siteId}/forms/kontakt/submissions`.
226
253
 
227
254
  ## Pitfalls
228
255
 
229
- - **Tokens expire.** The `submit_token` in the form embed is short-lived
230
- (24h by default). For forms on long-cached static pages, fetch a fresh
231
- token via `read_form` and rebuild the page HTML, then redeploy.
232
- Alternatively, fetch the token client-side via a small `<script>` that
233
- calls the portal's token endpoint before submit.
234
- - **Field names must be ASCII.** `message` not `meddelande`... wait,
235
- `meddelande` is ASCII. `foretag` not `företag`. Only Swedish/Nordic
236
- special characters (å, ä, ö) cause problems.
256
+ - **Tokens are stable, not expiring.** The `submit_token` stays valid
257
+ until the platform rotates its signing secret (rare, operator-driven).
258
+ Bake it into the static HTML; no refresh logic needed. If submissions
259
+ suddenly 403 after working, re-fetch via `read_form` and republish.
260
+ - **Inline `<script>` does not survive.** The page sanitizer strips it
261
+ from `html_content` never rely on client JS you embed yourself. The
262
+ plain form POST works without it (section 3).
263
+ - **Field names must be lowercase ASCII** (`[a-z][a-z0-9_-]*`):
264
+ `foretag` not `företag`, `amne` not `ämne`. Labels can be anything.
237
265
  - **Don't use the same form_id on two different forms.** IDs must be
238
266
  unique per site — use descriptive names: `kontakt`, `boka`, `nyhetsbrev`.
239
267
  - **Honeypot must be invisible.** `_hp` field must have `display:none`.
240
268
  If it's visible and a real user fills it, their submission is rejected.
241
- - **Recipient email is display-only in phase 1.** The portal stores
242
- submissions; email delivery to `recipient_email` depends on the platform's
243
- email configuration. Confirm with the customer that they'll check the inbox.
269
+ - **No email notifications yet.** Submissions are stored and visible in
270
+ the portal's Submissions inbox; the email action handler isn't wired
271
+ platform-side. Confirm with the customer that they'll check the inbox.