htmlhost-mcp 1.3.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
@@ -22,7 +22,9 @@ npx, keep `@latest` in the command: a bare `npx -y htmlhost-mcp` keeps reusing i
22
22
 
23
23
  ### 2. Authenticate
24
24
 
25
- 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:
26
28
 
27
29
  ```bash
28
30
  npx htmlhost-cli login
@@ -90,6 +92,7 @@ htmlhost-mcp
90
92
  | `get_site` | Get details about a specific site (pages, assets, version) |
91
93
  | `pull_site` | Download a site's files to a local directory |
92
94
  | `delete_site` | Permanently delete a site |
95
+ | `login` | Sign in: opens an approval page in your browser |
93
96
  | `whoami` | Show the current authenticated user |
94
97
  | `list_versions` | List a site's published versions and what each changed |
95
98
  | `restore_version` | Make an earlier version live again (Pro) |
@@ -149,6 +152,11 @@ slug (required) The site slug to delete
149
152
  confirm (required) Must be true — safety check
150
153
  ```
151
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
+
152
160
  ### `whoami`
153
161
 
154
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.3.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";
@@ -14,7 +14,7 @@ import { createHash } from "node:crypto";
14
14
  // ---------------------------------------------------------------------------
15
15
  const CONFIG_PATH = join(homedir(), ".htmlhostrc");
16
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";
17
+ export const CLIENT_VERSION = "1.4.0";
18
18
  const DEFAULT_API = "https://htmlhost.co";
19
19
 
20
20
  export function readConfig() {
@@ -56,7 +56,7 @@ class ApiError extends Error {
56
56
 
57
57
  function headers(extra = {}) {
58
58
  const token = getToken();
59
- if (!token) throw new ApiError("Not logged in. Run `htmlhost login` in a terminal first.", 0);
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
60
  return { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION, ...extra };
61
61
  }
62
62
 
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
@@ -27,6 +27,10 @@ import {
27
27
  CLIENT_VERSION,
28
28
  } from "./api.mjs";
29
29
  import { checkForUpdate } from "./update.mjs";
30
+ import { loginStep } from "./login.mjs";
31
+
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.";
30
34
 
31
35
  const VERSION = CLIENT_VERSION;
32
36
 
@@ -81,7 +85,7 @@ export function createServer() {
81
85
  }),
82
86
  },
83
87
  wrap(async ({ path, slug, title, ttl }) => {
84
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
88
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
85
89
 
86
90
  const resolvedPath = resolve(path);
87
91
  if (!existsSync(resolvedPath)) return `Path not found: ${path}`;
@@ -123,7 +127,7 @@ export function createServer() {
123
127
  inputSchema: z.object({}),
124
128
  },
125
129
  wrap(async () => {
126
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
130
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
127
131
 
128
132
  const data = await apiGet("/api/sites");
129
133
  const sites = (data.sites || []).map((s) => ({
@@ -158,7 +162,7 @@ export function createServer() {
158
162
  }),
159
163
  },
160
164
  wrap(async ({ slug }) => {
161
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
165
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
162
166
 
163
167
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
164
168
  const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&meta=true`);
@@ -197,7 +201,7 @@ export function createServer() {
197
201
  }),
198
202
  },
199
203
  wrap(async ({ slug, directory }) => {
200
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
204
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
201
205
 
202
206
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
203
207
  const destDir = resolve(directory);
@@ -263,7 +267,7 @@ export function createServer() {
263
267
  }),
264
268
  },
265
269
  wrap(async ({ slug, confirm }) => {
266
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
270
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
267
271
  if (!confirm) return "Deletion not confirmed. Set confirm to true to proceed.";
268
272
 
269
273
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
@@ -276,6 +280,19 @@ export function createServer() {
276
280
  // -------------------------------------------------------------------
277
281
  // Tool: whoami
278
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
+
279
296
  server.registerTool(
280
297
  "whoami",
281
298
  {
@@ -284,7 +301,7 @@ export function createServer() {
284
301
  inputSchema: z.object({}),
285
302
  },
286
303
  wrap(async () => {
287
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
304
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
288
305
 
289
306
  const data = await apiGet("/api/tokens/me");
290
307
  return {
@@ -314,7 +331,7 @@ export function createServer() {
314
331
  }),
315
332
  },
316
333
  wrap(async ({ html, slug, title, ttl }) => {
317
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
334
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
318
335
 
319
336
  const body = { html };
320
337
  if (slug) body.slug = slug;
@@ -349,7 +366,7 @@ export function createServer() {
349
366
  }),
350
367
  },
351
368
  wrap(async ({ slug }) => {
352
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
369
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
353
370
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
354
371
  return apiGet(`/api/sites/${encodeURIComponent(cleanSlug)}/versions`);
355
372
  })
@@ -372,7 +389,7 @@ export function createServer() {
372
389
  }),
373
390
  },
374
391
  wrap(async ({ slug, version, confirm }) => {
375
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
392
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
376
393
  if (!confirm) return "Restore not confirmed. Set confirm to true to proceed.";
377
394
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
378
395
  const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, {});