@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 +27 -1
- package/dist/tools/forms.js +7 -5
- package/dist/tools/pages.js +2 -0
- package/dist/tools/settings.js +2 -1
- package/package.json +1 -1
- package/skills/tr-forms.md +63 -35
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` |
|
package/dist/tools/forms.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
|
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),
|
package/dist/tools/pages.js
CHANGED
|
@@ -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
|
package/dist/tools/settings.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/skills/tr-forms.md
CHANGED
|
@@ -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
|
|
50
|
-
|
|
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` (
|
|
57
|
-
the
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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="
|
|
83
|
+
action="SUBMIT_URL"
|
|
74
84
|
method="POST">
|
|
75
|
-
<!--
|
|
76
|
-
|
|
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
|
-
- `
|
|
133
|
-
- `SIGNED_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
|
-
|
|
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
|
-
|
|
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, {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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: ' + (
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
- **
|
|
242
|
-
|
|
243
|
-
|
|
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.
|