htmlhost-mcp 1.2.0 → 1.4.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,9 +12,19 @@ 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
- The MCP server reuses the same token as the CLI. If you've already run `htmlhost login`, you're set.
25
+ Nothing to do up front: the first time your AI tool needs your account, it calls the `login` tool, which opens an
26
+ approval page in your browser. Check the code matches and click Approve. The server shares its token with the CLI, so
27
+ if you've already run `htmlhost login`, you're set. You can also sign in from a terminal:
18
28
 
19
29
  ```bash
20
30
  npx htmlhost-cli login
@@ -82,6 +92,7 @@ htmlhost-mcp
82
92
  | `get_site` | Get details about a specific site (pages, assets, version) |
83
93
  | `pull_site` | Download a site's files to a local directory |
84
94
  | `delete_site` | Permanently delete a site |
95
+ | `login` | Sign in: opens an approval page in your browser |
85
96
  | `whoami` | Show the current authenticated user |
86
97
  | `list_versions` | List a site's published versions and what each changed |
87
98
  | `restore_version` | Make an earlier version live again (Pro) |
@@ -141,6 +152,11 @@ slug (required) The site slug to delete
141
152
  confirm (required) Must be true — safety check
142
153
  ```
143
154
 
155
+ ### `login`
156
+
157
+ Signs in to your htmlhost.co account. Opens an approval page in your browser and waits briefly; if you haven't
158
+ approved yet, it returns the link and code, and your AI tool calls it again once you have.
159
+
144
160
  ### `whoami`
145
161
 
146
162
  Returns the authenticated user's email, name, handle, and plan.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.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
@@ -2,7 +2,7 @@
2
2
  * API client for the htmlhost MCP server.
3
3
  *
4
4
  * Reuses the same ~/.htmlhostrc token and API base as the CLI,
5
- * so `htmlhost login` is the only setup step needed.
5
+ * so signing in once (the `login` tool or `htmlhost login`) covers both.
6
6
  */
7
7
  import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync, existsSync } from "node:fs";
8
8
  import { join, basename, dirname, resolve } from "node:path";
@@ -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.4.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
  }
@@ -47,8 +56,8 @@ class ApiError extends Error {
47
56
 
48
57
  function headers(extra = {}) {
49
58
  const token = getToken();
50
- 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 };
59
+ if (!token) throw new ApiError("Not signed in to htmlhost. Call the `login` tool: it opens an approval page in the user's browser.", 0);
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) {
package/src/login.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The `login` tool: browser-approved sign-in, the same flow as `htmlhost login`
3
+ * in htmlhost-cli (keep the two in step). It opens the approval page in the
4
+ * user's browser and waits a little; if they haven't approved yet it returns
5
+ * the link and code for the AI to show, and the next call picks the same
6
+ * sign-in up. The token lands in ~/.htmlhostrc, shared with the CLI.
7
+ * Never writes to stdout (the MCP channel).
8
+ */
9
+ import { hostname } from "node:os";
10
+ import { exec } from "node:child_process";
11
+ import { readConfig, writeConfig, getApi, getToken, CLIENT_VERSION } from "./api.mjs";
12
+
13
+ /** How long one call waits for the approval before handing the link back. */
14
+ const CALL_WAIT_MS = 25_000;
15
+
16
+ function openBrowser(url) {
17
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
18
+ exec(`${cmd} "${url}"`, () => {});
19
+ }
20
+
21
+ async function currentUser() {
22
+ const token = getToken();
23
+ if (!token) return null;
24
+ try {
25
+ const res = await fetch(`${getApi()}/api/tokens/me`, { headers: { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION } });
26
+ return res.ok ? await res.json() : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ async function startLogin() {
33
+ const res = await fetch(`${getApi()}/api/cli-login`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json", "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION },
36
+ body: JSON.stringify({ client: "mcp", deviceName: hostname() }),
37
+ });
38
+ if (res.status === 404 || res.status === 405) return null;
39
+ const data = await res.json().catch(() => ({}));
40
+ if (!res.ok) throw new Error(data.error || `Couldn't start sign-in (HTTP ${res.status}).`);
41
+ const pending = {
42
+ deviceCode: data.deviceCode,
43
+ userCode: data.userCode,
44
+ verifyUrl: data.verifyUrl,
45
+ interval: data.interval || 3,
46
+ expiresAt: Date.now() + (data.expiresIn || 600) * 1000,
47
+ api: getApi(),
48
+ };
49
+ writeConfig({ pendingLogin: pending });
50
+ openBrowser(pending.verifyUrl);
51
+ return pending;
52
+ }
53
+
54
+ /** One call of the tool: the message for the AI. */
55
+ export async function loginStep() {
56
+ const user = await currentUser();
57
+ if (user) return `Already signed in to htmlhost as ${user.email}.`;
58
+
59
+ const saved = readConfig().pendingLogin;
60
+ let pending = saved && saved.api === getApi() && saved.expiresAt > Date.now() + 5_000 ? saved : null;
61
+ if (!pending) {
62
+ pending = await startLogin();
63
+ if (!pending) return `This htmlhost server doesn't offer browser sign-in yet. Ask the user to run \`npx htmlhost-cli login\` in a terminal.`;
64
+ }
65
+
66
+ const until = Math.min(pending.expiresAt, Date.now() + CALL_WAIT_MS);
67
+ let interval = pending.interval * 1000;
68
+ while (Date.now() < until) {
69
+ await new Promise((r) => setTimeout(r, interval));
70
+ let data;
71
+ try {
72
+ const res = await fetch(`${getApi()}/api/cli-login/poll`, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify({ deviceCode: pending.deviceCode }),
76
+ });
77
+ data = await res.json();
78
+ } catch {
79
+ continue;
80
+ }
81
+ if (data.status === "approved") {
82
+ writeConfig({ token: data.token, api: getApi(), pendingLogin: undefined });
83
+ return `Signed in to htmlhost as ${data.email || "the user's account"}. You can use the htmlhost tools now.`;
84
+ }
85
+ if (data.status === "slow_down") interval += 1000;
86
+ else if (data.status === "denied" || data.status === "expired" || data.status === "too_many_tokens") {
87
+ writeConfig({ pendingLogin: undefined });
88
+ if (data.status === "too_many_tokens") return `The user's account has 10 API keys. Ask them to remove one at ${getApi()}/settings#keys, then call login again.`;
89
+ return data.status === "denied" ? "The user cancelled the sign-in." : "That sign-in expired. Call login again for a new link.";
90
+ }
91
+ }
92
+ return `Not signed in yet. The approval page opened in the user's browser. Ask them to open ${pending.verifyUrl} if it didn't, check it shows the code ${pending.userCode}, and click Approve. Then call login again.`;
93
+ }
package/src/server.mjs CHANGED
@@ -24,18 +24,32 @@ import {
24
24
  findAllFiles,
25
25
  pagePathForFile,
26
26
  mimeFromExt,
27
+ CLIENT_VERSION,
27
28
  } from "./api.mjs";
29
+ import { checkForUpdate } from "./update.mjs";
30
+ import { loginStep } from "./login.mjs";
28
31
 
29
- const VERSION = "1.2.0";
32
+ /** What every tool says before the user has signed in: the login tool does it, no terminal needed. */
33
+ const NOT_SIGNED_IN = "Not signed in to htmlhost. Call the `login` tool: it opens an approval page in the user's browser.";
34
+
35
+ const VERSION = CLIENT_VERSION;
30
36
 
31
37
  /**
32
38
  * Helper: wraps a handler to return proper MCP content and catch errors.
33
39
  */
40
+ /** An update notice (update.mjs), shown once: with the first tool result after the check finishes. */
41
+ let updateNotice = null;
42
+ let updateNoticeShown = false;
43
+
34
44
  function wrap(fn) {
35
45
  return async (args) => {
36
46
  try {
37
47
  const result = await fn(args);
38
- const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
48
+ let text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
49
+ if (updateNotice && !updateNoticeShown) {
50
+ updateNoticeShown = true;
51
+ text += `\n\nNote for the user: ${updateNotice}`;
52
+ }
39
53
  return { content: [{ type: "text", text }] };
40
54
  } catch (e) {
41
55
  return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
@@ -44,6 +58,8 @@ function wrap(fn) {
44
58
  }
45
59
 
46
60
  export function createServer() {
61
+ // In the background: never delays starting up or a tool call.
62
+ void checkForUpdate(VERSION).then((notice) => { updateNotice = notice; }).catch(() => {});
47
63
  const server = new McpServer({
48
64
  name: "htmlhost",
49
65
  version: VERSION,
@@ -69,7 +85,7 @@ export function createServer() {
69
85
  }),
70
86
  },
71
87
  wrap(async ({ path, slug, title, ttl }) => {
72
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
88
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
73
89
 
74
90
  const resolvedPath = resolve(path);
75
91
  if (!existsSync(resolvedPath)) return `Path not found: ${path}`;
@@ -111,7 +127,7 @@ export function createServer() {
111
127
  inputSchema: z.object({}),
112
128
  },
113
129
  wrap(async () => {
114
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
130
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
115
131
 
116
132
  const data = await apiGet("/api/sites");
117
133
  const sites = (data.sites || []).map((s) => ({
@@ -146,7 +162,7 @@ export function createServer() {
146
162
  }),
147
163
  },
148
164
  wrap(async ({ slug }) => {
149
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
165
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
150
166
 
151
167
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
152
168
  const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&meta=true`);
@@ -185,7 +201,7 @@ export function createServer() {
185
201
  }),
186
202
  },
187
203
  wrap(async ({ slug, directory }) => {
188
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
204
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
189
205
 
190
206
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
191
207
  const destDir = resolve(directory);
@@ -251,7 +267,7 @@ export function createServer() {
251
267
  }),
252
268
  },
253
269
  wrap(async ({ slug, confirm }) => {
254
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
270
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
255
271
  if (!confirm) return "Deletion not confirmed. Set confirm to true to proceed.";
256
272
 
257
273
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
@@ -264,6 +280,19 @@ export function createServer() {
264
280
  // -------------------------------------------------------------------
265
281
  // Tool: whoami
266
282
  // -------------------------------------------------------------------
283
+ server.registerTool(
284
+ "login",
285
+ {
286
+ title: "Sign in to htmlhost",
287
+ description:
288
+ "Sign in to the user's htmlhost.co account. Opens an approval page in their browser and waits briefly; " +
289
+ "if they haven't approved yet, it returns the link and code to show them. Call it again after they approve. " +
290
+ "Use it when another htmlhost tool says you're not signed in.",
291
+ inputSchema: z.object({}),
292
+ },
293
+ wrap(async () => loginStep())
294
+ );
295
+
267
296
  server.registerTool(
268
297
  "whoami",
269
298
  {
@@ -272,7 +301,7 @@ export function createServer() {
272
301
  inputSchema: z.object({}),
273
302
  },
274
303
  wrap(async () => {
275
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
304
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
276
305
 
277
306
  const data = await apiGet("/api/tokens/me");
278
307
  return {
@@ -302,7 +331,7 @@ export function createServer() {
302
331
  }),
303
332
  },
304
333
  wrap(async ({ html, slug, title, ttl }) => {
305
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
334
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
306
335
 
307
336
  const body = { html };
308
337
  if (slug) body.slug = slug;
@@ -337,7 +366,7 @@ export function createServer() {
337
366
  }),
338
367
  },
339
368
  wrap(async ({ slug }) => {
340
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
369
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
341
370
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
342
371
  return apiGet(`/api/sites/${encodeURIComponent(cleanSlug)}/versions`);
343
372
  })
@@ -360,7 +389,7 @@ export function createServer() {
360
389
  }),
361
390
  },
362
391
  wrap(async ({ slug, version, confirm }) => {
363
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
392
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
364
393
  if (!confirm) return "Restore not confirmed. Set confirm to true to proceed.";
365
394
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
366
395
  const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, {});
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
+ }