htmlhost-mcp 1.6.0 → 1.8.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,8 @@ 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
+ **Using Claude with saved htmlhost projects?** Open [htmlhost in Claude’s directory](https://claude.ai/directory/htmlhost), choose **Connect**, then sign in to htmlhost and approve. Enable htmlhost in your chat; the same connection works on web and mobile. No npm install or server URL is needed. The setup below is for coding tools working with local files.
8
+
7
9
  ## Editing contract
8
10
 
9
11
  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.
@@ -95,13 +97,15 @@ htmlhost-mcp
95
97
  | `deploy` | Deploy a local file or directory to htmlhost.co |
96
98
  | `deploy_html` | Deploy raw HTML content directly (no local files needed) |
97
99
  | `list_sites` | List your sites and the ones shared with you |
98
- | `get_site` | Get details about a specific site (pages, assets, version) |
100
+ | `get_site` | Get details about a specific site (pages, assets, version, keys, secure calls, collections) |
99
101
  | `pull_site` | Download a site's files to a local directory |
100
102
  | `delete_site` | Permanently delete a site |
101
103
  | `login` | Sign in: opens an approval page in your browser |
102
104
  | `whoami` | Show the current authenticated user |
103
105
  | `list_versions` | List a site's published versions and what each changed |
104
106
  | `restore_version` | Make an earlier version live again (Pro) |
107
+ | `set_site_key` | Save a key the user gave you to a site's Keys (public or secret) |
108
+ | `remove_site_key` | Remove a key from a site's Keys |
105
109
 
106
110
  ### `deploy`
107
111
 
@@ -132,7 +136,7 @@ ttl (optional) Time-to-live: 1d, 7d, 30d, or never
132
136
 
133
137
  ### `list_sites`
134
138
 
135
- 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.
139
+ Returns all your sites, their URLs, sizes, TTLs, and plan usage, plus `shared`: the sites shared with you, with their owner and your `role` (`admin`, `editor`, `contributor` or `viewer`). Admins and Editors pull, deploy to and restore those with your own key; Contributors pull and save drafts but can't publish; Viewers can't change them. Only the owner can delete a site.
136
140
 
137
141
  ### `get_site`
138
142
 
@@ -140,7 +144,37 @@ Returns all your sites, their URLs, sizes, TTLs, and plan usage, plus `shared`:
140
144
  slug (required) The site slug
141
145
  ```
142
146
 
143
- Returns version, page routes, asset paths and sizes.
147
+ Returns version, page routes, asset paths and sizes. When the site has them, also:
148
+
149
+ - `keys`: the keys its owner saved for the services it uses, each with its `name`, `kind` (`public` or `secret`) and note. Only public keys carry a `value`; a secret key's value is never sent.
150
+ - `secureCalls`: how a page uses a secret key. Each has a `name`, the `path` a page POSTs to (`/__hh/call/<name>`), the `fields` it may send and the parts of the answer it `returns`.
151
+ - `collections`: the site's own data. Each has a `name`, its `path` (`/__hh/data/<name>`), its `fields` and its `read`/`add` rules. Records are never listed.
152
+
153
+ For questions about sign-in, members or private pages, the [agent guide](https://htmlhost.co/agents.md) has a Sign-in and access control section: htmlhost has a whole-site password (Pro) and team accounts, and individual visitor accounts use a browser sign-in service.
154
+
155
+ Secure calls and collections are set up in the site's dashboard (Keys and Data tabs). Keys can also be saved from your AI tool (below). See [Site keys](https://htmlhost.co/agents.md) in the agent guide, and the guides on [payments](https://htmlhost.co/guides/accept-payments-on-your-website), [API keys](https://htmlhost.co/guides/use-api-keys-safely-on-your-website) and [collections](https://htmlhost.co/guides/add-a-database-to-your-website).
156
+
157
+ ### `set_site_key`
158
+
159
+ ```
160
+ slug (required) The site slug
161
+ name (required) The key's name, e.g. STRIPE_SECRET_KEY
162
+ value (required) The key's value, exactly as the user gave it
163
+ kind (optional) public or secret: only if the user said which
164
+ note (optional) What it's for
165
+ confirmPublic (optional) Only after the user confirms a secret-looking value should be public
166
+ ```
167
+
168
+ Adds the key, or replaces the value of a key with that name. Without `kind`, htmlhost works it out from the value (`sk_…` secret, `pk_…` public, and similar formats) or the name. When it can't tell, it refuses with `KEY_KIND_UNKNOWN`, and the tool asks the user. Saving a secret-looking value as public is refused with `KEY_LOOKS_SECRET` until the user confirms. The site's owner or an Admin only, on Pro. The result never contains the value.
169
+
170
+ ### `remove_site_key`
171
+
172
+ ```
173
+ slug (required) The site slug
174
+ name (required) The key's name
175
+ ```
176
+
177
+ Removes the key. Refused while a secure call uses it.
144
178
 
145
179
  ### `pull_site`
146
180
 
@@ -149,7 +183,7 @@ slug (required) The site slug to pull
149
183
  directory (required) Absolute path to write files into
150
184
  ```
151
185
 
152
- Downloads all pages and assets into the specified directory.
186
+ Downloads all pages and assets into the specified directory. The result includes the same `keys`, `secureCalls` and `collections` as `get_site`.
153
187
 
154
188
  ### `delete_site`
155
189
 
@@ -216,7 +250,7 @@ MIT
216
250
 
217
251
  ## Unpublished sites and drafts
218
252
 
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.
253
+ Unpublished websites remain private until publishing is explicitly confirmed. Admins and Editors on a shared site can publish with confirmation and unpublish; Contributors save drafts only. The server also cancels deployments still running when someone unpublishes.
220
254
 
221
255
  - `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
256
  - `restore_version` also requires `confirmPublish: true` for an unpublished website; its general `confirm` argument is insufficient.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "htmlhost-mcp",
3
- "version": "1.6.0",
4
- "description": "MCP server for htmlhost.co \u2014 deploy, list, and manage sites from AI coding tools",
3
+ "version": "1.8.0",
4
+ "description": "MCP server for htmlhost.co — deploy, list, and manage sites from AI coding tools",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "htmlhost-mcp": "bin/htmlhost-mcp.mjs"
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.6.0";
17
+ export const CLIENT_VERSION = "1.8.0";
18
18
  const DEFAULT_API = "https://htmlhost.co";
19
19
 
20
20
  export function readConfig() {
@@ -54,6 +54,13 @@ class ApiError extends Error {
54
54
  }
55
55
  }
56
56
 
57
+ /** An error's message for the AI tool: a key refusal carries its code (KEY_KIND_UNKNOWN…), which the tool descriptions name. */
58
+ function errorText(data, status) {
59
+ if (data.code === "PUBLICATION_CONFIRMATION_REQUIRED") return `${data.error} Ask the user for explicit permission before retrying with confirmPublish: true, or use isDraft: true.`;
60
+ if (typeof data.code === "string" && /^KEYS?_/.test(data.code)) return `${data.code}: ${data.error}`;
61
+ return data.error || `HTTP ${status}`;
62
+ }
63
+
57
64
  function headers(extra = {}) {
58
65
  const token = getToken();
59
66
  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);
@@ -79,7 +86,7 @@ export async function apiGet(path) {
79
86
  return withRetry(async () => {
80
87
  const res = await fetch(`${getApi()}${path}`, { headers: headers() });
81
88
  const data = await res.json();
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);
89
+ if (!res.ok) throw new ApiError(errorText(data, res.status), res.status);
83
90
  return data;
84
91
  }, `GET ${path}`);
85
92
  }
@@ -92,7 +99,7 @@ export async function apiPost(path, body) {
92
99
  body: JSON.stringify(body),
93
100
  });
94
101
  const data = await res.json();
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);
102
+ if (!res.ok) throw new ApiError(errorText(data, res.status), res.status);
96
103
  return data;
97
104
  }, `POST ${path}`, body?.confirmPublish === true ? 1 : 3);
98
105
  }
@@ -104,7 +111,7 @@ export async function apiDelete(path) {
104
111
  headers: headers(),
105
112
  });
106
113
  const data = await res.json();
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);
114
+ if (!res.ok) throw new ApiError(errorText(data, res.status), res.status);
108
115
  return data;
109
116
  }, `DELETE ${path}`);
110
117
  }
package/src/server.mjs CHANGED
@@ -41,6 +41,37 @@ const VERSION = CLIENT_VERSION;
41
41
  let updateNotice = null;
42
42
  let updateNoticeShown = false;
43
43
 
44
+ /**
45
+ * The keys a site's owner saved for the services it uses, as the server sends
46
+ * them: every key's name and kind, and the value of public keys only. A
47
+ * secret's value is never sent. Nothing is added for a site without keys.
48
+ */
49
+ export function siteKeys(data) {
50
+ const keys = Array.isArray(data?.keys) ? data.keys : [];
51
+ const calls = Array.isArray(data?.calls) ? data.calls : [];
52
+ const collections = Array.isArray(data?.collections) ? data.collections : [];
53
+ if (!keys.length && !calls.length && !collections.length) return {};
54
+ return {
55
+ keys,
56
+ ...(calls.length ? {
57
+ secureCalls: calls,
58
+ secureCallsNote:
59
+ "A secure call is how page code uses a secret key: htmlhost makes the call. From a page of the site, fetch(call.path, { method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify({ ...its fields }) }) " +
60
+ "and read { ok, status, data, error }. Calls work on the published site only. See https://htmlhost.co/agents.md (Secure calls).",
61
+ } : {}),
62
+ ...(collections.length ? {
63
+ collections,
64
+ collectionsNote:
65
+ "The site's own data. A page reads a collection whose read rule is public with fetch(collection.path) → { ok, records, next }, and adds to one whose add rule is public with " +
66
+ "POST JSON of its fields → { ok, record, error }. Records are never listed here. Collections work on the published site only. See https://htmlhost.co/agents.md (Collections).",
67
+ } : {}),
68
+ keysNote:
69
+ "A public key's value may be written into the site's pages exactly as given. " +
70
+ "Never write, guess or ask the user to paste a secret key's value into any site file: every visitor can read a site's files. Refer to a secret key by its name only, " +
71
+ "and say plainly when a request needs a step that can't run from the page. When the user gives you a key, save it with set_site_key (htmlhost works out public or secret, and asks you to check with the user only when it can't).",
72
+ };
73
+ }
74
+
44
75
  function wrap(fn) {
45
76
  return async (args) => {
46
77
  try {
@@ -69,8 +100,10 @@ export function createServer() {
69
100
  "For focused edits, inspect the element, parent layout and relevant styles first; expand investigation as needed without skipping validation. " +
70
101
  "When resuming staged work, verify the original project is unchanged and retain validation failures; never treat a pause as successful completion. " +
71
102
  "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.",
103
+ "Check references, forms and desktop/mobile rendering with your own tools, and disclose unavailable checks. A frozen static render cannot verify animation playback. These hosting tools do not run the editor's validation loop. " +
104
+ "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. " +
105
+ "get_site and pull_site list the site's keys (payment, mailing-list and other service keys its owner saved) and its secure calls: use a public key's value as given, never put a secret key's value in a site file, use a secret key only through a secure call, and use the site's collections only as their read/add rules allow. " +
106
+ "For questions about login, members or private pages, answer from the agent guide's Sign-in and access control section: htmlhost has a whole-site password (Pro) and team accounts, not individual visitor accounts; those use a browser sign-in service such as Clerk, Auth0, Firebase Authentication or Supabase Auth.",
74
107
  });
75
108
 
76
109
  // -------------------------------------------------------------------
@@ -135,9 +168,11 @@ export function createServer() {
135
168
  {
136
169
  title: "List sites",
137
170
  description:
138
- "List the authenticated user's sites on htmlhost.co, and the sites shared with them as a collaborator (`shared`). " +
171
+ "List the authenticated user's sites on htmlhost.co, and the sites shared with them (`shared`, with the user's `role`). " +
139
172
  "Returns slug, URL, title, size, TTL, and expiration for each site. " +
140
- "Shared sites can be pulled, deployed to and restored with this key like your own, but only their owner can delete them.",
173
+ "On a shared site the role decides: admin and editor pull, deploy and restore; contributor pulls and saves drafts " +
174
+ "(isDraft: true) but can't publish, so ask the owner, an admin or an editor to publish; viewer changes nothing. " +
175
+ "Only the owner can delete a site.",
141
176
  inputSchema: z.object({}),
142
177
  },
143
178
  wrap(async () => {
@@ -161,6 +196,9 @@ export function createServer() {
161
196
  url: `https://${s.slug}.htmlhost.co`,
162
197
  title: s.title || null,
163
198
  owner: s.owner || null,
199
+ role: s.role || "editor",
200
+ canEdit: s.canEdit ?? true,
201
+ canPublish: s.canPublish ?? true,
164
202
  isMultiPage: s.isMultiPage,
165
203
  isPublished: s.isPublished,
166
204
  draftVersion: s.draftVersion,
@@ -184,7 +222,7 @@ export function createServer() {
184
222
  {
185
223
  title: "Get site details",
186
224
  description:
187
- "Get detailed information about a specific site including its pages and assets.",
225
+ "Get detailed information about a specific site including its pages and assets, the keys its owner saved for the services it uses (names, and the values of public keys only), its secure calls, and its collections (shapes and rules, never records).",
188
226
  inputSchema: z.object({
189
227
  slug: z.string().describe("The site slug (e.g. 'my-site')"),
190
228
  }),
@@ -212,6 +250,7 @@ export function createServer() {
212
250
  mimeType: a.mimeType,
213
251
  sizeBytes: a.sizeBytes,
214
252
  })),
253
+ ...siteKeys(data),
215
254
  };
216
255
  })
217
256
  );
@@ -284,6 +323,7 @@ export function createServer() {
284
323
  directory: destDir,
285
324
  version: data.version,
286
325
  files: written,
326
+ ...siteKeys(data),
287
327
  };
288
328
  })
289
329
  );
@@ -313,6 +353,62 @@ export function createServer() {
313
353
  })
314
354
  );
315
355
 
356
+ // -------------------------------------------------------------------
357
+ // Tools: set_site_key, remove_site_key (the site's Keys; site-keys.ts)
358
+ // -------------------------------------------------------------------
359
+ server.registerTool(
360
+ "set_site_key",
361
+ {
362
+ title: "Save site key",
363
+ description:
364
+ "Save a key the user gave you (a payment gateway's, a mailing list's, an analytics ID) to a site's Keys, by name, so its pages and secure calls can use it. " +
365
+ "Adds it, or replaces the value of a key with that name. Leave kind out unless the user said which it is: htmlhost works it out from the value " +
366
+ "(sk_… secret, pk_… public, and similar) and, when it can't, refuses with KEY_KIND_UNKNOWN: then ask the user whether it's public or secret and call again with kind. " +
367
+ "A secret-looking value saved as public is refused with KEY_LOOKS_SECRET unless the user confirms (confirmPublic: true). " +
368
+ "The site's owner or an Admin only, on Pro. The result never holds the value: never repeat a secret back to the user, and never write one into a site file.",
369
+ inputSchema: z.object({
370
+ slug: z.string().describe("The site slug"),
371
+ name: z.string().describe("The key's name, e.g. STRIPE_SECRET_KEY (stored in UPPER_SNAKE_CASE)"),
372
+ value: z.string().describe("The key's value, exactly as the user gave it"),
373
+ kind: z.enum(["public", "secret"]).optional().describe("Only if the user said which it is; otherwise leave out and htmlhost decides or asks you to check"),
374
+ note: z.string().optional().describe("What the key is for, in a few words"),
375
+ confirmPublic: z.boolean().optional().describe("Only after the user confirms a secret-looking value should be public"),
376
+ }),
377
+ },
378
+ wrap(async ({ slug, name, value, kind, note, confirmPublic }) => {
379
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
380
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
381
+ const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/keys`, { name, value, kind, note, confirmPublic });
382
+ return {
383
+ slug: cleanSlug,
384
+ name: data.key.name,
385
+ kind: data.key.kind,
386
+ created: data.created,
387
+ message: data.key.kind === "secret"
388
+ ? `Saved ${data.key.name} as a secret key. Its value is never shown again; pages use it only through a secure call.`
389
+ : `Saved ${data.key.name} as a public key. Pages may use its value as given.`,
390
+ };
391
+ })
392
+ );
393
+
394
+ server.registerTool(
395
+ "remove_site_key",
396
+ {
397
+ title: "Remove site key",
398
+ description: "Remove a key from a site's Keys by name, when the user asks. Refused while a secure call uses it. The site's owner or an Admin only.",
399
+ inputSchema: z.object({
400
+ slug: z.string().describe("The site slug"),
401
+ name: z.string().describe("The key's name"),
402
+ }),
403
+ },
404
+ wrap(async ({ slug, name }) => {
405
+ if (!isLoggedIn()) return NOT_SIGNED_IN;
406
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
407
+ const data = await apiDelete(`/api/sites/${encodeURIComponent(cleanSlug)}/keys?name=${encodeURIComponent(name)}`);
408
+ return { slug: cleanSlug, name, removed: data.removed, message: data.removed ? `Removed ${name}.` : `${cleanSlug} has no key named ${name}.` };
409
+ })
410
+ );
411
+
316
412
  // -------------------------------------------------------------------
317
413
  // Tool: whoami
318
414
  // -------------------------------------------------------------------
@@ -414,7 +510,7 @@ export function createServer() {
414
510
 
415
511
  server.registerTool("unpublish_site", {
416
512
  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.",
513
+ description: "For the owner, an admin or an editor: stop public serving while retaining files, saved drafts and history. Obtain the user's explicit confirmation first.",
418
514
  inputSchema: z.object({ slug: z.string(), confirm: z.boolean().describe("True only after the user agrees to take this website offline") }),
419
515
  }, wrap(async ({ slug, confirm }) => {
420
516
  if (!isLoggedIn()) return NOT_SIGNED_IN;