@typeroll/mcp-server 0.7.12 → 0.9.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
@@ -287,22 +287,22 @@ first if you're unsure what's writable.
287
287
  ```
288
288
  # Image lives on a URL somewhere (Unsplash, customer's existing CDN):
289
289
  upload_media_from_url source_url="https://..." alt_text="Hero photo of …"
290
- → returns { media_id, cdn_url, … }
290
+ → returns { media_id, cdn_url, finalize: {}, finalize_error: null }
291
291
 
292
292
  # OR image lives in your memory (image-gen output):
293
293
  upload_media_inline filename="hero.png" content_type="image/png"
294
294
  data_base64="iVBORw0KGgo…"
295
295
  → returns the same shape
296
296
 
297
- # OPTIONAL but recommended: produce srcset variants for responsive <img>:
298
- generate_image_variants media_id=<id>
299
- returns webp + avif variants at 320/640/1024/1920 widths
300
- (skips upscales; sub-5s per image)
297
+ # Both tools auto-finalize after PUT: immutable Cache-Control on the
298
+ # original PLUS AVIF/WebP variants at 320/640/1024/1920. No manual
299
+ # generate_image_variants call needed. The site-template renderer reads
300
+ # the variants array off the Media doc and emits <picture> automatically
301
+ # — you can keep the <img src="{cdn_url}"> markup simple.
301
302
 
302
303
  # Then embed in a page:
303
304
  read_page page_id=...
304
- # Build <picture> or <img srcset="..."> using variants[*].cdn_url
305
- update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' .../>" }
305
+ update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' alt='…' /></...>" }
306
306
  ```
307
307
 
308
308
  ### "Fill missing alt-text across the media library"
@@ -438,7 +438,7 @@ stakeholder review.
438
438
  | **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` |
439
439
  | **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |
440
440
  | **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
- | **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` |
441
+ | **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
442
  | **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
443
443
  | **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions` |
444
444
  | **Settings** | `update_site_settings` (whitelist) |
package/README.md CHANGED
@@ -105,9 +105,14 @@ the full reference + concrete operation recipes.
105
105
  itself (incl. `route_template` for per-item URLs); list/read/batch-
106
106
  read/create/update/delete items.
107
107
  - **Media** — list, read, signed upload URLs, `upload_media_from_url`,
108
- `upload_media_inline`, patch metadata, delete, `generate_image_variants`
109
- (build-time srcset pipeline), `suggest_alt_text_context` (returns a
110
- tuned prompt for your own vision model).
108
+ `upload_media_inline` (both auto-finalize after PUT — see below),
109
+ patch metadata, delete, `finalize_media` (per-item: applies immutable
110
+ Cache-Control + generates AVIF/WebP srcset variants — call after
111
+ `create_upload_url`'s raw PUT path), `finalize_all_media` (bulk
112
+ backfill for legacy libraries), `generate_image_variants` (the
113
+ variant half of finalize, kept for surgical reruns),
114
+ `suggest_alt_text_context` (returns a tuned prompt for your own
115
+ vision model).
111
116
  - **Redirects** — list, create, delete. Plus automatic 301 on slug change.
112
117
  - **Forms** — list, read, create, update, delete, list submissions.
113
118
  - **Settings** — read + patch (scripts and custom CSS not exposed).
package/dist/index.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -24,6 +24,7 @@ import { blockTypeTools } from './tools/block-types.js';
24
24
  import { pageBlockTools } from './tools/page-blocks.js';
25
25
  import { settingsTools } from './tools/settings.js';
26
26
  import { siteTools } from './tools/sites.js';
27
+ import { domainTools } from './tools/domain.js';
27
28
  import { fail } from './tools/helpers.js';
28
29
  const PERM_RANK = { read: 0, write: 1, admin: 2 };
29
30
  /**
@@ -73,6 +74,7 @@ export function buildServer(options) {
73
74
  ...versionTools,
74
75
  ...deployTools,
75
76
  ...previewTools,
77
+ ...domainTools,
76
78
  ];
77
79
  // SDK's registerTool has deeply-nested generics we can't unify across a
78
80
  // heterogeneous tool list at compile time — same shrug as stdio's index.ts.
@@ -52,6 +52,14 @@ export const collectionTools = [
52
52
  sort_dir: z.enum(['asc', 'desc']).optional(),
53
53
  route_template: z.string().optional(),
54
54
  item_template_html: z.string().optional(),
55
+ schema_type: z
56
+ .string()
57
+ .optional()
58
+ .describe('Free-form Schema.org type for auto JSON-LD on every item route ("BlogPosting", "PodcastEpisode", "Product", "Event", "Recipe", "Course", …). Pair with schema_field_map when item field names differ from the schema property names.'),
59
+ schema_field_map: z
60
+ .record(z.string())
61
+ .optional()
62
+ .describe('Override the default field→schema-property mapping. Example: { "audio_url": "contentUrl", "show_notes": "description" }. Values use camelCase (Schema.org convention).'),
55
63
  })
56
64
  .passthrough(),
57
65
  version: versionParam,
@@ -0,0 +1,98 @@
1
+ // Custom-domain lifecycle tools. Wrap the four /api/v1/sites/{id}/domain/*
2
+ // routes so an agent can read, attach, poll, activate, or remove a
3
+ // custom domain through MCP. Admin-only on the server side; site-scoped
4
+ // keys are implicitly admin, org-scoped keys forward the share's
5
+ // permission.
6
+ //
7
+ // State machine recap (see docs/domain-lifecycle-plan.md):
8
+ //
9
+ // no domain ──add_domain──▶ pending
10
+ // pending ──poll_domain──▶ pending | verified | failed
11
+ // verified ──activate_domain──▶ live
12
+ // any state ──remove_domain──▶ no domain
13
+ //
14
+ // `production_url` on the site response (list_sites / read_site) only
15
+ // reflects the custom domain when status === 'live'. Until then,
16
+ // agents should send users to the fallback URL.
17
+ import { z } from 'zod';
18
+ import { ok, withErrorBoundary } from './helpers.js';
19
+ export const domainTools = [
20
+ {
21
+ name: 'read_domain',
22
+ description: "Read the current state of the site's custom domain. Returns " +
23
+ '`{ hostname, status, dns_target, verified_at, failure_reason }` ' +
24
+ "or `null` when no domain is attached. Status is one of 'pending' " +
25
+ "(DNS not yet verified), 'verified' (DNS + cert OK, awaiting customer " +
26
+ "activation), 'live' (production URL is now the custom domain), " +
27
+ "'failed' (Cloudflare returned an error — see failure_reason).",
28
+ inputSchema: {},
29
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
30
+ const res = await client.get(siteId, 'domain');
31
+ return ok(res);
32
+ }),
33
+ },
34
+ {
35
+ name: 'add_domain',
36
+ description: "Attach a custom domain to this site. The site MUST NOT already have " +
37
+ "a domain set — remove the existing one first. Returns the new state, " +
38
+ "including the CNAME target the customer must point DNS at. Status " +
39
+ "starts as 'pending'; once DNS is correct + cert is issued, " +
40
+ "`poll_domain` will flip it to 'verified', then `activate_domain` " +
41
+ "makes it production.",
42
+ inputSchema: {
43
+ hostname: z
44
+ .string()
45
+ .describe('Hostname to attach, e.g. "www.example.com". Leading https:// and trailing slashes are stripped.'),
46
+ },
47
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
48
+ const res = await client.post(siteId, 'domain', { hostname: args.hostname });
49
+ return ok(res);
50
+ }),
51
+ },
52
+ {
53
+ name: 'poll_domain',
54
+ description: "Ask Cloudflare for the current state of the attached domain and " +
55
+ "update the site doc accordingly. Idempotent — call after DNS " +
56
+ "changes propagate. Returns the new state; status may have advanced " +
57
+ "from 'pending' to 'verified' or 'failed'.",
58
+ inputSchema: {},
59
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
60
+ const res = await client.post(siteId, 'domain/poll');
61
+ return ok(res);
62
+ }),
63
+ },
64
+ {
65
+ name: 'activate_domain',
66
+ description: "Make the verified domain the production URL. Precondition: status " +
67
+ "must be 'verified' (or already 'live' — idempotent). After this " +
68
+ "call, the site's `production_url` switches from the fallback URL " +
69
+ "to https://<hostname>. " +
70
+ "By default also enqueues a production deploy so the canonical URL, " +
71
+ "sitemap, and JSON-LD update to reflect the new hostname — the " +
72
+ "response includes `deploy.job_id` you can poll. Pass " +
73
+ "`auto_deploy: false` to skip the deploy (the canonical stays at " +
74
+ "the previous URL until the customer deploys manually).",
75
+ inputSchema: {
76
+ auto_deploy: z
77
+ .boolean()
78
+ .optional()
79
+ .describe('Default true. Enqueue a production deploy after activation so canonical URLs update.'),
80
+ },
81
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
82
+ const res = await client.post(siteId, 'domain/activate', args);
83
+ return ok(res);
84
+ }),
85
+ },
86
+ {
87
+ name: 'remove_domain',
88
+ description: "Detach the custom domain from this site. Removes it from Cloudflare " +
89
+ "Pages and clears the site doc. The fallback subdomain remains. " +
90
+ "Use this when the customer wants to start over with a different " +
91
+ "hostname or move the site to a different domain provider.",
92
+ inputSchema: {},
93
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
94
+ const res = await client.del(siteId, 'domain');
95
+ return ok(res);
96
+ }),
97
+ },
98
+ ];
@@ -59,7 +59,7 @@ export const mediaTools = [
59
59
  },
60
60
  {
61
61
  name: 'create_upload_url',
62
- description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. The media doc is pre-created so a follow-up update_media can attach alt_text.',
62
+ description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. REQUIRED follow-up: call `finalize_media` after the PUT succeeds — without it the original ships with no Cache-Control (Lighthouse will flag it) and no responsive variants get generated. The response carries `finalize_url` as a reminder. Prefer `upload_media_from_url` or `upload_media_inline` if you have the bytes in hand — they do the PUT + finalize for you in one call.',
63
63
  inputSchema: {
64
64
  filename: z.string().min(1),
65
65
  content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
@@ -108,12 +108,27 @@ export const mediaTools = [
108
108
  if (!putRes.ok) {
109
109
  throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
110
110
  }
111
+ // 4. Auto-finalize: set immutable Cache-Control on the original and
112
+ // generate AVIF/WebP responsive variants. Best-effort — if it
113
+ // fails (variant pipeline 500, missing env var on the server),
114
+ // the upload itself still succeeded so we return ok with a
115
+ // `finalize_error` flag instead of throwing.
116
+ let finalizeResult = null;
117
+ let finalizeError = null;
118
+ try {
119
+ finalizeResult = await client.post(siteId, `media/${encodeURIComponent(mint.media_id)}/finalize`);
120
+ }
121
+ catch (e) {
122
+ finalizeError = e instanceof Error ? e.message : String(e);
123
+ }
111
124
  return ok({
112
125
  media_id: mint.media_id,
113
126
  cdn_url: mint.cdn_url,
114
127
  filename,
115
128
  content_type: contentType,
116
129
  size_bytes: buf.byteLength,
130
+ finalize: finalizeResult,
131
+ finalize_error: finalizeError,
117
132
  });
118
133
  }),
119
134
  },
@@ -149,12 +164,24 @@ export const mediaTools = [
149
164
  if (!putRes.ok) {
150
165
  throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
151
166
  }
167
+ // Auto-finalize (cache headers + AVIF/WebP variants). See
168
+ // upload_media_from_url for the same pattern + rationale.
169
+ let finalizeResult = null;
170
+ let finalizeError = null;
171
+ try {
172
+ finalizeResult = await client.post(siteId, `media/${encodeURIComponent(mint.media_id)}/finalize`);
173
+ }
174
+ catch (e) {
175
+ finalizeError = e instanceof Error ? e.message : String(e);
176
+ }
152
177
  return ok({
153
178
  media_id: mint.media_id,
154
179
  cdn_url: mint.cdn_url,
155
180
  filename: args.filename,
156
181
  content_type: args.content_type,
157
182
  size_bytes: buf.byteLength,
183
+ finalize: finalizeResult,
184
+ finalize_error: finalizeError,
158
185
  });
159
186
  }),
160
187
  },
@@ -174,13 +201,31 @@ export const mediaTools = [
174
201
  },
175
202
  {
176
203
  name: 'generate_image_variants',
177
- description: 'Run the build-time srcset pipeline for one image: produces webp + avif variants at 320 / 640 / 1024 / 1920 (skipping upscales), uploads them to R2 alongside the original, and patches the media doc with a variants[] array. Synchronous; takes ~1-5s per image. Skips PDFs and non-image media without erroring. Loop this over list_media to rebuild an existing library; call once per image right after upload to keep new media current.',
204
+ description: 'Run the build-time srcset pipeline for one image: produces webp + avif variants at 320 / 640 / 1024 / 1920 (skipping upscales), uploads them to R2 alongside the original, and patches the media doc with a variants[] array. Synchronous; takes ~1-5s per image. Skips PDFs and non-image media without erroring. NOTE: prefer `finalize_media` for new uploads it does this PLUS sets immutable Cache-Control on the original. Use this tool only for surgical variant reruns.',
178
205
  inputSchema: { media_id: z.string() },
179
206
  handler: withErrorBoundary(async (args, { client, siteId }) => {
180
207
  const res = await client.post(siteId, `media/${encodeURIComponent(args.media_id)}/generate-variants`);
181
208
  return ok(res);
182
209
  }),
183
210
  },
211
+ {
212
+ name: 'finalize_media',
213
+ description: 'Post-upload finalize for a single image: (1) applies immutable Cache-Control (public, max-age=31536000) to the R2 original via in-place CopyObject so the CDN caches it for a year, and (2) generates AVIF/WebP responsive variants if not already present. Call this immediately after a successful upload (the presigned PUT does NOT set cache headers — without finalize, Cloudflare defaults to ~4h and every visit re-fetches the original). Idempotent: safe to call twice. Skips variant work when variants already exist on the doc.',
214
+ inputSchema: { media_id: z.string() },
215
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
216
+ const res = await client.post(siteId, `media/${encodeURIComponent(args.media_id)}/finalize`);
217
+ return ok(res);
218
+ }),
219
+ },
220
+ {
221
+ name: 'finalize_all_media',
222
+ description: 'Backfill version of finalize_media: walks the entire media library, applies cache headers + variant generation to every item missing them. Use this once on legacy sites to fix Lighthouse "cache lifetimes" + "image delivery" warnings in one go. Synchronous, can take minutes on large libraries (~1-5s per image needing variants, ~50ms for cache-only). Idempotent.',
223
+ inputSchema: {},
224
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
225
+ const res = await client.post(siteId, 'media/finalize-all');
226
+ return ok(res);
227
+ }),
228
+ },
184
229
  {
185
230
  name: 'suggest_alt_text_context',
186
231
  description: 'Get everything you need to generate good alt-text for a media item: the image URL, a tuned SEO/accessibility prompt, the site\'s content language, and the list of pages where the image is used (with the nearest heading per use site). YOU run the actual vision call with your own model — this endpoint does NOT generate text. After you decide on alt-text, save it with update_media.',
@@ -84,6 +84,8 @@ export const pageTools = [
84
84
  status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
85
85
  seo_title: z.string().optional(),
86
86
  seo_description: z.string().optional(),
87
+ seo_image_alt: z.string().optional(),
88
+ schema_type: z.string().optional().describe('Free-form Schema.org type ("Service", "Course", "Product", …) for auto JSON-LD.'),
87
89
  kind: z.enum(['page', 'article']).optional(),
88
90
  author: z.string().optional(),
89
91
  language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
@@ -115,9 +117,21 @@ export const pageTools = [
115
117
  seo_title: z.string().optional(),
116
118
  seo_description: z.string().optional(),
117
119
  og_image: z.string().optional(),
120
+ seo_image_alt: z.string().optional().describe('Alt text for og:image/twitter:image. Falls back to first <img alt> on the page when unset.'),
118
121
  canonical_url: z.string().optional(),
119
122
  noindex: z.boolean().optional(),
123
+ lastmod_override: z.string().optional().describe('Override the sitemap <lastmod>. Empty string suppresses lastmod for this page entirely.'),
120
124
  json_ld: z.string().optional(),
125
+ 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
+ service: z
127
+ .object({
128
+ price: z.union([z.number(), z.string()]).optional(),
129
+ price_currency: z.string().optional().describe('ISO 4217 (SEK, EUR, USD).'),
130
+ duration: z.string().optional(),
131
+ description: z.string().optional(),
132
+ url: z.string().optional(),
133
+ })
134
+ .optional(),
121
135
  template: z.string().optional(),
122
136
  })
123
137
  .passthrough(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.7.12",
3
+ "version": "0.9.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": {