@timelesscms-com/mcp-server 0.1.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 +319 -0
- package/README.md +135 -0
- package/dist/client.js +103 -0
- package/dist/index.js +112 -0
- package/dist/tools/block-types.js +42 -0
- package/dist/tools/bulk.js +24 -0
- package/dist/tools/collections.js +197 -0
- package/dist/tools/deploy.js +37 -0
- package/dist/tools/forms.js +98 -0
- package/dist/tools/helpers.js +59 -0
- package/dist/tools/media.js +202 -0
- package/dist/tools/pages.js +206 -0
- package/dist/tools/partials.js +108 -0
- package/dist/tools/preview.js +24 -0
- package/dist/tools/redirects.js +40 -0
- package/dist/tools/search.js +22 -0
- package/dist/tools/settings.js +56 -0
- package/dist/tools/sites.js +27 -0
- package/dist/tools/versions.js +52 -0
- package/package.json +43 -0
- package/skills/README.md +63 -0
- package/skills/tcms-content-write.md +105 -0
- package/skills/tcms-directory.md +214 -0
- package/skills/tcms-images.md +152 -0
- package/skills/tcms-migrate-wp.md +151 -0
- package/skills/tcms-redesign-branch.md +149 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Block-type tools. Surface is wired in now so the future block editor
|
|
2
|
+
// drops in without breaking integrations.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
5
|
+
function v(version) {
|
|
6
|
+
return version ? { version } : undefined;
|
|
7
|
+
}
|
|
8
|
+
export const blockTypeTools = [
|
|
9
|
+
{
|
|
10
|
+
name: 'list_block_types',
|
|
11
|
+
description: 'List registered block types with their schemas. Most sites have none today; this fills in when the block editor ships.',
|
|
12
|
+
inputSchema: { version: versionParam },
|
|
13
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
14
|
+
const res = await client.get(siteId, 'block-types', v(args.version));
|
|
15
|
+
return ok(res);
|
|
16
|
+
}),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'read_block_type',
|
|
20
|
+
description: 'Read one block type\'s schema.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type_id: z.string(),
|
|
23
|
+
version: versionParam,
|
|
24
|
+
},
|
|
25
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
26
|
+
const res = await client.get(siteId, `block-types/${encodeURIComponent(args.type_id)}`, v(args.version));
|
|
27
|
+
return ok(res);
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'find_pages_using_block_type',
|
|
32
|
+
description: 'Pages whose block tree contains this block type. Empty for HTML-mode sites (the default today); fills in once block-mode pages exist.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type_id: z.string(),
|
|
35
|
+
version: versionParam,
|
|
36
|
+
},
|
|
37
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
38
|
+
const res = await client.get(siteId, `block-types/${encodeURIComponent(args.type_id)}/usage`, v(args.version));
|
|
39
|
+
return ok(res);
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
function v(version) {
|
|
4
|
+
return version ? { version } : undefined;
|
|
5
|
+
}
|
|
6
|
+
export const bulkTools = [
|
|
7
|
+
{
|
|
8
|
+
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. Writes go through the normal pipeline (SEO transforms + revision snapshots).',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
pattern: z.string().min(1).describe('Literal substring (default) or JS regex source if regex=true.'),
|
|
12
|
+
replacement: z.string(),
|
|
13
|
+
regex: z.boolean().optional().describe('Treat pattern as a regex source. Always case-insensitive + global.'),
|
|
14
|
+
page_ids: z.array(z.string()).optional().describe('Restrict to these page ids. Omit to apply to every matching page.'),
|
|
15
|
+
dry_run: z.boolean().optional(),
|
|
16
|
+
version: versionParam,
|
|
17
|
+
},
|
|
18
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
19
|
+
const { version, ...body } = args;
|
|
20
|
+
const res = await client.post(siteId, 'bulk-replace', body, v(version));
|
|
21
|
+
return ok(res);
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Collections + items (blog, team, events, products, custom).
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
4
|
+
function v(version) {
|
|
5
|
+
return version ? { version } : undefined;
|
|
6
|
+
}
|
|
7
|
+
export const collectionTools = [
|
|
8
|
+
{
|
|
9
|
+
name: 'create_collection',
|
|
10
|
+
description: 'Create a new content collection with a field schema and (optionally) per-item URLs. Pass `route_template` (e.g. "/restaurants/{slug}") to give each published item its own URL — omit to default to "/{name}/{slug}", set to "" to disable per-item URLs entirely.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
name: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
|
|
13
|
+
label_singular: z.string().min(1),
|
|
14
|
+
label_plural: z.string().min(1),
|
|
15
|
+
icon: z.string().optional(),
|
|
16
|
+
fields: z
|
|
17
|
+
.array(z.object({
|
|
18
|
+
name: z.string(),
|
|
19
|
+
label: z.string(),
|
|
20
|
+
type: z.string(),
|
|
21
|
+
required: z.boolean().optional(),
|
|
22
|
+
default: z.unknown().optional(),
|
|
23
|
+
options: z.array(z.string()).optional(),
|
|
24
|
+
}).passthrough())
|
|
25
|
+
.min(1),
|
|
26
|
+
slug_field: z.string().optional(),
|
|
27
|
+
sort_field: z.string().optional(),
|
|
28
|
+
sort_dir: z.enum(['asc', 'desc']).optional(),
|
|
29
|
+
route_template: z.string().optional(),
|
|
30
|
+
item_template_html: z.string().optional(),
|
|
31
|
+
version: versionParam,
|
|
32
|
+
},
|
|
33
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
34
|
+
const { version, ...body } = args;
|
|
35
|
+
const res = await client.post(siteId, 'collections', body, v(version));
|
|
36
|
+
return ok(res);
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
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.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
name: z.string(),
|
|
44
|
+
patch: z
|
|
45
|
+
.object({
|
|
46
|
+
label_singular: z.string().optional(),
|
|
47
|
+
label_plural: z.string().optional(),
|
|
48
|
+
icon: z.string().optional(),
|
|
49
|
+
fields: z.array(z.record(z.unknown())).optional(),
|
|
50
|
+
slug_field: z.string().optional(),
|
|
51
|
+
sort_field: z.string().optional(),
|
|
52
|
+
sort_dir: z.enum(['asc', 'desc']).optional(),
|
|
53
|
+
route_template: z.string().optional(),
|
|
54
|
+
item_template_html: z.string().optional(),
|
|
55
|
+
})
|
|
56
|
+
.passthrough(),
|
|
57
|
+
version: versionParam,
|
|
58
|
+
},
|
|
59
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
60
|
+
const res = await client.patch(siteId, `collections/${encodeURIComponent(args.name)}`, args.patch, v(args.version));
|
|
61
|
+
return ok(res);
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'delete_collection',
|
|
66
|
+
description: 'Delete a collection AND every item in it. Destructive; requires `confirm: true`. Get-the-user-to-confirm workflow recommended.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
name: z.string(),
|
|
69
|
+
confirm: z.literal(true).describe('Must be true to actually delete.'),
|
|
70
|
+
version: versionParam,
|
|
71
|
+
},
|
|
72
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
73
|
+
const res = await client.del(siteId, `collections/${encodeURIComponent(args.name)}`, { confirm: 'true', ...v(args.version) });
|
|
74
|
+
return ok(res);
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'list_collections',
|
|
79
|
+
description: 'List content collections on the site (name, label, icon, field schemas).',
|
|
80
|
+
inputSchema: { version: versionParam },
|
|
81
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
82
|
+
const res = await client.get(siteId, 'collections', v(args.version));
|
|
83
|
+
return ok(res);
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'read_collection',
|
|
88
|
+
description: 'Fetch one collection\'s schema (label, fields, icon).',
|
|
89
|
+
inputSchema: { name: z.string(), version: versionParam },
|
|
90
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
91
|
+
const res = await client.get(siteId, `collections/${encodeURIComponent(args.name)}`, v(args.version));
|
|
92
|
+
return ok(res);
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'list_collection_items',
|
|
97
|
+
description: 'List items in a collection with status filter + cursor pagination (cap 200 per page). Defaults to summary mode (richtext/textarea fields replaced with a byte-count hint) so a 200-item directory listing stays compact; pass include_richtext: true if you actually need full bodies.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
collection: z.string(),
|
|
100
|
+
status: z.enum(['draft', 'published', 'all']).optional(),
|
|
101
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
102
|
+
cursor: z.string().optional(),
|
|
103
|
+
include_richtext: z.boolean().optional().describe('Include full richtext/textarea field values inline. Default false.'),
|
|
104
|
+
version: versionParam,
|
|
105
|
+
},
|
|
106
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
107
|
+
const { collection, version, include_richtext, ...query } = args;
|
|
108
|
+
const res = await client.get(siteId, `collections/${encodeURIComponent(collection)}/items`, { ...query, ...(include_richtext ? {} : { summary: 'true' }), ...v(version) });
|
|
109
|
+
return ok(res);
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'batch_read_collection_items',
|
|
114
|
+
description: 'Read up to 200 collection items in one call. Returns per-id found/not-found.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
collection: z.string(),
|
|
117
|
+
item_ids: z.array(z.string()).min(1).max(200),
|
|
118
|
+
version: versionParam,
|
|
119
|
+
},
|
|
120
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
121
|
+
const res = await client.post(siteId, `collections/${encodeURIComponent(args.collection)}/items/batch-read`, { item_ids: args.item_ids }, v(args.version));
|
|
122
|
+
return ok(res);
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'read_collection_item',
|
|
127
|
+
description: 'Fetch a single item with all fields.',
|
|
128
|
+
inputSchema: { collection: z.string(), item_id: z.string(), version: versionParam },
|
|
129
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
130
|
+
const res = await client.get(siteId, `collections/${encodeURIComponent(args.collection)}/items/${encodeURIComponent(args.item_id)}`, v(args.version));
|
|
131
|
+
return ok(res);
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'create_collection_item',
|
|
136
|
+
description: 'Create an item. Fields outside the collection schema are silently dropped — call list_collections first if you\'re unsure what fields exist.',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
collection: z.string(),
|
|
139
|
+
fields: z.record(z.unknown()),
|
|
140
|
+
status: z.enum(['draft', 'published']).optional(),
|
|
141
|
+
version: versionParam,
|
|
142
|
+
},
|
|
143
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
144
|
+
const { collection, version, ...body } = args;
|
|
145
|
+
const res = await client.post(siteId, `collections/${encodeURIComponent(collection)}/items`, body, v(version));
|
|
146
|
+
return ok(res);
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'update_collection_item',
|
|
151
|
+
description: 'Update a collection item. Fields outside the schema are dropped.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
collection: z.string(),
|
|
154
|
+
item_id: z.string(),
|
|
155
|
+
fields: z.record(z.unknown()).optional(),
|
|
156
|
+
status: z.enum(['draft', 'published']).optional(),
|
|
157
|
+
version: versionParam,
|
|
158
|
+
},
|
|
159
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
160
|
+
const { collection, item_id, version, ...body } = args;
|
|
161
|
+
const res = await client.patch(siteId, `collections/${encodeURIComponent(collection)}/items/${encodeURIComponent(item_id)}`, body, v(version));
|
|
162
|
+
return ok(res);
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: 'regenerate_collection_listing',
|
|
167
|
+
description: 'Refresh a hand-managed listing of collection items on a page. The agent inserts a marker pair `<!-- tcms:listing:{collection} -->` ... `<!-- /tcms:listing:{collection} -->` into a page body once; from then on this tool fills the section between the markers with the current published items, sorted by the collection\'s sort_field. Pass `item_template` to customize per-item HTML — {{field}} substitutes HTML-escaped, {{{field}}} raw, {{url}} resolves through the collection\'s route_template. Defaults to a basic <ul> of links.',
|
|
168
|
+
inputSchema: {
|
|
169
|
+
collection: z.string(),
|
|
170
|
+
page_id: z.string(),
|
|
171
|
+
item_template: z.string().optional()
|
|
172
|
+
.describe('Per-item HTML template. Default: <li><a href="{{url}}">{{title}}</a></li>'),
|
|
173
|
+
wrap_open: z.string().optional().describe('Override the opening wrap element. Default: <ul class="tcms-listing">'),
|
|
174
|
+
wrap_close: z.string().optional().describe('Override the closing wrap element. Default: </ul>'),
|
|
175
|
+
empty_html: z.string().optional().describe('Rendered when no items exist.'),
|
|
176
|
+
sort_field: z.string().optional().describe('Override the collection\'s default sort_field.'),
|
|
177
|
+
sort_dir: z.enum(['asc', 'desc']).optional(),
|
|
178
|
+
status_filter: z.enum(['published', 'all']).optional().describe('Default: published.'),
|
|
179
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
180
|
+
version: versionParam,
|
|
181
|
+
},
|
|
182
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
183
|
+
const { collection, version, ...body } = args;
|
|
184
|
+
const res = await client.post(siteId, `collections/${encodeURIComponent(collection)}/regenerate-listing`, body, v(version));
|
|
185
|
+
return ok(res);
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'delete_collection_item',
|
|
190
|
+
description: 'Delete a collection item.',
|
|
191
|
+
inputSchema: { collection: z.string(), item_id: z.string(), version: versionParam },
|
|
192
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
193
|
+
const res = await client.del(siteId, `collections/${encodeURIComponent(args.collection)}/items/${encodeURIComponent(args.item_id)}`, v(args.version));
|
|
194
|
+
return ok(res);
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
3
|
+
function v(version) {
|
|
4
|
+
return version ? { version } : undefined;
|
|
5
|
+
}
|
|
6
|
+
export const deployTools = [
|
|
7
|
+
{
|
|
8
|
+
name: 'trigger_deploy',
|
|
9
|
+
description: 'Enqueue a deploy of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
environment: z.enum(['production', 'staging']).optional(),
|
|
12
|
+
version: versionParam,
|
|
13
|
+
},
|
|
14
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
15
|
+
const { version, ...body } = args;
|
|
16
|
+
const res = await client.post(siteId, 'deploy', body, v(version));
|
|
17
|
+
return ok(res);
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'list_deploys',
|
|
22
|
+
description: 'Recent deploy jobs, newest first (capped at 50).',
|
|
23
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
24
|
+
const res = await client.get(siteId, 'deploys');
|
|
25
|
+
return ok(res);
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'get_deploy_status',
|
|
30
|
+
description: 'Status of a single deploy job (queued | running | succeeded | failed).',
|
|
31
|
+
inputSchema: { job_id: z.string() },
|
|
32
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
33
|
+
const res = await client.get(siteId, `deploys/${encodeURIComponent(args.job_id)}`);
|
|
34
|
+
return ok(res);
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Forms — agents can create/update/delete forms, and read submissions.
|
|
2
|
+
// The customer-facing submit endpoint is HMAC-signed and lives at
|
|
3
|
+
// /api/forms/submit; the in-portal chat's `get_form_embed` tool produces
|
|
4
|
+
// the snippet customers paste into their pages. This file is the
|
|
5
|
+
// management surface.
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { ok, withErrorBoundary } from './helpers.js';
|
|
8
|
+
const fieldSchema = z.object({
|
|
9
|
+
name: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
|
|
10
|
+
type: z.enum([
|
|
11
|
+
'text', 'email', 'tel', 'url', 'number', 'textarea',
|
|
12
|
+
'select', 'checkbox', 'radio', 'hidden', 'gdpr_consent',
|
|
13
|
+
]),
|
|
14
|
+
label: z.string(),
|
|
15
|
+
required: z.boolean().optional(),
|
|
16
|
+
placeholder: z.string().optional(),
|
|
17
|
+
options: z.array(z.string()).optional().describe('Required for type "select" and "radio".'),
|
|
18
|
+
default: z.unknown().optional(),
|
|
19
|
+
});
|
|
20
|
+
export const formTools = [
|
|
21
|
+
{
|
|
22
|
+
name: 'list_forms',
|
|
23
|
+
description: 'List every form defined on the site with its full field schema.',
|
|
24
|
+
handler: withErrorBoundary(async (_args, { client, siteId }) => {
|
|
25
|
+
const res = await client.get(siteId, 'forms');
|
|
26
|
+
return ok(res);
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'read_form',
|
|
31
|
+
description: 'Read one form by id, including fields, submit_text, and success_message.',
|
|
32
|
+
inputSchema: { form_id: z.string() },
|
|
33
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
34
|
+
const res = await client.get(siteId, `forms/${encodeURIComponent(args.form_id)}`);
|
|
35
|
+
return ok(res);
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'create_form',
|
|
40
|
+
description: 'Create a form. Each field has { name, type, label, required?, placeholder?, options? }. Allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent. The form can then be embedded on any page — use get_form_embed (in-portal chat) or render the form yourself by reading the schema.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
id: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
|
|
43
|
+
name: z.string().min(1),
|
|
44
|
+
fields: z.array(fieldSchema).min(1),
|
|
45
|
+
submit_text: z.string().optional(),
|
|
46
|
+
success_message: z.string().optional(),
|
|
47
|
+
actions: z.array(z.object({ type: z.string(), config: z.record(z.unknown()) })).optional(),
|
|
48
|
+
},
|
|
49
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
50
|
+
const res = await client.post(siteId, 'forms', args);
|
|
51
|
+
return ok(res);
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'update_form',
|
|
56
|
+
description: 'Patch a form. Provide only the fields you want to change. Passing `fields` replaces the entire field list (so reorder + rename + drop happens in one call). The existing form_id is immutable.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
form_id: z.string(),
|
|
59
|
+
patch: z.object({
|
|
60
|
+
name: z.string().optional(),
|
|
61
|
+
fields: z.array(fieldSchema).optional(),
|
|
62
|
+
submit_text: z.string().optional(),
|
|
63
|
+
success_message: z.string().optional(),
|
|
64
|
+
actions: z.array(z.object({ type: z.string(), config: z.record(z.unknown()) })).optional(),
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
68
|
+
const res = await client.patch(siteId, `forms/${encodeURIComponent(args.form_id)}`, args.patch);
|
|
69
|
+
return ok(res);
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'delete_form',
|
|
74
|
+
description: 'Delete a form. Existing submissions are preserved by default — pass delete_submissions: true to also drop them.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
form_id: z.string(),
|
|
77
|
+
delete_submissions: z.boolean().optional(),
|
|
78
|
+
},
|
|
79
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
80
|
+
const res = await client.del(siteId, `forms/${encodeURIComponent(args.form_id)}`, args.delete_submissions ? { delete_submissions: 'true' } : undefined);
|
|
81
|
+
return ok(res);
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'list_form_submissions',
|
|
86
|
+
description: 'List submissions received for a form, newest first, cursor-paginated (cap 200 per page). Use this to help a customer triage inbound contact / lead submissions.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
form_id: z.string(),
|
|
89
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
90
|
+
cursor: z.string().optional(),
|
|
91
|
+
},
|
|
92
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
93
|
+
const { form_id, ...query } = args;
|
|
94
|
+
const res = await client.get(siteId, `forms/${encodeURIComponent(form_id)}/submissions`, query);
|
|
95
|
+
return ok(res);
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Shared helpers for tool handlers.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ApiError } from '../client.js';
|
|
4
|
+
/** Format an arbitrary result as MCP tool output. Pretty-print JSON because
|
|
5
|
+
* Claude renders code blocks well and we want the model to see structured
|
|
6
|
+
* shapes, not blobby unfolded strings. */
|
|
7
|
+
export function ok(data) {
|
|
8
|
+
return {
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Turn an API error into a structured tool error. Preserves the status
|
|
18
|
+
* code so the agent can decide between "retry" and "give up". */
|
|
19
|
+
export function fail(err) {
|
|
20
|
+
if (err instanceof ApiError) {
|
|
21
|
+
return {
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: 'text',
|
|
25
|
+
text: JSON.stringify({
|
|
26
|
+
error: err.message,
|
|
27
|
+
status: err.status,
|
|
28
|
+
body: err.body,
|
|
29
|
+
}, null, 2),
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
isError: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
content: [
|
|
37
|
+
{ type: 'text', text: `Unexpected error: ${err instanceof Error ? err.message : String(err)}` },
|
|
38
|
+
],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Convenience to wrap a handler so all API errors become structured tool
|
|
43
|
+
* errors instead of throwing — Claude Code displays the result more
|
|
44
|
+
* usefully when isError is set. */
|
|
45
|
+
export function withErrorBoundary(handler) {
|
|
46
|
+
return async (args, deps) => {
|
|
47
|
+
try {
|
|
48
|
+
return await handler(args, deps);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return fail(err);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Shared zod fragment for the optional `version` parameter. */
|
|
56
|
+
export const versionParam = z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Branch id to target. Omit for main.');
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Media tools (list + signed upload URL + metadata patch).
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ok, withErrorBoundary } from './helpers.js';
|
|
4
|
+
// Best-effort content-type inference from the URL extension. The server's
|
|
5
|
+
// upload-URL endpoint requires content_type, so we have to pick *something*
|
|
6
|
+
// before the actual fetch — and a bad guess gets corrected by the HEAD
|
|
7
|
+
// response if the agent supplies an override.
|
|
8
|
+
function inferContentType(urlOrFilename) {
|
|
9
|
+
const lower = urlOrFilename.toLowerCase().split('?')[0];
|
|
10
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
|
|
11
|
+
return 'image/jpeg';
|
|
12
|
+
if (lower.endsWith('.png'))
|
|
13
|
+
return 'image/png';
|
|
14
|
+
if (lower.endsWith('.webp'))
|
|
15
|
+
return 'image/webp';
|
|
16
|
+
if (lower.endsWith('.gif'))
|
|
17
|
+
return 'image/gif';
|
|
18
|
+
if (lower.endsWith('.avif'))
|
|
19
|
+
return 'image/avif';
|
|
20
|
+
if (lower.endsWith('.svg'))
|
|
21
|
+
return 'image/svg+xml';
|
|
22
|
+
if (lower.endsWith('.pdf'))
|
|
23
|
+
return 'application/pdf';
|
|
24
|
+
return 'application/octet-stream';
|
|
25
|
+
}
|
|
26
|
+
function filenameFromUrl(url, fallback) {
|
|
27
|
+
if (fallback)
|
|
28
|
+
return fallback;
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url);
|
|
31
|
+
const last = u.pathname.split('/').filter(Boolean).pop();
|
|
32
|
+
if (last)
|
|
33
|
+
return decodeURIComponent(last);
|
|
34
|
+
}
|
|
35
|
+
catch { /* fall through */ }
|
|
36
|
+
return `import-${Date.now()}`;
|
|
37
|
+
}
|
|
38
|
+
export const mediaTools = [
|
|
39
|
+
{
|
|
40
|
+
name: 'list_media',
|
|
41
|
+
description: 'List uploaded media items (CDN URLs, alt text, mime). Newest first, cursor-paginated.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
44
|
+
cursor: z.string().optional(),
|
|
45
|
+
},
|
|
46
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
47
|
+
const res = await client.get(siteId, 'media', { limit: args.limit, cursor: args.cursor });
|
|
48
|
+
return ok(res);
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'read_media',
|
|
53
|
+
description: 'Read one media item\'s metadata by id.',
|
|
54
|
+
inputSchema: { media_id: z.string() },
|
|
55
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
56
|
+
const res = await client.get(siteId, `media/${encodeURIComponent(args.media_id)}`);
|
|
57
|
+
return ok(res);
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
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.',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
filename: z.string().min(1),
|
|
65
|
+
content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
|
|
66
|
+
size: z.number().int().nonnegative().optional(),
|
|
67
|
+
alt_text: z.string().optional(),
|
|
68
|
+
},
|
|
69
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
70
|
+
const res = await client.post(siteId, 'media/upload-url', args);
|
|
71
|
+
return ok(res);
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'upload_media_from_url',
|
|
76
|
+
description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The bytes pass through the agent\'s machine — they do NOT go through the TimelessCMS API — so this works whenever your agent can `fetch()` the source. Returns { media_id, cdn_url, filename }.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
source_url: z.string().url().describe('Public URL to download the image from.'),
|
|
79
|
+
filename: z.string().optional().describe('Override the filename used on R2. Defaults to the last path segment of source_url.'),
|
|
80
|
+
content_type: z.string().optional().describe('Override the inferred content type (e.g. when source_url has no extension).'),
|
|
81
|
+
alt_text: z.string().optional(),
|
|
82
|
+
},
|
|
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 TimelessCMS 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,
|
|
107
|
+
});
|
|
108
|
+
if (!putRes.ok) {
|
|
109
|
+
throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
|
|
110
|
+
}
|
|
111
|
+
return ok({
|
|
112
|
+
media_id: mint.media_id,
|
|
113
|
+
cdn_url: mint.cdn_url,
|
|
114
|
+
filename,
|
|
115
|
+
content_type: contentType,
|
|
116
|
+
size_bytes: buf.byteLength,
|
|
117
|
+
});
|
|
118
|
+
}),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'upload_media_inline',
|
|
122
|
+
description: 'Upload an image (or PDF) directly from base64-encoded bytes — useful when you generated the image with an image-gen tool and have it in memory. Same two-step signed-PUT flow as upload_media_from_url, no temp file needed. Returns { media_id, cdn_url, filename }.',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
filename: z.string().min(1),
|
|
125
|
+
content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
|
|
126
|
+
data_base64: z.string().min(1).describe('The file bytes, base64-encoded (without a data: URL prefix).'),
|
|
127
|
+
alt_text: z.string().optional(),
|
|
128
|
+
},
|
|
129
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
130
|
+
// Tolerate data: URL prefix in case the agent passed one through.
|
|
131
|
+
const raw = args.data_base64.startsWith('data:')
|
|
132
|
+
? args.data_base64.slice(args.data_base64.indexOf(',') + 1)
|
|
133
|
+
: args.data_base64;
|
|
134
|
+
const buf = Uint8Array.from(Buffer.from(raw, 'base64'));
|
|
135
|
+
if (buf.byteLength === 0) {
|
|
136
|
+
throw new Error('data_base64 decoded to zero bytes — check the encoding.');
|
|
137
|
+
}
|
|
138
|
+
const mint = await client.post(siteId, 'media/upload-url', {
|
|
139
|
+
filename: args.filename,
|
|
140
|
+
content_type: args.content_type,
|
|
141
|
+
size: buf.byteLength,
|
|
142
|
+
alt_text: args.alt_text,
|
|
143
|
+
});
|
|
144
|
+
const putRes = await fetch(mint.upload_url, {
|
|
145
|
+
method: 'PUT',
|
|
146
|
+
headers: { 'Content-Type': args.content_type },
|
|
147
|
+
body: buf,
|
|
148
|
+
});
|
|
149
|
+
if (!putRes.ok) {
|
|
150
|
+
throw new Error(`R2 upload failed: ${putRes.status} ${putRes.statusText}`);
|
|
151
|
+
}
|
|
152
|
+
return ok({
|
|
153
|
+
media_id: mint.media_id,
|
|
154
|
+
cdn_url: mint.cdn_url,
|
|
155
|
+
filename: args.filename,
|
|
156
|
+
content_type: args.content_type,
|
|
157
|
+
size_bytes: buf.byteLength,
|
|
158
|
+
});
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'update_media',
|
|
163
|
+
description: 'Patch a media item\'s alt_text or filename. Other fields are immutable.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
media_id: z.string(),
|
|
166
|
+
alt_text: z.string().optional(),
|
|
167
|
+
filename: z.string().optional(),
|
|
168
|
+
},
|
|
169
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
170
|
+
const { media_id, ...body } = args;
|
|
171
|
+
const res = await client.patch(siteId, `media/${encodeURIComponent(media_id)}`, body);
|
|
172
|
+
return ok(res);
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
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.',
|
|
178
|
+
inputSchema: { media_id: z.string() },
|
|
179
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
180
|
+
const res = await client.post(siteId, `media/${encodeURIComponent(args.media_id)}/generate-variants`);
|
|
181
|
+
return ok(res);
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'suggest_alt_text_context',
|
|
186
|
+
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.',
|
|
187
|
+
inputSchema: { media_id: z.string() },
|
|
188
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
189
|
+
const res = await client.get(siteId, `media/${encodeURIComponent(args.media_id)}/alt-text-context`);
|
|
190
|
+
return ok(res);
|
|
191
|
+
}),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'delete_media',
|
|
195
|
+
description: 'Delete a media item\'s metadata. (R2 object cleanup is a separate ops task.)',
|
|
196
|
+
inputSchema: { media_id: z.string() },
|
|
197
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
198
|
+
const res = await client.del(siteId, `media/${encodeURIComponent(args.media_id)}`);
|
|
199
|
+
return ok(res);
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
];
|