@typeroll/mcp-server 0.38.1 → 0.41.1

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/dist/server.js CHANGED
@@ -29,6 +29,7 @@ import { siteTools } from './tools/sites.js';
29
29
  import { domainTools } from './tools/domain.js';
30
30
  import { funnelAttributionTools } from './tools/funnel-attribution.js';
31
31
  import { appTools } from './tools/apps.js';
32
+ import { extensionTools } from './tools/extensions.js';
32
33
  import { skillTools } from './tools/skills.js';
33
34
  import { fail } from './tools/helpers.js';
34
35
  import { VERSION } from './version.js';
@@ -38,14 +39,17 @@ const PERM_RANK = { read: 0, write: 1, admin: 2 };
38
39
  * conservative — anything that mutates is `write`. The MCP route's per-call
39
40
  * gate then checks `effect <= sitePermission`.
40
41
  *
41
- * Naming convention is followed by all 16 tool files: read paths are
42
- * `list_*` / `read_*` / `get_*` / `preview_*` / `search_*`; everything else
42
+ * Naming convention is followed by the tool modules: read paths are
43
+ * `list_*` / `read_*` / `get_*` / `preview_*` / `search_*` / `check_*`; everything else
43
44
  * mutates.
44
45
  */
45
46
  function effectFor(name) {
46
47
  if (name === 'list_apps'
47
48
  || name === 'read_app'
48
49
  || name === 'update_app'
50
+ || name === 'list_extension_installations'
51
+ || name === 'read_extension_installation'
52
+ || name === 'update_extension_installation_config'
49
53
  || name === 'read_funnel_attribution'
50
54
  || name === 'update_funnel_attribution')
51
55
  return 'admin';
@@ -54,7 +58,8 @@ function effectFor(name) {
54
58
  name.startsWith('get_') ||
55
59
  name.startsWith('batch_read_') ||
56
60
  name.startsWith('preview_') ||
57
- name.startsWith('search_')) {
61
+ name.startsWith('search_') ||
62
+ name.startsWith('check_')) {
58
63
  return 'read';
59
64
  }
60
65
  return 'write';
@@ -80,8 +85,11 @@ playbook ships with this server — use it:
80
85
  list_skills FIRST, then read_skill <name> for the step-by-step recipe
81
86
  (tr-new-site, tr-migrate-wp, tr-brand, tr-blog, tr-responsive, …). These are
82
87
  the canonical how-to; don't improvise what a skill already covers.
83
- 2. Discover before you write: get_site, read_site_settings, list_pages,
84
- list_block_types. Never hardcode block ids or field names — they're per-site.
88
+ 2. Discover before you write: get_site, get_site_capabilities,
89
+ read_site_settings, list_pages, list_block_types. Never hardcode block ids
90
+ or field names — they're per-site. For installed Extension config, call
91
+ list_extension_installations then read_extension_installation before an
92
+ update.
85
93
  3. THE BUFFER MODEL: every content write (pages, blocks, partials,
86
94
  collection items) lands in an unsaved per-doc DRAFT — deploys and plain
87
95
  previews see saved content only. Iterate freely, view your drafts with
@@ -125,6 +133,7 @@ export function buildServer(options) {
125
133
  ...formTools,
126
134
  ...settingsTools,
127
135
  ...appTools,
136
+ ...extensionTools,
128
137
  ...funnelAttributionTools,
129
138
  ...searchTools,
130
139
  ...bulkTools,
@@ -4,14 +4,26 @@ function v(version) {
4
4
  return version ? { version } : undefined;
5
5
  }
6
6
  export const bulkTools = [
7
+ {
8
+ name: 'check_internal_links',
9
+ description: 'Check internal hrefs against the versioned database before deploy. Covers published pages, partials, collection templates/items, page templates, generated collection/facet routes, media URLs and redirect chains. Returns every broken source/href pair without requesting the live site.',
10
+ inputSchema: { version: versionParam },
11
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
12
+ return ok(await client.get(siteId, 'internal-links', v(args.version)));
13
+ }),
14
+ },
7
15
  {
8
16
  name: 'bulk_replace_text',
9
- description: 'Replace a literal substring or regex across pages in one call. ALWAYS run with dry_run=true first and show the sample_diffs to the user before running the real call. BUFFER MODEL: replacements land in each page\'s unsaved DRAFT; after the user approves the diffs, run with save:true to commit each touched page (revision snapshots + SEO transforms included). Response: { dry_run, updated, saved, total_matches, pages_with_matches, sample_diffs_shown, additional_pages_with_matches, sample_diffs[], skipped (deprecated) }.',
17
+ description: 'Replace a literal substring or regex across pages, collection-item schema fields, and partials. ALWAYS run with dry_run=true first and show sample_diffs before the real call. Defaults to scope=pages for backwards compatibility. BUFFER MODEL: replacements land in unsaved DRAFTS; save=true commits each touched resource through the canonical save path.',
10
18
  inputSchema: {
11
19
  pattern: z.string().min(1).describe('Literal substring (default) or JS regex source if regex=true.'),
12
20
  replacement: z.string(),
13
21
  regex: z.boolean().optional().describe('Treat pattern as a regex source. Always case-insensitive + global.'),
22
+ scope: z.enum(['pages', 'collection_items', 'partials', 'all']).optional().describe('Resource family to scan. Defaults to pages.'),
14
23
  page_ids: z.array(z.string()).optional().describe('Restrict to these page ids. Omit to apply to every matching page.'),
24
+ collection: z.string().optional().describe('Restrict collection-item replacement to this collection.'),
25
+ item_ids: z.array(z.string()).optional().describe('Restrict to item ids. Requires collection.'),
26
+ partial_ids: z.array(z.string()).optional().describe('Restrict to partial ids.'),
15
27
  dry_run: z.boolean().optional(),
16
28
  save: z.boolean().optional().describe('Commit every touched page\'s draft in the same call — the usual choice after the user approved the dry-run diffs.'),
17
29
  version: versionParam,
@@ -4,6 +4,12 @@ import { ok, withErrorBoundary, versionParam } from './helpers.js';
4
4
  function v(version) {
5
5
  return version ? { version } : undefined;
6
6
  }
7
+ function existingCollectionArg(args) {
8
+ const value = args.collection ?? args.name;
9
+ if (!value)
10
+ throw new Error('collection is required');
11
+ return value;
12
+ }
7
13
  export const collectionTools = [
8
14
  {
9
15
  name: 'create_collection',
@@ -38,9 +44,10 @@ export const collectionTools = [
38
44
  },
39
45
  {
40
46
  name: 'update_collection_schema',
41
- description: 'PATCH a collection\'s schema or routing. Pass only the fields you want to change. Renaming a field would orphan existing item data — adding new fields is safe, removing fields is silently allowed but item docs keep the dropped data.',
47
+ description: 'PATCH a collection\'s schema or routing. `collection` is required (`name` remains a deprecated compatibility alias). Pass only the fields you want to change. Renaming a field would orphan existing item data — adding new fields is safe, removing fields is silently allowed but item docs keep the dropped data.',
42
48
  inputSchema: {
43
- name: z.string(),
49
+ collection: z.string().optional().describe('Collection machine name. Preferred argument name.'),
50
+ name: z.string().optional().describe('Deprecated alias for collection.'),
44
51
  patch: z
45
52
  .object({
46
53
  label_singular: z.string().optional(),
@@ -65,20 +72,23 @@ export const collectionTools = [
65
72
  version: versionParam,
66
73
  },
67
74
  handler: withErrorBoundary(async (args, { client, siteId }) => {
68
- const res = await client.patch(siteId, `collections/${encodeURIComponent(args.name)}`, args.patch, v(args.version));
75
+ const collection = existingCollectionArg(args);
76
+ const res = await client.patch(siteId, `collections/${encodeURIComponent(collection)}`, { patch: args.patch }, v(args.version));
69
77
  return ok(res);
70
78
  }),
71
79
  },
72
80
  {
73
81
  name: 'delete_collection',
74
- description: 'Delete a collection AND every item in it. Destructive; requires `confirm: true`. Get-the-user-to-confirm workflow recommended.',
82
+ description: 'Delete a collection AND every item in it. `collection` is required (`name` remains a deprecated compatibility alias). Destructive; requires `confirm: true`. Get-the-user-to-confirm workflow recommended.',
75
83
  inputSchema: {
76
- name: z.string(),
84
+ collection: z.string().optional().describe('Collection machine name. Preferred argument name.'),
85
+ name: z.string().optional().describe('Deprecated alias for collection.'),
77
86
  confirm: z.literal(true).describe('Must be true to actually delete.'),
78
87
  version: versionParam,
79
88
  },
80
89
  handler: withErrorBoundary(async (args, { client, siteId }) => {
81
- const res = await client.del(siteId, `collections/${encodeURIComponent(args.name)}`, { confirm: 'true', ...v(args.version) });
90
+ const collection = existingCollectionArg(args);
91
+ const res = await client.del(siteId, `collections/${encodeURIComponent(collection)}`, { confirm: 'true', ...v(args.version) });
82
92
  return ok(res);
83
93
  }),
84
94
  },
@@ -93,10 +103,15 @@ export const collectionTools = [
93
103
  },
94
104
  {
95
105
  name: 'read_collection',
96
- description: 'Fetch one collection\'s schema (label, fields, icon).',
97
- inputSchema: { name: z.string(), version: versionParam },
106
+ description: 'Fetch one collection\'s schema (label, fields, icon). `collection` is required; `name` remains a deprecated compatibility alias.',
107
+ inputSchema: {
108
+ collection: z.string().optional().describe('Collection machine name. Preferred argument name.'),
109
+ name: z.string().optional().describe('Deprecated alias for collection.'),
110
+ version: versionParam,
111
+ },
98
112
  handler: withErrorBoundary(async (args, { client, siteId }) => {
99
- const res = await client.get(siteId, `collections/${encodeURIComponent(args.name)}`, v(args.version));
113
+ const collection = existingCollectionArg(args);
114
+ const res = await client.get(siteId, `collections/${encodeURIComponent(collection)}`, v(args.version));
100
115
  return ok(res);
101
116
  }),
102
117
  },
@@ -148,7 +163,7 @@ export const collectionTools = [
148
163
  },
149
164
  {
150
165
  name: 'read_collection_item',
151
- description: 'Fetch a single item with all fields.',
166
+ description: 'Fetch a single item with all fields. item_id may be the internal id or the value of the collection\'s slug_field.',
152
167
  inputSchema: { collection: z.string(), item_id: z.string(), version: versionParam },
153
168
  handler: withErrorBoundary(async (args, { client, siteId }) => {
154
169
  const res = await client.get(siteId, `collections/${encodeURIComponent(args.collection)}/items/${encodeURIComponent(args.item_id)}`, v(args.version));
@@ -172,7 +187,7 @@ export const collectionTools = [
172
187
  },
173
188
  {
174
189
  name: 'update_collection_item',
175
- description: 'Update a collection item. Fields outside the schema are dropped. BUFFER MODEL: field values land in the item\'s unsaved DRAFT (status applies immediately); pass save:true to commit in the same call, or commit_working_copy later. Scheduled publishing (0.29.0+): pass publish_at/unpublish_at INSIDE `fields` — ISO datetime at which the platform flips status (+ deploys the site); null clears; applies immediately like status.',
190
+ description: 'Update a collection item by internal id or slug_field value. Fields outside the schema are dropped. BUFFER MODEL: field values land in the item\'s unsaved DRAFT (status applies immediately); pass save:true to commit in the same call, or commit_working_copy later. Scheduled publishing (0.29.0+): pass publish_at/unpublish_at INSIDE `fields` — ISO datetime at which the platform flips status (+ deploys the site); null clears; applies immediately like status.',
176
191
  inputSchema: {
177
192
  collection: z.string(),
178
193
  item_id: z.string(),
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary } from './helpers.js';
3
+ export const extensionTools = [
4
+ {
5
+ name: 'list_extension_installations',
6
+ description: 'List the site\'s installed Extensions, including installation ids, manifests, config schemas, and masked current config. Read this before updating installation config. Admin permission required.',
7
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
8
+ return ok(await client.get(siteId, 'extensions'));
9
+ }),
10
+ },
11
+ {
12
+ name: 'read_extension_installation',
13
+ description: 'Read one Extension installation, its manifest config schema, and its masked current config. Secret values are never returned. Admin permission required.',
14
+ inputSchema: {
15
+ installation_id: z.string().min(1).describe('Installation id returned by list_extension_installations.'),
16
+ },
17
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
18
+ return ok(await client.get(siteId, `extensions/${encodeURIComponent(args.installation_id)}`));
19
+ }),
20
+ },
21
+ {
22
+ name: 'update_extension_installation_config',
23
+ description: 'Update schema-defined config for an installed Extension. Call read_extension_installation first and send only keys declared by manifest.config_schema. Omitted fields preserve their current values, including masked secrets. This can update public content such as consent text, policy-link text, and policy URLs. The response returns redeploy_required:true; call trigger_deploy separately after the change has been reviewed. Admin permission required.',
24
+ inputSchema: {
25
+ installation_id: z.string().min(1).describe('Installation id returned by list_extension_installations.'),
26
+ config: z.record(z.unknown()).describe('Config keys and values declared by the installation manifest config schema.'),
27
+ },
28
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
29
+ return ok(await client.patch(siteId, `extensions/${encodeURIComponent(args.installation_id)}`, { config: args.config }));
30
+ }),
31
+ },
32
+ ];
@@ -1,6 +1,7 @@
1
1
  // Media tools (list + signed upload URL + metadata patch).
2
2
  import { z } from 'zod';
3
3
  import { ok, withErrorBoundary } from './helpers.js';
4
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
4
5
  // Best-effort content-type inference from the URL extension. The server's
5
6
  // upload-URL endpoint requires content_type, so we have to pick *something*
6
7
  // before the actual fetch — and a bad guess gets corrected by the HEAD
@@ -35,6 +36,65 @@ function filenameFromUrl(url, fallback) {
35
36
  catch { /* fall through */ }
36
37
  return `import-${Date.now()}`;
37
38
  }
39
+ async function readSourceBytes(response) {
40
+ const declared = Number(response.headers.get('content-length'));
41
+ if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
42
+ throw new Error(`Source file too large (max ${MAX_UPLOAD_BYTES} bytes)`);
43
+ }
44
+ if (!response.body)
45
+ return new Uint8Array();
46
+ const reader = response.body.getReader();
47
+ const chunks = [];
48
+ let size = 0;
49
+ while (true) {
50
+ const { done, value } = await reader.read();
51
+ if (done)
52
+ break;
53
+ size += value.byteLength;
54
+ if (size > MAX_UPLOAD_BYTES) {
55
+ await reader.cancel();
56
+ throw new Error(`Source file too large (max ${MAX_UPLOAD_BYTES} bytes)`);
57
+ }
58
+ chunks.push(value);
59
+ }
60
+ const bytes = new Uint8Array(size);
61
+ let offset = 0;
62
+ for (const chunk of chunks) {
63
+ bytes.set(chunk, offset);
64
+ offset += chunk.byteLength;
65
+ }
66
+ return bytes;
67
+ }
68
+ async function uploadFromUrl(args, deps) {
69
+ const filename = filenameFromUrl(args.source_url, args.filename);
70
+ const sourceRes = await fetch(args.source_url);
71
+ if (!sourceRes.ok)
72
+ throw new Error(`Failed to fetch source URL: ${sourceRes.status} ${sourceRes.statusText}`);
73
+ const buf = await readSourceBytes(sourceRes);
74
+ const sourceCt = sourceRes.headers.get('content-type')?.split(';')[0]?.trim();
75
+ const contentType = args.content_type ?? sourceCt ?? inferContentType(filename);
76
+ const mint = await deps.client.post(deps.siteId, 'media/upload-url', {
77
+ filename, content_type: contentType, size: buf.byteLength, alt_text: args.alt_text,
78
+ });
79
+ const putRes = await fetch(mint.upload_url, {
80
+ method: 'PUT', headers: { 'Content-Type': contentType }, body: buf,
81
+ });
82
+ if (!putRes.ok)
83
+ throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
84
+ let finalizeResult = null;
85
+ let finalizeError = null;
86
+ try {
87
+ finalizeResult = await deps.client.post(deps.siteId, `media/${encodeURIComponent(mint.media_id)}/finalize`);
88
+ }
89
+ catch (error) {
90
+ finalizeError = error instanceof Error ? error.message : String(error);
91
+ }
92
+ return {
93
+ media_id: mint.media_id, cdn_url: mint.cdn_url, filename,
94
+ content_type: contentType, size_bytes: buf.byteLength,
95
+ finalize: finalizeResult, finalize_error: finalizeError,
96
+ };
97
+ }
38
98
  export const mediaTools = [
39
99
  {
40
100
  name: 'list_media',
@@ -80,55 +140,44 @@ export const mediaTools = [
80
140
  content_type: z.string().optional().describe('Override the inferred content type (e.g. when source_url has no extension).'),
81
141
  alt_text: z.string().optional(),
82
142
  },
83
- handler: withErrorBoundary(async (args, { client, siteId }) => {
84
- const filename = filenameFromUrl(args.source_url, args.filename);
85
- // 1. Fetch the source bytes on the agent's machine.
86
- const sourceRes = await fetch(args.source_url);
87
- if (!sourceRes.ok) {
88
- throw new Error(`Failed to fetch source URL: ${sourceRes.status} ${sourceRes.statusText}`);
89
- }
90
- const buf = new Uint8Array(await sourceRes.arrayBuffer());
91
- // Prefer the source's content-type header; fall back to extension
92
- // inference; let the caller override the whole thing.
93
- const sourceCt = sourceRes.headers.get('content-type')?.split(';')[0]?.trim();
94
- const contentType = args.content_type ?? sourceCt ?? inferContentType(filename);
95
- // 2. Mint a signed PUT URL through the Typeroll API.
96
- const mint = await client.post(siteId, 'media/upload-url', {
97
- filename,
98
- content_type: contentType,
99
- size: buf.byteLength,
100
- alt_text: args.alt_text,
101
- });
102
- // 3. PUT the bytes straight to R2.
103
- const putRes = await fetch(mint.upload_url, {
104
- method: 'PUT',
105
- headers: { 'Content-Type': contentType },
106
- body: buf,
143
+ handler: withErrorBoundary(async (args, deps) => ok(await uploadFromUrl({
144
+ source_url: String(args.source_url),
145
+ filename: args.filename,
146
+ content_type: args.content_type,
147
+ alt_text: args.alt_text,
148
+ }, deps))),
149
+ },
150
+ {
151
+ name: 'upload_media_batch_from_urls',
152
+ description: 'Upload 1–50 public images/PDFs (max 25 MiB each) in one MCP call. Uses the same integrity-safe download → signed PUT → finalize pipeline as upload_media_from_url, with four concurrent workers. Returns one result per source URL; individual failures do not abort the batch.',
153
+ inputSchema: {
154
+ items: z.array(z.object({
155
+ source_url: z.string().url(),
156
+ filename: z.string().optional(),
157
+ content_type: z.string().optional(),
158
+ alt_text: z.string().optional(),
159
+ })).min(1).max(50),
160
+ },
161
+ handler: withErrorBoundary(async (args, deps) => {
162
+ const results = new Array(args.items.length);
163
+ let cursor = 0;
164
+ const workers = Array.from({ length: Math.min(4, args.items.length) }, async () => {
165
+ while (cursor < args.items.length) {
166
+ const index = cursor++;
167
+ const item = args.items[index];
168
+ try {
169
+ results[index] = { source_url: item.source_url, ok: true, ...(await uploadFromUrl(item, deps)) };
170
+ }
171
+ catch (error) {
172
+ results[index] = { source_url: item.source_url, ok: false, error: error instanceof Error ? error.message : String(error) };
173
+ }
174
+ }
107
175
  });
108
- if (!putRes.ok) {
109
- throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
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
- }
176
+ await Promise.all(workers);
124
177
  return ok({
125
- media_id: mint.media_id,
126
- cdn_url: mint.cdn_url,
127
- filename,
128
- content_type: contentType,
129
- size_bytes: buf.byteLength,
130
- finalize: finalizeResult,
131
- finalize_error: finalizeError,
178
+ results,
179
+ succeeded: results.filter((result) => result.ok === true).length,
180
+ failed: results.filter((result) => result.ok === false).length,
132
181
  });
133
182
  }),
134
183
  },
@@ -2,6 +2,33 @@ import { z } from 'zod';
2
2
  import { ok, withErrorBoundary } from './helpers.js';
3
3
  const STATUS = z.enum(['migrated', 'redirected', 'excluded', 'unhandled']);
4
4
  export const migrationTools = [
5
+ {
6
+ name: 'import_sitemap',
7
+ description: 'Import an explicit sitemap URL into the migration URL inventory. Sitemap indexes are followed recursively; URLs outside source_origin are rejected and reported.',
8
+ inputSchema: {
9
+ url: z.string().url().describe('Absolute URL of a sitemap or sitemap index.'),
10
+ source_origin: z.string().url().optional().describe('Expected legacy-site origin. Defaults to the sitemap origin.'),
11
+ },
12
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
13
+ return ok(await client.post(siteId, 'migration-urls/import-sitemap', args));
14
+ }),
15
+ },
16
+ {
17
+ name: 'import_gsc_performance',
18
+ description: 'Import URL metrics from Google Search Console. Use property for a direct server-side Search Console API query, or csv for the manual export fallback. URL fragments are stripped, duplicate metrics are summed, and previously unknown URLs enter the inventory as unhandled.',
19
+ inputSchema: {
20
+ property: z.string().optional().describe('Search Console property, e.g. https://example.com/ or sc-domain:example.com.'),
21
+ months: z.number().int().min(1).max(16).optional(),
22
+ csv: z.string().optional().describe('Raw Search Console Pages CSV export.'),
23
+ source_origin: z.string().url().optional().describe('Legacy-site origin. Required for bare CSV paths or sc-domain properties.'),
24
+ },
25
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
26
+ if ((typeof args.property === 'string') === (typeof args.csv === 'string')) {
27
+ throw new Error('Provide exactly one of property or csv.');
28
+ }
29
+ return ok(await client.post(siteId, 'migration-urls/import-gsc', args));
30
+ }),
31
+ },
5
32
  {
6
33
  name: 'get_migration_readiness',
7
34
  description: "Preflight for an import: is this site actually ready to receive a migration? CALL THIS FIRST, before moving any content. Every check exists because its failure is INVISIBLE afterwards — the pages import, the previews render, the customer signs off, and something is quietly wrong. The blockers: media storage (without it every <img> keeps its original URL, so the shiny new site is still served images by the old host, and the day that hosting is cancelled every image breaks at once) and the hosting adapter (without credentials, deploys return a job id and publish nothing while reporting success). Warnings cover the pre-cutover verification URL, AI reconstruction, form notification email and whether the target has a design to rebuild INTO. Returns { ready, blockers[], warnings[], checks[] } — each with a `fix`. If `ready` is false, stop and report the blockers to the user rather than starting the import; the content work would have to be redone.",
@@ -75,6 +102,27 @@ export const migrationTools = [
75
102
  return ok(res);
76
103
  }),
77
104
  },
105
+ {
106
+ name: 'update_migration_urls',
107
+ description: 'Apply one shared patch to many inventory entries in a single API request. Select either `ids` (up to 2000) or `where: { source }`, never both. This is the migration-scale path for decisions such as “every wordpress-redirect-guess URL is an intentional 404”; it avoids hundreds of rate-limited PATCH calls. Returns matched/updated/unchanged counts, unknown ids, and the refreshed coverage summary.',
108
+ inputSchema: {
109
+ ids: z.array(z.string()).min(1).max(2000).optional(),
110
+ where: z.object({ source: z.string().min(1) }).optional(),
111
+ patch: z.object({
112
+ excluded: z.boolean().optional(),
113
+ notes: z.string().optional(),
114
+ gsc_clicks: z.number().nonnegative().optional(),
115
+ gsc_impressions: z.number().nonnegative().optional(),
116
+ }).refine((value) => Object.keys(value).length > 0, 'patch must include at least one writable field'),
117
+ },
118
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
119
+ if ((args.ids ? 1 : 0) + (args.where ? 1 : 0) !== 1) {
120
+ throw new Error('Provide exactly one selector: ids or where');
121
+ }
122
+ const res = await client.patch(siteId, 'migration-urls', args);
123
+ return ok(res);
124
+ }),
125
+ },
78
126
  {
79
127
  name: 'delete_migration_url',
80
128
  description: 'Remove an entry from the inventory entirely. Use for junk the crawl picked up (session URLs, faceted duplicates). To record a deliberate 404 instead, prefer update_migration_url with excluded: true — that keeps the decision visible in the coverage report.',
@@ -86,7 +134,7 @@ export const migrationTools = [
86
134
  },
87
135
  {
88
136
  name: 'verify_migration_urls',
89
- description: "Pre-cutover parity check: actually REQUEST every inventory URL against the new site and report what it answers. list_migration_urls tells you what the data says; this tells you what the server does — a redirect pointing at an unpublished page, a typo'd path, or a redirect loop all read as \"handled\" in coverage and as a 404 to Googlebot. Runs against the site's fallback subdomain by default, which is the right target while the real domain still points at the old host. Verdicts: `ok` (200 at the same path), `ok_redirect` (redirects to a 200), `missing` (404/410 the gap), `broken_redirect` (loop or chain ending badly), `error` (5xx/timeout, inconclusive). Also stamps verified/last_checked on the redirect rules it exercised. Deploy before running this — it tests the DEPLOYED site, not your drafts.",
137
+ description: "Pre-cutover parity check: actually REQUEST every inventory URL against the new site and report what it answers. list_migration_urls tells you what the data says; this tells you what the server does — a redirect pointing at an unpublished page, a typo'd path, or a redirect loop all read as \"handled\" in coverage and as a 404 to Googlebot. Runs against the site's fallback subdomain by default. The response is compact by default: the full summary plus only `missing`, `broken_redirect`, and `error` rows; successful rows are counted but omitted. Pass `verdicts` for an exact result filter or `include_successes: true` for every row. Canonical trailing-slash normalization counts as `ok`, not `ok_redirect`. Also stamps verified/last_checked on redirect rules it exercised. Deploy before running this — it tests the DEPLOYED site, not your drafts.",
90
138
  inputSchema: {
91
139
  target_origin: z
92
140
  .string()
@@ -101,6 +149,14 @@ export const migrationTools = [
101
149
  .array(STATUS)
102
150
  .optional()
103
151
  .describe('Only check entries with these coverage statuses. Default: all — "migrated" is exactly the claim this check exists to falsify.'),
152
+ verdicts: z
153
+ .array(z.enum(['ok', 'ok_redirect', 'missing', 'broken_redirect', 'error', 'excluded']))
154
+ .optional()
155
+ .describe('Only return rows with these verdicts. The summary still covers every checked URL.'),
156
+ include_successes: z
157
+ .boolean()
158
+ .optional()
159
+ .describe('Return all rows when verdicts is omitted. Default false: successful/excluded rows are summarized but omitted.'),
104
160
  limit: z.number().int().positive().max(500).optional().describe('Max URLs per run (default 150). `truncated: true` in the response means there are more.'),
105
161
  concurrency: z.number().int().positive().max(12).optional(),
106
162
  },
@@ -83,6 +83,7 @@ export const pageTools = [
83
83
  blocks: z.array(z.any()).optional().describe('Block tree — only used when content_mode="blocks". Omit to get the default heading+prose seed.'),
84
84
  status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
85
85
  seo_title: z.string().optional(),
86
+ append_seo_suffix: z.boolean().optional().describe('Set false to omit the site default SEO suffix on this page.'),
86
87
  seo_description: z.string().optional(),
87
88
  seo_image_alt: z.string().optional(),
88
89
  alternates: z
@@ -123,6 +124,7 @@ export const pageTools = [
123
124
  author: z.string().optional(),
124
125
  language: z.string().optional(),
125
126
  seo_title: z.string().optional(),
127
+ append_seo_suffix: z.boolean().optional().describe('Set false to use seo_title/page title verbatim without the site suffix.'),
126
128
  seo_description: z.string().optional(),
127
129
  og_image: z.string().optional(),
128
130
  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.'),
@@ -224,6 +226,7 @@ export const pageTools = [
224
226
  html_content: srcMode === 'html' ? src.html_content : '',
225
227
  blocks: srcMode === 'blocks' ? (src.blocks ?? []) : undefined,
226
228
  seo_title: src.seo_title,
229
+ append_seo_suffix: src.append_seo_suffix,
227
230
  seo_description: src.seo_description,
228
231
  og_image: src.og_image,
229
232
  canonical_url: undefined, // intentionally NOT copied — point of canonical is to differ
@@ -13,7 +13,7 @@ function v(version) {
13
13
  export const settingsTools = [
14
14
  {
15
15
  name: 'read_site_settings',
16
- description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, default meta description, language, robots_txt, image_sizes_default, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css. Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
16
+ description: "Read every site setting: name, tagline, logo, favicon/app icons, colors, fonts, contact info, social links, URL trailing-slash policy, iframe host allowlist, default SEO suffix/description, language, robots_txt, image_sizes_default, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css. Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
17
17
  inputSchema: {
18
18
  version: versionParam,
19
19
  },
@@ -38,6 +38,9 @@ export const settingsTools = [
38
38
  logo: z.string().optional(),
39
39
  favicon: z.string().optional(),
40
40
  apple_touch_icon: z.string().optional().describe('URL to a 180x180 PNG for iOS/Android home-screen bookmarks. Emitted as <link rel="apple-touch-icon">.'),
41
+ icon_192: z.string().optional().describe('URL to a 192x192 PNG app icon. Emitted with sizes="192x192".'),
42
+ trailing_slash: z.enum(['always', 'never', 'ignore']).optional().describe('Canonical URL style. `always` is the default; `never` emits extensionless URLs without a final slash; `ignore` preserves authored paths.'),
43
+ iframe_allowed_hosts: z.array(z.string()).max(50).optional().describe('Additional exact HTTPS iframe hostnames allowed on this site, e.g. ["player.example.com"]. No wildcards, schemes, ports or paths.'),
41
44
  default_seo_suffix: z.string().optional(),
42
45
  default_meta_description: z.string().optional().describe('Site-wide fallback <meta name="description">. Used when a page has no seo_description of its own; falls back further to the tagline when unset.'),
43
46
  language: z.string().optional().describe('BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> on the rendered site.'),
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.38.1';
11
+ export const VERSION = '0.41.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.38.1",
3
+ "version": "0.41.1",
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": {
@@ -20,6 +20,7 @@
20
20
  ".": "./dist/index.js",
21
21
  "./server": "./src/server.ts",
22
22
  "./client": "./src/client.ts",
23
+ "./version": "./src/version.ts",
23
24
  "./tools/helpers": "./src/tools/helpers.ts"
24
25
  },
25
26
  "files": [
@@ -126,12 +126,12 @@ partial just to keep it off live; that's no longer necessary.
126
126
 
127
127
  ### Site icons — always propose them, never leave them empty
128
128
 
129
- Every site gets a favicon + apple touch icon as part of brand setup:
129
+ Every site gets a favicon + apple touch icon + 192px app icon as part of brand setup:
130
130
 
131
131
  1. **Brand assets exist** (favicon-*.png, app icon, symbol): upload the
132
132
  right sizes via `upload_media_inline` (favicon: 32–64px PNG or SVG;
133
- apple touch icon: 180×180 PNG) and set BOTH in one call:
134
- `update_site_settings { "favicon": "<url>", "apple_touch_icon": "<url>" }`.
133
+ apple touch icon: 180×180 PNG; app icon: 192×192 PNG) and set all three in one call:
134
+ `update_site_settings { "favicon": "<url>", "apple_touch_icon": "<url>", "icon_192": "<url>" }`.
135
135
  2. **No icon assets:** derive a proposal instead of skipping — crop the
136
136
  logo's symbol to a square and resize locally (`sips -z 180 180 in.png
137
137
  --out icon-180.png` on macOS, or ImageMagick), or generate a simple
@@ -5,6 +5,12 @@ description: Use when building a rich per-item detail page for a Typeroll collec
5
5
 
6
6
  # Rich detail templates for collections
7
7
 
8
+ Prefer `item_template_blocks` when the design fits the block system. It can
9
+ include `template/item_navigation`, whose previous/next URLs and titles are
10
+ derived from the collection's `sort_field` and `sort_dir`; do not precompute
11
+ four navigation fields per item. Use the HTML patterns below when the detail
12
+ page genuinely needs richer loops or markup than the block schema provides.
13
+
8
14
  `item_template_html` uses lightweight Mustache substitution:
9
15
 
10
16
  - `{{field}}` — HTML-escaped value
@@ -9,6 +9,13 @@ The Typeroll API does NOT accept image bytes directly. Uploads go
9
9
  through a signed PUT URL straight to Cloudflare R2, and the API only
10
10
  sees the metadata. Two-step flow:
11
11
 
12
+ For public source URLs, prefer `upload_media_from_url`; for 2–50 files use
13
+ `upload_media_batch_from_urls` (max 25 MiB per file). Both download, upload,
14
+ and finalize each asset automatically. Batch results preserve input order and
15
+ report failures per item, so retry only the failed entries. Use the manual
16
+ signed-URL flow below for local files or when you need direct control over the
17
+ PUT.
18
+
12
19
  ## Recipe
13
20
 
14
21
  ### 1. Get an image
@@ -177,7 +177,12 @@ Rules:
177
177
  Then verify against reality before anything is cut over:
178
178
 
179
179
  ```
180
- verify_migration_urls # after trigger_deploy
180
+ import_sitemap url="https://old.example.com/sitemap.xml"
181
+ # Optional: direct Search Console query (platform service account must have property access)
182
+ import_gsc_performance property="https://old.example.com/" months=6
183
+ # Or paste a Search Console Pages CSV via csv="..." and source_origin.
184
+ check_internal_links # database preflight before deploy
185
+ verify_migration_urls # after trigger_deploy; compact exceptions by default
181
186
  ```
182
187
 
183
188
  ### 5. Preview + review with the user