@drop2run/mcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -4
  2. package/dist/index.js +389 -67
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @drop2run/mcp
2
2
 
3
- An MCP server that lets Claude — or any MCP client — publish what it made to
3
+ An MCP server that lets any MCP client — Claude, Cursor, VS Code, Codex, Gemini CLI — publish what it made to
4
4
  [Drop2Run](https://dropto.run): an HTML page, a markdown note, a folder of documents or a built site. It
5
5
  returns a live HTTPS URL.
6
6
 
@@ -35,11 +35,29 @@ add this to `claude_desktop_config.json` and restart the app:
35
35
  That file is at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, and
36
36
  `%APPDATA%\Claude\claude_desktop_config.json` on Windows.
37
37
 
38
+ **Cursor** — the same `mcpServers` block with `"type": "stdio"` added, in `~/.cursor/mcp.json` (or `.cursor/mcp.json` for one
39
+ project). **Gemini CLI** — the same block, in `~/.gemini/settings.json`.
40
+
41
+ **VS Code** — one command:
42
+
43
+ ```bash
44
+ code --add-mcp '{"name":"drop2run","command":"npx","args":["-y","@drop2run/mcp"]}'
45
+ ```
46
+
47
+ **Codex** — one command:
48
+
49
+ ```bash
50
+ codex mcp add drop2run -- npx -y @drop2run/mcp
51
+ ```
52
+
53
+ Zed and the rest: [dropto.run/docs/agents](https://dropto.run/docs/agents) has the file and the shape
54
+ for each client.
55
+
38
56
  Nothing is installed globally either way — `npx` fetches the package when the client starts it.
39
57
 
40
58
  ## Sign in
41
59
 
42
- Ask for a publish. If there is no token yet, Claude calls the `login` tool, a browser tab opens, you
60
+ Ask for a publish. If there is no token yet, the agent calls the `login` tool, a browser tab opens, you
43
61
  approve the sign-in, and the publish carries on. Nothing to install, nothing to paste.
44
62
 
45
63
  The token lands in `~/.config/drop2run/config.json` with mode `0600` — the same file the `drop2run`
@@ -47,9 +65,9 @@ CLI uses, so signing in once covers both. It is read on every call rather than a
47
65
  effect without restarting the client.
48
66
 
49
67
  **No browser on this machine?** In a container, over SSH, on a remote host, `login_code` gives a short
50
- code to enter at <https://dropto.run/device> from any other device.
68
+ code to enter at <https://app.dropto.run/device> from any other device.
51
69
 
52
- **Or set a token yourself.** Create one at <https://dropto.run/account/tokens> and either put it in
70
+ **Or set a token yourself.** Create one at <https://app.dropto.run/account/tokens> and either put it in
53
71
  `~/.config/drop2run/config.json` as `{"token": "d2r_..."}`, which needs no restart, or set
54
72
  `DROP2RUN_TOKEN` in the environment the client starts the server in. The environment takes precedence
55
73
  over the file — and because it is read once at startup, setting it in a shell afterwards changes
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ function configPath() {
13
13
  return join(homedir(), ".config", "drop2run", "config.json");
14
14
  }
15
15
  const DEFAULT_API_BASE_URL = "https://dropto.run/api";
16
+ const PRODUCTION_SITE_ORIGIN = "https://dropto.run";
17
+ const PRODUCTION_DASHBOARD_ORIGIN = "https://app.dropto.run";
16
18
  function loadCredentials(env = process.env, read = (path) => readFileSync(path, "utf8")) {
17
19
  let stored = {};
18
20
  try {
@@ -36,7 +38,8 @@ function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path
36
38
  return url.replace(/\/+$/, "");
37
39
  }
38
40
  function dashboardUrlFor(apiBaseUrl) {
39
- return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
41
+ const origin = apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
42
+ return origin === PRODUCTION_SITE_ORIGIN ? PRODUCTION_DASHBOARD_ORIGIN : origin;
40
43
  }
41
44
  function saveToken(token, path = configPath()) {
42
45
  let existing = {};
@@ -63,14 +66,14 @@ function missingCredentialsMessage(surface = "cli") {
63
66
  ] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
64
67
  const byHand = surface === "mcp" ? [
65
68
  "To set one without signing in here, create a token at",
66
- "https://dropto.run/account/tokens and either:",
69
+ "https://app.dropto.run/account/tokens and either:",
67
70
  ` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
68
71
  " server reads on every call and needs no restart, or",
69
72
  ` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
70
73
  " the environment it was started in."
71
74
  ] : [
72
75
  "For CI, or where no browser can be opened, create a token at",
73
- "https://dropto.run/account/tokens and either:",
76
+ "https://app.dropto.run/account/tokens and either:",
74
77
  ` - set ${TOKEN_VARIABLE} in the environment, or`,
75
78
  ` - put it in ${configPath()} as {"token": "d2r_..."}`
76
79
  ];
@@ -344,6 +347,12 @@ async function exchange(apiBaseUrl, code, verifier) {
344
347
  const body = await response.json();
345
348
  return { token: body.token, name: body.name, email: body.email };
346
349
  }
350
+ function sizeOf(content) {
351
+ return ArrayBuffer.isView(content) ? content.byteLength : content.size;
352
+ }
353
+ async function bytesOf(content) {
354
+ return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
355
+ }
347
356
  class DeployError extends Error {
348
357
  /**
349
358
  * @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
@@ -380,20 +389,55 @@ const ClientErrorCode = {
380
389
  /** The user cancelled. */
381
390
  Cancelled: "cancelled",
382
391
  /** An upload kept failing after every retry. */
383
- UploadFailed: "upload_failed"
392
+ UploadFailed: "upload_failed",
393
+ /**
394
+ * The browser could not hold what was dropped in memory. Raised when allocating the buffer for a
395
+ * large zip or file fails, which a browser reports as a `RangeError` rather than as anything that
396
+ * names memory.
397
+ */
398
+ OutOfMemory: "out_of_memory",
399
+ /**
400
+ * A dropped file could not be read when it came to be hashed. Files from a folder stay on disk until
401
+ * then, so one moved, deleted or changed after the drop fails here rather than at the drop.
402
+ */
403
+ ReadFailed: "read_failed",
404
+ /**
405
+ * A control-plane call got no answer: the connection failed before a response arrived, or the
406
+ * request timeout passed. Distinct from an HTTP error, which is an answer — this one says nothing
407
+ * about whether the server acted, which is why only an idempotent call may retry on it.
408
+ */
409
+ NetworkFailed: "network_failed"
384
410
  };
411
+ function describeError(error) {
412
+ const parts = [];
413
+ let current = error;
414
+ for (let depth = 0; depth < 5 && current !== void 0 && current !== null; depth++) {
415
+ if (current instanceof Error) {
416
+ const code = current.code;
417
+ parts.push(typeof code === "string" ? `${current.message} (${code})` : current.message);
418
+ current = current.cause;
419
+ } else {
420
+ parts.push(String(current));
421
+ break;
422
+ }
423
+ }
424
+ return parts.join(" ← ");
425
+ }
385
426
  const ACCOUNT_HEADER = "X-Drop2Run-Account";
386
427
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
428
+ const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
429
+ const COMPLETE_RETRY_DELAYS_MS = [1e3, 4e3];
387
430
  async function prepareDeploy(siteId, files, options, signal) {
388
431
  const manifest = files.map((file) => ({
389
432
  path: file.path,
390
433
  sha256: file.sha256,
391
- size: file.bytes.length
434
+ size: sizeOf(file.bytes)
392
435
  }));
393
436
  const prepared = await request(
394
437
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
395
438
  { files: manifest },
396
- options
439
+ options,
440
+ signal
397
441
  );
398
442
  return { ...prepared, uploadUrl: absolute(prepared.uploadUrl, options.baseUrl) };
399
443
  }
@@ -403,31 +447,65 @@ function absolute(uploadUrl, baseUrl) {
403
447
  return new URL(uploadUrl, baseUrl).toString();
404
448
  }
405
449
  async function completeDeploy(siteId, deployId, options, signal, name) {
406
- return request(
407
- `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`,
408
- // A bodyless POST when there is no name to send, which is what this call has always been. The
409
- // server treats an absent body and an absent field identically, so nothing depends on which of
410
- // the two a client picks.
411
- name === null || name === void 0 ? void 0 : { name },
412
- options
413
- );
450
+ const path = `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/complete`;
451
+ const body = name === null || name === void 0 ? void 0 : { name };
452
+ const delay = options.delay ?? wait;
453
+ for (let attempt2 = 0; ; attempt2++) {
454
+ try {
455
+ return await request(path, body, options, signal);
456
+ } catch (error) {
457
+ const unanswered = error instanceof DeployError && error.code === ClientErrorCode.NetworkFailed;
458
+ if (!unanswered || attempt2 >= COMPLETE_RETRY_DELAYS_MS.length || signal?.aborted) throw error;
459
+ await delay(COMPLETE_RETRY_DELAYS_MS[attempt2] ?? 1e3);
460
+ }
461
+ }
462
+ }
463
+ function wait(ms) {
464
+ return new Promise((resolve) => setTimeout(resolve, ms));
414
465
  }
415
466
  async function request(path, body, options, signal) {
416
467
  const doFetch = options.fetch ?? globalFetch$1();
417
468
  const base = options.baseUrl ?? "/api";
469
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
418
470
  const headers = {};
419
471
  if (body !== void 0) headers["Content-Type"] = "application/json";
420
472
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
421
473
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
422
474
  if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
423
- const response = await doFetch(`${base}/${path}`, {
424
- method: "POST",
425
- headers,
426
- ...body === void 0 ? {} : { body: JSON.stringify(body) },
427
- ...{}
428
- });
429
- if (!response.ok) throw await problemToError(response);
430
- return await response.json();
475
+ const controller = new AbortController();
476
+ let timedOut = false;
477
+ const timer = setTimeout(() => {
478
+ timedOut = true;
479
+ controller.abort();
480
+ }, timeoutMs);
481
+ try {
482
+ const response = await doFetch(`${base}/${path}`, {
483
+ method: "POST",
484
+ headers,
485
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
486
+ signal: controller.signal
487
+ });
488
+ if (!response.ok) throw await problemToError(response);
489
+ return await response.json();
490
+ } catch (error) {
491
+ if (error instanceof DeployError || signal?.aborted) throw error;
492
+ if (timedOut) {
493
+ throw new DeployError(
494
+ ClientErrorCode.NetworkFailed,
495
+ `The server did not answer within ${Math.round(timeoutMs / 1e3)} seconds.`,
496
+ { path, cause: describeError(error) }
497
+ );
498
+ }
499
+ if (error instanceof TypeError) {
500
+ throw new DeployError(ClientErrorCode.NetworkFailed, "Could not reach the server.", {
501
+ path,
502
+ cause: describeError(error)
503
+ });
504
+ }
505
+ throw error;
506
+ } finally {
507
+ clearTimeout(timer);
508
+ }
431
509
  }
432
510
  function globalFetch$1() {
433
511
  return globalThis.fetch.bind(globalThis);
@@ -926,17 +1004,29 @@ function extensionOf(path) {
926
1004
  return dot <= 0 ? "" : name.slice(dot).toLowerCase();
927
1005
  }
928
1006
  async function sha256Hex(bytes) {
929
- const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
1007
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
930
1008
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
931
1009
  }
932
1010
  async function hashAll(files, onProgress, signal) {
933
1011
  const hashed = [];
934
1012
  for (const [index, file] of files.entries()) {
935
- hashed.push({ ...file, sha256: await sha256Hex(file.bytes) });
1013
+ hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
936
1014
  onProgress?.(index + 1, files.length);
937
1015
  }
938
1016
  return hashed;
939
1017
  }
1018
+ async function readForHash(file) {
1019
+ try {
1020
+ return await bytesOf(file.bytes);
1021
+ } catch (error) {
1022
+ if (error instanceof RangeError) throw error;
1023
+ throw new DeployError(
1024
+ ClientErrorCode.ReadFailed,
1025
+ `Could not read ${file.path}. It may have been moved, renamed or changed since you dropped it — drop the folder again.`,
1026
+ { path: file.path, cause: describeError(error) }
1027
+ );
1028
+ }
1029
+ }
940
1030
  const IGNORED_EXACT = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".gitignore", ".gitkeep"]);
941
1031
  const IGNORED_DIRECTORIES = [
942
1032
  "__MACOSX",
@@ -946,7 +1036,11 @@ const IGNORED_DIRECTORIES = [
946
1036
  ".hg",
947
1037
  ".idea",
948
1038
  ".vscode",
949
- ".claude"
1039
+ ".claude",
1040
+ ".agents",
1041
+ ".cursor",
1042
+ ".codex",
1043
+ ".gemini"
950
1044
  ];
951
1045
  function shouldIgnore(path) {
952
1046
  const segments = path.split("/");
@@ -1008,7 +1102,7 @@ function checkLimits(files, limits) {
1008
1102
  );
1009
1103
  }
1010
1104
  const refusal = sizeRefusal(
1011
- files.map((file) => ({ path: file.path, size: file.bytes.length })),
1105
+ files.map((file) => ({ path: file.path, size: sizeOf(file.bytes) })),
1012
1106
  limits
1013
1107
  );
1014
1108
  if (refusal) throw refusal;
@@ -1049,13 +1143,24 @@ const HEAD_BYTES = 64 * 1024;
1049
1143
  const TITLE_SEPARATORS = ["|", "·", "—", "–", " - "];
1050
1144
  function suggestSiteName(files) {
1051
1145
  const index = files.find((file) => file.path === "index.html");
1052
- if (index === void 0) return null;
1146
+ if (index === void 0 || !ArrayBuffer.isView(index.bytes)) return null;
1053
1147
  try {
1054
1148
  return nameFromHtml(decodeHead(index.bytes));
1055
1149
  } catch {
1056
1150
  return null;
1057
1151
  }
1058
1152
  }
1153
+ async function readSiteName(files) {
1154
+ const index = files.find((file) => file.path === "index.html");
1155
+ if (index === void 0) return null;
1156
+ if (ArrayBuffer.isView(index.bytes)) return suggestSiteName(files);
1157
+ try {
1158
+ const head = new Uint8Array(await index.bytes.slice(0, HEAD_BYTES).arrayBuffer());
1159
+ return suggestSiteName([{ path: index.path, bytes: head }]);
1160
+ } catch {
1161
+ return null;
1162
+ }
1163
+ }
1059
1164
  function decodeHead(bytes) {
1060
1165
  return new TextDecoder("utf-8", { fatal: false }).decode(
1061
1166
  bytes.subarray(0, HEAD_BYTES)
@@ -1087,7 +1192,7 @@ function matchTag(html, tag) {
1087
1192
  const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1088
1193
  html
1089
1194
  );
1090
- return match?.[1] ?? null;
1195
+ return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1091
1196
  }
1092
1197
  function matchOgSiteName(html) {
1093
1198
  const patterns = [
@@ -1096,10 +1201,56 @@ function matchOgSiteName(html) {
1096
1201
  ];
1097
1202
  for (const pattern of patterns) {
1098
1203
  const match = pattern.exec(html);
1099
- if (match?.[1] !== void 0) return match[1];
1204
+ if (match?.[1] !== void 0) return decodeEntities(match[1]);
1100
1205
  }
1101
1206
  return null;
1102
1207
  }
1208
+ const LATIN1_ENTITIES = "nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml".split(" ");
1209
+ const NAMED_ENTITIES = new Map([
1210
+ ...LATIN1_ENTITIES.map((name, index) => [name, 160 + index]),
1211
+ ["amp", 38],
1212
+ ["lt", 60],
1213
+ ["gt", 62],
1214
+ ["quot", 34],
1215
+ ["apos", 39],
1216
+ ["ensp", 8194],
1217
+ ["emsp", 8195],
1218
+ ["thinsp", 8201],
1219
+ ["ndash", 8211],
1220
+ ["mdash", 8212],
1221
+ ["lsquo", 8216],
1222
+ ["rsquo", 8217],
1223
+ ["sbquo", 8218],
1224
+ ["ldquo", 8220],
1225
+ ["rdquo", 8221],
1226
+ ["bdquo", 8222],
1227
+ ["dagger", 8224],
1228
+ ["Dagger", 8225],
1229
+ ["bull", 8226],
1230
+ ["hellip", 8230],
1231
+ ["prime", 8242],
1232
+ ["lsaquo", 8249],
1233
+ ["rsaquo", 8250],
1234
+ ["euro", 8364],
1235
+ ["trade", 8482],
1236
+ ["larr", 8592],
1237
+ ["rarr", 8594],
1238
+ ["hearts", 9829]
1239
+ ]);
1240
+ function decodeEntities(text) {
1241
+ return text.replace(
1242
+ /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));/g,
1243
+ (whole, decimal, hex, name) => {
1244
+ if (name !== void 0) {
1245
+ const code2 = NAMED_ENTITIES.get(name);
1246
+ return code2 === void 0 ? whole : String.fromCodePoint(code2);
1247
+ }
1248
+ const code = decimal !== void 0 ? Number(decimal) : Number.parseInt(hex ?? "", 16);
1249
+ const valid = code > 0 && code <= 1114111 && (code < 55296 || code > 57343);
1250
+ return String.fromCodePoint(valid ? code : 65533);
1251
+ }
1252
+ );
1253
+ }
1103
1254
  const INVISIBLE = new RegExp(
1104
1255
  `[${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)}]`,
1105
1256
  "g"
@@ -1141,46 +1292,105 @@ function trimSuffix(title) {
1141
1292
  return title;
1142
1293
  }
1143
1294
  const DEFAULT_CONCURRENCY = 8;
1295
+ const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1144
1296
  const DEFAULT_RETRIES = 3;
1145
1297
  const BACKOFF_MS = [1e3, 2e3, 4e3];
1146
- async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}) {
1147
- const doFetch = deps.fetch ?? globalFetch();
1298
+ const DEFAULT_STALL_MS = 3e4;
1299
+ const PROGRESS_INTERVAL_MS = 200;
1300
+ class StalledError extends Error {
1301
+ /** @param seconds How long nothing moved. */
1302
+ constructor(seconds) {
1303
+ super(`No data moved for ${seconds}s.`);
1304
+ this.name = "StalledError";
1305
+ }
1306
+ }
1307
+ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {}, onRetry) {
1308
+ const put = deps.put ?? defaultTransport(deps.fetch);
1148
1309
  const delay = deps.delay ?? defaultDelay;
1149
1310
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1150
1311
  const retries = deps.retries ?? DEFAULT_RETRIES;
1312
+ const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1313
+ const now = deps.now ?? Date.now;
1314
+ const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1151
1315
  const byPath = new Map(files.map((file) => [file.path, file]));
1152
1316
  let nextIndex = 0;
1153
1317
  let done = 0;
1154
- let uploadedBytes = 0;
1318
+ let failed = false;
1319
+ let reserved = 0;
1320
+ let waiting = [];
1321
+ const acquire = async (size) => {
1322
+ while (reserved > 0 && reserved + size > maxBytesInFlight) {
1323
+ await new Promise((resolve) => waiting.push(resolve));
1324
+ }
1325
+ reserved += size;
1326
+ };
1327
+ const release = (size) => {
1328
+ reserved -= size;
1329
+ const woken = waiting;
1330
+ waiting = [];
1331
+ for (const wake of woken) wake();
1332
+ };
1333
+ let landedBytes = 0;
1334
+ const inFlight = /* @__PURE__ */ new Map();
1335
+ let lastReport = Number.NEGATIVE_INFINITY;
1336
+ const report2 = (path) => {
1337
+ if (onProgress === void 0) return;
1338
+ const at = now();
1339
+ if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1340
+ lastReport = at;
1341
+ let bytes = landedBytes;
1342
+ for (const sent of inFlight.values()) bytes += sent;
1343
+ onProgress(done, targets.length, bytes, path);
1344
+ };
1155
1345
  const worker = async () => {
1156
1346
  for (; ; ) {
1347
+ if (failed) return;
1157
1348
  const index = nextIndex++;
1158
1349
  const target = targets[index];
1159
1350
  if (target === void 0) return;
1160
1351
  const file = byPath.get(target.path);
1161
1352
  if (file === void 0) {
1353
+ failed = true;
1162
1354
  throw new DeployError(
1163
1355
  ClientErrorCode.UploadFailed,
1164
1356
  `The server asked for ${target.path}, which is not in the manifest.`,
1165
1357
  { path: target.path }
1166
1358
  );
1167
1359
  }
1168
- await uploadOne(target, file, {
1169
- doFetch,
1170
- delay,
1171
- retries,
1172
- signal,
1173
- uploadUrl
1174
- });
1360
+ const size = sizeOf(file.bytes);
1361
+ await acquire(size);
1362
+ try {
1363
+ if (failed) return;
1364
+ signal?.throwIfAborted();
1365
+ await uploadOne(target, file, {
1366
+ put,
1367
+ delay,
1368
+ retries,
1369
+ signal,
1370
+ uploadUrl,
1371
+ stallMs,
1372
+ onSent: (sent) => {
1373
+ inFlight.set(index, sent);
1374
+ report2();
1375
+ },
1376
+ onRetry: (attempt2, stalled) => {
1377
+ inFlight.set(index, 0);
1378
+ onRetry?.(target.path, attempt2, retries + 1, stalled);
1379
+ }
1380
+ });
1381
+ } catch (error) {
1382
+ failed = true;
1383
+ throw error;
1384
+ } finally {
1385
+ release(size);
1386
+ }
1387
+ inFlight.delete(index);
1175
1388
  done += 1;
1176
- uploadedBytes += file.bytes.length;
1177
- onProgress?.(done, targets.length, uploadedBytes, file.path);
1389
+ landedBytes += size;
1390
+ report2(file.path);
1178
1391
  }
1179
1392
  };
1180
- const workers = Array.from(
1181
- { length: Math.min(concurrency, targets.length) },
1182
- worker
1183
- );
1393
+ const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1184
1394
  try {
1185
1395
  await Promise.all(workers);
1186
1396
  } catch (error) {
@@ -1192,39 +1402,122 @@ async function uploadOne(target, file, context) {
1192
1402
  for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
1193
1403
  context.signal?.throwIfAborted();
1194
1404
  if (attempt2 > 0) {
1405
+ context.onRetry(attempt2 + 1, lastError instanceof StalledError);
1195
1406
  await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1196
1407
  context.signal?.throwIfAborted();
1197
1408
  }
1198
1409
  try {
1199
- const response = await context.doFetch(context.uploadUrl, {
1200
- method: "PUT",
1201
- body: new Blob([file.bytes.slice().buffer]),
1202
- // The permit is the whole request. `Content-Length` is required by the Worker and cannot
1203
- // be set here — it is a forbidden header name — but the browser derives it from the body,
1204
- // which is exactly the length the token names.
1205
- headers: { Authorization: `Bearer ${target.token}` },
1206
- ...context.signal ? { signal: context.signal } : {}
1410
+ const status = await context.put({
1411
+ url: context.uploadUrl,
1412
+ token: target.token,
1413
+ body: untyped(file.bytes),
1414
+ signal: context.signal,
1415
+ onSent: context.onSent,
1416
+ stallMs: context.stallMs
1207
1417
  });
1208
- if (response.ok) return;
1209
- if (response.status === 401 || response.status === 403 || response.status === 429) {
1418
+ if (status >= 200 && status < 300) return;
1419
+ if (status === 401 || status === 403 || status === 429) {
1210
1420
  throw new DeployError(
1211
1421
  ClientErrorCode.UploadFailed,
1212
1422
  `Upload of ${target.path} was refused. The upload window may have expired — try deploying again.`,
1213
- { path: target.path, status: response.status }
1423
+ { path: target.path, status }
1214
1424
  );
1215
1425
  }
1216
- lastError = new Error(`HTTP ${response.status}`);
1426
+ if (status === 413) {
1427
+ throw new DeployError(
1428
+ ClientErrorCode.UploadFailed,
1429
+ `${target.path} is too large to upload in one request. Make it smaller, or leave it out of this publish.`,
1430
+ { path: target.path, status }
1431
+ );
1432
+ }
1433
+ lastError = new Error(`HTTP ${status}`);
1217
1434
  } catch (error) {
1218
1435
  if (error instanceof DeployError) throw error;
1219
1436
  if (isAbort(error)) throw cancelled();
1220
1437
  lastError = error;
1221
1438
  }
1222
1439
  }
1223
- throw new DeployError(
1224
- ClientErrorCode.UploadFailed,
1225
- `Could not upload ${target.path} after ${context.retries + 1} attempts.`,
1226
- { path: target.path, cause: String(lastError) }
1227
- );
1440
+ const message = lastError instanceof StalledError ? `Upload of ${target.path} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${target.path} after ${context.retries + 1} attempts.`;
1441
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1442
+ path: target.path,
1443
+ cause: describeError(lastError)
1444
+ });
1445
+ }
1446
+ function untyped(content) {
1447
+ if (ArrayBuffer.isView(content) || content.type === "") return content;
1448
+ return content.slice(0, content.size);
1449
+ }
1450
+ function defaultTransport(doFetch) {
1451
+ if (doFetch !== void 0) return fetchTransport(doFetch);
1452
+ const Xhr = globalThis.XMLHttpRequest;
1453
+ return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1454
+ }
1455
+ function fetchTransport(doFetch) {
1456
+ return async ({ url, token, body, signal }) => {
1457
+ const response = await doFetch(url, {
1458
+ method: "PUT",
1459
+ // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
1460
+ // full-size copies of every file in flight — eight at once, so up to 400 MB on a drop of
1461
+ // 50 MB files. `fetch` sends exactly the range a view covers. The cast is TypeScript's
1462
+ // SharedArrayBuffer distinction again; see `sha256Hex`. A `Blob` goes as it is, and the runtime
1463
+ // streams it from disk.
1464
+ body,
1465
+ // The permit is the whole request. `Content-Length` is required by the Worker and cannot be
1466
+ // set here — it is a forbidden header name — but the runtime derives it from the body, which
1467
+ // is exactly the length the token names.
1468
+ headers: { Authorization: `Bearer ${token}` },
1469
+ ...signal ? { signal } : {}
1470
+ });
1471
+ return response.status;
1472
+ };
1473
+ }
1474
+ function xhrTransport(Xhr) {
1475
+ return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve, reject) => {
1476
+ const xhr = new Xhr();
1477
+ let settled = false;
1478
+ let timer;
1479
+ const settle = (finish) => {
1480
+ if (settled) return;
1481
+ settled = true;
1482
+ clearTimeout(timer);
1483
+ signal?.removeEventListener("abort", onAbort);
1484
+ finish();
1485
+ };
1486
+ const arm = () => {
1487
+ clearTimeout(timer);
1488
+ timer = setTimeout(() => {
1489
+ xhr.abort();
1490
+ settle(() => reject(new StalledError(Math.round(stallMs / 1e3))));
1491
+ }, stallMs);
1492
+ };
1493
+ function onAbort() {
1494
+ xhr.abort();
1495
+ settle(() => reject(abortReason(signal)));
1496
+ }
1497
+ if (signal?.aborted) {
1498
+ reject(abortReason(signal));
1499
+ return;
1500
+ }
1501
+ signal?.addEventListener("abort", onAbort, { once: true });
1502
+ xhr.open("PUT", url);
1503
+ xhr.setRequestHeader("Authorization", `Bearer ${token}`);
1504
+ xhr.upload.onprogress = (event) => {
1505
+ arm();
1506
+ onSent(event.loaded);
1507
+ };
1508
+ xhr.onprogress = arm;
1509
+ xhr.onload = () => settle(() => resolve(xhr.status));
1510
+ xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1511
+ arm();
1512
+ xhr.send(body);
1513
+ });
1514
+ }
1515
+ function abortReason(signal) {
1516
+ const reason = signal?.reason;
1517
+ if (reason instanceof Error) return reason;
1518
+ const error = new Error("Aborted.");
1519
+ error.name = "AbortError";
1520
+ return error;
1228
1521
  }
1229
1522
  function globalFetch() {
1230
1523
  return globalThis.fetch.bind(globalThis);
@@ -1238,9 +1531,17 @@ function isAbort(error) {
1238
1531
  function cancelled() {
1239
1532
  return new DeployError(ClientErrorCode.Cancelled, "Deploy cancelled.");
1240
1533
  }
1534
+ function outOfMemory(detail) {
1535
+ return new DeployError(
1536
+ ClientErrorCode.OutOfMemory,
1537
+ "This browser ran out of memory reading what you dropped. Drop the unzipped folder instead, or try again in a desktop browser with fewer tabs open.",
1538
+ detail
1539
+ );
1540
+ }
1241
1541
  function asDeployError(error) {
1242
1542
  if (error instanceof DeployError) return error;
1243
1543
  if (isAbort(error)) return cancelled();
1544
+ if (error instanceof RangeError) return outOfMemory(error.message);
1244
1545
  return new DeployError(
1245
1546
  ClientErrorCode.UploadFailed,
1246
1547
  error instanceof Error ? error.message : "Upload failed.",
@@ -1249,7 +1550,11 @@ function asDeployError(error) {
1249
1550
  }
1250
1551
  async function deploy(source, siteId, onProgress, signal, options = {}) {
1251
1552
  try {
1252
- const collected = await source(signal);
1553
+ onProgress({ type: "reading", bytes: 0, total: 0 });
1554
+ const collected = await source(
1555
+ signal,
1556
+ (bytes, total) => onProgress({ type: "reading", bytes, total })
1557
+ );
1253
1558
  onProgress({ type: "collecting", files: collected.length });
1254
1559
  const rename = renameOfLoneHtmlPage(collected.map((file) => file.path));
1255
1560
  const files = rename === null ? collected : collected.map((file) => file.path === rename ? { ...file, path: "index.html" } : file);
@@ -1265,6 +1570,21 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1265
1570
  onProgress({ type: "unchanged", url: prepared.url ?? "" });
1266
1571
  return;
1267
1572
  }
1573
+ const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1574
+ const totalBytes = prepared.upload.reduce(
1575
+ (sum, target) => sum + (sizes.get(target.path) ?? 0),
1576
+ 0
1577
+ );
1578
+ if (prepared.upload.length > 0) {
1579
+ onProgress({
1580
+ type: "uploading",
1581
+ done: prepared.reused,
1582
+ total: prepared.total,
1583
+ bytes: 0,
1584
+ totalBytes,
1585
+ reused: prepared.reused
1586
+ });
1587
+ }
1268
1588
  await uploadAll(
1269
1589
  prepared.upload,
1270
1590
  prepared.uploadUrl,
@@ -1274,11 +1594,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1274
1594
  done: prepared.reused + done,
1275
1595
  total: prepared.total,
1276
1596
  bytes,
1597
+ totalBytes,
1277
1598
  reused: prepared.reused,
1278
- path
1599
+ ...path === void 0 ? {} : { path }
1279
1600
  }),
1280
1601
  signal,
1281
- options.upload
1602
+ options.upload,
1603
+ (path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
1282
1604
  );
1283
1605
  onProgress({ type: "completing", reused: prepared.reused });
1284
1606
  const completed = await completeDeploy(
@@ -1286,7 +1608,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1286
1608
  prepared.deployId,
1287
1609
  options,
1288
1610
  signal,
1289
- suggestSiteName(files)
1611
+ await readSiteName(files)
1290
1612
  );
1291
1613
  onProgress({ type: "done", url: completed.url, name: completed.name });
1292
1614
  } catch (error) {
@@ -1603,7 +1925,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1603
1925
  "Call this tool again to keep waiting. Nothing has been changed yet."
1604
1926
  ].join("\n");
1605
1927
  }
1606
- const SERVER_VERSION = "0.7.0";
1928
+ const SERVER_VERSION = "0.8.0";
1607
1929
  const CREDENTIAL_WRITE = {
1608
1930
  readOnlyHint: false,
1609
1931
  destructiveHint: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "MCP server that publishes what an AI made — a page, a document or a whole site — to Drop2Run and returns a live URL.",
5
5
  "mcpName": "run.dropto/drop2run",
6
6
  "license": "MIT",