@drop2run/mcp 0.13.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -3
  2. package/dist/index.js +118 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,11 +1,32 @@
1
1
  # @drop2run/mcp
2
2
 
3
- An MCP server that lets any MCP client — Claude, Cursor, VS Code, Codex, Gemini CLI — publish what it made to
3
+ An MCP server that lets any MCP client — Claude, ChatGPT, 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
 
7
7
  Full documentation at [dropto.run/docs/mcp](https://dropto.run/docs/mcp).
8
8
 
9
+ ## In a chat on the web — no package at all
10
+
11
+ Claude on the web and on a phone, ChatGPT and other hosted chats cannot run this package, so the same
12
+ tools are hosted at a URL:
13
+
14
+ ```
15
+ https://mcp.dropto.run/mcp
16
+ ```
17
+
18
+ **Claude** — connect it from
19
+ [Claude's connector directory](https://claude.ai/directory/connectors/drop2run) and sign in to Drop2Run
20
+ when asked. To add it by URL instead, open
21
+ [this link](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Drop2Run&connectorUrl=https%3A%2F%2Fmcp.dropto.run%2Fmcp)
22
+ or *Settings → Connectors → Add custom connector* and paste the URL.
23
+ **Claude Code** — `claude mcp add --transport http drop2run https://mcp.dropto.run/mcp`. **ChatGPT** — add
24
+ it as a custom connector, where your plan offers developer mode.
25
+
26
+ The hosted server has no `login` / `login_code` (the chat signs in for it) and no `publish_dir` (there is
27
+ no disk), and `publish_files` takes text files only. For a folder on disk, a PDF or an image, run the
28
+ package below.
29
+
9
30
  ## Setup
10
31
 
11
32
  **Claude Desktop** — download [`drop2run.mcpb`](https://dropto.run/drop2run.mcpb) and open it. Claude
@@ -87,6 +108,7 @@ lose it.
87
108
  | `list_sites` | Lists the sites on the account |
88
109
  | `list_folders` | Lists the account's folders, each with the path `folder` accepts |
89
110
  | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
111
+ | `change_subdomain` | Moves a site to a new subdomain; the old one keeps redirecting — requires the current subdomain as `confirm` |
90
112
  | `get_site` | Reads a site's settings, whether it is paused, and its versions with the live one marked |
91
113
  | `update_site` | Changes a site's name, serving mode, password, invite-only, comments, forms, scheduled takedown or folder |
92
114
  | `list_people` | Lists who a site is shared with, what each may do, and whether only they can open it |
@@ -104,8 +126,8 @@ Both publish tools take an optional `site` — a subdomain or site id to publish
104
126
  new site is created.
105
127
 
106
128
  Both also take an optional `subdomain`, which names the new site instead of letting the server generate
107
- one. It is for a person who asked for a particular address: the name becomes the URL, cannot be changed
108
- afterwards, and a site made under a guessed one has to be deleted by hand. `site` and `subdomain` are
129
+ one. It is for a person who asked for a particular address: the name becomes the URL, and a site made
130
+ under a guessed one has to be deleted, or moved with `change_subdomain`, by hand. `site` and `subdomain` are
109
131
  opposites — one publishes over a site that exists, the other creates one that does not — so passing both
110
132
  is refused rather than resolved.
111
133
 
@@ -114,6 +136,11 @@ by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdoma
114
136
  site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
115
137
  with the list of those that do, and nothing is published.
116
138
 
139
+ `change_subdomain` moves a site to a new address. The old one keeps working by redirecting to the new
140
+ one, and nobody else can take it. Each site gets two changes, and a change back to an old name counts
141
+ too, so it asks for the current subdomain as `confirm`. Visitors who signed in to the site or typed its
142
+ password are asked again on the new address. `get_site` shows the changes left and the former names.
143
+
117
144
  `update_site` changes only the settings it is given. An empty `name`, `password` or `expiresAt` clears
118
145
  that setting, and `folder: "root"` moves the site back to the top level. A takedown pauses the site by
119
146
  default; `expiryAction: "delete"` removes it instead, and asks for the subdomain as `confirm` the way
package/dist/index.js CHANGED
@@ -88,10 +88,18 @@ function missingCredentialsMessage(surface = "cli") {
88
88
  ].join("\n");
89
89
  }
90
90
  function sizeOf(content) {
91
- return ArrayBuffer.isView(content) ? content.byteLength : content.size;
91
+ if (ArrayBuffer.isView(content)) return content.byteLength;
92
+ if (isArrayBuffer(content)) return content.byteLength;
93
+ if (typeof content.size === "number") return content.size;
94
+ throw new TypeError("File contents must be a Uint8Array, an ArrayBuffer or a Blob.");
95
+ }
96
+ function isArrayBuffer(value) {
97
+ return Object.prototype.toString.call(value) === "[object ArrayBuffer]";
92
98
  }
93
99
  async function bytesOf(content) {
94
- return ArrayBuffer.isView(content) ? content : new Uint8Array(await content.arrayBuffer());
100
+ if (ArrayBuffer.isView(content)) return content;
101
+ if (isArrayBuffer(content)) return new Uint8Array(content);
102
+ return new Uint8Array(await content.arrayBuffer());
95
103
  }
96
104
  class DeployError extends Error {
97
105
  /**
@@ -1654,6 +1662,7 @@ async function findSite(credentials, site) {
1654
1662
  return found;
1655
1663
  }
1656
1664
  const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
1665
+ const FORMER_NAME_CANDIDATES = 3;
1657
1666
  async function lookUpSite(credentials, site) {
1658
1667
  const wanted = site.trim().toLowerCase();
1659
1668
  if (wanted === "") return void 0;
@@ -1663,6 +1672,13 @@ async function lookUpSite(credentials, site) {
1663
1672
  );
1664
1673
  const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
1665
1674
  if (bySubdomain !== void 0) return bySubdomain;
1675
+ for (const candidate of sites.slice(0, FORMER_NAME_CANDIDATES)) {
1676
+ const detail2 = await call(
1677
+ credentials,
1678
+ `sites/${encodeURIComponent(candidate.siteId)}`
1679
+ );
1680
+ if (detail2.formerSubdomains?.some((former) => former.subdomain === wanted)) return candidate;
1681
+ }
1666
1682
  if (!SITE_ID_SHAPE.test(wanted)) return void 0;
1667
1683
  const response = await fetch(
1668
1684
  `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
@@ -1746,7 +1762,19 @@ async function getSite(credentials, siteId) {
1746
1762
  createdAt: deploy2.createdAt,
1747
1763
  completedAt: deploy2.completedAt,
1748
1764
  filesKept: deploy2.filesKept
1749
- }))
1765
+ })),
1766
+ subdomainChangesLeft: site.subdomainChangesLeft ?? null,
1767
+ formerSubdomains: (site.formerSubdomains ?? []).map((former) => former.subdomain)
1768
+ };
1769
+ }
1770
+ async function changeSubdomain(credentials, siteId, subdomain) {
1771
+ const changed = await call(credentials, `sites/${encodeURIComponent(siteId)}/subdomain`, {
1772
+ method: "POST",
1773
+ body: JSON.stringify({ subdomain })
1774
+ });
1775
+ return {
1776
+ ...changed,
1777
+ formerSubdomains: changed.formerSubdomains.map((former) => former.subdomain)
1750
1778
  };
1751
1779
  }
1752
1780
  async function updateSiteSettings(credentials, siteId, change) {
@@ -1938,6 +1966,10 @@ function describeMode(result) {
1938
1966
  if (result.mode === "spa") return "Served as a single-page app.";
1939
1967
  return "Served as a static site.";
1940
1968
  }
1969
+ async function ownSiteAt(credentials, subdomain) {
1970
+ const found = await lookUpSite(credentials, subdomain);
1971
+ return found?.subdomain.toLowerCase() === subdomain.trim().toLowerCase() ? found : void 0;
1972
+ }
1941
1973
  async function resolveSite(credentials, site, newSite) {
1942
1974
  const { subdomain, folder } = newSite;
1943
1975
  if (site !== void 0 && subdomain !== void 0) {
@@ -1952,6 +1984,13 @@ async function resolveSite(credentials, site, newSite) {
1952
1984
  }
1953
1985
  if (site === void 0) {
1954
1986
  const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1987
+ const held = subdomain === void 0 ? void 0 : await ownSiteAt(credentials, subdomain);
1988
+ if (held !== void 0) {
1989
+ if (held.liveDeployId === null) return held;
1990
+ throw new Error(
1991
+ `You already have a site at ${held.subdomain}, and it is live. To replace what it serves, publish to it as an existing site (the \`site\` argument, or \`--site\` on the command line); to make a new site, choose another subdomain.`
1992
+ );
1993
+ }
1955
1994
  return await createSite(credentials, subdomain, folderId);
1956
1995
  }
1957
1996
  const found = await lookUpSite(credentials, site);
@@ -2234,6 +2273,12 @@ function accessOf(site) {
2234
2273
  if (site.invitedOnly === false) return "public";
2235
2274
  return "no password (whether it is invite-only could not be read)";
2236
2275
  }
2276
+ function addressLines(site) {
2277
+ return [
2278
+ ...site.formerSubdomains.length > 0 ? [` formerly ${site.formerSubdomains.join(", ")} (redirect here)`] : [],
2279
+ ...site.subdomainChangesLeft === null ? [] : [` address ${site.subdomainChangesLeft} change(s) left`]
2280
+ ];
2281
+ }
2237
2282
  function describeSite(site, folderPath) {
2238
2283
  const unavailable = (available) => available ? "" : " (not on this plan)";
2239
2284
  const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
@@ -2253,6 +2298,7 @@ function describeSite(site, folderPath) {
2253
2298
  ` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
2254
2299
  ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
2255
2300
  ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
2301
+ ...addressLines(site),
2256
2302
  "",
2257
2303
  site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
2258
2304
  ...versions,
@@ -2435,7 +2481,7 @@ async function signInWithCode(waitSeconds, replace = false) {
2435
2481
  "Call this tool again to keep waiting. Nothing has been changed yet."
2436
2482
  ].join("\n");
2437
2483
  }
2438
- const SERVER_VERSION = "0.13.0";
2484
+ const SERVER_VERSION = "1.0.0";
2439
2485
  const REPLACES_A_SITE = {
2440
2486
  readOnlyHint: false,
2441
2487
  destructiveHint: true,
@@ -2461,6 +2507,12 @@ const REMOVES_A_SITE = {
2461
2507
  idempotentHint: false,
2462
2508
  openWorldHint: true
2463
2509
  };
2510
+ const MOVES_AN_ADDRESS = {
2511
+ readOnlyHint: false,
2512
+ destructiveHint: true,
2513
+ idempotentHint: true,
2514
+ openWorldHint: true
2515
+ };
2464
2516
  const CHANGES_A_SITE = {
2465
2517
  readOnlyHint: false,
2466
2518
  destructiveHint: true,
@@ -2621,7 +2673,9 @@ function structuredSite(site) {
2621
2673
  createdAt: deploy2.createdAt,
2622
2674
  filesKept: deploy2.filesKept,
2623
2675
  live: deploy2.deployId === site.liveDeployId
2624
- }))
2676
+ })),
2677
+ subdomainChangesLeft: site.subdomainChangesLeft,
2678
+ formerSubdomains: [...site.formerSubdomains]
2625
2679
  };
2626
2680
  }
2627
2681
  const SITE_OUTPUT = z.object({
@@ -2689,9 +2743,10 @@ function instructionsFor(surface) {
2689
2743
  "",
2690
2744
  "A new site gets a generated name unless `subdomain` asks for one. Pass it when the person",
2691
2745
  "named the address they want, and otherwise leave it out — a name is what somebody will be",
2692
- "given as a URL, it cannot be changed later, and a site created under a guessed one has to",
2693
- "be deleted by hand. `site` and `subdomain` are opposites: one publishes over a site that",
2694
- "exists, the other creates one that does not. Never pass both.",
2746
+ "given as a URL, changing it later spends one of the two changes a site gets, and a site",
2747
+ "created under a guessed one has to be renamed or deleted by hand. `site` and `subdomain` are",
2748
+ "opposites: one publishes over a site that exists, the other creates one that does not. Never",
2749
+ "pass both.",
2695
2750
  "",
2696
2751
  "A new site goes at the top level of the dashboard unless `folder` files it somewhere. Pass",
2697
2752
  "it when the person named a folder, as its path (Clients/Acme) or its id; list_folders shows",
@@ -2711,9 +2766,14 @@ function instructionsFor(surface) {
2711
2766
  "Publishing over an existing site replaces what it serves, so ask before doing that to a",
2712
2767
  "site the person did not name.",
2713
2768
  "",
2714
- "delete_site is permanent and frees the subdomain for anybody to claim. Ask the person for",
2715
- "the subdomain and pass what they say as `confirm` — do not fill it in from what you already",
2716
- "know, because being told it is the point of the step.",
2769
+ "delete_site is permanent, and the subdomain stays reserved: nobody, the person included, can",
2770
+ "use it again. Ask the person for the subdomain and pass what they say as `confirm` — do not",
2771
+ "fill it in from what you already know, because being told it is the point of the step.",
2772
+ "",
2773
+ "change_subdomain moves a site to a new address. The old one keeps redirecting and nobody else",
2774
+ "can take it, but each site gets only a couple of changes, a change back to an old name",
2775
+ "included. Use it only when the person named the new address, and pass the site's current",
2776
+ "subdomain, as they say it, as `confirm`.",
2717
2777
  "",
2718
2778
  "An existing site's settings are read with get_site and changed with update_site: its name,",
2719
2779
  "serving mode (static, spa, docs), a password, whether forms accept submissions, a scheduled",
@@ -2738,8 +2798,10 @@ function instructionsFor(surface) {
2738
2798
  ].join("\n");
2739
2799
  }
2740
2800
  function publishFilesDescription(surface) {
2741
- return surface === "local" ? "Publishes one or more files written here — markdown, HTML, CSS, JSON, source code, any text — as a site, and returns its URL. A single page goes at index.html; a single .md, .markdown or .pdf file is a site on its own, served through the documents viewer. Text only: a PDF or an image has to be published from disk with publish_dir." : "Publishes one or more files written here — markdown, HTML, CSS, JSON, source code, any text — as a site, and returns its URL. A single page goes at index.html; a single .md or .markdown file is a site on its own, served through the documents viewer. Text only: this connector cannot publish a PDF, an image or an Office file.";
2801
+ const body = surface === "local" ? "Publishes one or more files written here — markdown, HTML, CSS, JSON, source code, any text — as a site, and returns its URL. A single page goes at index.html; a single .md, .markdown or .pdf file is a site on its own, served through the documents viewer. Text only: a PDF or an image has to be published from disk with publish_dir." : "Publishes one or more files written here — markdown, HTML, CSS, JSON, source code, any text — as a site, and returns its URL. A single page goes at index.html; a single .md or .markdown file is a site on its own, served through the documents viewer. Text only: this connector cannot publish a PDF, an image or an Office file.";
2802
+ return `${body} ${RESERVE_FIRST}`;
2742
2803
  }
2804
+ const RESERVE_FIRST = "To hold an address before the content exists — the person asks for the site first and the files later — publish a placeholder index.html with `subdomain`, then publish the real files over it with `site`. There is no way to create an empty site.";
2743
2805
  function registerPublishFiles(server, context) {
2744
2806
  server.registerTool(
2745
2807
  "publish_files",
@@ -2755,7 +2817,7 @@ function registerPublishFiles(server, context) {
2755
2817
  ).min(1).describe("The files to publish."),
2756
2818
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
2757
2819
  subdomain: z.string().optional().describe(
2758
- "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."
2820
+ "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, changing it later spends one of the few changes a site gets, and an unused site made under a guessed name has to be deleted by hand."
2759
2821
  ),
2760
2822
  folder: FOLDER_INPUT
2761
2823
  },
@@ -2772,7 +2834,7 @@ function registerSiteTools(server, context) {
2772
2834
  "delete_site",
2773
2835
  {
2774
2836
  ...titled("Delete a site", REMOVES_A_SITE),
2775
- 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.",
2837
+ description: "Takes a site down permanently — its files, its history and its subdomain. Nothing here undoes it, and the subdomain stays reserved — nobody can use it again. 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.",
2776
2838
  inputSchema: {
2777
2839
  site: z.string().min(1).describe("Subdomain or site id to delete."),
2778
2840
  confirm: z.string().min(1).describe(
@@ -2793,11 +2855,46 @@ function registerSiteTools(server, context) {
2793
2855
  }
2794
2856
  await deleteSite(credentials, found.siteId);
2795
2857
  return report(
2796
- `Deleted ${found.subdomain}. Its files and history are gone, and the subdomain is free for anybody to claim.`,
2858
+ `Deleted ${found.subdomain}. Its files and history are gone; the subdomain stays reserved and cannot be used again.`,
2797
2859
  { siteId: found.siteId, subdomain: found.subdomain }
2798
2860
  );
2799
2861
  })
2800
2862
  );
2863
+ server.registerTool(
2864
+ "change_subdomain",
2865
+ {
2866
+ ...titled("Change a site's address", MOVES_AN_ADDRESS),
2867
+ description: "Moves a site to a new subdomain. The old one keeps working by redirecting to the new one, and nobody else can take it. Each site gets only a couple of changes, and a change back to an old name counts too, so use this only when the person named the new address. Requires `confirm` to repeat the site's current subdomain exactly; ask the person rather than filling it in. Visitors who signed in to the site or typed its password are asked again.",
2868
+ inputSchema: {
2869
+ site: SITE_INPUT,
2870
+ subdomain: z.string().min(1).describe("The new subdomain, as the person gave it."),
2871
+ confirm: z.string().min(1).describe(
2872
+ "The site's current subdomain, repeated exactly. A mismatch refuses the call rather than guessing which site was meant."
2873
+ )
2874
+ },
2875
+ outputSchema: {
2876
+ siteId: z.string().describe("Identifier of the site."),
2877
+ subdomain: z.string().describe("The subdomain it answers on now."),
2878
+ url: z.string().describe("Its address now."),
2879
+ subdomainChangesLeft: z.number().int().nullable().describe("How many more changes its plan allows, or null when it allows any number."),
2880
+ formerSubdomains: z.array(z.string()).describe("The subdomains it used to answer on, newest first; each redirects here.")
2881
+ }
2882
+ },
2883
+ ({ site, subdomain, confirm }) => withCredentials(context, async (credentials) => {
2884
+ const found = await findSite(credentials, site);
2885
+ if (confirm.trim().toLowerCase() !== found.subdomain.toLowerCase()) {
2886
+ throw new Error(
2887
+ `Refusing to move ${found.subdomain}: \`confirm\` said "${confirm}". Repeat the current subdomain exactly to go ahead.`
2888
+ );
2889
+ }
2890
+ const changed = await changeSubdomain(credentials, found.siteId, subdomain);
2891
+ const left = changed.subdomainChangesLeft === null ? "" : ` ${changed.subdomainChangesLeft} address change(s) left.`;
2892
+ return report(
2893
+ `${found.subdomain} is now ${changed.url}. The old address redirects there.${left}`,
2894
+ { ...changed, formerSubdomains: [...changed.formerSubdomains] }
2895
+ );
2896
+ })
2897
+ );
2801
2898
  server.registerTool(
2802
2899
  "list_sites",
2803
2900
  {
@@ -2851,7 +2948,11 @@ function registerSiteTools(server, context) {
2851
2948
  scheduledTakedown: z.boolean(),
2852
2949
  comments: z.boolean()
2853
2950
  }).describe("Which settings the account's plan includes."),
2854
- versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first.")
2951
+ versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first."),
2952
+ subdomainChangesLeft: z.number().int().nullable().describe(
2953
+ "How many more times change_subdomain may move it, or null when there is no limit."
2954
+ ),
2955
+ formerSubdomains: z.array(z.string()).describe("The subdomains it used to answer on, newest first; each redirects to it.")
2855
2956
  }
2856
2957
  },
2857
2958
  ({ site }) => withCredentials(context, async (credentials) => {
@@ -3261,7 +3362,7 @@ function createServer() {
3261
3362
  path: z.string().min(1).describe("Absolute path of the folder to publish."),
3262
3363
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
3263
3364
  subdomain: z.string().optional().describe(
3264
- "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."
3365
+ "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, changing it later spends one of the few changes a site gets, and an unused site made under a guessed name has to be deleted by hand."
3265
3366
  ),
3266
3367
  folder: FOLDER_INPUT
3267
3368
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.13.0",
3
+ "version": "1.0.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",