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 +11 -3
- package/package.json +1 -1
- package/src/api.mjs +6 -5
- package/src/login.mjs +93 -0
- package/src/server.mjs +39 -11
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
|
-
|
|
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
|
|
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
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 `
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`, {});
|