@drop2run/mcp 0.6.0 → 0.7.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 +6 -0
  2. package/dist/index.js +197 -83
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -67,6 +67,7 @@ lose it.
67
67
  | `publish_files` | Publishes files Claude wrote — a page, a markdown note, several files together |
68
68
  | `publish_dir` | Publishes a folder, given its absolute path |
69
69
  | `list_sites` | Lists the sites on the account |
70
+ | `list_folders` | Lists the account's folders, each with the path `folder` accepts |
70
71
  | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
71
72
 
72
73
  Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
@@ -78,6 +79,11 @@ afterwards, and a site made under a guessed one has to be deleted by hand. `site
78
79
  opposites — one publishes over a site that exists, the other creates one that does not — so passing both
79
80
  is refused rather than resolved.
80
81
 
82
+ And both take an optional `folder`, which files the new site in one of the account's dashboard folders —
83
+ by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdomain` it only describes a new
84
+ site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
85
+ with the list of those that do, and nothing is published.
86
+
81
87
  Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
82
88
  again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
83
89
  to with `replace`.
package/dist/index.js CHANGED
@@ -8,59 +8,6 @@ import { createServer as createServer$1 } from "node:http";
8
8
  import { realpath, readdir, stat, readFile } from "node:fs/promises";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
- async function call(credentials, path, init = {}) {
12
- const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
13
- ...init,
14
- headers: {
15
- ...init.headers,
16
- Authorization: `Bearer ${credentials.token}`,
17
- ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
18
- }
19
- });
20
- if (!response.ok) {
21
- const problem = await response.json().catch(() => null);
22
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
23
- }
24
- return await response.json();
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
- }
46
- async function listSites(credentials) {
47
- const body = await call(credentials, "sites");
48
- return body.sites;
49
- }
50
- async function createSite(credentials, subdomain) {
51
- const created = await call(
52
- credentials,
53
- "sites",
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
- }
61
- );
62
- return { ...created, name: null };
63
- }
64
11
  const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
65
12
  function configPath() {
66
13
  return join(homedir(), ".config", "drop2run", "config.json");
@@ -137,6 +84,131 @@ function missingCredentialsMessage(surface = "cli") {
137
84
  "The token is shown once when it is created and cannot be recovered afterwards."
138
85
  ].join("\n");
139
86
  }
87
+ async function call(credentials, path, init = {}) {
88
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
89
+ ...init,
90
+ headers: {
91
+ ...init.headers,
92
+ Authorization: `Bearer ${credentials.token}`,
93
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
94
+ }
95
+ });
96
+ if (!response.ok) {
97
+ const problem = await response.json().catch(() => null);
98
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
99
+ }
100
+ return await response.json();
101
+ }
102
+ async function findSite(credentials, site) {
103
+ const found = await lookUpSite(credentials, site);
104
+ if (found === void 0) {
105
+ throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
106
+ }
107
+ return found;
108
+ }
109
+ const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
110
+ async function lookUpSite(credentials, site) {
111
+ const wanted = site.trim().toLowerCase();
112
+ if (wanted === "") return void 0;
113
+ const { sites } = await call(
114
+ credentials,
115
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
116
+ );
117
+ const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
118
+ if (bySubdomain !== void 0) return bySubdomain;
119
+ if (!SITE_ID_SHAPE.test(wanted)) return void 0;
120
+ const response = await fetch(
121
+ `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
122
+ { headers: { Authorization: `Bearer ${credentials.token}` } }
123
+ );
124
+ if (response.status === 404) return void 0;
125
+ if (!response.ok) {
126
+ const problem = await response.json().catch(() => null);
127
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
128
+ }
129
+ const detail = await response.json();
130
+ return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
131
+ }
132
+ async function deleteSite(credentials, siteId) {
133
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
134
+ method: "DELETE",
135
+ headers: { Authorization: `Bearer ${credentials.token}` }
136
+ });
137
+ if (!response.ok) {
138
+ const problem = await response.json().catch(() => null);
139
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
140
+ }
141
+ }
142
+ const SITES_PER_REQUEST = 100;
143
+ async function listSites(credentials) {
144
+ const body = await call(
145
+ credentials,
146
+ `sites?pageSize=${SITES_PER_REQUEST}`
147
+ );
148
+ return { sites: body.sites, total: body.total };
149
+ }
150
+ async function createSite(credentials, subdomain, folderId) {
151
+ const created = await call(
152
+ credentials,
153
+ "sites",
154
+ {
155
+ method: "POST",
156
+ // Absent fields rather than null ones, because the API reads null, empty and missing the same
157
+ // way and a body carrying only what was asked for is the one that reads correctly in a log.
158
+ body: JSON.stringify({
159
+ ...subdomain === void 0 ? {} : { subdomain },
160
+ ...folderId === void 0 ? {} : { folderId }
161
+ })
162
+ }
163
+ );
164
+ return { ...created, name: null };
165
+ }
166
+ async function listFolders(credentials) {
167
+ const body = await call(credentials, "site-folders");
168
+ const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
169
+ const pathOf = (folderId) => {
170
+ const names = [];
171
+ let cursor = byId.get(folderId);
172
+ while (cursor !== void 0 && names.length <= byId.size) {
173
+ names.unshift(cursor.name);
174
+ cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
175
+ }
176
+ return names.join("/");
177
+ };
178
+ return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
179
+ }
180
+ function comparablePath(path) {
181
+ return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
182
+ }
183
+ const FOLDERS_LISTED_ON_MISS = 20;
184
+ async function resolveFolder(credentials, folder) {
185
+ const wanted = comparablePath(folder);
186
+ if (wanted === "") {
187
+ throw new Error(
188
+ `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
189
+ );
190
+ }
191
+ const folders = await listFolders(credentials);
192
+ const byId = folders.find((candidate) => candidate.folderId.toLowerCase() === wanted);
193
+ if (byId !== void 0) return byId.folderId;
194
+ const matches = folders.filter((candidate) => comparablePath(candidate.path) === wanted);
195
+ if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
196
+ if (matches.length > 1) {
197
+ throw new Error(
198
+ `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
199
+ );
200
+ }
201
+ if (folders.length === 0) {
202
+ throw new Error(
203
+ `No folder of yours is called "${folder}" — this account has no folders yet. Make one in the dashboard at ${dashboardUrlFor(credentials.apiBaseUrl)}, or leave the folder out.`
204
+ );
205
+ }
206
+ const listed = folders.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
207
+ const more = folders.length - listed.length;
208
+ throw new Error(
209
+ `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
210
+ );
211
+ }
140
212
  function newAttempt() {
141
213
  const verifier = randomBytes(32).toString("base64url");
142
214
  return {
@@ -1269,20 +1341,23 @@ function escapesRoot(root, candidate) {
1269
1341
  const inside = relative(root, candidate);
1270
1342
  return inside === "" || inside.startsWith("..");
1271
1343
  }
1272
- async function resolveSite(credentials, site, subdomain) {
1273
- if (subdomain !== void 0) {
1274
- if (site !== void 0) {
1275
- throw new Error(
1276
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1277
- );
1278
- }
1279
- return await createSite(credentials, subdomain);
1344
+ async function resolveSite(credentials, site, newSite) {
1345
+ const { subdomain, folder } = newSite;
1346
+ if (site !== void 0 && subdomain !== void 0) {
1347
+ throw new Error(
1348
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1349
+ );
1280
1350
  }
1281
- if (site === void 0) return await createSite(credentials);
1282
- const wanted = site.trim().toLowerCase();
1283
- const found = (await listSites(credentials)).find(
1284
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
1285
- );
1351
+ if (site !== void 0 && folder !== void 0) {
1352
+ throw new Error(
1353
+ "A folder only says where a new site goes, so it cannot be given with an existing site. Leave the site out to create one in that folder, or move the existing site from the dashboard."
1354
+ );
1355
+ }
1356
+ if (site === void 0) {
1357
+ const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1358
+ return await createSite(credentials, subdomain, folderId);
1359
+ }
1360
+ const found = await lookUpSite(credentials, site);
1286
1361
  if (found === void 0) {
1287
1362
  throw new Error(
1288
1363
  `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.`
@@ -1290,8 +1365,8 @@ async function resolveSite(credentials, site, subdomain) {
1290
1365
  }
1291
1366
  return found;
1292
1367
  }
1293
- async function publish(credentials, source, site, onProgress, subdomain) {
1294
- const target = await resolveSite(credentials, site, subdomain);
1368
+ async function publish(credentials, source, site, onProgress, newSite = {}) {
1369
+ const target = await resolveSite(credentials, site, newSite);
1295
1370
  let files = 0;
1296
1371
  let url = target.url;
1297
1372
  let unchanged = false;
@@ -1321,11 +1396,11 @@ async function publish(credentials, source, site, onProgress, subdomain) {
1321
1396
  });
1322
1397
  return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1323
1398
  }
1324
- async function publishDirectory(credentials, directory, site, onProgress, subdomain) {
1399
+ async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1325
1400
  const found = await stat(directory).catch(() => null);
1326
1401
  if (found === null) throw new Error(`There is no folder at ${directory}.`);
1327
1402
  if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
1328
- return await publish(credentials, directorySource(directory), site, onProgress, subdomain);
1403
+ return await publish(credentials, directorySource(directory), site, onProgress, newSite);
1329
1404
  }
1330
1405
  function cleanPath(path) {
1331
1406
  const cleaned = path.trim().replaceAll("\\", "/").replace(/^\.?\//, "");
@@ -1339,7 +1414,7 @@ function cleanPath(path) {
1339
1414
  }
1340
1415
  return cleaned;
1341
1416
  }
1342
- async function publishFiles(credentials, files, site, subdomain) {
1417
+ async function publishFiles(credentials, files, site, newSite = {}) {
1343
1418
  const encoder = new TextEncoder();
1344
1419
  const collected = files.map((file) => ({
1345
1420
  path: cleanPath(file.path),
@@ -1350,7 +1425,7 @@ async function publishFiles(credentials, files, site, subdomain) {
1350
1425
  if (seen.has(file.path)) throw new Error(`Two files were given the same path: ${file.path}`);
1351
1426
  seen.add(file.path);
1352
1427
  }
1353
- return publish(credentials, async () => collected, site, void 0, subdomain);
1428
+ return publish(credentials, async () => collected, site, void 0, newSite);
1354
1429
  }
1355
1430
  const DEFAULT_WAIT_SECONDS = 120;
1356
1431
  const MAX_WAIT_SECONDS = 240;
@@ -1528,7 +1603,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1528
1603
  "Call this tool again to keep waiting. Nothing has been changed yet."
1529
1604
  ].join("\n");
1530
1605
  }
1531
- const SERVER_VERSION = "0.6.0";
1606
+ const SERVER_VERSION = "0.7.0";
1532
1607
  const CREDENTIAL_WRITE = {
1533
1608
  readOnlyHint: false,
1534
1609
  destructiveHint: false,
@@ -1566,6 +1641,15 @@ const SITE_OUTPUT = z.object({
1566
1641
  url: z.string().describe("Its live URL."),
1567
1642
  name: z.string().nullable().describe("What it is called, or null when it has no name.")
1568
1643
  });
1644
+ const FOLDER_OUTPUT = z.object({
1645
+ folderId: z.string().describe("Identifier of the folder, which `folder` accepts."),
1646
+ path: z.string().describe(
1647
+ "Every name from the top level down to it, joined with /, which `folder` accepts too."
1648
+ )
1649
+ });
1650
+ const FOLDER_INPUT = z.string().optional().describe(
1651
+ "Folder to file the new site in, when the person asked for one: its path of names such as Clients/Acme, or its id from list_folders. Only for a new site — do not pass it together with `site`; moving an existing site is done in the dashboard. It never creates a folder: a folder that does not exist is refused with the list of those that do, and nothing is published."
1652
+ );
1569
1653
  function say(text, isError = false) {
1570
1654
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
1571
1655
  }
@@ -1613,6 +1697,10 @@ function createServer() {
1613
1697
  "be deleted by hand. `site` and `subdomain` are opposites: one publishes over a site that",
1614
1698
  "exists, the other creates one that does not. Never pass both.",
1615
1699
  "",
1700
+ "A new site goes at the top level of the dashboard unless `folder` files it somewhere. Pass",
1701
+ "it when the person named a folder, as its path (Clients/Acme) or its id; list_folders shows",
1702
+ "what exists. Like `subdomain`, it only describes a new site, so never pass it with `site`.",
1703
+ "",
1616
1704
  "A site needs an index.html at its top level, or at least one document (.md, .markdown,",
1617
1705
  ".pdf, .docx, .xlsx or .epub), which publishes as a documents site and is read through a",
1618
1706
  "viewer. So one page goes at index.html, and a single note is a whole publish that needs",
@@ -1674,13 +1762,14 @@ function createServer() {
1674
1762
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1675
1763
  subdomain: z.string().optional().describe(
1676
1764
  "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."
1677
- )
1765
+ ),
1766
+ folder: FOLDER_INPUT
1678
1767
  },
1679
1768
  outputSchema: PUBLISH_OUTPUT,
1680
1769
  annotations: REPLACES_A_SITE
1681
1770
  },
1682
- ({ files, site, subdomain }) => withCredentials(async (credentials) => {
1683
- const result = await publishFiles(credentials, files, site, subdomain);
1771
+ ({ files, site, subdomain, folder }) => withCredentials(async (credentials) => {
1772
+ const result = await publishFiles(credentials, files, site, { subdomain, folder });
1684
1773
  return report(describe(result), { ...result });
1685
1774
  })
1686
1775
  );
@@ -1694,13 +1783,17 @@ function createServer() {
1694
1783
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1695
1784
  subdomain: z.string().optional().describe(
1696
1785
  "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."
1697
- )
1786
+ ),
1787
+ folder: FOLDER_INPUT
1698
1788
  },
1699
1789
  outputSchema: PUBLISH_OUTPUT,
1700
1790
  annotations: REPLACES_A_SITE
1701
1791
  },
1702
- ({ path, site, subdomain }) => withCredentials(async (credentials) => {
1703
- const result = await publishDirectory(credentials, path, site, void 0, subdomain);
1792
+ ({ path, site, subdomain, folder }) => withCredentials(async (credentials) => {
1793
+ const result = await publishDirectory(credentials, path, site, void 0, {
1794
+ subdomain,
1795
+ folder
1796
+ });
1704
1797
  return report(describe(result), { ...result });
1705
1798
  })
1706
1799
  );
@@ -1741,15 +1834,36 @@ function createServer() {
1741
1834
  title: "List sites",
1742
1835
  description: "Lists the sites on this account, so a publish can go to one of them.",
1743
1836
  inputSchema: {},
1744
- outputSchema: { sites: z.array(SITE_OUTPUT).describe("Every site on this account.") },
1837
+ outputSchema: {
1838
+ sites: z.array(SITE_OUTPUT).describe("The account's sites, newest first — every one unless `total` is larger."),
1839
+ total: z.number().int().describe("How many sites the account holds. Larger than `sites` only past 100.")
1840
+ },
1745
1841
  annotations: READS_ONLY
1746
1842
  },
1747
1843
  () => withCredentials(async (credentials) => {
1748
- const sites = await listSites(credentials);
1844
+ const { sites, total } = await listSites(credentials);
1749
1845
  const text = sites.length === 0 ? "No sites on this account yet. Publishing without a `site` creates one." : sites.map(
1750
1846
  (site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`
1751
- ).join("\n");
1752
- return report(text, { sites: sites.map((site) => ({ ...site })) });
1847
+ ).join("\n") + (total > sites.length ? `
1848
+ … and ${total - sites.length} more; these are the newest ${sites.length} of ${total}.` : "");
1849
+ return report(text, { sites: sites.map((site) => ({ ...site })), total });
1850
+ })
1851
+ );
1852
+ server.registerTool(
1853
+ "list_folders",
1854
+ {
1855
+ title: "List folders",
1856
+ description: "Lists the folders on this account, each with the path a publish's `folder` accepts. Folders are made and rearranged in the dashboard; no tool here creates one.",
1857
+ inputSchema: {},
1858
+ outputSchema: {
1859
+ folders: z.array(FOLDER_OUTPUT).describe("Every folder on this account, ordered by path.")
1860
+ },
1861
+ annotations: READS_ONLY
1862
+ },
1863
+ () => withCredentials(async (credentials) => {
1864
+ const folders = await listFolders(credentials);
1865
+ const text = folders.length === 0 ? "No folders on this account. A new site goes at the top level; folders are made in the dashboard." : folders.map((folder) => `${folder.path} — ${folder.folderId}`).join("\n");
1866
+ return report(text, { folders: folders.map((folder) => ({ ...folder })) });
1753
1867
  })
1754
1868
  );
1755
1869
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",