htmlhost-mcp 1.5.0 → 1.6.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
@@ -4,6 +4,12 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that l
4
4
 
5
5
  Works with **Cursor**, **Claude Desktop**, **Windsurf**, **Cline**, **Zed**, and any MCP-compatible tool.
6
6
 
7
+ ## Editing contract
8
+
9
+ The server sends clients instructions to follow **inspect → stage → validate → repair → deploy**, with matching guidance on its pull and deploy tools. Read the [agent guide](https://htmlhost.co/agents.md) before editing. Preserve separate project files and shared header/footer markers, inherit the existing design on new pages, and validate desktop/mobile rendering with the client's own tools. Report checks that could not run.
10
+
11
+ MCP tools manage hosting; they do not execute the browser editor's validation loop. Update an existing project by deploying its complete folder with its slug, rather than sending one generated HTML file.
12
+
7
13
  ## Setup
8
14
 
9
15
  ### 1. Install
@@ -206,3 +212,15 @@ echo '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":1
206
212
  ## License
207
213
 
208
214
  MIT
215
+
216
+
217
+ ## Unpublished sites and drafts
218
+
219
+ Unpublished websites remain private until publishing is explicitly confirmed. Collaborators can publish with confirmation; only owners can unpublish. The server also cancels deployments still running when someone unpublishes.
220
+
221
+ - `deploy` / `deploy_html`: use `isDraft: true` to save without changing publication. To publish an unpublished site, obtain the user's explicit agreement to make it public, then pass `confirmPublish: true`.
222
+ - `restore_version` also requires `confirmPublish: true` for an unpublished website; its general `confirm` argument is insufficient.
223
+ - Never automatically retry a publication refusal with confirmation enabled. Older clients are safely blocked by the server.
224
+ - `unpublish_site`: owner only; pass `confirm: true` after agreement to take the site offline.
225
+ - `list_sites` returns `isPublished` and `draftVersion` for owned and shared sites.
226
+ - `pull_site` defaults to `source: "working"` (including the saved draft). Use `source: "published"` only to deliberately retrieve last-deployed files, or `source: "draft"` to require a draft.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "htmlhost-mcp",
3
- "version": "1.5.0",
4
- "description": "MCP server for htmlhost.co — deploy, list, and manage sites from AI coding tools",
3
+ "version": "1.6.0",
4
+ "description": "MCP server for htmlhost.co \u2014 deploy, list, and manage sites from AI coding tools",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "htmlhost-mcp": "bin/htmlhost-mcp.mjs"
@@ -33,5 +33,8 @@
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/server": "^2.1.0",
35
35
  "zod": "^4.6.5"
36
+ },
37
+ "scripts": {
38
+ "test": "node --experimental-test-module-mocks --test test/"
36
39
  }
37
40
  }
package/src/api.mjs CHANGED
@@ -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.5.0";
17
+ export const CLIENT_VERSION = "1.6.0";
18
18
  const DEFAULT_API = "https://htmlhost.co";
19
19
 
20
20
  export function readConfig() {
@@ -79,7 +79,7 @@ export async function apiGet(path) {
79
79
  return withRetry(async () => {
80
80
  const res = await fetch(`${getApi()}${path}`, { headers: headers() });
81
81
  const data = await res.json();
82
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
82
+ if (!res.ok) throw new ApiError(data.code === "PUBLICATION_CONFIRMATION_REQUIRED" ? `${data.error} Ask the user for explicit permission before retrying with confirmPublish: true, or use isDraft: true.` : data.error || `HTTP ${res.status}`, res.status);
83
83
  return data;
84
84
  }, `GET ${path}`);
85
85
  }
@@ -92,9 +92,9 @@ export async function apiPost(path, body) {
92
92
  body: JSON.stringify(body),
93
93
  });
94
94
  const data = await res.json();
95
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
95
+ if (!res.ok) throw new ApiError(data.code === "PUBLICATION_CONFIRMATION_REQUIRED" ? `${data.error} Ask the user for explicit permission before retrying with confirmPublish: true, or use isDraft: true.` : data.error || `HTTP ${res.status}`, res.status);
96
96
  return data;
97
- }, `POST ${path}`);
97
+ }, `POST ${path}`, body?.confirmPublish === true ? 1 : 3);
98
98
  }
99
99
 
100
100
  export async function apiDelete(path) {
@@ -104,7 +104,7 @@ export async function apiDelete(path) {
104
104
  headers: headers(),
105
105
  });
106
106
  const data = await res.json();
107
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
107
+ if (!res.ok) throw new ApiError(data.code === "PUBLICATION_CONFIRMATION_REQUIRED" ? `${data.error} Ask the user for explicit permission before retrying with confirmPublish: true, or use isDraft: true.` : data.error || `HTTP ${res.status}`, res.status);
108
108
  return data;
109
109
  }, `DELETE ${path}`);
110
110
  }
@@ -182,7 +182,7 @@ export function pagePathForFile(relativePath) {
182
182
  * Deploy a directory to htmlhost. Non-interactive version of CLI deploy.
183
183
  * Returns { slug, url, version, pageCount }.
184
184
  */
185
- export async function deployDirectory(dirPath, { slug = null, title = null, ttl = null } = {}) {
185
+ export async function deployDirectory(dirPath, { slug = null, title = null, ttl = null, confirmPublish = false, isDraft = false } = {}) {
186
186
  const resolvedDir = resolve(dirPath);
187
187
  const allFiles = findAllFiles(resolvedDir, resolvedDir);
188
188
 
@@ -248,7 +248,7 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
248
248
 
249
249
  // Final deploy. A root `_redirects` file (Netlify style) replaces the site's
250
250
  // redirects from this file; rules added in the dashboard stay. No file, no change.
251
- const body = { pages: pagePayloads, assets: assetPayloads };
251
+ const body = { pages: pagePayloads, assets: assetPayloads, confirmPublish, isDraft };
252
252
  const redirectsPath = join(resolvedDir, "_redirects");
253
253
  if (existsSync(redirectsPath)) body.redirects = readFileSync(redirectsPath, "utf8");
254
254
  if (ttl) body.ttl = ttl;
@@ -262,11 +262,11 @@ export async function deployDirectory(dirPath, { slug = null, title = null, ttl
262
262
  /**
263
263
  * Deploy a single HTML file. Non-interactive version of CLI deploy.
264
264
  */
265
- export async function deploySingleFile(filePath, { slug = null, title = null, ttl = null } = {}) {
265
+ export async function deploySingleFile(filePath, { slug = null, title = null, ttl = null, confirmPublish = false, isDraft = false } = {}) {
266
266
  const resolvedPath = resolve(filePath);
267
267
  const html = readFileSync(resolvedPath, "utf8");
268
268
 
269
- const body = { html };
269
+ const body = { html, confirmPublish, isDraft };
270
270
  if (ttl) body.ttl = ttl;
271
271
  if (slug) body.slug = slug;
272
272
  if (title) body.title = title;
package/src/server.mjs CHANGED
@@ -63,6 +63,14 @@ export function createServer() {
63
63
  const server = new McpServer({
64
64
  name: "htmlhost",
65
65
  version: VERSION,
66
+ }, {
67
+ instructions: "Read https://htmlhost.co/agents.md before building or editing a site. " +
68
+ "For existing sites: pull current files into a safe working directory, inspect source and the existing design, stage precise edits, validate, repair failures, then deploy. " +
69
+ "For focused edits, inspect the element, parent layout and relevant styles first; expand investigation as needed without skipping validation. " +
70
+ "When resuming staged work, verify the original project is unchanged and retain validation failures; never treat a pause as successful completion. " +
71
+ "Keep HTML/CSS/JS as separate files. New pages inherit the site's design, header, footer and dependencies; preserve shared markers and synchronize shared copies. " +
72
+ "Check references, forms and desktop/mobile rendering with your own tools, and disclose unavailable checks. These hosting tools do not run the editor's validation loop. " +
73
+ "Deploy the complete project folder with the existing slug when updating; do not replace a multi-file site with deploy_html or a lone HTML file.",
66
74
  });
67
75
 
68
76
  // -------------------------------------------------------------------
@@ -75,6 +83,7 @@ export function createServer() {
75
83
  description:
76
84
  "Deploy a file or directory to htmlhost.co. " +
77
85
  "Pass an absolute path to a directory (deploys all files) or a single HTML file. " +
86
+ "Inspect and validate changes before deploying. For an existing multi-file site, deploy its complete folder with slug to preserve its pages and assets. " +
78
87
  "Optionally set slug (to update an existing site), title, and ttl. " +
79
88
  "A `_redirects` file at the directory's root (one `/old /new [301|302]` per line) sets the site's redirects and isn't published.",
80
89
  inputSchema: z.object({
@@ -82,16 +91,18 @@ export function createServer() {
82
91
  slug: z.string().optional().describe("Existing site slug to update (omit to create a new site)"),
83
92
  title: z.string().optional().describe("Title for the site"),
84
93
  ttl: z.string().optional().describe("Time-to-live: 1d, 7d, 30d, or never"),
94
+ isDraft: z.boolean().optional().describe("Save files as a draft without publishing. Does not unpublish a live site."),
95
+ confirmPublish: z.boolean().optional().describe("Set true ONLY after the user explicitly agrees to make an unpublished website public. Ordinary editing or deploy instructions are not confirmation."),
85
96
  }),
86
97
  },
87
- wrap(async ({ path, slug, title, ttl }) => {
98
+ wrap(async ({ path, slug, title, ttl, isDraft, confirmPublish }) => {
88
99
  if (!isLoggedIn()) return NOT_SIGNED_IN;
89
100
 
90
101
  const resolvedPath = resolve(path);
91
102
  if (!existsSync(resolvedPath)) return `Path not found: ${path}`;
92
103
 
93
104
  const stat = statSync(resolvedPath);
94
- const opts = { slug, title, ttl };
105
+ const opts = { slug, title, ttl, isDraft, confirmPublish };
95
106
 
96
107
  let data;
97
108
  if (stat.isDirectory()) {
@@ -102,7 +113,9 @@ export function createServer() {
102
113
 
103
114
  const pc = data.pageCount || 1;
104
115
  return {
105
- message: "Deployed successfully",
116
+ message: data.isPublished === false ? "Saved as an unpublished draft" : isDraft ? "Draft saved; live site unchanged" : "Deployed successfully",
117
+ isPublished: data.isPublished,
118
+ draftVersion: data.draftVersion,
106
119
  url: `https://${data.url}`,
107
120
  slug: data.slug,
108
121
  version: data.version,
@@ -138,6 +151,8 @@ export function createServer() {
138
151
  size: s.size,
139
152
  ttl: s.ttl,
140
153
  isMultiPage: s.isMultiPage,
154
+ isPublished: s.isPublished,
155
+ draftVersion: s.draftVersion,
141
156
  expiresAt: s.expiresAt || null,
142
157
  }));
143
158
 
@@ -147,6 +162,8 @@ export function createServer() {
147
162
  title: s.title || null,
148
163
  owner: s.owner || null,
149
164
  isMultiPage: s.isMultiPage,
165
+ isPublished: s.isPublished,
166
+ draftVersion: s.draftVersion,
150
167
  expiresAt: s.expiresAt || null,
151
168
  }));
152
169
 
@@ -183,6 +200,9 @@ export function createServer() {
183
200
  url: `https://${cleanSlug}.htmlhost.co`,
184
201
  version: data.version,
185
202
  updatedAt: data.updatedAt,
203
+ isPublished: data.isPublished,
204
+ draftVersion: data.draftVersion,
205
+ source: data.source,
186
206
  pages: (data.pages || []).map((p) => ({
187
207
  path: p.path,
188
208
  sizeBytes: p.sizeBytes || (p.html ? Buffer.byteLength(p.html) : 0),
@@ -205,20 +225,22 @@ export function createServer() {
205
225
  title: "Pull site to local directory",
206
226
  description:
207
227
  "Download all pages and assets from a deployed htmlhost site into a local directory. " +
208
- "Creates the directory if it doesn't exist.",
228
+ "Creates the directory if it doesn't exist; existing files may be overwritten, so use a fresh directory when local work must be preserved. " +
229
+ "Read https://htmlhost.co/agents.md, inspect the downloaded source and design, then validate staged edits before deploying the complete folder.",
209
230
  inputSchema: z.object({
210
231
  slug: z.string().describe("The site slug to pull"),
211
232
  directory: z.string().describe("Absolute path to the local directory to write files into"),
233
+ source: z.enum(["working", "draft", "published"]).optional().describe("Default working includes the latest saved draft. published explicitly retrieves the last deployed files."),
212
234
  }),
213
235
  },
214
- wrap(async ({ slug, directory }) => {
236
+ wrap(async ({ slug, directory, source = "working" }) => {
215
237
  if (!isLoggedIn()) return NOT_SIGNED_IN;
216
238
 
217
239
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
218
240
  const destDir = resolve(directory);
219
241
  mkdirSync(destDir, { recursive: true });
220
242
 
221
- const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}`);
243
+ const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&source=${source}`);
222
244
  const written = [];
223
245
 
224
246
  // Write pages
@@ -256,6 +278,9 @@ export function createServer() {
256
278
 
257
279
  return {
258
280
  message: `Pulled ${written.length} files from ${cleanSlug}.htmlhost.co`,
281
+ source: data.source,
282
+ isPublished: data.isPublished,
283
+ draftVersion: data.draftVersion,
259
284
  directory: destDir,
260
285
  version: data.version,
261
286
  files: written,
@@ -333,18 +358,20 @@ export function createServer() {
333
358
  title: "Deploy HTML content",
334
359
  description:
335
360
  "Deploy raw HTML content directly (no local files needed). " +
336
- "Useful when an AI generates HTML and wants to deploy it immediately.",
361
+ "For standalone single-page output after validation. Do not use this to edit an existing multi-page or multi-asset project: pull_site, edit and validate locally, then deploy the complete folder with its slug.",
337
362
  inputSchema: z.object({
338
363
  html: z.string().describe("The HTML content to deploy"),
339
364
  slug: z.string().optional().describe("Existing site slug to update (omit to create new)"),
340
365
  title: z.string().optional().describe("Title for the site"),
341
366
  ttl: z.string().optional().describe("Time-to-live: 1d, 7d, 30d, or never"),
367
+ isDraft: z.boolean().optional().describe("Save files as a draft without publishing. Does not unpublish a live site."),
368
+ confirmPublish: z.boolean().optional().describe("Set true ONLY after the user explicitly agrees to make an unpublished website public. Ordinary editing or deploy instructions are not confirmation."),
342
369
  }),
343
370
  },
344
- wrap(async ({ html, slug, title, ttl }) => {
371
+ wrap(async ({ html, slug, title, ttl, isDraft, confirmPublish }) => {
345
372
  if (!isLoggedIn()) return NOT_SIGNED_IN;
346
373
 
347
- const body = { html };
374
+ const body = { html, isDraft, confirmPublish };
348
375
  if (slug) body.slug = slug;
349
376
  if (title) body.title = title;
350
377
  if (ttl) body.ttl = ttl;
@@ -352,7 +379,9 @@ export function createServer() {
352
379
  const data = await apiPost("/api/sites", body);
353
380
 
354
381
  return {
355
- message: "Deployed successfully",
382
+ message: data.isPublished === false ? "Saved as an unpublished draft" : isDraft ? "Draft saved; live site unchanged" : "Deployed successfully",
383
+ isPublished: data.isPublished,
384
+ draftVersion: data.draftVersion,
356
385
  url: `https://${data.url}`,
357
386
  slug: data.slug,
358
387
  version: data.version,
@@ -383,6 +412,16 @@ export function createServer() {
383
412
  })
384
413
  );
385
414
 
415
+ server.registerTool("unpublish_site", {
416
+ title: "Unpublish a website",
417
+ description: "Owner only: stop public serving while retaining files, saved drafts and history. Obtain the user's explicit confirmation first.",
418
+ inputSchema: z.object({ slug: z.string(), confirm: z.boolean().describe("True only after the user agrees to take this website offline") }),
419
+ }, wrap(async ({ slug, confirm }) => {
420
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
421
+ if (confirm !== true) return "Unpublish not confirmed.";
422
+ return apiPost(`/api/sites/${encodeURIComponent(slug.replace(/\.htmlhost\.co$/, ""))}/unpublish`, {});
423
+ }));
424
+
386
425
  // -------------------------------------------------------------------
387
426
  // Tool: restore_version
388
427
  // -------------------------------------------------------------------
@@ -397,13 +436,14 @@ export function createServer() {
397
436
  slug: z.string().describe("The site slug"),
398
437
  version: z.number().int().positive().describe("The version number to restore (from list_versions)"),
399
438
  confirm: z.boolean().describe("Must be true to confirm replacing the live site"),
439
+ confirmPublish: z.boolean().optional().describe("For unpublished sites, set true only after the user explicitly agrees to make the website public again."),
400
440
  }),
401
441
  },
402
- wrap(async ({ slug, version, confirm }) => {
442
+ wrap(async ({ slug, version, confirm, confirmPublish }) => {
403
443
  if (!isLoggedIn()) return NOT_SIGNED_IN;
404
444
  if (!confirm) return "Restore not confirmed. Set confirm to true to proceed.";
405
445
  const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
406
- const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, {});
446
+ const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, { confirmPublish });
407
447
  return {
408
448
  message: `Restored v${version}; it's live as v${data.version}.`,
409
449
  url: data.url ? (String(data.url).startsWith("http") ? data.url : `https://${data.url}`) : `https://${cleanSlug}.htmlhost.co`,