@drop2run/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.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @drop2run/mcp
2
+
3
+ An MCP server that publishes to [Drop2Run](https://dropto.run) from inside a chat. Three tools:
4
+
5
+ | Tool | What it does |
6
+ |---|---|
7
+ | `publish_html` | Takes one HTML document, publishes it as a whole site, returns the URL |
8
+ | `publish_dir` | Takes an absolute folder path and publishes it |
9
+ | `list_sites` | Lists the account's sites, so a publish can go to one of them |
10
+
11
+ ## Getting a token
12
+
13
+ The server authenticates with a personal access token — there is no browser in a chat, so there is no
14
+ cookie to use. Create one at <https://dropto.run/account/tokens>. It is shown once and stored as a hash,
15
+ so it cannot be recovered afterwards.
16
+
17
+ Then either put it in the environment:
18
+
19
+ ```
20
+ DROP2RUN_TOKEN=d2r_...
21
+ ```
22
+
23
+ or in `~/.config/drop2run/config.json`:
24
+
25
+ ```json
26
+ { "token": "d2r_..." }
27
+ ```
28
+
29
+ The environment wins. That file is deliberately the same one the `drop2run` CLI reads, so
30
+ `drop2run login` covers both — and so the two can never disagree about which account is yours.
31
+
32
+ Point it at a different API with `DROP2RUN_API_URL`, or `apiBaseUrl` in the same file.
33
+
34
+ ## Wiring it up
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "drop2run": {
40
+ "command": "npx",
41
+ "args": ["-y", "@drop2run/mcp"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ That is the whole configuration — `npx` fetches the package, and there is nothing to install globally.
48
+
49
+ To run it from a checkout instead, build first, because `dist/` is not in the repository:
50
+
51
+ ```bash
52
+ cd packages/mcp && npm run build
53
+ node bin/drop2run-mcp.mjs
54
+ ```
55
+
56
+ ## Two decisions worth knowing about
57
+
58
+ **Publishing without naming a site creates a new one.** The obvious alternative — reuse the most recent —
59
+ would silently replace whatever the account last published, and a tool a model calls on somebody's
60
+ behalf is the worst place for that default. "Put this online" is not "and overwrite my last site".
61
+
62
+ **A token never reaches the storage host.** The bearer credential goes to the control plane only. The
63
+ file uploads are PUTs to presigned URLs on another host, which need nothing from us — a token attached
64
+ there would be an account credential handed to a service that never asked. There is a test for it,
65
+ because it is the kind of thing a later refactor tidies into a shared header helper.
66
+
67
+ ## What the tarball contains
68
+
69
+ `bin/` and `dist/` only. `dist/index.js` is a bundle: the publish engine this package shares with the
70
+ `drop2run` CLI is folded in at build time, so nothing in the published files imports a package npm
71
+ cannot fetch.
72
+
73
+ The two real dependencies — the MCP SDK and zod — stay external and are installed by npm. Inlining an
74
+ SDK would mean shipping a copy that never gets a security update.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry point for `npx @drop2run/mcp`.
4
+ *
5
+ * Loads the built server. Kept as a separate file with nothing in it because a `bin` has to survive the
6
+ * build: `files` publishes `bin` and `dist`, and this is the one part of the package whose path an MCP
7
+ * client's configuration names.
8
+ */
9
+ import { main } from "../dist/index.js";
10
+
11
+ main().catch((error) => {
12
+ // stderr, never stdout: stdout is the MCP transport, and a stray line on it corrupts the protocol
13
+ // rather than showing up anywhere a person reads.
14
+ console.error(error instanceof Error ? error.message : error);
15
+ process.exit(1);
16
+ });
package/dist/index.js ADDED
@@ -0,0 +1,688 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, relative, sep } from "node:path";
5
+ import { realpath, readdir, stat, readFile } from "node:fs/promises";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { z } from "zod";
8
+ async function call(credentials, path, init = {}) {
9
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
10
+ ...init,
11
+ headers: {
12
+ ...init.headers,
13
+ Authorization: `Bearer ${credentials.token}`,
14
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
15
+ }
16
+ });
17
+ if (!response.ok) {
18
+ const problem = await response.json().catch(() => null);
19
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
20
+ }
21
+ return await response.json();
22
+ }
23
+ async function listSites(credentials) {
24
+ const body = await call(credentials, "sites");
25
+ return body.sites;
26
+ }
27
+ async function createSite(credentials) {
28
+ const created = await call(
29
+ credentials,
30
+ "sites",
31
+ { method: "POST", body: JSON.stringify({}) }
32
+ );
33
+ return { ...created, name: null };
34
+ }
35
+ const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
36
+ function configPath() {
37
+ return join(homedir(), ".config", "drop2run", "config.json");
38
+ }
39
+ const DEFAULT_API_BASE_URL = "https://dropto.run/api";
40
+ function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
41
+ let stored = {};
42
+ try {
43
+ stored = JSON.parse(read(configPath()));
44
+ } catch {
45
+ }
46
+ const token = env[TOKEN_VARIABLE]?.trim() || stored.token?.trim();
47
+ if (!token) return null;
48
+ return {
49
+ token,
50
+ apiBaseUrl: env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL
51
+ };
52
+ }
53
+ function missingCredentialsMessage() {
54
+ return [
55
+ "No Drop2Run access token.",
56
+ "",
57
+ "Run `drop2run login` to sign in through a browser, which stores one for you.",
58
+ "",
59
+ "For CI, or where no browser can be opened, create a token at",
60
+ "https://dropto.run/account/tokens and either:",
61
+ ` - set ${TOKEN_VARIABLE} in the environment, or`,
62
+ ` - put it in ${configPath()} as {"token": "d2r_..."}`,
63
+ "",
64
+ "The token is shown once when it is created and cannot be recovered afterwards."
65
+ ].join("\n");
66
+ }
67
+ class DeployError extends Error {
68
+ /**
69
+ * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
70
+ * the client-side codes in {@link ClientErrorCode}.
71
+ * @param message Text safe to show the user.
72
+ * @param detail Anything extra worth logging, such as the RFC 9457 body from the API.
73
+ */
74
+ constructor(code, message, detail) {
75
+ super(message);
76
+ this.code = code;
77
+ this.detail = detail;
78
+ this.name = "DeployError";
79
+ }
80
+ }
81
+ const ClientErrorCode = {
82
+ /** The drop held no files once junk was filtered out. */
83
+ Empty: "empty_drop",
84
+ /** No `index.html` at the root of the drop. */
85
+ MissingIndex: "missing_index",
86
+ /** The drop is larger than the plan allows; the server enforces this too. */
87
+ TooLarge: "too_large",
88
+ /** A single file is larger than the plan allows. */
89
+ FileTooLarge: "file_too_large",
90
+ /** More files than the plan allows. */
91
+ TooManyFiles: "too_many_files",
92
+ /**
93
+ * A zip claims to expand to more than the plan allows.
94
+ *
95
+ * Distinct from {@link TooLarge}, which is about a drop whose files have already been read. This one
96
+ * is raised from the archive's own directory before anything is decompressed, so the numbers in it
97
+ * are what the zip declared rather than what was measured.
98
+ */
99
+ ZipTooLarge: "zip_too_large",
100
+ /** The user cancelled. */
101
+ Cancelled: "cancelled",
102
+ /** An upload kept failing after every retry. */
103
+ UploadFailed: "upload_failed"
104
+ };
105
+ const CLAIM_TOKEN_HEADER = "X-Claim-Token";
106
+ async function prepareDeploy(siteId, files, options, signal) {
107
+ const manifest = files.map((file) => ({
108
+ path: file.path,
109
+ sha256: file.sha256,
110
+ size: file.bytes.length
111
+ }));
112
+ const prepared = await request(
113
+ `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
114
+ { files: manifest },
115
+ options
116
+ );
117
+ return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
118
+ }
119
+ function absolute(uploadUrl, baseUrl) {
120
+ if (/^[a-z][a-z0-9+.-]*:/i.test(uploadUrl)) return uploadUrl;
121
+ if (baseUrl === void 0 || !/^[a-z][a-z0-9+.-]*:/i.test(baseUrl)) return uploadUrl;
122
+ return new URL(uploadUrl, baseUrl).toString();
123
+ }
124
+ async function completeDeploy(siteId, deployId, options, signal, name) {
125
+ return request(
126
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
127
+ // A bodyless POST when there is no name to send, which is what this call has always been. The
128
+ // server treats an absent body and an absent field identically, so nothing depends on which of
129
+ // the two a client picks.
130
+ name === null || name === void 0 ? void 0 : { name },
131
+ options
132
+ );
133
+ }
134
+ async function request(path, body, options, signal) {
135
+ const doFetch = options.fetch ?? globalFetch$1();
136
+ const base = options.baseUrl ?? "/api";
137
+ const headers = {};
138
+ if (body !== void 0) headers["Content-Type"] = "application/json";
139
+ if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
140
+ if (options.token) headers.Authorization = `Bearer ${options.token}`;
141
+ const response = await doFetch(`${base}/${path}`, {
142
+ method: "POST",
143
+ headers,
144
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
145
+ ...{}
146
+ });
147
+ if (!response.ok) throw await problemToError(response);
148
+ return await response.json();
149
+ }
150
+ function globalFetch$1() {
151
+ return globalThis.fetch.bind(globalThis);
152
+ }
153
+ async function problemToError(response) {
154
+ let problem = {};
155
+ try {
156
+ problem = await response.json();
157
+ } catch {
158
+ return new DeployError(
159
+ `http_${response.status}`,
160
+ `The server responded with ${response.status}.`
161
+ );
162
+ }
163
+ const code = typeof problem.type === "string" ? problem.type : `http_${response.status}`;
164
+ const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
165
+ return new DeployError(code, message, problem);
166
+ }
167
+ async function sha256Hex(bytes) {
168
+ const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
169
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
170
+ }
171
+ async function hashAll(files, onProgress, signal) {
172
+ const hashed = [];
173
+ for (const [index, file] of files.entries()) {
174
+ hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
175
+ onProgress?.(index + 1, files.length);
176
+ }
177
+ return hashed;
178
+ }
179
+ const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
180
+ const IGNORED_DIRECTORIES = [
181
+ "__MACOSX",
182
+ ".git",
183
+ "node_modules",
184
+ ".svn",
185
+ ".hg",
186
+ ".idea",
187
+ ".vscode",
188
+ ".claude"
189
+ ];
190
+ function shouldIgnore(path) {
191
+ const segments = path.split("/");
192
+ const name = segments.at(-1) ?? "";
193
+ if (name === "" || IGNORED_EXACT.has(name)) return true;
194
+ if (name.startsWith("._")) return true;
195
+ return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
196
+ }
197
+ const REQUIRED_INDEX = "index.html";
198
+ const DOCUMENT_EXTENSIONS = [".md", ".markdown", ".pdf"];
199
+ function isDocumentPath(path) {
200
+ const lower = path.toLowerCase();
201
+ return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
202
+ }
203
+ function formatBytes(bytes) {
204
+ if (bytes < 1024) return `${bytes} B`;
205
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
206
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
207
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
208
+ }
209
+ function checkLimits(files, limits) {
210
+ if (files.length === 0) {
211
+ throw new DeployError(
212
+ ClientErrorCode.Empty,
213
+ "That drop contained no files to deploy. Folders like .git and node_modules are skipped."
214
+ );
215
+ }
216
+ const hasIndex = files.some((file) => file.path === REQUIRED_INDEX);
217
+ if (!hasIndex && !files.some((file) => isDocumentPath(file.path))) {
218
+ throw new DeployError(
219
+ ClientErrorCode.MissingIndex,
220
+ `A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site. Drop the folder that contains it, not the folder above.`
221
+ );
222
+ }
223
+ if (!limits) return;
224
+ if (files.length > limits.maxFiles) {
225
+ throw new DeployError(
226
+ ClientErrorCode.TooManyFiles,
227
+ `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
228
+ { limit: limits.maxFiles, actual: files.length }
229
+ );
230
+ }
231
+ const oversized = files.find(
232
+ (file) => file.bytes.length > limits.maxFileBytes
233
+ );
234
+ if (oversized) {
235
+ throw new DeployError(
236
+ ClientErrorCode.FileTooLarge,
237
+ `${oversized.path} is ${formatBytes(oversized.bytes.length)}, and this plan allows ${formatBytes(limits.maxFileBytes)} per file.`,
238
+ {
239
+ path: oversized.path,
240
+ limit: limits.maxFileBytes,
241
+ actual: oversized.bytes.length
242
+ }
243
+ );
244
+ }
245
+ const total = totalBytes(files);
246
+ if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
247
+ throw new DeployError(
248
+ ClientErrorCode.TooLarge,
249
+ `That drop is ${formatBytes(total)}, and this plan allows ${formatBytes(limits.maxSiteBytes)}.`,
250
+ { limit: limits.maxSiteBytes, actual: total }
251
+ );
252
+ }
253
+ }
254
+ function totalBytes(files) {
255
+ return files.reduce((sum, file) => sum + file.bytes.length, 0);
256
+ }
257
+ const MAX_NAME_LENGTH = 60;
258
+ const HEAD_BYTES = 64 * 1024;
259
+ const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
260
+ function suggestSiteName(files) {
261
+ const index = files.find((file) => file.path === "index.html");
262
+ if (index === void 0) return null;
263
+ try {
264
+ return nameFromHtml(decodeHead(index.bytes));
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+ function decodeHead(bytes) {
270
+ return new TextDecoder("utf-8", { fatal: false }).decode(
271
+ bytes.subarray(0, HEAD_BYTES)
272
+ );
273
+ }
274
+ function nameFromHtml(html) {
275
+ const document = parse(html);
276
+ const candidates = document ? [
277
+ document.querySelector('meta[property="og:site_name"]')?.getAttribute("content"),
278
+ document.querySelector("title")?.textContent,
279
+ document.querySelector("h1")?.textContent
280
+ ] : [matchOgSiteName(html), matchTag(html, "title"), matchTag(html, "h1")];
281
+ for (const candidate of candidates) {
282
+ const name = clean(candidate);
283
+ if (name !== null) return name;
284
+ }
285
+ return null;
286
+ }
287
+ function parse(html) {
288
+ const parser = globalThis.DOMParser;
289
+ if (parser === void 0) return null;
290
+ try {
291
+ return new parser().parseFromString(html, "text/html");
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+ function matchTag(html, tag) {
297
+ const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
298
+ html
299
+ );
300
+ return match?.[1] ?? null;
301
+ }
302
+ function matchOgSiteName(html) {
303
+ const patterns = [
304
+ /<meta[^>]*property\s*=\s*["']og:site_name["'][^>]*content\s*=\s*["']([^"']*)["']/i,
305
+ /<meta[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:site_name["']/i
306
+ ];
307
+ for (const pattern of patterns) {
308
+ const match = pattern.exec(html);
309
+ if (match?.[1] !== void 0) return match[1];
310
+ }
311
+ return null;
312
+ }
313
+ const INVISIBLE = new RegExp(
314
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}${String.fromCharCode(8203)}-${String.fromCharCode(8207)}${String.fromCharCode(8232)}-${String.fromCharCode(8238)}${String.fromCharCode(8288)}-${String.fromCharCode(8292)}${String.fromCharCode(8294)}-${String.fromCharCode(8303)}${String.fromCharCode(65279)}]`,
315
+ "g"
316
+ );
317
+ function clean(raw) {
318
+ if (raw === null || raw === void 0) return null;
319
+ if (raw.includes("<") || raw.includes(">")) return null;
320
+ const collapsed = raw.replace(INVISIBLE, " ").replace(/\s+/g, " ").trim();
321
+ const name = trimSuffix(collapsed);
322
+ if (name.length === 0) return null;
323
+ if (PLACEHOLDERS.has(name.toLowerCase())) return null;
324
+ return name.length > MAX_NAME_LENGTH ? name.slice(0, MAX_NAME_LENGTH).trimEnd() : name;
325
+ }
326
+ const PLACEHOLDERS = /* @__PURE__ */ new Set([
327
+ "document",
328
+ "untitled",
329
+ "untitled document",
330
+ "index",
331
+ "home",
332
+ "new page",
333
+ "my site",
334
+ "vite app",
335
+ "vite + react",
336
+ "react app",
337
+ "create next app",
338
+ "svelte app",
339
+ "webpack app",
340
+ "hello world",
341
+ "title"
342
+ ]);
343
+ function trimSuffix(title) {
344
+ for (const separator of TITLE_SEPARATORS) {
345
+ const at = title.lastIndexOf(separator);
346
+ if (at <= 0) continue;
347
+ const before = title.slice(0, at).trim();
348
+ const after = title.slice(at + separator.length).trim();
349
+ if (before.length > 0 && after.length > 0) return after;
350
+ }
351
+ return title;
352
+ }
353
+ const DEFAULT_CONCURRENCY = 8;
354
+ const DEFAULT_RETRIES = 3;
355
+ const BACKOFF_MS = [1e3, 2e3, 4e3];
356
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
357
+ const doFetch = deps.fetch ?? globalFetch();
358
+ const delay = deps.delay ?? defaultDelay;
359
+ const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
360
+ const retries = deps.retries ?? DEFAULT_RETRIES;
361
+ const byPath = new Map(files.map((file) => [file.path, file]));
362
+ let nextIndex = 0;
363
+ let done = 0;
364
+ let uploadedBytes = 0;
365
+ const worker = async () => {
366
+ for (; ; ) {
367
+ const index = nextIndex++;
368
+ const target = targets[index];
369
+ if (target === void 0) return;
370
+ const file = byPath.get(target.path);
371
+ if (file === void 0) {
372
+ throw new DeployError(
373
+ ClientErrorCode.UploadFailed,
374
+ `The server asked for ${target.path}, which is not in the manifest.`,
375
+ { path: target.path }
376
+ );
377
+ }
378
+ await uploadOne(target, file, {
379
+ doFetch,
380
+ delay,
381
+ retries,
382
+ signal,
383
+ uploadUrl
384
+ });
385
+ done += 1;
386
+ uploadedBytes += file.bytes.length;
387
+ onProgress?.(done, targets.length, uploadedBytes);
388
+ }
389
+ };
390
+ const workers = Array.from(
391
+ { length: Math.min(concurrency, targets.length) },
392
+ worker
393
+ );
394
+ try {
395
+ await Promise.all(workers);
396
+ } catch (error) {
397
+ throw asDeployError(error);
398
+ }
399
+ }
400
+ async function uploadOne(target, file, context) {
401
+ let lastError;
402
+ for (let attempt = 0; attempt <= context.retries; attempt++) {
403
+ context.signal?.throwIfAborted();
404
+ if (attempt > 0) {
405
+ await context.delay(BACKOFF_MS[attempt - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
406
+ context.signal?.throwIfAborted();
407
+ }
408
+ try {
409
+ const response = await context.doFetch(context.uploadUrl, {
410
+ method: "PUT",
411
+ body: new Blob([file.bytes.slice().buffer]),
412
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot
413
+ // be set here — it is a forbidden header name — but the browser derives it from the body,
414
+ // which is exactly the length the token names.
415
+ headers: { Authorization: `Bearer ${target.token}` },
416
+ ...context.signal ? { signal: context.signal } : {}
417
+ });
418
+ if (response.ok) return;
419
+ if (response.status === 401 || response.status === 403 || response.status === 429) {
420
+ throw new DeployError(
421
+ ClientErrorCode.UploadFailed,
422
+ `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
423
+ { path: target.path, status: response.status }
424
+ );
425
+ }
426
+ lastError = new Error(`HTTP ${response.status}`);
427
+ } catch (error) {
428
+ if (error instanceof DeployError) throw error;
429
+ if (isAbort(error)) throw cancelled();
430
+ lastError = error;
431
+ }
432
+ }
433
+ throw new DeployError(
434
+ ClientErrorCode.UploadFailed,
435
+ `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
436
+ { path: target.path, cause: String(lastError) }
437
+ );
438
+ }
439
+ function globalFetch() {
440
+ return globalThis.fetch.bind(globalThis);
441
+ }
442
+ function defaultDelay(ms) {
443
+ return new Promise((resolve) => setTimeout(resolve, ms));
444
+ }
445
+ function isAbort(error) {
446
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
447
+ }
448
+ function cancelled() {
449
+ return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
450
+ }
451
+ function asDeployError(error) {
452
+ if (error instanceof DeployError) return error;
453
+ if (isAbort(error)) return cancelled();
454
+ return new DeployError(
455
+ ClientErrorCode.UploadFailed,
456
+ error instanceof Error ? error.message : "Upload failed.",
457
+ error
458
+ );
459
+ }
460
+ async function deploy(source, siteId, onProgress, signal, options = {}) {
461
+ try {
462
+ const files = await source(signal);
463
+ onProgress({ type: "collecting", files: files.length });
464
+ const hashed = await hashAll(
465
+ files,
466
+ (done, total) => onProgress({ type: "hashing", done, total }),
467
+ signal
468
+ );
469
+ checkLimits(hashed, options.limits ?? null);
470
+ onProgress({ type: "preparing" });
471
+ const prepared = await prepareDeploy(siteId, hashed, options, signal);
472
+ if (prepared.unchanged) {
473
+ onProgress({ type: "unchanged", url: prepared.url ?? "" });
474
+ return;
475
+ }
476
+ await uploadAll(
477
+ prepared.upload,
478
+ prepared.uploadUrl,
479
+ hashed,
480
+ (done, _total, bytes) => onProgress({
481
+ type: "uploading",
482
+ done: prepared.reused + done,
483
+ total: prepared.total,
484
+ bytes,
485
+ reused: prepared.reused
486
+ }),
487
+ signal,
488
+ options.upload
489
+ );
490
+ onProgress({ type: "completing", reused: prepared.reused });
491
+ const completed = await completeDeploy(
492
+ siteId,
493
+ prepared.deployId,
494
+ options,
495
+ signal,
496
+ suggestSiteName(files)
497
+ );
498
+ onProgress({ type: "done", url: completed.url, name: completed.name });
499
+ } catch (error) {
500
+ const failure = asDeployError(error);
501
+ onProgress({
502
+ type: "error",
503
+ code: failure.code,
504
+ message: failure.message,
505
+ ...failure.detail === void 0 ? {} : { detail: failure.detail }
506
+ });
507
+ throw failure;
508
+ }
509
+ }
510
+ const MAX_FILES = 2e4;
511
+ function directorySource(root) {
512
+ return async () => {
513
+ const base = await realpath(root);
514
+ const files = [];
515
+ await walk(base, base, files);
516
+ return files;
517
+ };
518
+ }
519
+ async function walk(root, directory, into) {
520
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
521
+ const full = join(directory, entry.name);
522
+ const path = relative(root, full).split(sep).join("/");
523
+ if (shouldIgnore(path)) continue;
524
+ if (entry.isSymbolicLink()) {
525
+ const target = await realpath(full).catch(() => null);
526
+ if (target === null || escapesRoot(root, target)) continue;
527
+ const targeted = await stat(target);
528
+ if (targeted.isDirectory()) {
529
+ await walk(root, target, into);
530
+ continue;
531
+ }
532
+ if (!targeted.isFile()) continue;
533
+ } else if (entry.isDirectory()) {
534
+ await walk(root, full, into);
535
+ continue;
536
+ } else if (!entry.isFile()) {
537
+ continue;
538
+ }
539
+ if (into.length >= MAX_FILES) {
540
+ throw new Error(
541
+ `This folder holds more than ${MAX_FILES.toLocaleString()} files. Publish a build output directory rather than a whole project.`
542
+ );
543
+ }
544
+ into.push({ path, bytes: new Uint8Array(await readFile(full)) });
545
+ }
546
+ }
547
+ function escapesRoot(root, candidate) {
548
+ const inside = relative(root, candidate);
549
+ return inside === "" || inside.startsWith("..");
550
+ }
551
+ async function resolveSite(credentials, site) {
552
+ if (site === void 0) return await createSite(credentials);
553
+ const wanted = site.trim().toLowerCase();
554
+ const found = (await listSites(credentials)).find(
555
+ (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
556
+ );
557
+ if (found === void 0) {
558
+ throw new Error(
559
+ `No site of yours is called "${site}". Leave the site out to publish to a new one, or use list_sites to see what exists.`
560
+ );
561
+ }
562
+ return found;
563
+ }
564
+ async function publish(credentials, source, site) {
565
+ const target = await resolveSite(credentials, site);
566
+ let files = 0;
567
+ let url = target.url;
568
+ let unchanged = false;
569
+ let failure = null;
570
+ let failureDetail;
571
+ const record = (event) => {
572
+ if (event.type === "collecting") files = event.files;
573
+ if (event.type === "unchanged") {
574
+ unchanged = true;
575
+ url = event.url || target.url;
576
+ }
577
+ if (event.type === "done") url = event.url;
578
+ if (event.type === "error") {
579
+ failure = event.message;
580
+ failureDetail = event.detail;
581
+ }
582
+ };
583
+ await deploy(source, target.siteId, record, void 0, {
584
+ baseUrl: credentials.apiBaseUrl,
585
+ token: credentials.token
586
+ }).catch((error) => {
587
+ const thrown = new Error(
588
+ failure ?? (error instanceof Error ? error.message : String(error))
589
+ );
590
+ thrown.detail = failureDetail ?? error?.detail;
591
+ throw thrown;
592
+ });
593
+ return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
594
+ }
595
+ function publishDirectory(credentials, directory, site) {
596
+ return publish(credentials, directorySource(directory), site);
597
+ }
598
+ function publishHtml(credentials, html, site) {
599
+ const bytes = new TextEncoder().encode(html);
600
+ return publish(credentials, async () => [{ path: "index.html", bytes }], site);
601
+ }
602
+ function say(text, isError = false) {
603
+ return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
604
+ }
605
+ async function withCredentials(work) {
606
+ const credentials = loadCredentials();
607
+ if (credentials === null) return say(missingCredentialsMessage(), true);
608
+ try {
609
+ return say(await work(credentials));
610
+ } catch (error) {
611
+ return say(error instanceof Error ? error.message : String(error), true);
612
+ }
613
+ }
614
+ function describe(result) {
615
+ const what = result.unchanged ? "Already live — every file matched what the site already serves, so no new version was published." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
616
+ return [
617
+ what,
618
+ "",
619
+ result.url,
620
+ "",
621
+ `Site ${result.subdomain} (${result.siteId}). Reaching every edge takes up to about a minute.`
622
+ ].join("\n");
623
+ }
624
+ function createServer() {
625
+ const server = new McpServer(
626
+ { name: "drop2run", version: "0.0.0" },
627
+ {
628
+ instructions: [
629
+ "Publishes static sites to Drop2Run.",
630
+ "",
631
+ "Use publish_html for a single page you have written, and publish_dir for a folder that is",
632
+ "already built. Leave `site` unset to publish to a brand-new site; pass a subdomain to",
633
+ "publish over an existing one. list_sites shows what already exists.",
634
+ "",
635
+ "Publishing over an existing site replaces what it serves, so ask before doing that to a",
636
+ "site the person did not name."
637
+ ].join("\n")
638
+ }
639
+ );
640
+ server.registerTool(
641
+ "publish_html",
642
+ {
643
+ title: "Publish a page",
644
+ description: "Publishes one HTML page as a whole site and returns its URL. The page becomes index.html.",
645
+ inputSchema: {
646
+ html: z.string().min(1).describe("The complete HTML document to publish."),
647
+ site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site.")
648
+ }
649
+ },
650
+ ({ html, site }) => withCredentials(async (credentials) => describe(await publishHtml(credentials, html, site)))
651
+ );
652
+ server.registerTool(
653
+ "publish_dir",
654
+ {
655
+ title: "Publish a folder",
656
+ description: "Publishes a folder of static files and returns its URL. The folder needs an index.html at its root, or .md and .pdf files, which are served through the reader.",
657
+ inputSchema: {
658
+ path: z.string().min(1).describe("Absolute path of the folder to publish."),
659
+ site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site.")
660
+ }
661
+ },
662
+ ({ path, site }) => withCredentials(
663
+ async (credentials) => describe(await publishDirectory(credentials, path, site))
664
+ )
665
+ );
666
+ server.registerTool(
667
+ "list_sites",
668
+ {
669
+ title: "List sites",
670
+ description: "Lists the sites on this account, so a publish can go to one of them.",
671
+ inputSchema: {}
672
+ },
673
+ () => withCredentials(async (credentials) => {
674
+ const sites = await listSites(credentials);
675
+ if (sites.length === 0) {
676
+ return "No sites on this account yet. Publishing without a `site` creates one.";
677
+ }
678
+ return sites.map((site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`).join("\n");
679
+ })
680
+ );
681
+ return server;
682
+ }
683
+ async function main() {
684
+ await createServer().connect(new StdioServerTransport());
685
+ }
686
+ export {
687
+ main
688
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@drop2run/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that publishes a folder or a page to Drop2Run from inside a chat.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "drop2run-mcp": "bin/drop2run-mcp.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "dist"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "homepage": "https://dropto.run",
21
+ "keywords": [
22
+ "mcp",
23
+ "ai",
24
+ "claude",
25
+ "agent",
26
+ "llm",
27
+ "static-site",
28
+ "deploy",
29
+ "hosting"
30
+ ],
31
+ "scripts": {
32
+ "prepublishOnly": "npm run build && npm test",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "vitest run",
35
+ "lint": "biome check .",
36
+ "format": "biome check --write .",
37
+ "build": "vite build"
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.30.0",
41
+ "zod": "^4.1.12"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.5.11",
45
+ "@types/node": "^26.4.0",
46
+ "typescript": "^5.9.3",
47
+ "vitest": "^4.1.11",
48
+ "vite": "^7.2.1"
49
+ }
50
+ }