htmlhost-mcp 1.0.0 → 1.2.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/README.md CHANGED
@@ -83,6 +83,8 @@ htmlhost-mcp
83
83
  | `pull_site` | Download a site's files to a local directory |
84
84
  | `delete_site` | Permanently delete a site |
85
85
  | `whoami` | Show the current authenticated user |
86
+ | `list_versions` | List a site's published versions and what each changed |
87
+ | `restore_version` | Make an earlier version live again (Pro) |
86
88
 
87
89
  ### `deploy`
88
90
 
@@ -95,6 +97,11 @@ title (optional) Title for the site
95
97
  ttl (optional) Time-to-live: 1d, 7d, 30d, or never
96
98
  ```
97
99
 
100
+ A `_redirects` file at the root of a deployed directory sets the site's redirects,
101
+ one per line: `/old-page /new-page`, optionally followed by `301` (default) or `302`.
102
+ It isn't published. Each deploy with the file replaces the redirects it set before;
103
+ redirects added in the dashboard stay. `pull_site` writes the file back.
104
+
98
105
  ### `deploy_html`
99
106
 
100
107
  Deploy generated HTML directly — no files on disk needed. Perfect for when an AI assistant generates a webpage and wants to make it live immediately.
@@ -138,6 +145,24 @@ confirm (required) Must be true — safety check
138
145
 
139
146
  Returns the authenticated user's email, name, handle, and plan.
140
147
 
148
+ ### `list_versions`
149
+
150
+ ```
151
+ slug (required) The site slug
152
+ ```
153
+
154
+ Returns the site's versions, newest first, with time, source, size, what changed, and which one is live. `usable: false` means the owner's plan can't restore them.
155
+
156
+ ### `restore_version`
157
+
158
+ ```
159
+ slug (required) The site slug
160
+ version (required) Version number from list_versions
161
+ confirm (required) Must be true — safety check
162
+ ```
163
+
164
+ Publishes that version again as a new version, so it can be undone the same way. Needs the site owner to be on Pro.
165
+
141
166
  ## Architecture
142
167
 
143
168
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for htmlhost.co — deploy, list, and manage sites from AI coding tools",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  * Reuses the same ~/.htmlhostrc token and API base as the CLI,
5
5
  * so `htmlhost login` is the only setup step needed.
6
6
  */
7
- import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync } from "node:fs";
7
+ import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync, existsSync } from "node:fs";
8
8
  import { join, basename, dirname, resolve } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { createHash } from "node:crypto";
@@ -48,7 +48,7 @@ class ApiError extends Error {
48
48
  function headers(extra = {}) {
49
49
  const token = getToken();
50
50
  if (!token) throw new ApiError("Not logged in. Run `htmlhost login` in a terminal first.", 0);
51
- return { Authorization: `Bearer ${token}`, ...extra };
51
+ return { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", ...extra };
52
52
  }
53
53
 
54
54
  async function withRetry(fn, label = "request", retries = 3) {
@@ -132,6 +132,8 @@ const DEFAULT_IGNORE = [
132
132
  ".gitignore", ".npmrc", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
133
133
  "AGENTS.md", "CLAUDE.md", "GEMINI.md", ".claude", ".cursor", ".cursorrules",
134
134
  ".windsurfrules", ".github",
135
+ // Redirect rules: sent with the deploy and applied by htmlhost, never published as a file.
136
+ "_redirects",
135
137
  ];
136
138
 
137
139
  function shouldIgnore(name) {
@@ -184,6 +186,8 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
184
186
 
185
187
  const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
186
188
  const assetFiles = allFiles.filter((f) => !f.relativePath.endsWith(".html"));
189
+ // Decided before upload-url may hand back a generated slug for the new site.
190
+ const isNewSite = !slug;
187
191
 
188
192
  // Build page payloads
189
193
  const pagePayloads = [];
@@ -232,12 +236,15 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
232
236
  }
233
237
  }
234
238
 
235
- // Final deploy
239
+ // Final deploy. A root `_redirects` file (Netlify style) replaces the site's
240
+ // redirects from this file; rules added in the dashboard stay. No file, no change.
236
241
  const body = { pages: pagePayloads, assets: assetPayloads };
242
+ const redirectsPath = join(resolvedDir, "_redirects");
243
+ if (existsSync(redirectsPath)) body.redirects = readFileSync(redirectsPath, "utf8");
237
244
  if (ttl) body.ttl = ttl;
238
245
  if (slug) body.slug = slug;
239
246
  if (title) body.title = title;
240
- if (!slug) body.isNewSite = true;
247
+ if (isNewSite) body.isNewSite = true;
241
248
 
242
249
  return await apiPost("/api/sites", body);
243
250
  }
package/src/server.mjs CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  mimeFromExt,
27
27
  } from "./api.mjs";
28
28
 
29
- const VERSION = "1.0.0";
29
+ const VERSION = "1.2.0";
30
30
 
31
31
  /**
32
32
  * Helper: wraps a handler to return proper MCP content and catch errors.
@@ -59,7 +59,8 @@ export function createServer() {
59
59
  description:
60
60
  "Deploy a file or directory to htmlhost.co. " +
61
61
  "Pass an absolute path to a directory (deploys all files) or a single HTML file. " +
62
- "Optionally set slug (to update an existing site), title, and ttl.",
62
+ "Optionally set slug (to update an existing site), title, and ttl. " +
63
+ "A `_redirects` file at the directory's root (one `/old /new [301|302]` per line) sets the site's redirects and isn't published.",
63
64
  inputSchema: z.object({
64
65
  path: z.string().describe("Absolute path to the file or directory to deploy"),
65
66
  slug: z.string().optional().describe("Existing site slug to update (omit to create a new site)"),
@@ -92,6 +93,7 @@ export function createServer() {
92
93
  pages: pc,
93
94
  ttl: data.ttl,
94
95
  expiresAt: data.expiresAt || null,
96
+ ...(data.redirects ? { redirects: data.redirects } : {}),
95
97
  };
96
98
  })
97
99
  );
@@ -219,6 +221,12 @@ export function createServer() {
219
221
  written.push(asset.path);
220
222
  }
221
223
 
224
+ // Redirects that came from a deployed _redirects file
225
+ if (data.redirectsFile) {
226
+ writeFileSync(join(destDir, "_redirects"), data.redirectsFile, "utf8");
227
+ written.push("_redirects");
228
+ }
229
+
222
230
  return {
223
231
  message: `Pulled ${written.length} files from ${cleanSlug}.htmlhost.co`,
224
232
  directory: destDir,
@@ -314,5 +322,56 @@ export function createServer() {
314
322
  })
315
323
  );
316
324
 
325
+ // -------------------------------------------------------------------
326
+ // Tool: list_versions
327
+ // -------------------------------------------------------------------
328
+ server.registerTool(
329
+ "list_versions",
330
+ {
331
+ title: "List site versions",
332
+ description:
333
+ "List the published versions of a site, newest first, with what each one changed. " +
334
+ "`usable: false` means the owner's plan can't restore them (Pro feature).",
335
+ inputSchema: z.object({
336
+ slug: z.string().describe("The site slug"),
337
+ }),
338
+ },
339
+ wrap(async ({ slug }) => {
340
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
341
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
342
+ return apiGet(`/api/sites/${encodeURIComponent(cleanSlug)}/versions`);
343
+ })
344
+ );
345
+
346
+ // -------------------------------------------------------------------
347
+ // Tool: restore_version
348
+ // -------------------------------------------------------------------
349
+ server.registerTool(
350
+ "restore_version",
351
+ {
352
+ title: "Restore a site version",
353
+ description:
354
+ "Make an earlier version of a site live again. It's published as a new version " +
355
+ "(the current one stays in history), so this can be undone by restoring again. Needs Pro.",
356
+ inputSchema: z.object({
357
+ slug: z.string().describe("The site slug"),
358
+ version: z.number().int().positive().describe("The version number to restore (from list_versions)"),
359
+ confirm: z.boolean().describe("Must be true to confirm replacing the live site"),
360
+ }),
361
+ },
362
+ wrap(async ({ slug, version, confirm }) => {
363
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
364
+ if (!confirm) return "Restore not confirmed. Set confirm to true to proceed.";
365
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
366
+ const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, {});
367
+ return {
368
+ message: `Restored v${version}; it's live as v${data.version}.`,
369
+ url: data.url ? (String(data.url).startsWith("http") ? data.url : `https://${data.url}`) : `https://${cleanSlug}.htmlhost.co`,
370
+ version: data.version,
371
+ restoredFrom: version,
372
+ };
373
+ })
374
+ );
375
+
317
376
  return server;
318
377
  }