@drop2run/mcp 0.4.2 → 0.5.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 +10 -1
  2. package/dist/index.js +142 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -66,10 +66,17 @@ lose it.
66
66
  | `publish_files` | Publishes files Claude wrote — a page, a markdown note, several files together |
67
67
  | `publish_dir` | Publishes a folder, given its absolute path |
68
68
  | `list_sites` | Lists the sites on the account |
69
+ | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
69
70
 
70
71
  Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
71
72
  new site is created.
72
73
 
74
+ Both also take an optional `subdomain`, which names the new site instead of letting the server generate
75
+ one. It is for a person who asked for a particular address: the name becomes the URL, cannot be changed
76
+ afterwards, and a site made under a guessed one has to be deleted by hand. `site` and `subdomain` are
77
+ opposites — one publishes over a site that exists, the other creates one that does not — so passing both
78
+ is refused rather than resolved.
79
+
73
80
  Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
74
81
  again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
75
82
  to with `replace`.
@@ -77,7 +84,9 @@ to with `replace`.
77
84
  ## Behaviour worth knowing
78
85
 
79
86
  **Publishing without a `site` creates a new one.** It never replaces your most recent site by default:
80
- "put this online" is not "and overwrite what I published last time".
87
+ "put this online" is not "and overwrite what I published last time". Say what address you want and pass
88
+ it as `subdomain`; say nothing and the name is generated, which is the right default for something
89
+ nobody is going to link to.
81
90
 
82
91
  **A site can be documents instead of a built site.** A publish needs an `index.html` at the top level,
83
92
  or at least one `.md`, `.markdown` or `.pdf` file — those are served through the reader. So a single
package/dist/index.js CHANGED
@@ -23,15 +23,41 @@ async function call(credentials, path, init = {}) {
23
23
  }
24
24
  return await response.json();
25
25
  }
26
+ async function findSite(credentials, site) {
27
+ const wanted = site.trim().toLowerCase();
28
+ const found = (await listSites(credentials)).find(
29
+ (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
30
+ );
31
+ if (found === void 0) {
32
+ throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
33
+ }
34
+ return found;
35
+ }
36
+ async function deleteSite(credentials, siteId) {
37
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
38
+ method: "DELETE",
39
+ headers: { Authorization: `Bearer ${credentials.token}` }
40
+ });
41
+ if (!response.ok) {
42
+ const problem = await response.json().catch(() => null);
43
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
44
+ }
45
+ }
26
46
  async function listSites(credentials) {
27
47
  const body = await call(credentials, "sites");
28
48
  return body.sites;
29
49
  }
30
- async function createSite(credentials) {
50
+ async function createSite(credentials, subdomain) {
31
51
  const created = await call(
32
52
  credentials,
33
53
  "sites",
34
- { method: "POST", body: JSON.stringify({}) }
54
+ {
55
+ method: "POST",
56
+ // An absent field rather than a null one, because the API reads null, empty and missing the
57
+ // same way and a body carrying only what was asked for is the one that reads correctly in a
58
+ // log.
59
+ body: JSON.stringify(subdomain === void 0 ? {} : { subdomain })
60
+ }
35
61
  );
36
62
  return { ...created, name: null };
37
63
  }
@@ -284,6 +310,7 @@ const ClientErrorCode = {
284
310
  /** An upload kept failing after every retry. */
285
311
  UploadFailed: "upload_failed"
286
312
  };
313
+ const ACCOUNT_HEADER = "X-Drop2Run-Account";
287
314
  const CLAIM_TOKEN_HEADER = "X-Claim-Token";
288
315
  async function prepareDeploy(siteId, files, options, signal) {
289
316
  const manifest = files.map((file) => ({
@@ -320,6 +347,7 @@ async function request(path, body, options, signal) {
320
347
  if (body !== void 0) headers["Content-Type"] = "application/json";
321
348
  if (options.claimToken) headers[CLAIM_TOKEN_HEADER] = options.claimToken;
322
349
  if (options.token) headers.Authorization = `Bearer ${options.token}`;
350
+ if (options.accountId) headers[ACCOUNT_HEADER] = options.accountId;
323
351
  const response = await doFetch(`${base}/${path}`, {
324
352
  method: "POST",
325
353
  headers,
@@ -613,7 +641,7 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
613
641
  });
614
642
  done += 1;
615
643
  uploadedBytes += file.bytes.length;
616
- onProgress?.(done, targets.length, uploadedBytes);
644
+ onProgress?.(done, targets.length, uploadedBytes, file.path);
617
645
  }
618
646
  };
619
647
  const workers = Array.from(
@@ -708,12 +736,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
708
736
  prepared.upload,
709
737
  prepared.uploadUrl,
710
738
  hashed,
711
- (done, _total, bytes) => onProgress({
739
+ (done, _total, bytes, path) => onProgress({
712
740
  type: "uploading",
713
741
  done: prepared.reused + done,
714
742
  total: prepared.total,
715
743
  bytes,
716
- reused: prepared.reused
744
+ reused: prepared.reused,
745
+ path
717
746
  }),
718
747
  signal,
719
748
  options.upload
@@ -779,7 +808,15 @@ function escapesRoot(root, candidate) {
779
808
  const inside = relative(root, candidate);
780
809
  return inside === "" || inside.startsWith("..");
781
810
  }
782
- async function resolveSite(credentials, site) {
811
+ async function resolveSite(credentials, site, subdomain) {
812
+ if (subdomain !== void 0) {
813
+ if (site !== void 0) {
814
+ throw new Error(
815
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
816
+ );
817
+ }
818
+ return await createSite(credentials, subdomain);
819
+ }
783
820
  if (site === void 0) return await createSite(credentials);
784
821
  const wanted = site.trim().toLowerCase();
785
822
  const found = (await listSites(credentials)).find(
@@ -787,13 +824,13 @@ async function resolveSite(credentials, site) {
787
824
  );
788
825
  if (found === void 0) {
789
826
  throw new Error(
790
- `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.`
827
+ `No site of yours is called "${site}". Leave the site out to publish to a new one, or list your sites to see what exists.`
791
828
  );
792
829
  }
793
830
  return found;
794
831
  }
795
- async function publish(credentials, source, site) {
796
- const target = await resolveSite(credentials, site);
832
+ async function publish(credentials, source, site, onProgress, subdomain) {
833
+ const target = await resolveSite(credentials, site, subdomain);
797
834
  let files = 0;
798
835
  let url = target.url;
799
836
  let unchanged = false;
@@ -823,8 +860,8 @@ async function publish(credentials, source, site) {
823
860
  });
824
861
  return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
825
862
  }
826
- function publishDirectory(credentials, directory, site) {
827
- return publish(credentials, directorySource(directory), site);
863
+ function publishDirectory(credentials, directory, site, onProgress, subdomain) {
864
+ return publish(credentials, directorySource(directory), site, onProgress, subdomain);
828
865
  }
829
866
  function cleanPath(path) {
830
867
  const cleaned = path.trim().replaceAll("\\", "/").replace(/^\.?\//, "");
@@ -838,7 +875,7 @@ function cleanPath(path) {
838
875
  }
839
876
  return cleaned;
840
877
  }
841
- async function publishFiles(credentials, files, site) {
878
+ async function publishFiles(credentials, files, site, subdomain) {
842
879
  const encoder = new TextEncoder();
843
880
  const collected = files.map((file) => ({
844
881
  path: cleanPath(file.path),
@@ -849,7 +886,7 @@ async function publishFiles(credentials, files, site) {
849
886
  if (seen.has(file.path)) throw new Error(`Two files were given the same path: ${file.path}`);
850
887
  seen.add(file.path);
851
888
  }
852
- return publish(credentials, async () => collected, site);
889
+ return publish(credentials, async () => collected, site, void 0, subdomain);
853
890
  }
854
891
  const DEFAULT_WAIT_SECONDS = 120;
855
892
  const MAX_WAIT_SECONDS = 240;
@@ -1027,7 +1064,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1027
1064
  "Call this tool again to keep waiting. Nothing has been changed yet."
1028
1065
  ].join("\n");
1029
1066
  }
1030
- const SERVER_VERSION = "0.4.2";
1067
+ const SERVER_VERSION = "0.5.0";
1031
1068
  const CREDENTIAL_WRITE = {
1032
1069
  readOnlyHint: false,
1033
1070
  destructiveHint: false,
@@ -1044,9 +1081,33 @@ const READS_ONLY = {
1044
1081
  readOnlyHint: true,
1045
1082
  openWorldHint: true
1046
1083
  };
1084
+ const PUBLISH_OUTPUT = {
1085
+ url: z.string().describe("The live URL of the published site."),
1086
+ siteId: z.string().describe("Identifier of the site, stable across publishes."),
1087
+ subdomain: z.string().describe("The site's subdomain, which is what `site` accepts."),
1088
+ files: z.number().int().describe("How many files the site now serves."),
1089
+ unchanged: z.boolean().describe(
1090
+ "True when every file already matched what the site served, so no new version was published."
1091
+ )
1092
+ };
1093
+ const REMOVES_A_SITE = {
1094
+ readOnlyHint: false,
1095
+ destructiveHint: true,
1096
+ idempotentHint: false,
1097
+ openWorldHint: true
1098
+ };
1099
+ const SITE_OUTPUT = z.object({
1100
+ siteId: z.string().describe("Identifier of the site."),
1101
+ subdomain: z.string().describe("Its subdomain, which is what `site` accepts."),
1102
+ url: z.string().describe("Its live URL."),
1103
+ name: z.string().nullable().describe("What it is called, or null when it has no name.")
1104
+ });
1047
1105
  function say(text, isError = false) {
1048
1106
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
1049
1107
  }
1108
+ function report(text, structured) {
1109
+ return { content: [{ type: "text", text }], structuredContent: structured };
1110
+ }
1050
1111
  async function withCredentials(work) {
1051
1112
  const credentials = loadCredentials();
1052
1113
  if (credentials === null) return say(missingCredentialsMessage("mcp"), true);
@@ -1054,7 +1115,8 @@ async function withCredentials(work) {
1054
1115
  }
1055
1116
  async function attempt(work) {
1056
1117
  try {
1057
- return say(await work());
1118
+ const answer = await work();
1119
+ return typeof answer === "string" ? say(answer) : answer;
1058
1120
  } catch (error) {
1059
1121
  return say(error instanceof Error ? error.message : String(error), true);
1060
1122
  }
@@ -1081,6 +1143,12 @@ function createServer() {
1081
1143
  "publish to a brand-new site; pass a subdomain to publish over an existing one. list_sites",
1082
1144
  "shows what already exists.",
1083
1145
  "",
1146
+ "A new site gets a generated name unless `subdomain` asks for one. Pass it when the person",
1147
+ "named the address they want, and otherwise leave it out — a name is what somebody will be",
1148
+ "given as a URL, it cannot be changed later, and a site created under a guessed one has to",
1149
+ "be deleted by hand. `site` and `subdomain` are opposites: one publishes over a site that",
1150
+ "exists, the other creates one that does not. Never pass both.",
1151
+ "",
1084
1152
  "A site needs an index.html at its top level, or at least one .md, .markdown or .pdf file,",
1085
1153
  "which publishes as a documents site and is read through a viewer. So one page goes at",
1086
1154
  "index.html, and a single note is a whole publish that needs no wrapping in HTML.",
@@ -1090,7 +1158,11 @@ function createServer() {
1090
1158
  "opened, login_code gives a short code to approve from another machine.",
1091
1159
  "",
1092
1160
  "Publishing over an existing site replaces what it serves, so ask before doing that to a",
1093
- "site the person did not name."
1161
+ "site the person did not name.",
1162
+ "",
1163
+ "delete_site is permanent and frees the subdomain for anybody to claim. Ask the person for",
1164
+ "the subdomain and pass what they say as `confirm` — do not fill it in from what you already",
1165
+ "know, because being told it is the point of the step."
1094
1166
  ].join("\n")
1095
1167
  }
1096
1168
  );
@@ -1134,13 +1206,18 @@ function createServer() {
1134
1206
  content: z.string().describe("The file's contents.")
1135
1207
  })
1136
1208
  ).min(1).describe("The files to publish."),
1137
- site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site.")
1209
+ site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1210
+ subdomain: z.string().optional().describe(
1211
+ "Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
1212
+ )
1138
1213
  },
1214
+ outputSchema: PUBLISH_OUTPUT,
1139
1215
  annotations: REPLACES_A_SITE
1140
1216
  },
1141
- ({ files, site }) => withCredentials(
1142
- async (credentials) => describe(await publishFiles(credentials, files, site))
1143
- )
1217
+ ({ files, site, subdomain }) => withCredentials(async (credentials) => {
1218
+ const result = await publishFiles(credentials, files, site, subdomain);
1219
+ return report(describe(result), { ...result });
1220
+ })
1144
1221
  );
1145
1222
  server.registerTool(
1146
1223
  "publish_dir",
@@ -1149,13 +1226,49 @@ function createServer() {
1149
1226
  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.",
1150
1227
  inputSchema: {
1151
1228
  path: z.string().min(1).describe("Absolute path of the folder to publish."),
1152
- site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site.")
1229
+ site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1230
+ subdomain: z.string().optional().describe(
1231
+ "Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
1232
+ )
1153
1233
  },
1234
+ outputSchema: PUBLISH_OUTPUT,
1154
1235
  annotations: REPLACES_A_SITE
1155
1236
  },
1156
- ({ path, site }) => withCredentials(
1157
- async (credentials) => describe(await publishDirectory(credentials, path, site))
1158
- )
1237
+ ({ path, site, subdomain }) => withCredentials(async (credentials) => {
1238
+ const result = await publishDirectory(credentials, path, site, void 0, subdomain);
1239
+ return report(describe(result), { ...result });
1240
+ })
1241
+ );
1242
+ server.registerTool(
1243
+ "delete_site",
1244
+ {
1245
+ title: "Delete a site",
1246
+ description: "Takes a site down permanently — its files, its history and its subdomain. Nothing here undoes it, and the subdomain becomes available for anybody to claim. Requires `confirm` to repeat the site's own subdomain exactly; ask the person for it rather than filling it in from what you already know, because that is the step this asks for.",
1247
+ inputSchema: {
1248
+ site: z.string().min(1).describe("Subdomain or site id to delete."),
1249
+ confirm: z.string().min(1).describe(
1250
+ "The subdomain of the site being deleted, repeated exactly. A mismatch refuses the call rather than guessing which site was meant."
1251
+ )
1252
+ },
1253
+ outputSchema: {
1254
+ siteId: z.string().describe("Identifier of the site that was deleted."),
1255
+ subdomain: z.string().describe("Its subdomain, now unclaimed.")
1256
+ },
1257
+ annotations: REMOVES_A_SITE
1258
+ },
1259
+ ({ site, confirm }) => withCredentials(async (credentials) => {
1260
+ const found = await findSite(credentials, site);
1261
+ if (confirm.trim().toLowerCase() !== found.subdomain.toLowerCase()) {
1262
+ throw new Error(
1263
+ `Refusing to delete ${found.subdomain}: \`confirm\` said "${confirm}". Repeat the subdomain exactly to go ahead.`
1264
+ );
1265
+ }
1266
+ await deleteSite(credentials, found.siteId);
1267
+ return report(
1268
+ `Deleted ${found.subdomain}. Its files and history are gone, and the subdomain is free for anybody to claim.`,
1269
+ { siteId: found.siteId, subdomain: found.subdomain }
1270
+ );
1271
+ })
1159
1272
  );
1160
1273
  server.registerTool(
1161
1274
  "list_sites",
@@ -1163,14 +1276,15 @@ function createServer() {
1163
1276
  title: "List sites",
1164
1277
  description: "Lists the sites on this account, so a publish can go to one of them.",
1165
1278
  inputSchema: {},
1279
+ outputSchema: { sites: z.array(SITE_OUTPUT).describe("Every site on this account.") },
1166
1280
  annotations: READS_ONLY
1167
1281
  },
1168
1282
  () => withCredentials(async (credentials) => {
1169
1283
  const sites = await listSites(credentials);
1170
- if (sites.length === 0) {
1171
- return "No sites on this account yet. Publishing without a `site` creates one.";
1172
- }
1173
- return sites.map((site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`).join("\n");
1284
+ const text = sites.length === 0 ? "No sites on this account yet. Publishing without a `site` creates one." : sites.map(
1285
+ (site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`
1286
+ ).join("\n");
1287
+ return report(text, { sites: sites.map((site) => ({ ...site })) });
1174
1288
  })
1175
1289
  );
1176
1290
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server that publishes a folder or an HTML page to Drop2Run and returns a live URL.",
5
5
  "mcpName": "run.dropto/drop2run",
6
6
  "license": "MIT",