@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.
@@ -0,0 +1,206 @@
1
+ // Pages tools. Read + write + batch + blocks-view + preview.
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 pageTools = [
8
+ {
9
+ name: 'list_pages',
10
+ description: 'List pages on the active site. Returns id, title, slug, status, and SEO summary. Supports filtering by status and forward-cursor pagination.',
11
+ inputSchema: {
12
+ status: z.enum(['draft', 'review', 'unlisted', 'published', 'all']).optional(),
13
+ limit: z.number().int().min(1).max(200).optional(),
14
+ cursor: z.string().optional(),
15
+ version: versionParam,
16
+ },
17
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
18
+ const res = await client.get(siteId, 'pages', {
19
+ status: args.status,
20
+ limit: args.limit,
21
+ cursor: args.cursor,
22
+ ...v(args.version),
23
+ });
24
+ return ok(res);
25
+ }),
26
+ },
27
+ {
28
+ name: 'read_page',
29
+ description: 'Fetch one page in full — title, slug, status, html_content, SEO fields.',
30
+ inputSchema: {
31
+ page_id: z.string(),
32
+ version: versionParam,
33
+ },
34
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
35
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}`, v(args.version));
36
+ return ok(res);
37
+ }),
38
+ },
39
+ {
40
+ name: 'batch_read_pages',
41
+ description: 'Read up to 200 pages in a single call. Use to bulk-load context before a redesign sweep.',
42
+ inputSchema: {
43
+ page_ids: z.array(z.string()).min(1).max(200),
44
+ version: versionParam,
45
+ },
46
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
47
+ const res = await client.post(siteId, 'pages/batch-read', { page_ids: args.page_ids }, v(args.version));
48
+ return ok(res);
49
+ }),
50
+ },
51
+ {
52
+ name: 'create_page',
53
+ description: 'Create a new HTML-mode page. Slug is derived from title if omitted; default status is "draft". Returns the created page.',
54
+ inputSchema: {
55
+ title: z.string().min(1),
56
+ slug: z.string().optional(),
57
+ html_content: z.string().optional(),
58
+ status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
59
+ seo_title: z.string().optional(),
60
+ seo_description: z.string().optional(),
61
+ kind: z.enum(['page', 'article']).optional(),
62
+ author: z.string().optional(),
63
+ language: z.string().optional().describe('BCP-47 tag overriding the site default (e.g. "en" on an otherwise Swedish site).'),
64
+ version: versionParam,
65
+ },
66
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
67
+ const { version, ...body } = args;
68
+ const res = await client.post(siteId, 'pages', body, v(version));
69
+ return ok(res);
70
+ }),
71
+ },
72
+ {
73
+ name: 'update_page',
74
+ description: 'Shallow-merge update on a page (only the fields you pass change). Returns the updated page. For "replace this page entirely", use replace_page instead.',
75
+ inputSchema: {
76
+ page_id: z.string(),
77
+ patch: z
78
+ .object({
79
+ title: z.string().optional(),
80
+ slug: z.string().optional(),
81
+ html_content: z.string().optional(),
82
+ status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
83
+ kind: z.enum(['page', 'article']).optional(),
84
+ author: z.string().optional(),
85
+ language: z.string().optional(),
86
+ seo_title: z.string().optional(),
87
+ seo_description: z.string().optional(),
88
+ og_image: z.string().optional(),
89
+ canonical_url: z.string().optional(),
90
+ noindex: z.boolean().optional(),
91
+ json_ld: z.string().optional(),
92
+ template: z.string().optional(),
93
+ })
94
+ .passthrough(),
95
+ version: versionParam,
96
+ },
97
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
98
+ const res = await client.patch(siteId, `pages/${encodeURIComponent(args.page_id)}`, args.patch, v(args.version));
99
+ return ok(res);
100
+ }),
101
+ },
102
+ {
103
+ name: 'replace_page',
104
+ description: 'Full replace of a page\'s writable fields (PUT). Omitted fields are reset to undefined; use update_page if you want shallow merge.',
105
+ inputSchema: {
106
+ page_id: z.string(),
107
+ page: z.object({ title: z.string().min(1) }).passthrough(),
108
+ version: versionParam,
109
+ },
110
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
111
+ const res = await client.put(siteId, `pages/${encodeURIComponent(args.page_id)}`, args.page, v(args.version));
112
+ return ok(res);
113
+ }),
114
+ },
115
+ {
116
+ name: 'batch_update_pages',
117
+ description: 'Apply per-page patches in one call (up to 200 entries). Each entry is { page_id, patch }; failures are reported per-row, the rest still apply.',
118
+ inputSchema: {
119
+ updates: z
120
+ .array(z.object({
121
+ page_id: z.string(),
122
+ patch: z.record(z.unknown()),
123
+ }))
124
+ .min(1)
125
+ .max(200),
126
+ version: versionParam,
127
+ },
128
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
129
+ const res = await client.post(siteId, 'pages/batch-write', args.updates, v(args.version));
130
+ return ok(res);
131
+ }),
132
+ },
133
+ {
134
+ name: 'clone_page',
135
+ description: 'Duplicate an existing page under a new title + slug. Use this when you want a near-copy of an existing page as the starting point (e.g. duplicate "Privatflytt" → "Privatflytt Härnösand"). The new page is created as a draft regardless of the source\'s status. Returns the created page.',
136
+ inputSchema: {
137
+ source_page_id: z.string(),
138
+ title: z.string().min(1).describe('Title for the new page.'),
139
+ slug: z.string().optional().describe('Slug for the new page. Omit to auto-derive from title.'),
140
+ version: versionParam,
141
+ },
142
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
143
+ // 1. Read the source — full body + SEO fields.
144
+ const sourceRes = await client.get(siteId, `pages/${encodeURIComponent(args.source_page_id)}`, v(args.version));
145
+ const src = sourceRes.page;
146
+ // 2. Build the new page body. Title + slug from args, everything else
147
+ // copied — except the new page always starts as a draft so cloning
148
+ // a published page doesn't accidentally publish a half-edited copy.
149
+ const body = {
150
+ title: args.title,
151
+ html_content: src.html_content,
152
+ seo_title: src.seo_title,
153
+ seo_description: src.seo_description,
154
+ og_image: src.og_image,
155
+ canonical_url: undefined, // intentionally NOT copied — point of canonical is to differ
156
+ noindex: src.noindex,
157
+ kind: src.kind,
158
+ author: src.author,
159
+ json_ld: undefined, // structured data is usually page-specific; force re-author
160
+ template: src.template,
161
+ status: 'draft',
162
+ };
163
+ if (args.slug)
164
+ body.slug = args.slug;
165
+ // 3. Create. The server auto-uniques the slug if it clashes.
166
+ const created = await client.post(siteId, 'pages', body, v(args.version));
167
+ return ok(created);
168
+ }),
169
+ },
170
+ {
171
+ name: 'delete_page',
172
+ description: 'Delete a page (tombstoned on branches, removed on main).',
173
+ inputSchema: {
174
+ page_id: z.string(),
175
+ version: versionParam,
176
+ },
177
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
178
+ const res = await client.del(siteId, `pages/${encodeURIComponent(args.page_id)}`, v(args.version));
179
+ return ok(res);
180
+ }),
181
+ },
182
+ {
183
+ name: 'get_page_blocks',
184
+ description: 'Get the block hierarchy for a page. For today\'s HTML-mode pages returns { content_mode: "html", html_content }; once the block editor lands, block-mode pages return { content_mode: "blocks", blocks: [...] }.',
185
+ inputSchema: {
186
+ page_id: z.string(),
187
+ version: versionParam,
188
+ },
189
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
190
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/blocks`, v(args.version));
191
+ return ok(res);
192
+ }),
193
+ },
194
+ {
195
+ name: 'get_page_preview',
196
+ description: 'Get rendered HTML for one page (header-authed read). Returns { rendered_html, internal_links[] }. For a clickable preview URL use get_preview_link instead.',
197
+ inputSchema: {
198
+ page_id: z.string(),
199
+ version: versionParam,
200
+ },
201
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
202
+ const res = await client.get(siteId, `pages/${encodeURIComponent(args.page_id)}/preview`, v(args.version));
203
+ return ok(res);
204
+ }),
205
+ },
206
+ ];
@@ -0,0 +1,108 @@
1
+ // Partials (global blocks) tools.
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 partialTools = [
8
+ {
9
+ name: 'list_partials',
10
+ description: 'List global blocks (header, footer, free blocks). Defaults to summary mode (no html_content, just metadata + byte count) to keep agent context lean — pass include_content: true if you actually want the bodies inline, otherwise call read_partial on the one you care about.',
11
+ inputSchema: {
12
+ include_content: z.boolean().optional().describe('Include full html_content in the response. Default false.'),
13
+ version: versionParam,
14
+ },
15
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
16
+ const res = await client.get(siteId, 'partials', {
17
+ ...(args.include_content ? {} : { summary: 'true' }),
18
+ ...v(args.version),
19
+ });
20
+ return ok(res);
21
+ }),
22
+ },
23
+ {
24
+ name: 'read_partial',
25
+ description: 'Fetch one global block including its full HTML content.',
26
+ inputSchema: {
27
+ partial_id: z.string(),
28
+ version: versionParam,
29
+ },
30
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
31
+ const res = await client.get(siteId, `partials/${encodeURIComponent(args.partial_id)}`, v(args.version));
32
+ return ok(res);
33
+ }),
34
+ },
35
+ {
36
+ name: 'update_partial',
37
+ description: 'Shallow-merge update on a global block. Use partial_id "header" or "footer" for the auto-injected layout blocks, or a kebab-case id for a free block. HTML is sanitized server-side.',
38
+ inputSchema: {
39
+ partial_id: z.string(),
40
+ patch: z
41
+ .object({
42
+ name: z.string().optional(),
43
+ html_content: z.string().optional(),
44
+ status: z.enum(['draft', 'published']).optional(),
45
+ kind: z.enum(['header', 'footer', 'free']).optional(),
46
+ })
47
+ .passthrough(),
48
+ version: versionParam,
49
+ },
50
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
51
+ const res = await client.patch(siteId, `partials/${encodeURIComponent(args.partial_id)}`, args.patch, v(args.version));
52
+ return ok(res);
53
+ }),
54
+ },
55
+ {
56
+ name: 'replace_partial',
57
+ description: 'Full replace of a global block (PUT). Use this when you want to set every field including auto-inferring kind from id.',
58
+ inputSchema: {
59
+ partial_id: z.string(),
60
+ partial: z.object({ html_content: z.string() }).passthrough(),
61
+ version: versionParam,
62
+ },
63
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
64
+ const res = await client.put(siteId, `partials/${encodeURIComponent(args.partial_id)}`, args.partial, v(args.version));
65
+ return ok(res);
66
+ }),
67
+ },
68
+ {
69
+ name: 'create_free_block',
70
+ description: 'Create a new free (reusable) global block. Use a kebab-case id and embed it on pages with <x-include name="block-id" />.',
71
+ inputSchema: {
72
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/),
73
+ name: z.string().optional(),
74
+ html_content: z.string(),
75
+ status: z.enum(['draft', 'published']).optional(),
76
+ version: versionParam,
77
+ },
78
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
79
+ const { version, ...body } = args;
80
+ const res = await client.post(siteId, 'partials', body, v(version));
81
+ return ok(res);
82
+ }),
83
+ },
84
+ {
85
+ name: 'delete_partial',
86
+ description: 'Delete a free global block. header and footer cannot be deleted (they are layout blocks).',
87
+ inputSchema: {
88
+ partial_id: z.string(),
89
+ version: versionParam,
90
+ },
91
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
92
+ const res = await client.del(siteId, `partials/${encodeURIComponent(args.partial_id)}`, v(args.version));
93
+ return ok(res);
94
+ }),
95
+ },
96
+ {
97
+ name: 'find_pages_using_block',
98
+ description: 'Pages that embed a given block via <x-include name="…">. For partial_id "header" or "footer" returns the full page list (they are auto-injected on every page). Use this to know the blast radius before editing a shared block.',
99
+ inputSchema: {
100
+ partial_id: z.string(),
101
+ version: versionParam,
102
+ },
103
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
104
+ const res = await client.get(siteId, `partials/${encodeURIComponent(args.partial_id)}/usage`, v(args.version));
105
+ return ok(res);
106
+ }),
107
+ },
108
+ ];
@@ -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 previewTools = [
7
+ {
8
+ name: 'get_preview_link',
9
+ description: 'Mint a short-lived signed URL the user (or your own browser tool) can open to SEE the rendered preview. Internal links inside the preview keep the same token attached, so the agent can navigate the whole site from one mint. Default TTL 15 min, max 24h. Target the preview at a page (page_id), a collection item (collection_name + item_id, resolves via route_template), or a raw slug. Omit all to land on the home page.',
10
+ inputSchema: {
11
+ page_id: z.string().optional(),
12
+ slug: z.string().optional(),
13
+ collection_name: z.string().optional(),
14
+ item_id: z.string().optional(),
15
+ ttl_seconds: z.number().int().min(60).max(86_400).optional(),
16
+ version: versionParam,
17
+ },
18
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
19
+ const { version, ...body } = args;
20
+ const res = await client.post(siteId, 'preview-link', body, v(version));
21
+ return ok(res);
22
+ }),
23
+ },
24
+ ];
@@ -0,0 +1,40 @@
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 redirectTools = [
7
+ {
8
+ name: 'list_redirects',
9
+ description: 'List redirect rules (from_path → to_path, status code).',
10
+ inputSchema: { version: versionParam },
11
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
12
+ const res = await client.get(siteId, 'redirects', v(args.version));
13
+ return ok(res);
14
+ }),
15
+ },
16
+ {
17
+ name: 'create_redirect',
18
+ description: 'Create a redirect rule. Defaults to 301; pass status_code=302 for a temporary redirect.',
19
+ inputSchema: {
20
+ from_path: z.string().describe('Old path, leading slash (e.g. "/old-about")'),
21
+ to_path: z.string().describe('Target path or absolute URL'),
22
+ status_code: z.union([z.literal(301), z.literal(302)]).optional(),
23
+ version: versionParam,
24
+ },
25
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
26
+ const { version, ...body } = args;
27
+ const res = await client.post(siteId, 'redirects', body, v(version));
28
+ return ok(res);
29
+ }),
30
+ },
31
+ {
32
+ name: 'delete_redirect',
33
+ description: 'Remove a redirect rule.',
34
+ inputSchema: { redirect_id: z.string(), version: versionParam },
35
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
36
+ const res = await client.del(siteId, `redirects/${encodeURIComponent(args.redirect_id)}`, v(args.version));
37
+ return ok(res);
38
+ }),
39
+ },
40
+ ];
@@ -0,0 +1,22 @@
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 searchTools = [
7
+ {
8
+ name: 'search_pages',
9
+ description: 'Search page bodies by literal substring or regex. Returns up to 500 matches each with an excerpt around the first hit. Use this to scope a redesign or a bulk replacement before running it.',
10
+ inputSchema: {
11
+ contains: z.string().optional().describe('Case-insensitive literal substring'),
12
+ regex: z.string().optional().describe('JS regex source (without slashes), case-insensitive'),
13
+ limit: z.number().int().min(1).max(500).optional(),
14
+ version: versionParam,
15
+ },
16
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
17
+ const { version, ...query } = args;
18
+ const res = await client.get(siteId, 'search', { ...query, ...v(version) });
19
+ return ok(res);
20
+ }),
21
+ },
22
+ ];
@@ -0,0 +1,56 @@
1
+ // Settings tools. scripts_head, scripts_body_end, and custom_css are NOT
2
+ // exposed — the server omits them on read and silently drops them on
3
+ // write. Customers edit those manually in /app/sites/{id}/settings.
4
+ import { z } from 'zod';
5
+ import { ok, withErrorBoundary } from './helpers.js';
6
+ export const settingsTools = [
7
+ {
8
+ name: 'read_site_settings',
9
+ description: 'Read the site\'s name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix.',
10
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
11
+ const res = await client.get(siteId, 'settings');
12
+ return ok(res);
13
+ }),
14
+ },
15
+ {
16
+ name: 'update_site_settings',
17
+ description: 'Patch site settings. Only the fields you pass change. Nested objects (colors, fonts, contact, social) are shallow-merged. scripts_* and custom_css are not writable.',
18
+ inputSchema: {
19
+ site_name: z.string().optional(),
20
+ tagline: z.string().optional(),
21
+ logo: z.string().optional(),
22
+ favicon: z.string().optional(),
23
+ default_seo_suffix: z.string().optional(),
24
+ language: z.string().optional().describe('BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> on the rendered site.'),
25
+ robots_txt: z.string().optional(),
26
+ colors: z.record(z.string()).optional(),
27
+ fonts: z.record(z.unknown()).optional(),
28
+ contact: z
29
+ .object({
30
+ email: z.string().optional(),
31
+ phone: z.string().optional(),
32
+ // address can be a plain string (legacy) OR a structured
33
+ // PostalAddress for rich Schema.org JSON-LD.
34
+ address: z
35
+ .union([
36
+ z.string(),
37
+ z.object({
38
+ street_address: z.string().optional(),
39
+ postal_code: z.string().optional(),
40
+ address_locality: z.string().optional(),
41
+ address_region: z.string().optional(),
42
+ address_country: z.string().optional(),
43
+ }).passthrough(),
44
+ ])
45
+ .optional(),
46
+ })
47
+ .passthrough()
48
+ .optional(),
49
+ social: z.record(z.string()).optional(),
50
+ },
51
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
52
+ const res = await client.patch(siteId, 'settings', args);
53
+ return ok(res);
54
+ }),
55
+ },
56
+ ];
@@ -0,0 +1,27 @@
1
+ // Sites tools — discovery + Site-level identity edits.
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary } from './helpers.js';
4
+ export const siteTools = [
5
+ {
6
+ name: 'get_site',
7
+ description: 'Read this site\'s metadata (id, name, slug, domain, active version) + a urls object covering the production / fallback / preview_base URLs. Useful as a first call to confirm the key is wired up and to learn what URLs the site is reachable at.',
8
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
9
+ const res = await client.get(siteId, '');
10
+ return ok(res);
11
+ }),
12
+ },
13
+ {
14
+ name: 'update_site',
15
+ description: 'Edit Site-level identity fields: name (display), slug (drives the {slug}.timelesscms-fallback subdomain — kebab-case, 3-48 chars, unique across the org), domain (the customer\'s real hostname; pass "" to clear). For colors / fonts / contact info / tagline use update_site_settings instead.',
16
+ inputSchema: {
17
+ name: z.string().min(1).optional(),
18
+ slug: z.string().optional().describe('Kebab-case identifier, 3-48 chars [a-z0-9-]. Empty string clears.'),
19
+ domain: z.string().optional().describe('Bare hostname e.g. "example.com". Empty string clears.'),
20
+ language: z.string().optional().describe('Default content language as a BCP-47 tag (e.g. "en", "sv", "en-GB"). Drives <html lang> and the default for alt-text generation. Empty string clears.'),
21
+ },
22
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
23
+ const res = await client.patch(siteId, '', args);
24
+ return ok(res);
25
+ }),
26
+ },
27
+ ];
@@ -0,0 +1,52 @@
1
+ // Version (branch) tools.
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary } from './helpers.js';
4
+ export const versionTools = [
5
+ {
6
+ name: 'list_versions',
7
+ description: 'List versions (main + branches).',
8
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
9
+ const res = await client.get(siteId, 'versions');
10
+ return ok(res);
11
+ }),
12
+ },
13
+ {
14
+ name: 'create_branch',
15
+ description: 'Create a copy-on-write branch from main (or the version passed in `base`). New branches default to robots_blocked:true. Pass the new branch\'s id as ?version= on subsequent calls to read/write against it.',
16
+ inputSchema: {
17
+ name: z.string().min(1),
18
+ base: z.string().optional().describe('Source version id; defaults to main.'),
19
+ },
20
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
21
+ const res = await client.post(siteId, 'versions', args);
22
+ return ok(res);
23
+ }),
24
+ },
25
+ {
26
+ name: 'read_version',
27
+ description: 'Read one version\'s metadata.',
28
+ inputSchema: { version_id: z.string() },
29
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
30
+ const res = await client.get(siteId, `versions/${encodeURIComponent(args.version_id)}`);
31
+ return ok(res);
32
+ }),
33
+ },
34
+ {
35
+ name: 'delete_branch',
36
+ description: 'Discard a branch (main cannot be deleted).',
37
+ inputSchema: { version_id: z.string() },
38
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
39
+ const res = await client.del(siteId, `versions/${encodeURIComponent(args.version_id)}`);
40
+ return ok(res);
41
+ }),
42
+ },
43
+ {
44
+ name: 'merge_branch',
45
+ description: 'Promote a branch\'s overrides + tombstones onto main. The branch is left in place so you can keep iterating; delete_branch removes it when you\'re done.',
46
+ inputSchema: { version_id: z.string() },
47
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
48
+ const res = await client.post(siteId, `versions/${encodeURIComponent(args.version_id)}/merge`);
49
+ return ok(res);
50
+ }),
51
+ },
52
+ ];
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@timelesscms-com/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for the TimelessCMS public API. Use with Claude Code or any MCP-compatible client to manage a TimelessCMS site.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "timelesscms-mcp": "./dist/index.js"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "AGENTS.md",
14
+ "README.md",
15
+ "skills"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p .",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.0.0",
28
+ "zod": "^3.23.0"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.6.0",
32
+ "vitest": "^4.1.7"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "keywords": [
38
+ "mcp",
39
+ "timelesscms",
40
+ "claude",
41
+ "model-context-protocol"
42
+ ]
43
+ }
@@ -0,0 +1,63 @@
1
+ # TimelessCMS skills for Claude Code
2
+
3
+ Boilerplate skills that pair with [`@timelesscms-com/mcp-server`](../packages/mcp-server/README.md).
4
+ Each one is a self-contained markdown file the agent reads when its
5
+ description matches the user's request. Recipes call MCP tools; the agent
6
+ adapts them to the specific job.
7
+
8
+ ## Installation
9
+
10
+ Copy whichever skills you need into your Claude Code skills directory.
11
+ Either:
12
+
13
+ ```bash
14
+ # project-scoped (recommended)
15
+ mkdir -p .claude/skills
16
+ cp skills/*.md .claude/skills/
17
+
18
+ # or user-scoped (available in every project)
19
+ cp skills/*.md ~/.claude/skills/
20
+ ```
21
+
22
+ Symlinks work too, so you can stay in sync with the upstream:
23
+
24
+ ```bash
25
+ ln -s "$PWD/skills/tcms-migrate-wp.md" ~/.claude/skills/
26
+ ```
27
+
28
+ ## The skills
29
+
30
+ | File | When it triggers | What it does |
31
+ |---|---|---|
32
+ | `tcms-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, rebuilds each page in the target's design, transfers media, sets redirects, leaves everything as drafts for review |
33
+ | `tcms-content-write.md` | "write a page about…", "draft copy for…" | Discovery first (settings + sample pages), then drafts in the site's voice, previews, iterates |
34
+ | `tcms-images.md` | "make an image / hero / illustration", media uploads | Generates locally → signed upload URL → metadata patch → embed |
35
+ | `tcms-directory.md` | Building a directory site, importing structured data | Schema → items → per-item URLs via `route_template` → listing page → preview → deploy |
36
+ | `tcms-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved |
37
+
38
+ ## Prerequisites for every skill
39
+
40
+ 1. `@timelesscms-com/mcp-server` configured in `.claude.json` with a valid
41
+ `TCMS_API_KEY` and `TCMS_API_URL`.
42
+ 2. The agent has read `AGENTS.md` (ships with the MCP package — see
43
+ `node_modules/@timelesscms-com/mcp-server/AGENTS.md` after install, or
44
+ reference it directly).
45
+
46
+ If those are missing, every skill will fail at the first MCP call with
47
+ "Missing bearer token" or "Invalid or revoked token".
48
+
49
+ ## Authoring more
50
+
51
+ Skills are markdown files that the agent loads on demand. Each one
52
+ should include:
53
+
54
+ - A `description:` frontmatter explaining when to load it (Claude uses
55
+ this to pick the right skill).
56
+ - A clear set of preconditions (what MCP tools must work, what state the
57
+ agent needs to know).
58
+ - A numbered recipe with concrete tool calls.
59
+ - A "Pitfalls" section that captures lessons learned across customer
60
+ jobs.
61
+
62
+ Keep them under ~150 lines so the agent reads them quickly. If a skill
63
+ balloons, split it.