htmlhost-mcp 1.1.0 → 1.3.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 +13 -0
- package/package.json +1 -1
- package/src/api.mjs +21 -5
- package/src/server.mjs +23 -3
- package/src/update.mjs +118 -0
package/README.md
CHANGED
|
@@ -12,6 +12,14 @@ 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
25
|
The MCP server reuses the same token as the CLI. If you've already run `htmlhost login`, you're set.
|
|
@@ -97,6 +105,11 @@ title (optional) Title for the site
|
|
|
97
105
|
ttl (optional) Time-to-live: 1d, 7d, 30d, or never
|
|
98
106
|
```
|
|
99
107
|
|
|
108
|
+
A `_redirects` file at the root of a deployed directory sets the site's redirects,
|
|
109
|
+
one per line: `/old-page /new-page`, optionally followed by `301` (default) or `302`.
|
|
110
|
+
It isn't published. Each deploy with the file replaces the redirects it set before;
|
|
111
|
+
redirects added in the dashboard stay. `pull_site` writes the file back.
|
|
112
|
+
|
|
100
113
|
### `deploy_html`
|
|
101
114
|
|
|
102
115
|
Deploy generated HTML directly — no files on disk needed. Perfect for when an AI assistant generates a webpage and wants to make it live immediately.
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Reuses the same ~/.htmlhostrc token and API base as the CLI,
|
|
5
5
|
* so `htmlhost login` is the only setup step needed.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync, existsSync } from "node:fs";
|
|
8
8
|
import { join, basename, dirname, resolve } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
@@ -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.3.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
|
}
|
|
@@ -48,7 +57,7 @@ class ApiError extends Error {
|
|
|
48
57
|
function headers(extra = {}) {
|
|
49
58
|
const token = getToken();
|
|
50
59
|
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 };
|
|
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) {
|
|
@@ -132,6 +141,8 @@ const DEFAULT_IGNORE = [
|
|
|
132
141
|
".gitignore", ".npmrc", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
133
142
|
"AGENTS.md", "CLAUDE.md", "GEMINI.md", ".claude", ".cursor", ".cursorrules",
|
|
134
143
|
".windsurfrules", ".github",
|
|
144
|
+
// Redirect rules: sent with the deploy and applied by htmlhost, never published as a file.
|
|
145
|
+
"_redirects",
|
|
135
146
|
];
|
|
136
147
|
|
|
137
148
|
function shouldIgnore(name) {
|
|
@@ -184,6 +195,8 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
|
|
|
184
195
|
|
|
185
196
|
const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
|
|
186
197
|
const assetFiles = allFiles.filter((f) => !f.relativePath.endsWith(".html"));
|
|
198
|
+
// Decided before upload-url may hand back a generated slug for the new site.
|
|
199
|
+
const isNewSite = !slug;
|
|
187
200
|
|
|
188
201
|
// Build page payloads
|
|
189
202
|
const pagePayloads = [];
|
|
@@ -232,12 +245,15 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
|
|
|
232
245
|
}
|
|
233
246
|
}
|
|
234
247
|
|
|
235
|
-
// Final deploy
|
|
248
|
+
// Final deploy. A root `_redirects` file (Netlify style) replaces the site's
|
|
249
|
+
// redirects from this file; rules added in the dashboard stay. No file, no change.
|
|
236
250
|
const body = { pages: pagePayloads, assets: assetPayloads };
|
|
251
|
+
const redirectsPath = join(resolvedDir, "_redirects");
|
|
252
|
+
if (existsSync(redirectsPath)) body.redirects = readFileSync(redirectsPath, "utf8");
|
|
237
253
|
if (ttl) body.ttl = ttl;
|
|
238
254
|
if (slug) body.slug = slug;
|
|
239
255
|
if (title) body.title = title;
|
|
240
|
-
if (
|
|
256
|
+
if (isNewSite) body.isNewSite = true;
|
|
241
257
|
|
|
242
258
|
return await apiPost("/api/sites", body);
|
|
243
259
|
}
|
package/src/server.mjs
CHANGED
|
@@ -24,18 +24,28 @@ import {
|
|
|
24
24
|
findAllFiles,
|
|
25
25
|
pagePathForFile,
|
|
26
26
|
mimeFromExt,
|
|
27
|
+
CLIENT_VERSION,
|
|
27
28
|
} from "./api.mjs";
|
|
29
|
+
import { checkForUpdate } from "./update.mjs";
|
|
28
30
|
|
|
29
|
-
const VERSION =
|
|
31
|
+
const VERSION = CLIENT_VERSION;
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Helper: wraps a handler to return proper MCP content and catch errors.
|
|
33
35
|
*/
|
|
36
|
+
/** An update notice (update.mjs), shown once: with the first tool result after the check finishes. */
|
|
37
|
+
let updateNotice = null;
|
|
38
|
+
let updateNoticeShown = false;
|
|
39
|
+
|
|
34
40
|
function wrap(fn) {
|
|
35
41
|
return async (args) => {
|
|
36
42
|
try {
|
|
37
43
|
const result = await fn(args);
|
|
38
|
-
|
|
44
|
+
let text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
45
|
+
if (updateNotice && !updateNoticeShown) {
|
|
46
|
+
updateNoticeShown = true;
|
|
47
|
+
text += `\n\nNote for the user: ${updateNotice}`;
|
|
48
|
+
}
|
|
39
49
|
return { content: [{ type: "text", text }] };
|
|
40
50
|
} catch (e) {
|
|
41
51
|
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
@@ -44,6 +54,8 @@ function wrap(fn) {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export function createServer() {
|
|
57
|
+
// In the background: never delays starting up or a tool call.
|
|
58
|
+
void checkForUpdate(VERSION).then((notice) => { updateNotice = notice; }).catch(() => {});
|
|
47
59
|
const server = new McpServer({
|
|
48
60
|
name: "htmlhost",
|
|
49
61
|
version: VERSION,
|
|
@@ -59,7 +71,8 @@ export function createServer() {
|
|
|
59
71
|
description:
|
|
60
72
|
"Deploy a file or directory to htmlhost.co. " +
|
|
61
73
|
"Pass an absolute path to a directory (deploys all files) or a single HTML file. " +
|
|
62
|
-
"Optionally set slug (to update an existing site), title, and ttl."
|
|
74
|
+
"Optionally set slug (to update an existing site), title, and ttl. " +
|
|
75
|
+
"A `_redirects` file at the directory's root (one `/old /new [301|302]` per line) sets the site's redirects and isn't published.",
|
|
63
76
|
inputSchema: z.object({
|
|
64
77
|
path: z.string().describe("Absolute path to the file or directory to deploy"),
|
|
65
78
|
slug: z.string().optional().describe("Existing site slug to update (omit to create a new site)"),
|
|
@@ -92,6 +105,7 @@ export function createServer() {
|
|
|
92
105
|
pages: pc,
|
|
93
106
|
ttl: data.ttl,
|
|
94
107
|
expiresAt: data.expiresAt || null,
|
|
108
|
+
...(data.redirects ? { redirects: data.redirects } : {}),
|
|
95
109
|
};
|
|
96
110
|
})
|
|
97
111
|
);
|
|
@@ -219,6 +233,12 @@ export function createServer() {
|
|
|
219
233
|
written.push(asset.path);
|
|
220
234
|
}
|
|
221
235
|
|
|
236
|
+
// Redirects that came from a deployed _redirects file
|
|
237
|
+
if (data.redirectsFile) {
|
|
238
|
+
writeFileSync(join(destDir, "_redirects"), data.redirectsFile, "utf8");
|
|
239
|
+
written.push("_redirects");
|
|
240
|
+
}
|
|
241
|
+
|
|
222
242
|
return {
|
|
223
243
|
message: `Pulled ${written.length} files from ${cleanSlug}.htmlhost.co`,
|
|
224
244
|
directory: destDir,
|
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
|
+
}
|