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