htmlhost-mcp 1.3.0 → 1.5.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
@@ -86,10 +88,11 @@ htmlhost-mcp
86
88
  |---|---|
87
89
  | `deploy` | Deploy a local file or directory to htmlhost.co |
88
90
  | `deploy_html` | Deploy raw HTML content directly (no local files needed) |
89
- | `list_sites` | List all your sites |
91
+ | `list_sites` | List your sites and the ones shared with you |
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) |
@@ -123,7 +126,7 @@ ttl (optional) Time-to-live: 1d, 7d, 30d, or never
123
126
 
124
127
  ### `list_sites`
125
128
 
126
- Returns all sites, their URLs, sizes, TTLs, and plan usage.
129
+ Returns all your sites, their URLs, sizes, TTLs, and plan usage, plus `shared`: the sites shared with you as a collaborator, with their owner. You can pull, deploy to and restore those with your own key; only the owner can delete them.
127
130
 
128
131
  ### `get_site`
129
132
 
@@ -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.5.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,10 +2,10 @@
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
- import { join, basename, dirname, resolve } from "node:path";
8
+ import { join, basename, dirname, resolve, sep } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { createHash } from "node:crypto";
11
11
 
@@ -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.5.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
 
@@ -161,7 +161,8 @@ export function findAllFiles(baseDir, currentDir) {
161
161
  } else if (entry.isFile()) {
162
162
  let size;
163
163
  try { size = statSync(fullPath).size; } catch { continue; }
164
- results.push({ filePath: fullPath, relativePath: fullPath.slice(baseDir.length + 1), size });
164
+ // Always with "/" (Windows paths have "\", an address no browser can ask for).
165
+ results.push({ filePath: fullPath, relativePath: fullPath.slice(baseDir.length + 1).split(sep).join("/"), size });
165
166
  }
166
167
  }
167
168
  return results;
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}`;
@@ -118,12 +122,13 @@ export function createServer() {
118
122
  {
119
123
  title: "List sites",
120
124
  description:
121
- "List all sites owned by the authenticated user on htmlhost.co. " +
122
- "Returns slug, URL, title, size, TTL, and expiration for each site.",
125
+ "List the authenticated user's sites on htmlhost.co, and the sites shared with them as a collaborator (`shared`). " +
126
+ "Returns slug, URL, title, size, TTL, and expiration for each site. " +
127
+ "Shared sites can be pulled, deployed to and restored with this key like your own, but only their owner can delete them.",
123
128
  inputSchema: z.object({}),
124
129
  },
125
130
  wrap(async () => {
126
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
131
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
127
132
 
128
133
  const data = await apiGet("/api/sites");
129
134
  const sites = (data.sites || []).map((s) => ({
@@ -136,10 +141,20 @@ export function createServer() {
136
141
  expiresAt: s.expiresAt || null,
137
142
  }));
138
143
 
144
+ const shared = (data.shared || []).map((s) => ({
145
+ slug: s.slug,
146
+ url: `https://${s.slug}.htmlhost.co`,
147
+ title: s.title || null,
148
+ owner: s.owner || null,
149
+ isMultiPage: s.isMultiPage,
150
+ expiresAt: s.expiresAt || null,
151
+ }));
152
+
139
153
  return {
140
154
  count: sites.length,
141
155
  usage: data.usage,
142
156
  sites,
157
+ ...(shared.length && { shared }),
143
158
  };
144
159
  })
145
160
  );
@@ -158,7 +173,7 @@ export function createServer() {
158
173
  }),
159
174
  },
160
175
  wrap(async ({ slug }) => {
161
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
176
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
162
177
 
163
178
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
164
179
  const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&meta=true`);
@@ -197,7 +212,7 @@ export function createServer() {
197
212
  }),
198
213
  },
199
214
  wrap(async ({ slug, directory }) => {
200
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
215
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
201
216
 
202
217
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
203
218
  const destDir = resolve(directory);
@@ -263,7 +278,7 @@ export function createServer() {
263
278
  }),
264
279
  },
265
280
  wrap(async ({ slug, confirm }) => {
266
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
281
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
267
282
  if (!confirm) return "Deletion not confirmed. Set confirm to true to proceed.";
268
283
 
269
284
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
@@ -276,6 +291,19 @@ export function createServer() {
276
291
  // -------------------------------------------------------------------
277
292
  // Tool: whoami
278
293
  // -------------------------------------------------------------------
294
+ server.registerTool(
295
+ "login",
296
+ {
297
+ title: "Sign in to htmlhost",
298
+ description:
299
+ "Sign in to the user's htmlhost.co account. Opens an approval page in their browser and waits briefly; " +
300
+ "if they haven't approved yet, it returns the link and code to show them. Call it again after they approve. " +
301
+ "Use it when another htmlhost tool says you're not signed in.",
302
+ inputSchema: z.object({}),
303
+ },
304
+ wrap(async () => loginStep())
305
+ );
306
+
279
307
  server.registerTool(
280
308
  "whoami",
281
309
  {
@@ -284,7 +312,7 @@ export function createServer() {
284
312
  inputSchema: z.object({}),
285
313
  },
286
314
  wrap(async () => {
287
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
315
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
288
316
 
289
317
  const data = await apiGet("/api/tokens/me");
290
318
  return {
@@ -314,7 +342,7 @@ export function createServer() {
314
342
  }),
315
343
  },
316
344
  wrap(async ({ html, slug, title, ttl }) => {
317
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
345
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
318
346
 
319
347
  const body = { html };
320
348
  if (slug) body.slug = slug;
@@ -349,7 +377,7 @@ export function createServer() {
349
377
  }),
350
378
  },
351
379
  wrap(async ({ slug }) => {
352
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
380
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
353
381
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
354
382
  return apiGet(`/api/sites/${encodeURIComponent(cleanSlug)}/versions`);
355
383
  })
@@ -372,7 +400,7 @@ export function createServer() {
372
400
  }),
373
401
  },
374
402
  wrap(async ({ slug, version, confirm }) => {
375
- if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
403
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
376
404
  if (!confirm) return "Restore not confirmed. Set confirm to true to proceed.";
377
405
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
378
406
  const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, {});