@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
package/AGENTS.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# AGENTS.md — Working on a TimelessCMS site
|
|
2
|
+
|
|
3
|
+
You are connected to a TimelessCMS site through `@timelesscms-com/mcp-server`.
|
|
4
|
+
This file is your briefing: what the system is, what conventions matter,
|
|
5
|
+
what tools to reach for first.
|
|
6
|
+
|
|
7
|
+
If anything below conflicts with what you observe in the tools, trust the
|
|
8
|
+
tools — the platform may have moved since this was written.
|
|
9
|
+
|
|
10
|
+
## What this is
|
|
11
|
+
|
|
12
|
+
TimelessCMS is a static-site CMS: content lives in a database, the user
|
|
13
|
+
edits it through an in-app editor, and a deploy step compiles everything
|
|
14
|
+
to a fast static site hosted on Cloudflare Pages. The in-app chat handles
|
|
15
|
+
single-page or single-block edits by the editor audience. You — through
|
|
16
|
+
this MCP — handle the work that doesn't fit there: site-wide redesigns,
|
|
17
|
+
bulk content updates, structural migrations, directory imports.
|
|
18
|
+
|
|
19
|
+
The MCP server is a thin wrapper around the public REST API. Each tool
|
|
20
|
+
maps to one HTTP endpoint; the actual logic runs in the customer's portal
|
|
21
|
+
(SaaS or self-hosted).
|
|
22
|
+
|
|
23
|
+
## The data model in 90 seconds
|
|
24
|
+
|
|
25
|
+
- **Pages.** Title, slug, status (`draft | review | unlisted | published`),
|
|
26
|
+
HTML body (`html_content`), SEO fields. Slug can contain slashes, so
|
|
27
|
+
`2024/01/foo-bar` is a valid slug → `/2024/01/foo-bar` URL. Encode
|
|
28
|
+
hierarchy in the slug; the renderer doesn't use `parent` for routing.
|
|
29
|
+
|
|
30
|
+
- **Partials = global blocks.** Three kinds:
|
|
31
|
+
- `header` — auto-injected at the top of every page.
|
|
32
|
+
- `footer` — auto-injected at the bottom of every page.
|
|
33
|
+
- `free` — reusable HTML you drop into a page with
|
|
34
|
+
`<x-include name="block-id" />`. Free blocks are how you avoid
|
|
35
|
+
duplicating HTML across pages.
|
|
36
|
+
|
|
37
|
+
- **Collections.** Repeatable content types (blog, team, events,
|
|
38
|
+
products, restaurants for a directory site, etc.). Each has a schema
|
|
39
|
+
(`fields[]`) and optional **per-item routing** via `route_template`
|
|
40
|
+
(e.g. `/restaurants/{slug}`). When set, every published item gets its
|
|
41
|
+
own static URL rendered through `item_template_html`. Set
|
|
42
|
+
`route_template=""` to opt out and keep the collection listing-only.
|
|
43
|
+
|
|
44
|
+
- **Settings.** Site name, tagline, logo, favicon, colors, fonts,
|
|
45
|
+
contact info, social links, SEO suffix. Scripts and custom CSS exist
|
|
46
|
+
on the settings doc but the API does NOT expose them — the customer
|
|
47
|
+
edits those manually.
|
|
48
|
+
|
|
49
|
+
- **Redirects.** `from_path → to_path` with status code 301 / 302.
|
|
50
|
+
Auto-created when you change a page's slug.
|
|
51
|
+
|
|
52
|
+
- **Versions / branches.** Copy-on-write. The "main" version is the
|
|
53
|
+
live one. Create a branch (`create_branch`) for multi-step work;
|
|
54
|
+
everything you write through `?version=<branch-id>` lives on the
|
|
55
|
+
branch until you `merge_branch` it back to main. Branches default
|
|
56
|
+
`robots_blocked: true` so a half-finished redesign can't be indexed,
|
|
57
|
+
and deploys land at a stable `{branch}.{project}.pages.dev` URL.
|
|
58
|
+
|
|
59
|
+
- **Deploys.** Customers see live changes only after a deploy. Preview
|
|
60
|
+
always sees drafts. `trigger_deploy` enqueues; `get_deploy_status`
|
|
61
|
+
reports `queued → running → succeeded | failed`.
|
|
62
|
+
|
|
63
|
+
- **Site URLs.** `get_site` returns a `urls` object with:
|
|
64
|
+
- `production` — the customer's real domain (or null)
|
|
65
|
+
- `fallback` — the auto `{slug}.timelesscms.app`-style preview URL
|
|
66
|
+
- `preview_base` — the portal preview origin (for token URLs)
|
|
67
|
+
Use these in answers to "what's the URL?" — never invent.
|
|
68
|
+
|
|
69
|
+
## Discovering this site
|
|
70
|
+
|
|
71
|
+
Don't hardcode assumptions about what's here. Every fact about the site
|
|
72
|
+
goes through the MCP:
|
|
73
|
+
|
|
74
|
+
1. `get_site` — confirm the key works; learn the site name + URLs.
|
|
75
|
+
2. `read_site_settings` — colors, fonts, contact info, SEO suffix,
|
|
76
|
+
content language (used by `suggest_alt_text_context`).
|
|
77
|
+
3. `list_pages` — what pages exist, paginated.
|
|
78
|
+
4. `list_partials` — what shared blocks already exist. **Defaults to
|
|
79
|
+
summary mode** (no html_content, just bytes count) — pass
|
|
80
|
+
`include_content: true` if you actually need the bodies inline.
|
|
81
|
+
5. `list_collections` — what content types exist + their schemas +
|
|
82
|
+
`route_template` (so you know if items have URLs).
|
|
83
|
+
6. `list_block_types` — registered block types (often empty today;
|
|
84
|
+
fills in once the block editor lands).
|
|
85
|
+
|
|
86
|
+
You usually want at least #1 + #2 + a sampling from #3 before
|
|
87
|
+
proposing any design change, so you mirror the conventions in use.
|
|
88
|
+
|
|
89
|
+
## Common operations
|
|
90
|
+
|
|
91
|
+
### "Replace this string across the whole site"
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
search_pages contains="299 kr" → matches + excerpts
|
|
95
|
+
bulk_replace_text dry_run=true ... → sample_diffs
|
|
96
|
+
# show the user, get confirmation
|
|
97
|
+
bulk_replace_text dry_run=false ... → write
|
|
98
|
+
trigger_deploy → ship
|
|
99
|
+
get_deploy_status job_id=… → poll until succeeded
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Writes go through the normal save pipeline (SEO transform + revision
|
|
103
|
+
snapshot) so changes are reversible from the in-app History tab.
|
|
104
|
+
|
|
105
|
+
### "Audit / understand the site"
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
list_pages limit=200 → inventory
|
|
109
|
+
batch_read_pages page_ids=[…] → bulk-load bodies
|
|
110
|
+
list_partials → shared blocks (summary)
|
|
111
|
+
find_pages_using_block partial_id=<id> → blast radius per block
|
|
112
|
+
list_collections → content types + routing
|
|
113
|
+
list_collection_items collection=<name> → items (richtext hidden)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`find_pages_using_block` for the header or footer returns the full
|
|
117
|
+
page list (they're auto-injected on every page).
|
|
118
|
+
|
|
119
|
+
### "Redesign the home page"
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
get_site + read_site_settings
|
|
123
|
+
read_partial partial_id="header"
|
|
124
|
+
list_pages → batch_read_pages a few existing pages # learn conventions
|
|
125
|
+
# Propose redesign locally; ask user to confirm.
|
|
126
|
+
create_branch name="Home redesign" # ID is, say, "home-redesign"
|
|
127
|
+
update_page page_id=home patch={ html_content: "…" } version=home-redesign
|
|
128
|
+
get_preview_link page_id=home version=home-redesign # signed clickable URL
|
|
129
|
+
# Iterate. When approved:
|
|
130
|
+
merge_branch version_id=home-redesign
|
|
131
|
+
trigger_deploy
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The branch also has its own permanent deploy URL at
|
|
135
|
+
`https://home-redesign.<project>.pages.dev` after `trigger_deploy
|
|
136
|
+
version=home-redesign` — useful for "share with stakeholders without
|
|
137
|
+
showing them my preview token". `read_version version_id=home-redesign`
|
|
138
|
+
returns it as `deploy_url`.
|
|
139
|
+
|
|
140
|
+
### "Build a reusable block"
|
|
141
|
+
|
|
142
|
+
If you see the same HTML on 3+ pages, propose a free block instead of
|
|
143
|
+
duplicating it:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
create_free_block id="newsletter-cta" html_content="<form>…</form>"
|
|
147
|
+
# Then on each page where it should appear:
|
|
148
|
+
update_page page_id=… patch={ html_content: "<…><x-include name=\"newsletter-cta\" />" }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Edits to the block update every page that includes it. Use
|
|
152
|
+
`find_pages_using_block` before changing it.
|
|
153
|
+
|
|
154
|
+
### "Build a directory site / import structured data"
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
create_collection
|
|
158
|
+
name="restaurants"
|
|
159
|
+
label_singular="Restaurant" label_plural="Restaurants"
|
|
160
|
+
fields=[ ...title, slug, address, phone, cuisine, body... ]
|
|
161
|
+
route_template="/restaurants/{slug}"
|
|
162
|
+
item_template_html="<article><h1>{{title}}</h1>… {{{body}}}</article>"
|
|
163
|
+
|
|
164
|
+
# For each row in your source data:
|
|
165
|
+
create_collection_item collection="restaurants" fields={…} status="published"
|
|
166
|
+
|
|
167
|
+
# Each published item now lives at /restaurants/{slug}, included in
|
|
168
|
+
# sitemap.xml. Preview a specific one:
|
|
169
|
+
get_preview_link collection_name="restaurants" item_id="<id>"
|
|
170
|
+
|
|
171
|
+
# Optional listing page:
|
|
172
|
+
list_collection_items collection="restaurants" limit=200
|
|
173
|
+
update_page page_id=restaurants patch={ html_content: "<hand-written listing>" }
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### "Migrate a content type (e.g. WP custom post type)"
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
list_collections # what exists today?
|
|
180
|
+
read_collection name=blog # what fields are writable?
|
|
181
|
+
batch_read_collection_items … # load items (richtext hidden)
|
|
182
|
+
# Transform locally; then:
|
|
183
|
+
update_collection_item … (or) create_collection_item …
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Fields outside the schema are silently dropped — call `read_collection`
|
|
187
|
+
first if you're unsure what's writable.
|
|
188
|
+
|
|
189
|
+
### "Add images to a page"
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
# Image lives on a URL somewhere (Unsplash, customer's existing CDN):
|
|
193
|
+
upload_media_from_url source_url="https://..." alt_text="Hero photo of …"
|
|
194
|
+
→ returns { media_id, cdn_url, … }
|
|
195
|
+
|
|
196
|
+
# OR image lives in your memory (image-gen output):
|
|
197
|
+
upload_media_inline filename="hero.png" content_type="image/png"
|
|
198
|
+
data_base64="iVBORw0KGgo…"
|
|
199
|
+
→ returns the same shape
|
|
200
|
+
|
|
201
|
+
# OPTIONAL but recommended: produce srcset variants for responsive <img>:
|
|
202
|
+
generate_image_variants media_id=<id>
|
|
203
|
+
→ returns webp + avif variants at 320/640/1024/1920 widths
|
|
204
|
+
(skips upscales; sub-5s per image)
|
|
205
|
+
|
|
206
|
+
# Then embed in a page:
|
|
207
|
+
read_page page_id=...
|
|
208
|
+
# Build <picture> or <img srcset="..."> using variants[*].cdn_url
|
|
209
|
+
update_page page_id=... patch={ html_content: "<...><img src='{cdn_url}' .../>" }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### "Fill missing alt-text across the media library"
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
list_media → find items where alt_text is empty
|
|
216
|
+
suggest_alt_text_context media_id=<id> → returns image_url + tuned prompt
|
|
217
|
+
+ language + nearest-heading context
|
|
218
|
+
# Pass image_url + the returned suggested_prompt to YOUR OWN vision
|
|
219
|
+
# capability. The platform does NOT run vision for you.
|
|
220
|
+
update_media media_id=<id> alt_text="<what vision returned>"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The prompt is tuned for SEO-grade output: 5-15 words, written in
|
|
224
|
+
`settings.language`, skips "image of" filler, decorative images return
|
|
225
|
+
empty string.
|
|
226
|
+
|
|
227
|
+
### "Change a page's URL safely"
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
update_page page_id=about patch={ slug: "om-oss" }
|
|
231
|
+
→ response includes:
|
|
232
|
+
auto_redirects: [{ from_path: "/about", to_path: "/om-oss",
|
|
233
|
+
status_code: 301 }]
|
|
234
|
+
sanitization_warnings: []
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The 301 fires automatically — you don't have to remember.
|
|
238
|
+
|
|
239
|
+
## Safety boundaries
|
|
240
|
+
|
|
241
|
+
- **HTML is sanitized at save.** No `<script>`, no `onclick`, no
|
|
242
|
+
`javascript:` URLs in page or partial bodies. Write responses include
|
|
243
|
+
a `sanitization_warnings: []` array listing what got stripped, so you
|
|
244
|
+
see when an input was modified.
|
|
245
|
+
- **scripts_head, scripts_body_end, custom_css** are read-stripped on
|
|
246
|
+
`read_site_settings` and silently dropped on `update_site_settings`.
|
|
247
|
+
- **The API key is site-scoped.** Cross-site reach is impossible — a
|
|
248
|
+
key on the wrong site returns 401, indistinguishable from "bad token".
|
|
249
|
+
- **Audit log.** Every state-changing call (POST / PATCH / PUT /
|
|
250
|
+
DELETE) is logged. Reads aren't. The customer sees "Acme agency key
|
|
251
|
+
wrote to /pages/home at 14:32" in the portal.
|
|
252
|
+
- **Rate limits.** 600 reads/min, 60 writes/min per key. On 429 the
|
|
253
|
+
response carries `Retry-After`.
|
|
254
|
+
|
|
255
|
+
## Preview-driven workflow
|
|
256
|
+
|
|
257
|
+
After any non-trivial change, call `get_preview_link` and ask the user
|
|
258
|
+
(or your own browser tool) to confirm the result before moving on. One
|
|
259
|
+
HTTP call vs. shipping a broken redesign — always worth it.
|
|
260
|
+
|
|
261
|
+
Preview shows DB state (drafts included). Live (`get_site → urls.production`)
|
|
262
|
+
shows the most recent deploy. Branch deploys live at
|
|
263
|
+
`get_version → deploy_url` (`{branch}.{project}.pages.dev`).
|
|
264
|
+
|
|
265
|
+
## Branches
|
|
266
|
+
|
|
267
|
+
For multi-step work, create a branch:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
create_branch name="Pricing refresh"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The response includes `id` — pass that as `version=<id>` on every
|
|
274
|
+
subsequent call. The branch is independent of main; writes don't affect
|
|
275
|
+
the live site until you `merge_branch`.
|
|
276
|
+
|
|
277
|
+
Branches default `robots_blocked: true`. Deploys to a branch land at a
|
|
278
|
+
stable, share-able URL (`{branch}.{project}.pages.dev`); use that for
|
|
279
|
+
stakeholder review.
|
|
280
|
+
|
|
281
|
+
## When in doubt
|
|
282
|
+
|
|
283
|
+
- **Read before you write.** A `read_page` round-trip is cheap and
|
|
284
|
+
stops you overwriting unrelated changes.
|
|
285
|
+
- **Dry-run bulk operations.** `bulk_replace_text` accepts `dry_run:
|
|
286
|
+
true` and returns 3 sample diffs. Show them to the user before the
|
|
287
|
+
real run.
|
|
288
|
+
- **Watch the sanitization_warnings array.** If it's non-empty, the
|
|
289
|
+
stored HTML differs from what you sent. Read it back to confirm.
|
|
290
|
+
- **One small confirmation > one large undo.** The audit log makes it
|
|
291
|
+
obvious who did what, but a clean revert across many pages is still
|
|
292
|
+
more work than asking "ok to proceed?" first.
|
|
293
|
+
- **Match the site's design.** Read a partial or two before designing
|
|
294
|
+
new components. CSS variables (`var(--color-primary)`) are common
|
|
295
|
+
but not universal — mirror what's already in use.
|
|
296
|
+
|
|
297
|
+
## Reference: tool families
|
|
298
|
+
|
|
299
|
+
| Family | Tools |
|
|
300
|
+
|---|---|
|
|
301
|
+
| **Discovery** | `get_site`, `update_site`, `list_versions`, `read_site_settings` |
|
|
302
|
+
| **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |
|
|
303
|
+
| **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |
|
|
304
|
+
| **Pages — meta** | `get_page_blocks`, `get_page_preview` |
|
|
305
|
+
| **Global blocks** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |
|
|
306
|
+
| **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type` |
|
|
307
|
+
| **Collections** | `create_collection`, `update_collection_schema`, `delete_collection`, `list_collections`, `read_collection`, `list_collection_items` (richtext hidden by default), `read_collection_item`, `batch_read_collection_items`, `create_collection_item`, `update_collection_item`, `delete_collection_item`, `regenerate_collection_listing` |
|
|
308
|
+
| **Media** | `list_media`, `read_media`, `create_upload_url`, `upload_media_from_url`, `upload_media_inline`, `update_media`, `delete_media`, `generate_image_variants`, `suggest_alt_text_context` |
|
|
309
|
+
| **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |
|
|
310
|
+
| **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions` |
|
|
311
|
+
| **Settings** | `update_site_settings` (whitelist) |
|
|
312
|
+
| **Search + bulk** | `search_pages`, `bulk_replace_text` |
|
|
313
|
+
| **Branches** | `create_branch`, `read_version`, `delete_branch`, `merge_branch` |
|
|
314
|
+
| **Deploy** | `trigger_deploy`, `list_deploys`, `get_deploy_status` |
|
|
315
|
+
| **Preview** | `get_preview_link`, `get_page_preview` |
|
|
316
|
+
|
|
317
|
+
Every tool's input is validated server-side; the MCP server only does
|
|
318
|
+
auth + shape. If a tool returns `isError: true`, the body carries
|
|
319
|
+
`{ error, status, body }` from the underlying HTTP response.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @timelesscms-com/mcp-server
|
|
2
|
+
|
|
3
|
+
Model Context Protocol server for the [TimelessCMS](https://timelesscms.com)
|
|
4
|
+
public API. Lets Claude Code (or any MCP-compatible client) manage a
|
|
5
|
+
TimelessCMS site through the same tool surface a human agency would use:
|
|
6
|
+
read and write pages, partials, collections, media, redirects, versions;
|
|
7
|
+
trigger deploys; mint preview links.
|
|
8
|
+
|
|
9
|
+
The server is a **thin transport adapter** — every tool wraps one HTTP
|
|
10
|
+
endpoint of the TimelessCMS REST API. Auth happens at the API layer with
|
|
11
|
+
a site-scoped key; the MCP just carries the bearer through.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
1. **Create an API key** in your TimelessCMS portal at
|
|
16
|
+
`/app/sites/{siteId}/settings/api-keys`. The key is shown once at
|
|
17
|
+
creation — copy it somewhere safe.
|
|
18
|
+
|
|
19
|
+
2. **Add the server to Claude Code.** Drop this into `~/.claude.json`
|
|
20
|
+
(or your local `.claude/config.json`):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"timelesscms": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "@timelesscms-com/mcp-server"],
|
|
28
|
+
"env": {
|
|
29
|
+
"TCMS_API_URL": "https://app.timelesscms.com",
|
|
30
|
+
"TCMS_API_KEY": "tcms_live_REPLACE_WITH_YOUR_KEY"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For a self-hosted portal, point `TCMS_API_URL` at it (e.g.
|
|
38
|
+
`https://cms.example.com`).
|
|
39
|
+
|
|
40
|
+
3. **Tell the agent what kind of work you want.** A good first message:
|
|
41
|
+
|
|
42
|
+
> "Connect to TimelessCMS and tell me what you find — site name,
|
|
43
|
+
> number of pages, what global blocks exist, what collections are
|
|
44
|
+
> defined. Then I'll give you a task."
|
|
45
|
+
|
|
46
|
+
Claude will call `get_site`, `list_pages`, `list_partials`,
|
|
47
|
+
`list_collections` in sequence and report back.
|
|
48
|
+
|
|
49
|
+
## Environment variables
|
|
50
|
+
|
|
51
|
+
| Var | Required | Description |
|
|
52
|
+
|-----------------|----------|-------------|
|
|
53
|
+
| `TCMS_API_URL` | yes | Base URL of your TimelessCMS portal. |
|
|
54
|
+
| `TCMS_API_KEY` | yes | A `tcms_live_…` bearer token from the API keys page. |
|
|
55
|
+
| `TCMS_SITE_ID` | no | Pin the server to a specific site. Defaults to autodetect (calls `GET /v1/sites` — per-site keys return exactly one). |
|
|
56
|
+
|
|
57
|
+
## What the agent should read first
|
|
58
|
+
|
|
59
|
+
The package ships [AGENTS.md](./AGENTS.md), a self-contained briefing
|
|
60
|
+
that explains TimelessCMS conventions, common operations, and the safety
|
|
61
|
+
boundaries an agent needs to respect. Point Claude at it (or include it
|
|
62
|
+
in your project's `CLAUDE.md` / `AGENTS.md`) so it knows when to use
|
|
63
|
+
which tool.
|
|
64
|
+
|
|
65
|
+
## Tool surface
|
|
66
|
+
|
|
67
|
+
Around 50 tools across these families. See [AGENTS.md](./AGENTS.md) for
|
|
68
|
+
the full reference + concrete operation recipes.
|
|
69
|
+
|
|
70
|
+
- **Discovery** — `get_site`, `update_site` (name/slug/domain), `list_versions`,
|
|
71
|
+
`read_site_settings`, `update_site_settings`.
|
|
72
|
+
- **Pages** — list, read, batch-read, create, update (PATCH), replace
|
|
73
|
+
(PUT), batch-update, delete, clone, get-blocks, get-preview.
|
|
74
|
+
- **Global blocks (partials)** — list (summary mode by default), read,
|
|
75
|
+
create free block, update, replace, delete, find-pages-using-block.
|
|
76
|
+
- **Block types** — list, read, find-pages-using-block-type. Surface
|
|
77
|
+
for the future block editor.
|
|
78
|
+
- **Collections + items** — create/update/delete the collection schema
|
|
79
|
+
itself (incl. `route_template` for per-item URLs); list/read/batch-
|
|
80
|
+
read/create/update/delete items.
|
|
81
|
+
- **Media** — list, read, signed upload URLs, `upload_media_from_url`,
|
|
82
|
+
`upload_media_inline`, patch metadata, delete, `generate_image_variants`
|
|
83
|
+
(build-time srcset pipeline), `suggest_alt_text_context` (returns a
|
|
84
|
+
tuned prompt for your own vision model).
|
|
85
|
+
- **Redirects** — list, create, delete. Plus automatic 301 on slug change.
|
|
86
|
+
- **Forms** — list, read, create, update, delete, list submissions.
|
|
87
|
+
- **Settings** — read + patch (scripts and custom CSS not exposed).
|
|
88
|
+
- **Search** — `search_pages` with substring or regex.
|
|
89
|
+
- **Bulk** — `bulk_replace_text` with dry-run.
|
|
90
|
+
- **Branches** — create, read, delete, merge. Branch deploys get their
|
|
91
|
+
own URL at `{branch}.{project}.pages.dev`.
|
|
92
|
+
- **Deploy** — trigger, list, get status.
|
|
93
|
+
- **Preview** — `get_preview_link` (signed URL for browser navigation;
|
|
94
|
+
supports `page_id`, `slug`, or `collection_name + item_id`).
|
|
95
|
+
|
|
96
|
+
## Direct REST API access
|
|
97
|
+
|
|
98
|
+
If you don't want the MCP wrapper, the same surface is reachable directly
|
|
99
|
+
with curl:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
curl -H "Authorization: Bearer tcms_live_..." \
|
|
103
|
+
https://app.timelesscms.com/api/v1/sites/<siteId>/pages
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The MCP server is purely an ergonomics layer on top of that.
|
|
107
|
+
|
|
108
|
+
## Security model
|
|
109
|
+
|
|
110
|
+
- API keys are **site-scoped**. A key cannot read or write another site
|
|
111
|
+
in the same organization — server-side authoritative.
|
|
112
|
+
- All write calls (`POST`, `PUT`, `PATCH`, `DELETE`) are **audit-logged**
|
|
113
|
+
with the key prefix, IP, method, path, and status. Reads are not
|
|
114
|
+
logged (cost vs. value).
|
|
115
|
+
- **Rate limits**: 600 reads/min, 60 writes/min per key. 429 responses
|
|
116
|
+
carry `Retry-After` headers.
|
|
117
|
+
- **HTML sanitization** happens at save time on the server — `<script>`,
|
|
118
|
+
event handlers, and `javascript:` URLs are stripped. The customer's
|
|
119
|
+
`scripts_*` and `custom_css` settings are read-stripped and write-dropped
|
|
120
|
+
by the API.
|
|
121
|
+
- Keys can be **revoked** at any time from the portal. Revocation takes
|
|
122
|
+
effect on the next request (no in-flight requests get cancelled, but
|
|
123
|
+
the next one returns 401).
|
|
124
|
+
|
|
125
|
+
## More
|
|
126
|
+
|
|
127
|
+
- Full end-to-end production setup recipe with troubleshooting:
|
|
128
|
+
[docs/claude-code-mcp-setup.md](../../docs/claude-code-mcp-setup.md)
|
|
129
|
+
- Agent operations briefing: [AGENTS.md](./AGENTS.md)
|
|
130
|
+
- Boilerplate skills (migration, content, images, directory, redesign):
|
|
131
|
+
[skills/](./skills/)
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT — see [LICENSE](../../LICENSE).
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Thin fetch client for the TimelessCMS REST API. The MCP server is just a
|
|
2
|
+
// transport adapter — every tool delegates to one of these methods.
|
|
3
|
+
//
|
|
4
|
+
// Surface kept deliberately tiny so it's easy to reason about: get/post/
|
|
5
|
+
// patch/put/del + a path helper. Errors come back as a structured
|
|
6
|
+
// ApiError with the status and the parsed body so the caller can shape
|
|
7
|
+
// useful tool-result messages.
|
|
8
|
+
export class ApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
body;
|
|
11
|
+
constructor(status, body, message) {
|
|
12
|
+
super(message ?? `API request failed (${status})`);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.body = body;
|
|
15
|
+
this.name = 'ApiError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class TcmsClient {
|
|
19
|
+
baseUrl;
|
|
20
|
+
apiKey;
|
|
21
|
+
fetchImpl;
|
|
22
|
+
constructor(config) {
|
|
23
|
+
if (!config.baseUrl)
|
|
24
|
+
throw new Error('baseUrl is required');
|
|
25
|
+
if (!config.apiKey)
|
|
26
|
+
throw new Error('apiKey is required');
|
|
27
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
28
|
+
this.apiKey = config.apiKey;
|
|
29
|
+
this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
30
|
+
}
|
|
31
|
+
/** Build the full URL for a path (relative to /api/v1/sites/{siteId}/...) */
|
|
32
|
+
url(siteId, path, query) {
|
|
33
|
+
const cleanPath = path.replace(/^\/+/, '');
|
|
34
|
+
const url = new URL(`${this.baseUrl}/api/v1/sites/${encodeURIComponent(siteId)}/${cleanPath}`);
|
|
35
|
+
if (query) {
|
|
36
|
+
for (const [k, v] of Object.entries(query)) {
|
|
37
|
+
if (v !== undefined && v !== null)
|
|
38
|
+
url.searchParams.set(k, String(v));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return url.toString();
|
|
42
|
+
}
|
|
43
|
+
/** Top-level URL (no siteId prefix) — used by GET /v1/sites only. */
|
|
44
|
+
rootUrl(path, query) {
|
|
45
|
+
const cleanPath = path.replace(/^\/+/, '');
|
|
46
|
+
const url = new URL(`${this.baseUrl}/api/v1/${cleanPath}`);
|
|
47
|
+
if (query) {
|
|
48
|
+
for (const [k, v] of Object.entries(query)) {
|
|
49
|
+
if (v !== undefined && v !== null)
|
|
50
|
+
url.searchParams.set(k, String(v));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return url.toString();
|
|
54
|
+
}
|
|
55
|
+
async request(method, url, body) {
|
|
56
|
+
const headers = {
|
|
57
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
58
|
+
accept: 'application/json',
|
|
59
|
+
};
|
|
60
|
+
if (body !== undefined)
|
|
61
|
+
headers['content-type'] = 'application/json';
|
|
62
|
+
const res = await this.fetchImpl(url, {
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
66
|
+
});
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
let parsed = undefined;
|
|
69
|
+
if (text) {
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
parsed = text;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const message = parsed?.error ?? `HTTP ${res.status}`;
|
|
79
|
+
throw new ApiError(res.status, parsed, message);
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
// ── Site-scoped helpers ────────────────────────────────────────────────
|
|
84
|
+
get(siteId, path, query) {
|
|
85
|
+
return this.request('GET', this.url(siteId, path, query));
|
|
86
|
+
}
|
|
87
|
+
post(siteId, path, body, query) {
|
|
88
|
+
return this.request('POST', this.url(siteId, path, query), body);
|
|
89
|
+
}
|
|
90
|
+
patch(siteId, path, body, query) {
|
|
91
|
+
return this.request('PATCH', this.url(siteId, path, query), body);
|
|
92
|
+
}
|
|
93
|
+
put(siteId, path, body, query) {
|
|
94
|
+
return this.request('PUT', this.url(siteId, path, query), body);
|
|
95
|
+
}
|
|
96
|
+
del(siteId, path, query) {
|
|
97
|
+
return this.request('DELETE', this.url(siteId, path, query));
|
|
98
|
+
}
|
|
99
|
+
// ── Root helpers ───────────────────────────────────────────────────────
|
|
100
|
+
rootGet(path) {
|
|
101
|
+
return this.request('GET', this.rootUrl(path));
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TimelessCMS MCP server — stdio entry point.
|
|
3
|
+
//
|
|
4
|
+
// Reads two env vars at startup:
|
|
5
|
+
// TCMS_API_URL — base URL of the portal (e.g. https://app.timelesscms.com)
|
|
6
|
+
// TCMS_API_KEY — a tcms_live_... bearer token from /app/sites/{id}/settings/api-keys
|
|
7
|
+
//
|
|
8
|
+
// Optionally:
|
|
9
|
+
// TCMS_SITE_ID — pre-set the site id. If omitted we discover it by
|
|
10
|
+
// calling GET /v1/sites at startup (the v1 keys are
|
|
11
|
+
// per-site so that endpoint always returns exactly one).
|
|
12
|
+
//
|
|
13
|
+
// Each tool wraps a single REST endpoint. The MCP server itself does no
|
|
14
|
+
// business logic — that all lives in the portal. This keeps the package
|
|
15
|
+
// boring to maintain and lets the customer's self-hosted portal serve the
|
|
16
|
+
// same MCP without needing two implementations to stay in sync.
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { TcmsClient } from './client.js';
|
|
20
|
+
import { pageTools } from './tools/pages.js';
|
|
21
|
+
import { partialTools } from './tools/partials.js';
|
|
22
|
+
import { collectionTools } from './tools/collections.js';
|
|
23
|
+
import { mediaTools } from './tools/media.js';
|
|
24
|
+
import { redirectTools } from './tools/redirects.js';
|
|
25
|
+
import { formTools } from './tools/forms.js';
|
|
26
|
+
import { searchTools } from './tools/search.js';
|
|
27
|
+
import { bulkTools } from './tools/bulk.js';
|
|
28
|
+
import { versionTools } from './tools/versions.js';
|
|
29
|
+
import { deployTools } from './tools/deploy.js';
|
|
30
|
+
import { previewTools } from './tools/preview.js';
|
|
31
|
+
import { blockTypeTools } from './tools/block-types.js';
|
|
32
|
+
import { settingsTools } from './tools/settings.js';
|
|
33
|
+
import { siteTools } from './tools/sites.js';
|
|
34
|
+
const VERSION = '0.1.0';
|
|
35
|
+
async function resolveSiteId(client) {
|
|
36
|
+
const fromEnv = process.env.TCMS_SITE_ID?.trim();
|
|
37
|
+
if (fromEnv)
|
|
38
|
+
return fromEnv;
|
|
39
|
+
// Per-site keys → exactly one site. We fetch it so the agent doesn't
|
|
40
|
+
// have to know its own site id ahead of time.
|
|
41
|
+
const res = await client.rootGet('sites');
|
|
42
|
+
if (!res.sites || res.sites.length === 0) {
|
|
43
|
+
throw new Error('No sites returned for this API key. Check the key is valid.');
|
|
44
|
+
}
|
|
45
|
+
if (res.sites.length > 1) {
|
|
46
|
+
throw new Error(`This key authorises ${res.sites.length} sites; set TCMS_SITE_ID to pick one.`);
|
|
47
|
+
}
|
|
48
|
+
return res.sites[0].id;
|
|
49
|
+
}
|
|
50
|
+
function bail(message) {
|
|
51
|
+
console.error(`timelesscms-mcp: ${message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
async function main() {
|
|
55
|
+
const apiUrl = process.env.TCMS_API_URL?.trim();
|
|
56
|
+
const apiKey = process.env.TCMS_API_KEY?.trim();
|
|
57
|
+
if (!apiUrl)
|
|
58
|
+
bail('TCMS_API_URL is not set. Point it at your TimelessCMS portal (e.g. https://app.timelesscms.com).');
|
|
59
|
+
if (!apiKey)
|
|
60
|
+
bail('TCMS_API_KEY is not set. Create a key in /app/sites/{siteId}/settings/api-keys and copy it once.');
|
|
61
|
+
if (!apiKey.startsWith('tcms_live_')) {
|
|
62
|
+
bail('TCMS_API_KEY does not look like a TimelessCMS key (expected tcms_live_... prefix).');
|
|
63
|
+
}
|
|
64
|
+
const client = new TcmsClient({ baseUrl: apiUrl, apiKey });
|
|
65
|
+
let siteId;
|
|
66
|
+
try {
|
|
67
|
+
siteId = await resolveSiteId(client);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
bail(`Failed to discover site: ${e instanceof Error ? e.message : String(e)}`);
|
|
71
|
+
}
|
|
72
|
+
const deps = { client, siteId };
|
|
73
|
+
const server = new McpServer({ name: 'timelesscms', version: VERSION }, { capabilities: { tools: {} } });
|
|
74
|
+
const allTools = [
|
|
75
|
+
...siteTools,
|
|
76
|
+
...pageTools,
|
|
77
|
+
...partialTools,
|
|
78
|
+
...blockTypeTools,
|
|
79
|
+
...collectionTools,
|
|
80
|
+
...mediaTools,
|
|
81
|
+
...redirectTools,
|
|
82
|
+
...formTools,
|
|
83
|
+
...settingsTools,
|
|
84
|
+
...searchTools,
|
|
85
|
+
...bulkTools,
|
|
86
|
+
...versionTools,
|
|
87
|
+
...deployTools,
|
|
88
|
+
...previewTools,
|
|
89
|
+
];
|
|
90
|
+
// The SDK's registerTool signature has very deep generic constraints
|
|
91
|
+
// (ZodRawShape × AnySchema × annotations) that TypeScript can't unify
|
|
92
|
+
// when we iterate heterogeneous tools at runtime. We cast the bag once
|
|
93
|
+
// here and rely on our own ToolDef contract for type safety.
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
const register = server.registerTool.bind(server);
|
|
96
|
+
for (const tool of allTools) {
|
|
97
|
+
register(tool.name, {
|
|
98
|
+
description: tool.description,
|
|
99
|
+
...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
|
|
100
|
+
},
|
|
101
|
+
// The SDK's callback gets the args object (when inputSchema is set) or
|
|
102
|
+
// no args (when it isn't). Both are valid for our handler signature.
|
|
103
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
+
async (args) => tool.handler(args ?? {}, deps));
|
|
105
|
+
}
|
|
106
|
+
const transport = new StdioServerTransport();
|
|
107
|
+
await server.connect(transport);
|
|
108
|
+
}
|
|
109
|
+
main().catch((err) => {
|
|
110
|
+
console.error('timelesscms-mcp fatal:', err);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
});
|