htmlhost-mcp 1.0.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 ADDED
@@ -0,0 +1,167 @@
1
+ # htmlhost MCP Server
2
+
3
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that lets AI coding tools deploy and manage sites on [htmlhost.co](https://htmlhost.co).
4
+
5
+ Works with **Cursor**, **Claude Desktop**, **Windsurf**, **Cline**, **Zed**, and any MCP-compatible tool.
6
+
7
+ ## Setup
8
+
9
+ ### 1. Install
10
+
11
+ ```bash
12
+ npm install -g htmlhost-mcp
13
+ ```
14
+
15
+ ### 2. Authenticate
16
+
17
+ The MCP server reuses the same token as the CLI. If you've already run `htmlhost login`, you're set.
18
+
19
+ ```bash
20
+ npx htmlhost-cli login
21
+ ```
22
+
23
+ ### 3. Configure your AI tool
24
+
25
+ #### Claude Desktop / Claude Code
26
+
27
+ Add to your `claude_desktop_config.json`:
28
+
29
+ ```json
30
+ {
31
+ "mcpServers": {
32
+ "htmlhost": {
33
+ "command": "htmlhost-mcp"
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ #### Cursor
40
+
41
+ Add to `.cursor/mcp.json` in your project:
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "htmlhost": {
47
+ "command": "htmlhost-mcp"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ #### Windsurf
54
+
55
+ Add to your MCP config:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "htmlhost": {
61
+ "command": "htmlhost-mcp"
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ #### Any MCP-compatible tool
68
+
69
+ The server speaks MCP over **stdio**. Run it as:
70
+
71
+ ```bash
72
+ htmlhost-mcp
73
+ ```
74
+
75
+ ## Tools
76
+
77
+ | Tool | Description |
78
+ |---|---|
79
+ | `deploy` | Deploy a local file or directory to htmlhost.co |
80
+ | `deploy_html` | Deploy raw HTML content directly (no local files needed) |
81
+ | `list_sites` | List all your sites |
82
+ | `get_site` | Get details about a specific site (pages, assets, version) |
83
+ | `pull_site` | Download a site's files to a local directory |
84
+ | `delete_site` | Permanently delete a site |
85
+ | `whoami` | Show the current authenticated user |
86
+
87
+ ### `deploy`
88
+
89
+ Deploy a file or directory to htmlhost.co.
90
+
91
+ ```
92
+ path (required) Absolute path to the file or directory
93
+ slug (optional) Existing site slug to update
94
+ title (optional) Title for the site
95
+ ttl (optional) Time-to-live: 1d, 7d, 30d, or never
96
+ ```
97
+
98
+ ### `deploy_html`
99
+
100
+ 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.
101
+
102
+ ```
103
+ html (required) The HTML content to deploy
104
+ slug (optional) Existing site slug to update
105
+ title (optional) Title for the site
106
+ ttl (optional) Time-to-live: 1d, 7d, 30d, or never
107
+ ```
108
+
109
+ ### `list_sites`
110
+
111
+ Returns all sites, their URLs, sizes, TTLs, and plan usage.
112
+
113
+ ### `get_site`
114
+
115
+ ```
116
+ slug (required) The site slug
117
+ ```
118
+
119
+ Returns version, page routes, asset paths and sizes.
120
+
121
+ ### `pull_site`
122
+
123
+ ```
124
+ slug (required) The site slug to pull
125
+ directory (required) Absolute path to write files into
126
+ ```
127
+
128
+ Downloads all pages and assets into the specified directory.
129
+
130
+ ### `delete_site`
131
+
132
+ ```
133
+ slug (required) The site slug to delete
134
+ confirm (required) Must be true — safety check
135
+ ```
136
+
137
+ ### `whoami`
138
+
139
+ Returns the authenticated user's email, name, handle, and plan.
140
+
141
+ ## Architecture
142
+
143
+ ```
144
+ packages/mcp/
145
+ ├── bin/htmlhost-mcp.mjs # Entry point (stdio transport)
146
+ ├── src/
147
+ │ ├── server.mjs # MCP tool definitions
148
+ │ └── api.mjs # API client (reuses ~/.htmlhostrc auth)
149
+ ├── package.json
150
+ └── README.md
151
+ ```
152
+
153
+ The MCP server reuses the same auth token (`~/.htmlhostrc`) and API endpoints as the [htmlhost CLI](https://www.npmjs.com/package/htmlhost-cli). No additional backend work is needed.
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ cd packages/mcp
159
+ npm install
160
+
161
+ # Test locally — the server reads from stdin, writes to stdout
162
+ echo '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":1}' | node bin/htmlhost-mcp.mjs
163
+ ```
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * htmlhost-mcp — MCP server entry point.
4
+ * Runs over stdio; designed for AI coding tools (Cursor, Claude, Windsurf, etc.)
5
+ */
6
+ import { createServer } from "../src/server.mjs";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
8
+
9
+ const server = createServer();
10
+ const transport = new StdioServerTransport();
11
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "htmlhost-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for htmlhost.co — deploy, list, and manage sites from AI coding tools",
5
+ "type": "module",
6
+ "bin": {
7
+ "htmlhost-mcp": "bin/htmlhost-mcp.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "mcp",
19
+ "htmlhost",
20
+ "deploy",
21
+ "hosting",
22
+ "model-context-protocol",
23
+ "ai",
24
+ "cursor",
25
+ "claude"
26
+ ],
27
+ "author": "htmlhost.co",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/htmlhost/mcp.git"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/server": "^2.1.0",
35
+ "zod": "^4.6.5"
36
+ }
37
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * API client for the htmlhost MCP server.
3
+ *
4
+ * Reuses the same ~/.htmlhostrc token and API base as the CLI,
5
+ * so `htmlhost login` is the only setup step needed.
6
+ */
7
+ import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync } from "node:fs";
8
+ import { join, basename, dirname, resolve } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { createHash } from "node:crypto";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Config (mirrors packages/cli/src/config.mjs)
14
+ // ---------------------------------------------------------------------------
15
+ const CONFIG_PATH = join(homedir(), ".htmlhostrc");
16
+ const DEFAULT_API = "https://htmlhost.co";
17
+
18
+ function readConfig() {
19
+ try {
20
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ export function getToken() {
27
+ return readConfig().token || null;
28
+ }
29
+
30
+ export function getApi() {
31
+ return readConfig().api || DEFAULT_API;
32
+ }
33
+
34
+ export function isLoggedIn() {
35
+ return !!getToken();
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // HTTP helpers (mirrors packages/cli/src/api.mjs — minus terminal UI)
40
+ // ---------------------------------------------------------------------------
41
+ class ApiError extends Error {
42
+ constructor(message, status) {
43
+ super(message);
44
+ this.status = status;
45
+ }
46
+ }
47
+
48
+ function headers(extra = {}) {
49
+ const token = getToken();
50
+ if (!token) throw new ApiError("Not logged in. Run `htmlhost login` in a terminal first.", 0);
51
+ return { Authorization: `Bearer ${token}`, ...extra };
52
+ }
53
+
54
+ async function withRetry(fn, label = "request", retries = 3) {
55
+ let lastError;
56
+ for (let attempt = 1; attempt <= retries; attempt++) {
57
+ try {
58
+ return await fn();
59
+ } catch (e) {
60
+ lastError = e;
61
+ const isRetryable = !(e instanceof ApiError) || (e.status >= 500 && e.status < 600);
62
+ if (!isRetryable || attempt === retries) throw e;
63
+ await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
64
+ }
65
+ }
66
+ throw lastError;
67
+ }
68
+
69
+ export async function apiGet(path) {
70
+ return withRetry(async () => {
71
+ const res = await fetch(`${getApi()}${path}`, { headers: headers() });
72
+ const data = await res.json();
73
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
74
+ return data;
75
+ }, `GET ${path}`);
76
+ }
77
+
78
+ export async function apiPost(path, body) {
79
+ return withRetry(async () => {
80
+ const res = await fetch(`${getApi()}${path}`, {
81
+ method: "POST",
82
+ headers: headers({ "Content-Type": "application/json" }),
83
+ body: JSON.stringify(body),
84
+ });
85
+ const data = await res.json();
86
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
87
+ return data;
88
+ }, `POST ${path}`);
89
+ }
90
+
91
+ export async function apiDelete(path) {
92
+ return withRetry(async () => {
93
+ const res = await fetch(`${getApi()}${path}`, {
94
+ method: "DELETE",
95
+ headers: headers(),
96
+ });
97
+ const data = await res.json();
98
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
99
+ return data;
100
+ }, `DELETE ${path}`);
101
+ }
102
+
103
+ export async function downloadBuffer(url) {
104
+ const res = await fetch(url);
105
+ if (!res.ok) throw new ApiError(`Download failed: HTTP ${res.status}`, res.status);
106
+ return Buffer.from(await res.arrayBuffer());
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // MIME detection (mirrors packages/cli/src/ui.mjs)
111
+ // ---------------------------------------------------------------------------
112
+ const MIME_MAP = {
113
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
114
+ svg: "image/svg+xml", webp: "image/webp", avif: "image/avif", ico: "image/x-icon",
115
+ woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
116
+ css: "text/css", js: "text/javascript", mjs: "text/javascript",
117
+ json: "application/json", mp4: "video/mp4", webm: "video/webm",
118
+ mp3: "audio/mpeg", ogg: "audio/ogg", wav: "audio/wav", pdf: "application/pdf",
119
+ };
120
+
121
+ export function mimeFromExt(filename) {
122
+ const ext = filename.split(".").pop()?.toLowerCase();
123
+ return MIME_MAP[ext] || "application/octet-stream";
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // File discovery (mirrors packages/cli/src/commands/deploy.mjs)
128
+ // ---------------------------------------------------------------------------
129
+ const DEFAULT_IGNORE = [
130
+ "node_modules", ".git", ".DS_Store", ".htmlhost", ".env", ".env.local",
131
+ ".env.production", ".env.development", ".vscode", ".idea", "Thumbs.db",
132
+ ".gitignore", ".npmrc", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
133
+ "AGENTS.md", "CLAUDE.md", "GEMINI.md", ".claude", ".cursor", ".cursorrules",
134
+ ".windsurfrules", ".github",
135
+ ];
136
+
137
+ function shouldIgnore(name) {
138
+ if (name.startsWith(".") && DEFAULT_IGNORE.includes(name)) return true;
139
+ return DEFAULT_IGNORE.includes(name);
140
+ }
141
+
142
+ export function findAllFiles(baseDir, currentDir) {
143
+ const entries = readdirSync(currentDir, { withFileTypes: true });
144
+ const results = [];
145
+ for (const entry of entries) {
146
+ if (shouldIgnore(entry.name)) continue;
147
+ const fullPath = join(currentDir, entry.name);
148
+ if (entry.isDirectory()) {
149
+ results.push(...findAllFiles(baseDir, fullPath));
150
+ } else if (entry.isFile()) {
151
+ let size;
152
+ try { size = statSync(fullPath).size; } catch { continue; }
153
+ results.push({ filePath: fullPath, relativePath: fullPath.slice(baseDir.length + 1), size });
154
+ }
155
+ }
156
+ return results;
157
+ }
158
+
159
+ export function pagePathForFile(relativePath) {
160
+ if (relativePath === "index.html") return "/";
161
+ if (basename(relativePath) === "index.html") return `/${dirname(relativePath)}`;
162
+ return "/" + relativePath.replace(/\.html$/, "");
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Deploy helpers
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Deploy a directory to htmlhost. Non-interactive version of CLI deploy.
171
+ * Returns { slug, url, version, pageCount }.
172
+ */
173
+ export async function deployDirectory(dirPath, { slug = null, title = null, ttl = null } = {}) {
174
+ const resolvedDir = resolve(dirPath);
175
+ const allFiles = findAllFiles(resolvedDir, resolvedDir);
176
+
177
+ if (allFiles.length === 0) throw new Error("No files found in directory.");
178
+
179
+ const hasIndex = allFiles.some((f) => f.relativePath === "index.html");
180
+ if (!hasIndex) {
181
+ const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
182
+ if (htmlFiles.length === 0) throw new Error("No index.html or HTML files found.");
183
+ }
184
+
185
+ const htmlFiles = allFiles.filter((f) => f.relativePath.endsWith(".html"));
186
+ const assetFiles = allFiles.filter((f) => !f.relativePath.endsWith(".html"));
187
+
188
+ // Build page payloads
189
+ const pagePayloads = [];
190
+ const seenPaths = new Set();
191
+ for (const file of htmlFiles) {
192
+ const html = readFileSync(file.filePath, "utf8");
193
+ const pagePath = pagePathForFile(file.relativePath);
194
+ if (seenPaths.has(pagePath)) continue;
195
+ seenPaths.add(pagePath);
196
+ pagePayloads.push({ path: pagePath, html });
197
+ }
198
+
199
+ // Upload assets via presigned URLs
200
+ const assetPayloads = [];
201
+ if (assetFiles.length > 0) {
202
+ const uploadUrlReqBody = {
203
+ slug: slug || undefined,
204
+ isNewSite: !slug,
205
+ html: pagePayloads[0]?.html || "",
206
+ assets: assetFiles.map((f) => {
207
+ const buffer = readFileSync(f.filePath);
208
+ const hash = createHash("sha256").update(buffer).digest("hex");
209
+ f.buffer = buffer;
210
+ return { path: f.relativePath, sizeBytes: f.size, mimeType: mimeFromExt(basename(f.filePath)), hash };
211
+ }),
212
+ };
213
+
214
+ const urlData = await apiPost("/api/sites/upload-url", uploadUrlReqBody);
215
+ const { slug: generatedSlug, uploadUrls } = urlData;
216
+ if (generatedSlug && !slug) slug = generatedSlug;
217
+
218
+ for (const u of uploadUrls) {
219
+ const file = assetFiles.find((f) => f.relativePath === u.path);
220
+ if (u.uploadUrl) {
221
+ const res = await fetch(u.uploadUrl, {
222
+ method: "PUT",
223
+ headers: { "Content-Type": u.mimeType },
224
+ body: file.buffer,
225
+ });
226
+ if (!res.ok) throw new Error(`Asset upload failed for ${u.path}: HTTP ${res.status}`);
227
+ }
228
+ assetPayloads.push({
229
+ path: u.path, mimeType: u.mimeType, sizeBytes: u.sizeBytes,
230
+ storageKey: u.storageKey, hash: u.hash,
231
+ });
232
+ }
233
+ }
234
+
235
+ // Final deploy
236
+ const body = { pages: pagePayloads, assets: assetPayloads };
237
+ if (ttl) body.ttl = ttl;
238
+ if (slug) body.slug = slug;
239
+ if (title) body.title = title;
240
+ if (!slug) body.isNewSite = true;
241
+
242
+ return await apiPost("/api/sites", body);
243
+ }
244
+
245
+ /**
246
+ * Deploy a single HTML file. Non-interactive version of CLI deploy.
247
+ */
248
+ export async function deploySingleFile(filePath, { slug = null, title = null, ttl = null } = {}) {
249
+ const resolvedPath = resolve(filePath);
250
+ const html = readFileSync(resolvedPath, "utf8");
251
+
252
+ const body = { html };
253
+ if (ttl) body.ttl = ttl;
254
+ if (slug) body.slug = slug;
255
+ if (title) body.title = title;
256
+
257
+ return await apiPost("/api/sites", body);
258
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * htmlhost MCP server — tool definitions.
3
+ *
4
+ * Exposes htmlhost operations as MCP tools so AI coding assistants
5
+ * (Cursor, Claude, Windsurf, Cline, Zed, etc.) can deploy and manage
6
+ * sites on behalf of the user.
7
+ *
8
+ * Auth: reuses the CLI token stored in ~/.htmlhostrc.
9
+ * Run `htmlhost login` once in a terminal to authenticate.
10
+ */
11
+ import { McpServer } from "@modelcontextprotocol/server";
12
+ import { z } from "zod";
13
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import { resolve, dirname, join, basename } from "node:path";
15
+ import {
16
+ isLoggedIn,
17
+ getApi,
18
+ apiGet,
19
+ apiPost,
20
+ apiDelete,
21
+ downloadBuffer,
22
+ deployDirectory,
23
+ deploySingleFile,
24
+ findAllFiles,
25
+ pagePathForFile,
26
+ mimeFromExt,
27
+ } from "./api.mjs";
28
+
29
+ const VERSION = "1.0.0";
30
+
31
+ /**
32
+ * Helper: wraps a handler to return proper MCP content and catch errors.
33
+ */
34
+ function wrap(fn) {
35
+ return async (args) => {
36
+ try {
37
+ const result = await fn(args);
38
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
39
+ return { content: [{ type: "text", text }] };
40
+ } catch (e) {
41
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
42
+ }
43
+ };
44
+ }
45
+
46
+ export function createServer() {
47
+ const server = new McpServer({
48
+ name: "htmlhost",
49
+ version: VERSION,
50
+ });
51
+
52
+ // -------------------------------------------------------------------
53
+ // Tool: deploy
54
+ // -------------------------------------------------------------------
55
+ server.registerTool(
56
+ "deploy",
57
+ {
58
+ title: "Deploy to htmlhost",
59
+ description:
60
+ "Deploy a file or directory to htmlhost.co. " +
61
+ "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.",
63
+ inputSchema: z.object({
64
+ path: z.string().describe("Absolute path to the file or directory to deploy"),
65
+ slug: z.string().optional().describe("Existing site slug to update (omit to create a new site)"),
66
+ title: z.string().optional().describe("Title for the site"),
67
+ ttl: z.string().optional().describe("Time-to-live: 1d, 7d, 30d, or never"),
68
+ }),
69
+ },
70
+ wrap(async ({ path, slug, title, ttl }) => {
71
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
72
+
73
+ const resolvedPath = resolve(path);
74
+ if (!existsSync(resolvedPath)) return `Path not found: ${path}`;
75
+
76
+ const stat = statSync(resolvedPath);
77
+ const opts = { slug, title, ttl };
78
+
79
+ let data;
80
+ if (stat.isDirectory()) {
81
+ data = await deployDirectory(resolvedPath, opts);
82
+ } else {
83
+ data = await deploySingleFile(resolvedPath, opts);
84
+ }
85
+
86
+ const pc = data.pageCount || 1;
87
+ return {
88
+ message: "Deployed successfully",
89
+ url: `https://${data.url}`,
90
+ slug: data.slug,
91
+ version: data.version,
92
+ pages: pc,
93
+ ttl: data.ttl,
94
+ expiresAt: data.expiresAt || null,
95
+ };
96
+ })
97
+ );
98
+
99
+ // -------------------------------------------------------------------
100
+ // Tool: list_sites
101
+ // -------------------------------------------------------------------
102
+ server.registerTool(
103
+ "list_sites",
104
+ {
105
+ title: "List sites",
106
+ description:
107
+ "List all sites owned by the authenticated user on htmlhost.co. " +
108
+ "Returns slug, URL, title, size, TTL, and expiration for each site.",
109
+ inputSchema: z.object({}),
110
+ },
111
+ wrap(async () => {
112
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
113
+
114
+ const data = await apiGet("/api/sites");
115
+ const sites = (data.sites || []).map((s) => ({
116
+ slug: s.slug,
117
+ url: `https://${s.slug}.htmlhost.co`,
118
+ title: s.title || null,
119
+ size: s.size,
120
+ ttl: s.ttl,
121
+ isMultiPage: s.isMultiPage,
122
+ expiresAt: s.expiresAt || null,
123
+ }));
124
+
125
+ return {
126
+ count: sites.length,
127
+ usage: data.usage,
128
+ sites,
129
+ };
130
+ })
131
+ );
132
+
133
+ // -------------------------------------------------------------------
134
+ // Tool: get_site
135
+ // -------------------------------------------------------------------
136
+ server.registerTool(
137
+ "get_site",
138
+ {
139
+ title: "Get site details",
140
+ description:
141
+ "Get detailed information about a specific site including its pages and assets.",
142
+ inputSchema: z.object({
143
+ slug: z.string().describe("The site slug (e.g. 'my-site')"),
144
+ }),
145
+ },
146
+ wrap(async ({ slug }) => {
147
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
148
+
149
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
150
+ const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&meta=true`);
151
+
152
+ return {
153
+ slug: cleanSlug,
154
+ url: `https://${cleanSlug}.htmlhost.co`,
155
+ version: data.version,
156
+ updatedAt: data.updatedAt,
157
+ pages: (data.pages || []).map((p) => ({
158
+ path: p.path,
159
+ sizeBytes: p.sizeBytes || (p.html ? Buffer.byteLength(p.html) : 0),
160
+ })),
161
+ assets: (data.assets || []).map((a) => ({
162
+ path: a.path,
163
+ mimeType: a.mimeType,
164
+ sizeBytes: a.sizeBytes,
165
+ })),
166
+ };
167
+ })
168
+ );
169
+
170
+ // -------------------------------------------------------------------
171
+ // Tool: pull_site
172
+ // -------------------------------------------------------------------
173
+ server.registerTool(
174
+ "pull_site",
175
+ {
176
+ title: "Pull site to local directory",
177
+ description:
178
+ "Download all pages and assets from a deployed htmlhost site into a local directory. " +
179
+ "Creates the directory if it doesn't exist.",
180
+ inputSchema: z.object({
181
+ slug: z.string().describe("The site slug to pull"),
182
+ directory: z.string().describe("Absolute path to the local directory to write files into"),
183
+ }),
184
+ },
185
+ wrap(async ({ slug, directory }) => {
186
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
187
+
188
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
189
+ const destDir = resolve(directory);
190
+ mkdirSync(destDir, { recursive: true });
191
+
192
+ const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}`);
193
+ const written = [];
194
+
195
+ // Write pages
196
+ for (const page of data.pages || []) {
197
+ const localFile = page.path === "/"
198
+ ? "index.html"
199
+ : `${page.path.replace(/^\//, "")}.html`;
200
+ const localPath = join(destDir, localFile);
201
+ mkdirSync(dirname(localPath), { recursive: true });
202
+ writeFileSync(localPath, page.html, "utf8");
203
+ written.push(localFile);
204
+ }
205
+
206
+ // Write assets
207
+ for (const asset of data.assets || []) {
208
+ const localPath = join(destDir, asset.path);
209
+ mkdirSync(dirname(localPath), { recursive: true });
210
+
211
+ if (asset.content !== null && asset.content !== undefined) {
212
+ writeFileSync(localPath, asset.content, "utf8");
213
+ } else if (asset.downloadUrl) {
214
+ const buf = await downloadBuffer(asset.downloadUrl);
215
+ writeFileSync(localPath, buf);
216
+ } else {
217
+ continue;
218
+ }
219
+ written.push(asset.path);
220
+ }
221
+
222
+ return {
223
+ message: `Pulled ${written.length} files from ${cleanSlug}.htmlhost.co`,
224
+ directory: destDir,
225
+ version: data.version,
226
+ files: written,
227
+ };
228
+ })
229
+ );
230
+
231
+ // -------------------------------------------------------------------
232
+ // Tool: delete_site
233
+ // -------------------------------------------------------------------
234
+ server.registerTool(
235
+ "delete_site",
236
+ {
237
+ title: "Delete site",
238
+ description:
239
+ "Permanently delete a site from htmlhost.co. This cannot be undone.",
240
+ inputSchema: z.object({
241
+ slug: z.string().describe("The site slug to delete"),
242
+ confirm: z.boolean().describe("Must be true to confirm deletion"),
243
+ }),
244
+ },
245
+ wrap(async ({ slug, confirm }) => {
246
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
247
+ if (!confirm) return "Deletion not confirmed. Set confirm to true to proceed.";
248
+
249
+ const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
250
+ await apiDelete(`/api/sites?slug=${encodeURIComponent(cleanSlug)}`);
251
+
252
+ return { message: `Deleted ${cleanSlug}.htmlhost.co` };
253
+ })
254
+ );
255
+
256
+ // -------------------------------------------------------------------
257
+ // Tool: whoami
258
+ // -------------------------------------------------------------------
259
+ server.registerTool(
260
+ "whoami",
261
+ {
262
+ title: "Who am I",
263
+ description: "Show the currently authenticated htmlhost user and plan details.",
264
+ inputSchema: z.object({}),
265
+ },
266
+ wrap(async () => {
267
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
268
+
269
+ const data = await apiGet("/api/tokens/me");
270
+ return {
271
+ email: data.email,
272
+ name: data.name || null,
273
+ handle: data.handle || null,
274
+ plan: data.plan,
275
+ };
276
+ })
277
+ );
278
+
279
+ // -------------------------------------------------------------------
280
+ // Tool: deploy_html
281
+ // -------------------------------------------------------------------
282
+ server.registerTool(
283
+ "deploy_html",
284
+ {
285
+ title: "Deploy HTML content",
286
+ description:
287
+ "Deploy raw HTML content directly (no local files needed). " +
288
+ "Useful when an AI generates HTML and wants to deploy it immediately.",
289
+ inputSchema: z.object({
290
+ html: z.string().describe("The HTML content to deploy"),
291
+ slug: z.string().optional().describe("Existing site slug to update (omit to create new)"),
292
+ title: z.string().optional().describe("Title for the site"),
293
+ ttl: z.string().optional().describe("Time-to-live: 1d, 7d, 30d, or never"),
294
+ }),
295
+ },
296
+ wrap(async ({ html, slug, title, ttl }) => {
297
+ if (!isLoggedIn()) return "Not logged in. Run `htmlhost login` in a terminal first.";
298
+
299
+ const body = { html };
300
+ if (slug) body.slug = slug;
301
+ if (title) body.title = title;
302
+ if (ttl) body.ttl = ttl;
303
+
304
+ const data = await apiPost("/api/sites", body);
305
+
306
+ return {
307
+ message: "Deployed successfully",
308
+ url: `https://${data.url}`,
309
+ slug: data.slug,
310
+ version: data.version,
311
+ ttl: data.ttl,
312
+ expiresAt: data.expiresAt || null,
313
+ };
314
+ })
315
+ );
316
+
317
+ return server;
318
+ }