dropthehassle-mcp 0.1.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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/bin/dth-mcp.js +158 -0
  3. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # DropTheHassle MCP server
2
+
3
+ Let your AI put sites online, find real available domain names, and point names at sites, all from
4
+ your editor. It **cannot spend money**: buying a domain stays a click you make in the dashboard.
5
+
6
+ ## Connect
7
+
8
+ 1. On [dropthehassle.com](https://dropthehassle.com), open the menu (top right) and pick
9
+ **Connect your AI**. Copy your token (starts with `dth_`).
10
+ 2. Add this server to your AI's MCP config, with the token as `DTH_TOKEN`.
11
+
12
+ Claude Desktop / Claude Code (`claude_desktop_config.json` or `.mcp.json`):
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "dropthehassle": {
18
+ "command": "npx",
19
+ "args": ["-y", "dropthehassle-mcp"],
20
+ "env": { "DTH_TOKEN": "dth_your-token-here" }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## Tools your AI gets
27
+
28
+ - **whoami** — confirm the connected account.
29
+ - **list_sites** — your sites, their live URLs and the domains on each.
30
+ - **search_domain** — is a name free, and what does it cost? (read-only; never buys)
31
+ - **deploy_site** — put a built folder online on a free link (or update an existing site).
32
+ - **point_domain** — point one of your names at one of your sites.
33
+
34
+ Just ask, e.g. *"put the site in ./dist online"* or *"find me a free .com like pocketlighthouse"*.
package/bin/dth-mcp.js ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * DropTheHassle MCP server (npx dropthehassle-mcp)
5
+ *
6
+ * Lets an AI (Claude Desktop / Claude Code / any MCP client) act on the user's DropTheHassle account
7
+ * so a vibecoder never has to leave their editor: put a site online on a free link, find a REAL free
8
+ * domain name, see what they already have, and point a name at a site. It can NEVER spend money --
9
+ * buying a domain stays a human step in the dashboard (the backend refuses it for this token).
10
+ *
11
+ * Auth: the user pastes their account token (from dropthehassle.com -> menu -> Connect your AI) into
12
+ * the MCP config as env DTH_TOKEN. Override the endpoint with DTH_API. Zero dependencies (Node stdlib
13
+ * only): the MCP protocol is plain newline-delimited JSON-RPC 2.0 over stdio, implemented directly.
14
+ */
15
+ const http = require('http');
16
+ const https = require('https');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { URL } = require('url');
20
+
21
+ const API = (process.env.DTH_API || 'https://dropthehassle.com/api/v1').replace(/\/+$/, '');
22
+ const TOKEN = process.env.DTH_TOKEN || '';
23
+ const SKIP = new Set(['node_modules', '.git', '.DS_Store', '.next', '.vercel', '.cache', '__MACOSX']);
24
+ const SERVER = { name: 'dropthehassle', version: '0.1.0' };
25
+
26
+ // ---------------------------------------------------------------- HTTP + zip (stdlib, like the CLI)
27
+ function request(method, url, opts = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const u = new URL(url);
30
+ const lib = u.protocol === 'https:' ? https : http;
31
+ const headers = Object.assign({}, opts.headers);
32
+ if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
33
+ let payload = opts.body;
34
+ if (opts.json !== undefined) { payload = Buffer.from(JSON.stringify(opts.json)); headers['Content-Type'] = 'application/json'; }
35
+ if (payload) headers['Content-Length'] = payload.length;
36
+ const req = lib.request(u, { method, headers }, (res) => {
37
+ const chunks = [];
38
+ res.on('data', (c) => chunks.push(c));
39
+ res.on('end', () => {
40
+ const text = Buffer.concat(chunks).toString();
41
+ let data; try { data = JSON.parse(text || '{}'); } catch (_) { data = text; }
42
+ resolve({ status: res.statusCode, data });
43
+ });
44
+ });
45
+ req.on('error', reject);
46
+ if (payload) req.write(payload);
47
+ req.end();
48
+ });
49
+ }
50
+
51
+ const _crc = (() => { const t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })();
52
+ function crc32(buf) { let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) c = _crc[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
53
+ function walk(dir, base, out) {
54
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
55
+ if (SKIP.has(entry.name)) continue;
56
+ const abs = path.join(dir, entry.name);
57
+ const rel = base ? base + '/' + entry.name : entry.name;
58
+ if (entry.isDirectory()) walk(abs, rel, out);
59
+ else if (entry.isFile()) out.push({ name: rel, data: fs.readFileSync(abs) });
60
+ }
61
+ return out;
62
+ }
63
+ function buildZip(files) {
64
+ const u16 = (n) => Buffer.from([n & 0xFF, (n >> 8) & 0xFF]);
65
+ const u32 = (n) => { n = n >>> 0; return Buffer.from([n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF]); };
66
+ const local = [], central = []; let offset = 0;
67
+ for (const f of files) {
68
+ const name = Buffer.from(f.name.replace(/\\/g, '/'));
69
+ const crc = crc32(f.data), sz = f.data.length;
70
+ const lfh = Buffer.concat([u32(0x04034b50), u16(20), u16(0), u16(0), u16(0), u16(0), u32(crc), u32(sz), u32(sz), u16(name.length), u16(0)]);
71
+ local.push(lfh, name, f.data);
72
+ central.push(Buffer.concat([u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(0), u16(0), u32(crc), u32(sz), u32(sz), u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset)]), name);
73
+ offset += lfh.length + name.length + sz;
74
+ }
75
+ const cd = Buffer.concat(central);
76
+ const eocd = Buffer.concat([u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length), u32(cd.length), u32(offset), u16(0)]);
77
+ return Buffer.concat([...local, cd, eocd]);
78
+ }
79
+ function multipart(filename, buffer) {
80
+ const boundary = '----dthboundary' + Date.now().toString(16) + Math.floor(Math.random() * 1e9).toString(16);
81
+ const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/zip\r\n\r\n`);
82
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
83
+ return { body: Buffer.concat([head, buffer, tail]), contentType: `multipart/form-data; boundary=${boundary}` };
84
+ }
85
+ function apiErr(r) { const d = r.data; return (d && (d.detail && (d.detail.message || d.detail) || d.message)) || d || ('HTTP ' + r.status); }
86
+
87
+ // ---------------------------------------------------------------- the tools
88
+ const TOOLS = [
89
+ { name: 'whoami', description: 'Confirm the DropTheHassle account this AI is connected to. Call first to verify the connection works.',
90
+ inputSchema: { type: 'object', properties: {} },
91
+ run: async () => { const r = await request('GET', `${API}/ai/whoami`); if (r.status >= 300) throw new Error(apiErr(r)); return `Connected to DropTheHassle as ${r.data.email}.`; } },
92
+ { name: 'list_sites', description: "List the account's sites with their live URL, status and the domains pointing at each. Use to find a site_id to deploy to or point a name at.",
93
+ inputSchema: { type: 'object', properties: {} },
94
+ run: async () => { const r = await request('GET', `${API}/ai/sites`); if (r.status >= 300) throw new Error(apiErr(r)); return JSON.stringify(r.data.sites, null, 2); } },
95
+ { name: 'search_domain', description: 'Check whether a domain name is available to register and what it would cost (EUR). Read-only: use it to propose a REAL free name. Buying is a human step in the dashboard; this never spends money.',
96
+ inputSchema: { type: 'object', required: ['name'], properties: { name: { type: 'string', description: 'The domain to check, e.g. "myidea.com".' } } },
97
+ run: async (a) => { const r = await request('GET', `${API}/ai/domains/search?q=${encodeURIComponent(a.name || '')}`); if (r.status >= 300) throw new Error(apiErr(r)); const d = r.data; return `${d.name}: ${d.available ? 'AVAILABLE' : 'taken'}${d.available ? ` (~EUR ${d.price_eur}/yr)` : ''}. ${d.note}`; } },
98
+ { name: 'deploy_site', description: 'Put a built static site online on a free link. Point it at the folder that contains index.html. Without site_id it creates a new free site; with site_id it updates that existing site. Returns the live URL. Free only; never buys a name.',
99
+ inputSchema: { type: 'object', required: ['folder'], properties: { folder: { type: 'string', description: 'Absolute path to the built site folder (the one with index.html).' }, site_id: { type: 'number', description: 'Optional: an existing site to update instead of creating a new one.' } } },
100
+ run: async (a) => {
101
+ const dir = path.resolve(a.folder || '.');
102
+ if (!fs.existsSync(path.join(dir, 'index.html'))) throw new Error(`No index.html in ${dir}. Point me at the built folder that has index.html.`);
103
+ const zip = buildZip(walk(dir, '', []));
104
+ const mp = multipart('site.zip', zip);
105
+ const q = a.site_id ? `?site_id=${encodeURIComponent(a.site_id)}` : '';
106
+ const r = await request('POST', `${API}/ai/deploy${q}`, { headers: { 'Content-Type': mp.contentType }, body: mp.body });
107
+ if (r.status >= 300) throw new Error(apiErr(r));
108
+ return `Live at ${r.data.live_url} (site_id ${r.data.site_id}). Redeploy anytime with the same site_id.`;
109
+ } },
110
+ { name: 'point_domain', description: "Point one of the account's own domains at one of its sites (redirect the name to that site's content). Both must be on this account. No money.",
111
+ inputSchema: { type: 'object', required: ['domain', 'to_site_id'], properties: { domain: { type: 'string', description: 'A domain already on this account.' }, to_site_id: { type: 'number', description: 'The site to point it at (from list_sites).' } } },
112
+ run: async (a) => { const r = await request('POST', `${API}/ai/point`, { json: { domain: a.domain, to_site_id: a.to_site_id } }); if (r.status >= 300) throw new Error(apiErr(r)); return `${a.domain} now points at site ${a.to_site_id}.`; } },
113
+ ];
114
+
115
+ // ---------------------------------------------------------------- MCP (JSON-RPC 2.0 over stdio)
116
+ function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
117
+ function reply(id, result) { send({ jsonrpc: '2.0', id, result }); }
118
+ function replyErr(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
119
+
120
+ async function handle(msg) {
121
+ const { id, method, params } = msg;
122
+ if (method === 'initialize') {
123
+ reply(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER });
124
+ } else if (method === 'notifications/initialized' || method === 'notifications/cancelled') {
125
+ // notifications carry no id and need no reply
126
+ } else if (method === 'ping') {
127
+ reply(id, {});
128
+ } else if (method === 'tools/list') {
129
+ reply(id, { tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
130
+ } else if (method === 'tools/call') {
131
+ const tool = TOOLS.find((t) => t.name === (params && params.name));
132
+ if (!tool) return replyErr(id, -32602, `Unknown tool: ${params && params.name}`);
133
+ if (!TOKEN) return reply(id, { content: [{ type: 'text', text: 'No DropTheHassle token set. Add DTH_TOKEN (from dropthehassle.com -> menu -> Connect your AI) to this server\'s env.' }], isError: true });
134
+ try {
135
+ const text = await tool.run((params && params.arguments) || {});
136
+ reply(id, { content: [{ type: 'text', text }] });
137
+ } catch (e) {
138
+ reply(id, { content: [{ type: 'text', text: 'Error: ' + (e && e.message || e) }], isError: true });
139
+ }
140
+ } else if (id !== undefined) {
141
+ replyErr(id, -32601, `Method not found: ${method}`);
142
+ }
143
+ }
144
+
145
+ let buf = '';
146
+ process.stdin.setEncoding('utf8');
147
+ process.stdin.on('data', (chunk) => {
148
+ buf += chunk;
149
+ let nl;
150
+ while ((nl = buf.indexOf('\n')) >= 0) {
151
+ const line = buf.slice(0, nl).trim();
152
+ buf = buf.slice(nl + 1);
153
+ if (!line) continue;
154
+ let msg; try { msg = JSON.parse(line); } catch (_) { continue; }
155
+ handle(msg).catch((e) => { if (msg && msg.id !== undefined) replyErr(msg.id, -32603, String(e && e.message || e)); });
156
+ }
157
+ });
158
+ process.stdin.on('end', () => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "dropthehassle-mcp",
3
+ "mcpName": "io.github.bosmdavid-gif/dropthehassle",
4
+ "version": "0.1.0",
5
+ "description": "MCP server for DropTheHassle: let your AI put sites online, find real free domains, and point names, without leaving your editor.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/bosmdavid-gif/dropthehassle-mcp.git"
9
+ },
10
+ "bin": {
11
+ "dropthehassle-mcp": "bin/dth-mcp.js"
12
+ },
13
+ "type": "commonjs",
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "deploy",
25
+ "static-site",
26
+ "hosting",
27
+ "ai",
28
+ "vibecoding"
29
+ ],
30
+ "author": "DropTheHassle",
31
+ "homepage": "https://dropthehassle.com",
32
+ "license": "MIT"
33
+ }