htmlhost-mcp 1.1.0 → 1.3.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
@@ -12,6 +12,14 @@ Works with **Cursor**, **Claude Desktop**, **Windsurf**, **Cline**, **Zed**, and
12
12
  npm install -g htmlhost-mcp
13
13
  ```
14
14
 
15
+ Or skip installing and use `npx -y htmlhost-mcp@latest` as the command, which fetches the newest version each
16
+ time your AI tool starts the server.
17
+
18
+ **Updates:** the server checks npm once a day. A global install updates itself in the background (restart your
19
+ AI tool to load it), and the first tool result of a session tells your AI tool when a newer version exists. With
20
+ npx, keep `@latest` in the command: a bare `npx -y htmlhost-mcp` keeps reusing its cached copy. Set
21
+ `HTMLHOST_NO_UPDATE_CHECK=1` to turn the check off.
22
+
15
23
  ### 2. Authenticate
16
24
 
17
25
  The MCP server reuses the same token as the CLI. If you've already run `htmlhost login`, you're set.
@@ -97,6 +105,11 @@ title (optional) Title for the site
97
105
  ttl (optional) Time-to-live: 1d, 7d, 30d, or never
98
106
  ```
99
107
 
108
+ A `_redirects` file at the root of a deployed directory sets the site's redirects,
109
+ one per line: `/old-page /new-page`, optionally followed by `301` (default) or `302`.
110
+ It isn't published. Each deploy with the file replaces the redirects it set before;
111
+ redirects added in the dashboard stay. `pull_site` writes the file back.
112
+
100
113
  ### `deploy_html`
101
114
 
102
115
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.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";
@@ -13,9 +13,11 @@ import { createHash } from "node:crypto";
13
13
  // Config (mirrors packages/cli/src/config.mjs)
14
14
  // ---------------------------------------------------------------------------
15
15
  const CONFIG_PATH = join(homedir(), ".htmlhostrc");
16
+ /** Sent with every request, so the API can tell which version is calling. Kept in step with server.mjs. */
17
+ export const CLIENT_VERSION = "1.3.0";
16
18
  const DEFAULT_API = "https://htmlhost.co";
17
19
 
18
- function readConfig() {
20
+ export function readConfig() {
19
21
  try {
20
22
  return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
21
23
  } catch {
@@ -23,6 +25,13 @@ function readConfig() {
23
25
  }
24
26
  }
25
27
 
28
+ /** Merge keys into ~/.htmlhostrc (shared with the CLI), keeping it readable by its owner only. */
29
+ export function writeConfig(data) {
30
+ try {
31
+ writeFileSync(CONFIG_PATH, JSON.stringify({ ...readConfig(), ...data }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
32
+ } catch { /* a read-only home: the check just runs again next time */ }
33
+ }
34
+
26
35
  export function getToken() {
27
36
  return readConfig().token || null;
28
37
  }
@@ -48,7 +57,7 @@ class ApiError extends Error {
48
57
  function headers(extra = {}) {
49
58
  const token = getToken();
50
59
  if (!token) throw new ApiError("Not logged in. Run `htmlhost login` in a terminal first.", 0);
51
- return { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", ...extra };
60
+ return { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION, ...extra };
52
61
  }
53
62
 
54
63
  async function withRetry(fn, label = "request", retries = 3) {
@@ -132,6 +141,8 @@ const DEFAULT_IGNORE = [
132
141
  ".gitignore", ".npmrc", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
133
142
  "AGENTS.md", "CLAUDE.md", "GEMINI.md", ".claude", ".cursor", ".cursorrules",
134
143
  ".windsurfrules", ".github",
144
+ // Redirect rules: sent with the deploy and applied by htmlhost, never published as a file.
145
+ "_redirects",
135
146
  ];
136
147
 
137
148
  function shouldIgnore(name) {
@@ -184,6 +195,8 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
184
195
 
185
196
  const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
186
197
  const assetFiles = allFiles.filter((f) => !f.relativePath.endsWith(".html"));
198
+ // Decided before upload-url may hand back a generated slug for the new site.
199
+ const isNewSite = !slug;
187
200
 
188
201
  // Build page payloads
189
202
  const pagePayloads = [];
@@ -232,12 +245,15 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
232
245
  }
233
246
  }
234
247
 
235
- // Final deploy
248
+ // Final deploy. A root `_redirects` file (Netlify style) replaces the site's
249
+ // redirects from this file; rules added in the dashboard stay. No file, no change.
236
250
  const body = { pages: pagePayloads, assets: assetPayloads };
251
+ const redirectsPath = join(resolvedDir, "_redirects");
252
+ if (existsSync(redirectsPath)) body.redirects = readFileSync(redirectsPath, "utf8");
237
253
  if (ttl) body.ttl = ttl;
238
254
  if (slug) body.slug = slug;
239
255
  if (title) body.title = title;
240
- if (!slug) body.isNewSite = true;
256
+ if (isNewSite) body.isNewSite = true;
241
257
 
242
258
  return await apiPost("/api/sites", body);
243
259
  }
package/src/server.mjs CHANGED
@@ -24,18 +24,28 @@ import {
24
24
  findAllFiles,
25
25
  pagePathForFile,
26
26
  mimeFromExt,
27
+ CLIENT_VERSION,
27
28
  } from "./api.mjs";
29
+ import { checkForUpdate } from "./update.mjs";
28
30
 
29
- const VERSION = "1.1.0";
31
+ const VERSION = CLIENT_VERSION;
30
32
 
31
33
  /**
32
34
  * Helper: wraps a handler to return proper MCP content and catch errors.
33
35
  */
36
+ /** An update notice (update.mjs), shown once: with the first tool result after the check finishes. */
37
+ let updateNotice = null;
38
+ let updateNoticeShown = false;
39
+
34
40
  function wrap(fn) {
35
41
  return async (args) => {
36
42
  try {
37
43
  const result = await fn(args);
38
- const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
44
+ let text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
45
+ if (updateNotice && !updateNoticeShown) {
46
+ updateNoticeShown = true;
47
+ text += `\n\nNote for the user: ${updateNotice}`;
48
+ }
39
49
  return { content: [{ type: "text", text }] };
40
50
  } catch (e) {
41
51
  return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
@@ -44,6 +54,8 @@ function wrap(fn) {
44
54
  }
45
55
 
46
56
  export function createServer() {
57
+ // In the background: never delays starting up or a tool call.
58
+ void checkForUpdate(VERSION).then((notice) => { updateNotice = notice; }).catch(() => {});
47
59
  const server = new McpServer({
48
60
  name: "htmlhost",
49
61
  version: VERSION,
@@ -59,7 +71,8 @@ export function createServer() {
59
71
  description:
60
72
  "Deploy a file or directory to htmlhost.co. " +
61
73
  "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.",
74
+ "Optionally set slug (to update an existing site), title, and ttl. " +
75
+ "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
76
  inputSchema: z.object({
64
77
  path: z.string().describe("Absolute path to the file or directory to deploy"),
65
78
  slug: z.string().optional().describe("Existing site slug to update (omit to create a new site)"),
@@ -92,6 +105,7 @@ export function createServer() {
92
105
  pages: pc,
93
106
  ttl: data.ttl,
94
107
  expiresAt: data.expiresAt || null,
108
+ ...(data.redirects ? { redirects: data.redirects } : {}),
95
109
  };
96
110
  })
97
111
  );
@@ -219,6 +233,12 @@ export function createServer() {
219
233
  written.push(asset.path);
220
234
  }
221
235
 
236
+ // Redirects that came from a deployed _redirects file
237
+ if (data.redirectsFile) {
238
+ writeFileSync(join(destDir, "_redirects"), data.redirectsFile, "utf8");
239
+ written.push("_redirects");
240
+ }
241
+
222
242
  return {
223
243
  message: `Pulled ${written.length} files from ${cleanSlug}.htmlhost.co`,
224
244
  directory: destDir,
package/src/update.mjs ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Keeping htmlhost-mcp current, like the CLI's update check.
3
+ *
4
+ * An MCP server is a child process of the AI tool, talking over stdio, so it
5
+ * can't swap itself out while running and must never print to stdout. On
6
+ * start it checks the npm registry (at most once a day, cached in the shared
7
+ * ~/.htmlhostrc). When a newer version exists:
8
+ *
9
+ * - installed globally (`htmlhost-mcp` command): it installs the new version
10
+ * in the background, so the next start of the AI tool uses it;
11
+ * - run with `npx -y htmlhost-mcp@latest`: npx fetches it on the next start;
12
+ * - run with a bare `npx -y htmlhost-mcp`: npx keeps reusing its cached copy,
13
+ * so the notice asks for `@latest` in the MCP config.
14
+ *
15
+ * The first tool result of the session carries a one-line notice, so the AI
16
+ * tells the user. Set HTMLHOST_NO_UPDATE_CHECK to turn all of this off.
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import { realpathSync } from "node:fs";
20
+ import { sep } from "node:path";
21
+ import { readConfig, writeConfig } from "./api.mjs";
22
+
23
+ const PACKAGE_NAME = "htmlhost-mcp";
24
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
25
+
26
+ /** True when a is a newer x.y.z than b. */
27
+ export function isNewer(a, b) {
28
+ const pa = String(a).split(".").map(Number);
29
+ const pb = String(b).split(".").map(Number);
30
+ for (let i = 0; i < 3; i++) {
31
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
32
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ /** How this server was started: "npx" (from npx's cache), "global", or "local" (a project's node_modules, or a checkout). */
38
+ export function installKind(entry = process.argv[1] || "", globalRoot = null) {
39
+ let real = entry;
40
+ try { real = realpathSync(entry); } catch { /* keep the path as given */ }
41
+ if (real.includes(`${sep}_npx${sep}`)) return "npx";
42
+ if (globalRoot && real.startsWith(globalRoot + sep)) return "global";
43
+ return "local";
44
+ }
45
+
46
+ /** `npm root -g`, or null. */
47
+ function npmGlobalRoot() {
48
+ return new Promise((resolve) => {
49
+ try {
50
+ const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] });
51
+ let out = "";
52
+ child.stdout.on("data", (d) => (out += d));
53
+ child.on("error", () => resolve(null));
54
+ child.on("close", () => resolve(out.trim() || null));
55
+ setTimeout(() => { child.kill(); resolve(null); }, 5000).unref();
56
+ } catch {
57
+ resolve(null);
58
+ }
59
+ });
60
+ }
61
+
62
+ /** The newest published version, from the cache when it's less than a day old. */
63
+ async function latestVersion() {
64
+ const config = readConfig();
65
+ if (config.mcpLatestVersion && Date.now() - (config.mcpLastUpdateCheck || 0) < CHECK_INTERVAL_MS) {
66
+ return config.mcpLatestVersion;
67
+ }
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), 5000);
70
+ try {
71
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
72
+ if (!res.ok) return null;
73
+ const data = await res.json();
74
+ if (!data?.version) return null;
75
+ writeConfig({ mcpLastUpdateCheck: Date.now(), mcpLatestVersion: data.version });
76
+ return data.version;
77
+ } catch {
78
+ return null;
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+
84
+ /** Install the latest version globally, detached and silent (stdout belongs to MCP). */
85
+ function installInBackground() {
86
+ try {
87
+ const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
88
+ stdio: "ignore",
89
+ detached: true,
90
+ });
91
+ child.on("error", () => {});
92
+ child.unref();
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Check for a newer version and act on it. Resolves to a one-line notice for
101
+ * the AI to pass on, or null. Never throws, never writes to stdout.
102
+ */
103
+ export async function checkForUpdate(currentVersion) {
104
+ if (process.env.HTMLHOST_NO_UPDATE_CHECK || process.env.CI) return null;
105
+ const latest = await latestVersion();
106
+ if (!latest || !isNewer(latest, currentVersion)) return null;
107
+ const kind = installKind(process.argv[1], await npmGlobalRoot());
108
+ const head = `htmlhost-mcp ${latest} is available (this is ${currentVersion}).`;
109
+ if (kind === "global") {
110
+ return installInBackground()
111
+ ? `${head} It's being installed now; restart your AI tool (or its MCP servers) to use it.`
112
+ : `${head} Update with: npm install -g ${PACKAGE_NAME}@latest, then restart your AI tool.`;
113
+ }
114
+ if (kind === "npx") {
115
+ return `${head} Restart your AI tool to load it. If it still reports ${currentVersion}, change the MCP config to run "npx -y ${PACKAGE_NAME}@latest" so npx always fetches the newest version.`;
116
+ }
117
+ return `${head} Update the htmlhost-mcp package this server runs from, then restart your AI tool.`;
118
+ }