@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 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
+ });