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 +167 -0
- package/bin/htmlhost-mcp.mjs +11 -0
- package/package.json +37 -0
- package/src/api.mjs +258 -0
- package/src/server.mjs +318 -0
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
|
+
}
|