@typeroll/mcp-server 0.38.0 → 0.41.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 +30 -6
- package/README.md +32 -8
- package/dist/bundled-content.js +8 -8
- package/dist/extension-cli.js +14 -6
- package/dist/server.js +14 -5
- package/dist/tools/bulk.js +13 -1
- package/dist/tools/collections.js +26 -11
- package/dist/tools/extensions.js +32 -0
- package/dist/tools/media.js +96 -47
- package/dist/tools/migration.js +57 -1
- package/dist/tools/pages.js +3 -0
- package/dist/tools/settings.js +4 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/skills/tr-brand.md +3 -3
- package/skills/tr-collection-template.md +6 -0
- package/skills/tr-images.md +7 -0
- package/skills/tr-migrate-wp.md +6 -1
- package/skills/tr-new-site.md +74 -14
- package/skills/tr-seo.md +6 -0
package/dist/extension-cli.js
CHANGED
|
@@ -67,6 +67,19 @@ function executionOrigins(manifest) {
|
|
|
67
67
|
urls.push(value);
|
|
68
68
|
return [...new Set(urls.map((value) => new URL(value).origin))];
|
|
69
69
|
}
|
|
70
|
+
export function extensionMetadataUpdate(manifest, registered, manifestOrigins) {
|
|
71
|
+
const trusted = Array.isArray(registered.trusted_origins)
|
|
72
|
+
? registered.trusted_origins.map(String)
|
|
73
|
+
: [];
|
|
74
|
+
const update = {
|
|
75
|
+
name: manifest.name,
|
|
76
|
+
trusted_origins: [...new Set([...trusted, ...manifestOrigins])],
|
|
77
|
+
};
|
|
78
|
+
if (registered.distribution !== manifest.distribution) {
|
|
79
|
+
update.distribution = manifest.distribution;
|
|
80
|
+
}
|
|
81
|
+
return update;
|
|
82
|
+
}
|
|
70
83
|
async function api(path, init = {}, allowed = []) {
|
|
71
84
|
const baseUrl = process.env.TYPEROLL_API_URL?.trim().replace(/\/$/, '');
|
|
72
85
|
const apiKey = process.env.TYPEROLL_API_KEY?.trim();
|
|
@@ -126,14 +139,9 @@ export async function runExtensionCli(args) {
|
|
|
126
139
|
}
|
|
127
140
|
else {
|
|
128
141
|
const registered = current.data.extension;
|
|
129
|
-
const trusted = Array.isArray(registered?.trusted_origins) ? registered.trusted_origins.map(String) : [];
|
|
130
142
|
await api(`/api/developer/extensions/${encodeURIComponent(extensionId)}`, {
|
|
131
143
|
method: 'PATCH',
|
|
132
|
-
body: JSON.stringify(
|
|
133
|
-
name: manifest.name,
|
|
134
|
-
distribution: manifest.distribution,
|
|
135
|
-
trusted_origins: [...new Set([...trusted, ...executionOrigins(manifest)])],
|
|
136
|
-
}),
|
|
144
|
+
body: JSON.stringify(extensionMetadataUpdate(manifest, registered, executionOrigins(manifest))),
|
|
137
145
|
});
|
|
138
146
|
}
|
|
139
147
|
const pushed = await api(`/api/developer/extensions/${encodeURIComponent(extensionId)}/versions`, {
|
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
|
|
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,
|
|
84
|
-
list_block_types. Never hardcode block ids
|
|
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,
|
package/dist/tools/bulk.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
+
];
|
package/dist/tools/media.js
CHANGED
|
@@ -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,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
},
|
package/dist/tools/migration.js
CHANGED
|
@@ -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
|
|
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
|
},
|
package/dist/tools/pages.js
CHANGED
|
@@ -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
|
package/dist/tools/settings.js
CHANGED
|
@@ -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,
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeroll/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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": {
|
|
@@ -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": [
|
package/skills/tr-brand.md
CHANGED
|
@@ -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
|
|
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
|
package/skills/tr-images.md
CHANGED
|
@@ -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
|
package/skills/tr-migrate-wp.md
CHANGED
|
@@ -177,7 +177,12 @@ Rules:
|
|
|
177
177
|
Then verify against reality before anything is cut over:
|
|
178
178
|
|
|
179
179
|
```
|
|
180
|
-
|
|
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
|