@drop2run/mcp 0.8.0 → 0.9.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 +11 -0
  2. package/dist/index.js +462 -9
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -87,6 +87,11 @@ lose it.
87
87
  | `list_sites` | Lists the sites on the account |
88
88
  | `list_folders` | Lists the account's folders, each with the path `folder` accepts |
89
89
  | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
90
+ | `get_site` | Reads a site's settings, whether it is paused, and its versions with the live one marked |
91
+ | `update_site` | Changes a site's name, serving mode, password, forms, scheduled takedown or folder |
92
+ | `pause_site` | Takes a site off the air, keeping its files, versions and subdomain |
93
+ | `resume_site` | Puts a paused site back on the air |
94
+ | `rollback_site` | Serves an earlier version again, by its id from `get_site` |
90
95
 
91
96
  Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
92
97
  new site is created.
@@ -102,6 +107,12 @@ by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdoma
102
107
  site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
103
108
  with the list of those that do, and nothing is published.
104
109
 
110
+ `update_site` changes only the settings it is given. An empty `name`, `password` or `expiresAt` clears
111
+ that setting, and `folder: "root"` moves the site back to the top level. A takedown pauses the site by
112
+ default; `expiryAction: "delete"` removes it instead, and asks for the subdomain as `confirm` the way
113
+ `delete_site` does. Settings your plan does not include are refused with the reason, and `get_site` says
114
+ which ones those are.
115
+
105
116
  Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
106
117
  again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
107
118
  to with `replace`.
package/dist/index.js CHANGED
@@ -105,7 +105,9 @@ async function call(credentials, path, init = {}) {
105
105
  async function findSite(credentials, site) {
106
106
  const found = await lookUpSite(credentials, site);
107
107
  if (found === void 0) {
108
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
108
+ throw new Error(
109
+ `No site of yours is called "${site}". \`drop2run ls\` or the list_sites tool shows what exists.`
110
+ );
109
111
  }
110
112
  return found;
111
113
  }
@@ -142,6 +144,115 @@ async function deleteSite(credentials, siteId) {
142
144
  throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
143
145
  }
144
146
  }
147
+ async function promoteDeploy(credentials, siteId, deployId) {
148
+ return await call(
149
+ credentials,
150
+ `sites/${encodeURIComponent(siteId)}/deploys/${encodeURIComponent(deployId)}/promote`,
151
+ { method: "POST" }
152
+ );
153
+ }
154
+ function enumName(value) {
155
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
156
+ }
157
+ function actionOf(expiresAt, action) {
158
+ return expiresAt === null ? null : enumName(action);
159
+ }
160
+ async function readInvitedOnly(credentials, siteId) {
161
+ try {
162
+ const page = await call(
163
+ credentials,
164
+ `sites/${encodeURIComponent(siteId)}/viewers?pageSize=1`
165
+ );
166
+ return typeof page.invitedOnly === "boolean" ? page.invitedOnly : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ async function folderFromListing(credentials, siteId, subdomain) {
172
+ const { sites } = await call(
173
+ credentials,
174
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(subdomain)}`
175
+ );
176
+ return sites.find((candidate) => candidate.siteId === siteId)?.folderId ?? null;
177
+ }
178
+ async function getSite(credentials, siteId) {
179
+ const [site, invitedOnly] = await Promise.all([
180
+ call(
181
+ credentials,
182
+ `sites/${encodeURIComponent(siteId)}`
183
+ ),
184
+ readInvitedOnly(credentials, siteId)
185
+ ]);
186
+ const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
187
+ return {
188
+ siteId: site.siteId,
189
+ subdomain: site.subdomain,
190
+ url: site.url,
191
+ name: site.name,
192
+ status: enumName(site.status),
193
+ invitedOnly,
194
+ liveDeployId: site.liveDeployId,
195
+ spaMode: site.spaMode,
196
+ docsMode: site.docsMode,
197
+ modeIsManual: site.modeIsManual,
198
+ passwordProtected: site.passwordProtected,
199
+ passwordProtectionAvailable: site.passwordProtectionAvailable,
200
+ formsEnabled: site.formsEnabled,
201
+ formsAvailable: site.formsAvailable,
202
+ expiresAt: site.expiresAt,
203
+ expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
204
+ scheduledExpiryAvailable: site.scheduledExpiryAvailable,
205
+ folderId,
206
+ deploys: site.deploys.map((deploy2) => ({
207
+ deployId: deploy2.deployId,
208
+ status: deploy2.status,
209
+ fileCount: deploy2.fileCount,
210
+ totalBytes: deploy2.totalBytes,
211
+ createdAt: deploy2.createdAt,
212
+ completedAt: deploy2.completedAt,
213
+ filesKept: deploy2.filesKept
214
+ }))
215
+ };
216
+ }
217
+ async function updateSiteSettings(credentials, siteId, change) {
218
+ const body = {};
219
+ if (change.name !== void 0) body.name = change.name;
220
+ if (change.mode !== void 0) {
221
+ body.spaMode = change.mode === "spa";
222
+ body.docsMode = change.mode === "docs";
223
+ }
224
+ if (change.password !== void 0) body.password = change.password;
225
+ if (change.expiresAt !== void 0) body.expiresAt = change.expiresAt;
226
+ if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
227
+ if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
228
+ if (change.folderId !== void 0) body.folderId = change.folderId;
229
+ if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
230
+ const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
231
+ const settings = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
232
+ method: "PATCH",
233
+ body: JSON.stringify(body)
234
+ });
235
+ const invitedOnly = typeof settings.invitedOnly === "boolean" ? settings.invitedOnly : null;
236
+ return {
237
+ ...settings,
238
+ expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
239
+ invitedOnly,
240
+ replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
241
+ };
242
+ }
243
+ async function setSitePaused(credentials, siteId, action) {
244
+ const state = await call(
245
+ credentials,
246
+ `sites/${encodeURIComponent(siteId)}/${action}`,
247
+ { method: "POST" }
248
+ );
249
+ return {
250
+ siteId: state.siteId,
251
+ subdomain: state.subdomain,
252
+ status: enumName(state.status),
253
+ ownerPaused: state.ownerPaused
254
+ };
255
+ }
145
256
  const SITES_PER_REQUEST = 100;
146
257
  async function listSites(credentials) {
147
258
  const body = await call(
@@ -212,6 +323,46 @@ async function resolveFolder(credentials, folder) {
212
323
  `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
213
324
  );
214
325
  }
326
+ const ROOT_FOLDER = "root";
327
+ async function resolveFolderTarget(credentials, folder) {
328
+ return folder.trim().toLowerCase() === ROOT_FOLDER ? ROOT_FOLDER : await resolveFolder(credentials, folder);
329
+ }
330
+ const VERSIONS_LISTED = 10;
331
+ function servingModeOf$1(site) {
332
+ if (site.spaMode) return "spa";
333
+ if (site.docsMode) return "docs";
334
+ return "static";
335
+ }
336
+ function accessOf(site) {
337
+ if (site.passwordProtected) return "password";
338
+ if (site.invitedOnly === true) return "invite-only";
339
+ if (site.invitedOnly === false) return "public";
340
+ return "no password (whether it is invite-only could not be read)";
341
+ }
342
+ function describeSite(site, folderPath) {
343
+ const unavailable = (available) => available ? "" : " (not on this plan)";
344
+ const versions = site.deploys.slice(0, VERSIONS_LISTED).map((deploy2) => {
345
+ const live = deploy2.deployId === site.liveDeployId ? " ← served" : "";
346
+ const kept = deploy2.filesKept ? "" : ", files collected — cannot be rolled back to";
347
+ return ` ${deploy2.deployId} ${deploy2.createdAt} ${deploy2.status}, ${deploy2.fileCount} files${kept}${live}`;
348
+ });
349
+ const more = site.deploys.length - versions.length;
350
+ return [
351
+ `${site.subdomain} — ${site.url}`,
352
+ ` name ${site.name ?? "(none)"}`,
353
+ ` status ${site.status}`,
354
+ ` access ${accessOf(site)}`,
355
+ ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
356
+ ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
357
+ ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
358
+ ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
359
+ ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
360
+ "",
361
+ site.deploys.length === 0 ? "No versions yet." : "Versions, newest first:",
362
+ ...versions,
363
+ ...more > 0 ? [` … and ${more} older`] : []
364
+ ].join("\n");
365
+ }
215
366
  function newAttempt() {
216
367
  const verifier = randomBytes(32).toString("base64url");
217
368
  return {
@@ -1075,6 +1226,24 @@ function canPublish(paths) {
1075
1226
  if (isImageFolder(paths)) return true;
1076
1227
  return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
1077
1228
  }
1229
+ function servingModeOf(flags) {
1230
+ if (flags.docsMode) return "docs";
1231
+ if (flags.spaMode) return "spa";
1232
+ return "static";
1233
+ }
1234
+ function predictedModeOf(paths) {
1235
+ if (paths.some((path) => path === REQUIRED_INDEX)) return "static";
1236
+ if (paths.some(isDocumentPath)) return "docs";
1237
+ if (isImageFolder(paths)) return "docs";
1238
+ if (paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0])) return "docs";
1239
+ return "static";
1240
+ }
1241
+ function nestedIndexOf(paths) {
1242
+ if (predictedModeOf(paths) !== "docs") return null;
1243
+ const suffix = `/${REQUIRED_INDEX}`;
1244
+ const folders = paths.filter((path) => path.endsWith(suffix)).map((path) => path.slice(0, -suffix.length)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
1245
+ return folders[0] ?? null;
1246
+ }
1078
1247
  function renameOfLoneHtmlPage(paths) {
1079
1248
  if (paths.length !== 1) return null;
1080
1249
  const [only] = paths;
@@ -1610,7 +1779,13 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1610
1779
  signal,
1611
1780
  await readSiteName(files)
1612
1781
  );
1613
- onProgress({ type: "done", url: completed.url, name: completed.name });
1782
+ onProgress({
1783
+ type: "done",
1784
+ url: completed.url,
1785
+ name: completed.name,
1786
+ mode: servingModeOf(completed),
1787
+ modeIsManual: completed.modeIsManual
1788
+ });
1614
1789
  } catch (error) {
1615
1790
  const failure = asDeployError(error);
1616
1791
  onProgress({
@@ -1663,6 +1838,15 @@ function escapesRoot(root, candidate) {
1663
1838
  const inside = relative(root, candidate);
1664
1839
  return inside === "" || inside.startsWith("..");
1665
1840
  }
1841
+ function describeMode(result) {
1842
+ if (result.mode === null) return null;
1843
+ if (result.nestedIndex !== null) {
1844
+ return `Served as documents: there is no index.html at the top, but ${result.nestedIndex}/index.html is there. To publish the website, publish the ${result.nestedIndex} folder instead.`;
1845
+ }
1846
+ if (result.mode === "docs") return "Served as documents, in the documents reader.";
1847
+ if (result.mode === "spa") return "Served as a single-page app.";
1848
+ return "Served as a static site.";
1849
+ }
1666
1850
  async function resolveSite(credentials, site, newSite) {
1667
1851
  const { subdomain, folder } = newSite;
1668
1852
  if (site !== void 0 && subdomain !== void 0) {
@@ -1672,7 +1856,7 @@ async function resolveSite(credentials, site, newSite) {
1672
1856
  }
1673
1857
  if (site !== void 0 && folder !== void 0) {
1674
1858
  throw new Error(
1675
- "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."
1859
+ "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 with `drop2run set folder` or the `update_site` tool's `folder`."
1676
1860
  );
1677
1861
  }
1678
1862
  if (site === void 0) {
@@ -1692,6 +1876,8 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1692
1876
  let files = 0;
1693
1877
  let url = target.url;
1694
1878
  let unchanged = false;
1879
+ let mode = null;
1880
+ let paths = [];
1695
1881
  let failure = null;
1696
1882
  let failureDetail;
1697
1883
  const record = (event) => {
@@ -1700,13 +1886,21 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1700
1886
  unchanged = true;
1701
1887
  url = event.url || target.url;
1702
1888
  }
1703
- if (event.type === "done") url = event.url;
1889
+ if (event.type === "done") {
1890
+ url = event.url;
1891
+ mode = event.mode;
1892
+ }
1704
1893
  if (event.type === "error") {
1705
1894
  failure = event.message;
1706
1895
  failureDetail = event.detail;
1707
1896
  }
1708
1897
  };
1709
- await deploy(source, target.siteId, record, void 0, {
1898
+ const watched = async (signal, onRead) => {
1899
+ const collected = await source(signal, onRead);
1900
+ paths = collected.map((file) => file.path);
1901
+ return collected;
1902
+ };
1903
+ await deploy(watched, target.siteId, record, void 0, {
1710
1904
  baseUrl: credentials.apiBaseUrl,
1711
1905
  token: credentials.token
1712
1906
  }).catch((error) => {
@@ -1716,7 +1910,15 @@ async function publish(credentials, source, site, onProgress, newSite = {}) {
1716
1910
  thrown.detail = failureDetail ?? error?.detail;
1717
1911
  throw thrown;
1718
1912
  });
1719
- return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
1913
+ return {
1914
+ url,
1915
+ siteId: target.siteId,
1916
+ subdomain: target.subdomain,
1917
+ files,
1918
+ unchanged,
1919
+ mode,
1920
+ nestedIndex: mode === "docs" ? nestedIndexOf(paths) : null
1921
+ };
1720
1922
  }
1721
1923
  async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
1722
1924
  const found = await stat(directory).catch(() => null);
@@ -1925,7 +2127,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1925
2127
  "Call this tool again to keep waiting. Nothing has been changed yet."
1926
2128
  ].join("\n");
1927
2129
  }
1928
- const SERVER_VERSION = "0.8.0";
2130
+ const SERVER_VERSION = "0.9.0";
1929
2131
  const CREDENTIAL_WRITE = {
1930
2132
  readOnlyHint: false,
1931
2133
  destructiveHint: false,
@@ -1957,6 +2159,83 @@ const REMOVES_A_SITE = {
1957
2159
  idempotentHint: false,
1958
2160
  openWorldHint: true
1959
2161
  };
2162
+ const CHANGES_A_SITE = {
2163
+ readOnlyHint: false,
2164
+ destructiveHint: true,
2165
+ idempotentHint: true,
2166
+ openWorldHint: true
2167
+ };
2168
+ const RESTORES_A_SITE = {
2169
+ readOnlyHint: false,
2170
+ destructiveHint: false,
2171
+ idempotentHint: true,
2172
+ openWorldHint: true
2173
+ };
2174
+ const SITE_INPUT = z.string().min(1).describe("Subdomain or site id, from list_sites.");
2175
+ const VERSION_OUTPUT = z.object({
2176
+ deployId: z.string().describe("Identifier of the version, which rollback_site takes."),
2177
+ status: z.string().describe(
2178
+ "`live` for the one being served, `superseded` for one served before, `ready` for one that finished but was never served, `failed`, or a stage still under way. Whether it can be rolled back to is `filesKept`, not this."
2179
+ ),
2180
+ fileCount: z.number().int().describe("How many files it carries."),
2181
+ createdAt: z.string().describe("When it was published, ISO 8601."),
2182
+ filesKept: z.boolean().describe(
2183
+ "Whether its files are still stored; a version without them cannot be rolled back to."
2184
+ ),
2185
+ live: z.boolean().describe("Whether this is the version being served.")
2186
+ });
2187
+ const SETTINGS_OUTPUT = {
2188
+ siteId: z.string().describe("Identifier of the site."),
2189
+ name: z.string().nullable().describe("What it is called, or null."),
2190
+ mode: z.enum(["static", "spa", "docs"]).describe("How it serves its files."),
2191
+ passwordProtected: z.boolean().describe("Whether visitors must type a password."),
2192
+ invitedOnly: z.boolean().nullable().describe(
2193
+ "Whether only invited people can open it, or null when that could not be read. Null does not mean public."
2194
+ ),
2195
+ formsEnabled: z.boolean().describe("Whether it accepts form submissions."),
2196
+ expiresAt: z.string().nullable().describe("When it is scheduled to come down, or null."),
2197
+ expiryAction: z.string().nullable().describe("What the takedown does: `pause` or `delete`, or null when none is scheduled."),
2198
+ folderId: z.string().nullable().describe("The folder it is filed in, or null at the top level.")
2199
+ };
2200
+ const PAUSE_OUTPUT = {
2201
+ siteId: z.string().describe("Identifier of the site."),
2202
+ subdomain: z.string().describe("Its subdomain."),
2203
+ status: z.string().describe("`active` or `paused` afterwards."),
2204
+ ownerPaused: z.boolean().describe("Whether the pause is the owner's own.")
2205
+ };
2206
+ async function folderPathOf(credentials, folderId) {
2207
+ if (folderId === null) return void 0;
2208
+ return (await listFolders(credentials)).find((folder) => folder.folderId === folderId)?.path;
2209
+ }
2210
+ function structuredSite(site) {
2211
+ return {
2212
+ siteId: site.siteId,
2213
+ subdomain: site.subdomain,
2214
+ url: site.url,
2215
+ name: site.name,
2216
+ status: site.status,
2217
+ mode: servingModeOf$1(site),
2218
+ passwordProtected: site.passwordProtected,
2219
+ invitedOnly: site.invitedOnly,
2220
+ formsEnabled: site.formsEnabled,
2221
+ expiresAt: site.expiresAt,
2222
+ expiryAction: site.expiryAction,
2223
+ folderId: site.folderId,
2224
+ available: {
2225
+ password: site.passwordProtectionAvailable,
2226
+ forms: site.formsAvailable,
2227
+ scheduledTakedown: site.scheduledExpiryAvailable
2228
+ },
2229
+ versions: site.deploys.map((deploy2) => ({
2230
+ deployId: deploy2.deployId,
2231
+ status: deploy2.status,
2232
+ fileCount: deploy2.fileCount,
2233
+ createdAt: deploy2.createdAt,
2234
+ filesKept: deploy2.filesKept,
2235
+ live: deploy2.deployId === site.liveDeployId
2236
+ }))
2237
+ };
2238
+ }
1960
2239
  const SITE_OUTPUT = z.object({
1961
2240
  siteId: z.string().describe("Identifier of the site."),
1962
2241
  subdomain: z.string().describe("Its subdomain, which is what `site` accepts."),
@@ -1970,7 +2249,7 @@ const FOLDER_OUTPUT = z.object({
1970
2249
  )
1971
2250
  });
1972
2251
  const FOLDER_INPUT = z.string().optional().describe(
1973
- "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."
2252
+ "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 update_site's `folder`. It never creates a folder: a folder that does not exist is refused with the list of those that do, and nothing is published."
1974
2253
  );
1975
2254
  function say(text, isError = false) {
1976
2255
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
@@ -1993,11 +2272,13 @@ async function attempt(work) {
1993
2272
  }
1994
2273
  function describe(result) {
1995
2274
  const what = result.unchanged ? "Already live — every file matched what the site already serves, so no new version was published." : `Published ${result.files.toLocaleString()} ${result.files === 1 ? "file" : "files"}.`;
2275
+ const mode = describeMode(result);
1996
2276
  return [
1997
2277
  what,
1998
2278
  "",
1999
2279
  result.url,
2000
2280
  "",
2281
+ ...mode === null ? [] : [mode, ""],
2001
2282
  `Site ${result.subdomain} (${result.siteId}). Reaching every edge takes up to about a minute.`
2002
2283
  ].join("\n");
2003
2284
  }
@@ -2037,7 +2318,14 @@ function createServer() {
2037
2318
  "",
2038
2319
  "delete_site is permanent and frees the subdomain for anybody to claim. Ask the person for",
2039
2320
  "the subdomain and pass what they say as `confirm` — do not fill it in from what you already",
2040
- "know, because being told it is the point of the step."
2321
+ "know, because being told it is the point of the step.",
2322
+ "",
2323
+ "An existing site's settings are read with get_site and changed with update_site: its name,",
2324
+ "serving mode (static, spa, docs), a password, whether forms accept submissions, a scheduled",
2325
+ "takedown, and its folder. pause_site and resume_site take it off the air and back without",
2326
+ "deleting anything, and rollback_site serves an earlier version from get_site's list. Use",
2327
+ "a password or a date only when the person gave one, and scheduling a deletion asks for",
2328
+ "`confirm` the way delete_site does."
2041
2329
  ].join("\n")
2042
2330
  }
2043
2331
  );
@@ -2188,6 +2476,171 @@ function createServer() {
2188
2476
  return report(text, { folders: folders.map((folder) => ({ ...folder })) });
2189
2477
  })
2190
2478
  );
2479
+ server.registerTool(
2480
+ "get_site",
2481
+ {
2482
+ title: "Read a site's settings",
2483
+ description: "Reads one site's settings and state — serving mode, password, forms, scheduled takedown, folder, whether it is paused — and its versions, newest first, with the one being served marked. Also says which settings the account's plan does not include.",
2484
+ inputSchema: { site: SITE_INPUT },
2485
+ outputSchema: {
2486
+ ...SETTINGS_OUTPUT,
2487
+ subdomain: z.string().describe("Its subdomain."),
2488
+ url: z.string().describe("Its live URL."),
2489
+ status: z.string().describe("`active`, `paused`, or a state the platform put it in."),
2490
+ available: z.object({
2491
+ password: z.boolean(),
2492
+ forms: z.boolean(),
2493
+ scheduledTakedown: z.boolean()
2494
+ }).describe("Which settings the account's plan includes."),
2495
+ versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first.")
2496
+ },
2497
+ annotations: READS_ONLY
2498
+ },
2499
+ ({ site }) => withCredentials(async (credentials) => {
2500
+ const found = await findSite(credentials, site);
2501
+ const detail = await getSite(credentials, found.siteId);
2502
+ return report(
2503
+ describeSite(detail, await folderPathOf(credentials, detail.folderId)),
2504
+ structuredSite(detail)
2505
+ );
2506
+ })
2507
+ );
2508
+ server.registerTool(
2509
+ "update_site",
2510
+ {
2511
+ title: "Change a site's settings",
2512
+ description: "Changes one or more settings of an existing site; anything left out stays as it is. Takes effect on every edge within about a minute, without publishing again. Settings the plan does not include are refused with the reason. Only change what the person asked for — never invent a password or a date.",
2513
+ inputSchema: {
2514
+ site: SITE_INPUT,
2515
+ name: z.string().optional().describe("What to call the site in the dashboard. An empty string clears it."),
2516
+ mode: z.enum(["static", "spa", "docs"]).optional().describe(
2517
+ "How the site serves: `static` answers 404 for a missing path, `spa` falls back to index.html for a client-side router, `docs` reads documents through the viewer. Setting it stops the mode being detected on each publish."
2518
+ ),
2519
+ password: z.string().optional().describe(
2520
+ "A password visitors must type before seeing the site, as the person gave it. An empty string removes the password. On an invite-only site a password replaces invite-only and shuts out everyone on its list, so check get_site's `invitedOnly` and ask first."
2521
+ ),
2522
+ forms: z.boolean().optional().describe("Whether the site's forms (marked data-drop2run) accept submissions."),
2523
+ expiresAt: z.string().optional().describe(
2524
+ "When the site should come down, as an ISO 8601 instant in the future such as 2026-12-01T09:00:00Z. An empty string cancels a scheduled takedown."
2525
+ ),
2526
+ expiryAction: z.enum(["pause", "delete"]).optional().describe(
2527
+ "What the takedown does, read only with `expiresAt`: `pause` (the default) keeps the files and can be resumed; `delete` removes the site and frees its subdomain, and needs `confirm`."
2528
+ ),
2529
+ folder: z.string().optional().describe(
2530
+ "Folder to move the site to — a path such as Clients/Acme or an id from list_folders — or `root` for the top level. Never creates a folder."
2531
+ ),
2532
+ confirm: z.string().optional().describe(
2533
+ "Required only with `expiryAction: delete`: the site's subdomain, repeated exactly as the person said it. Ask for it rather than filling it in."
2534
+ )
2535
+ },
2536
+ outputSchema: SETTINGS_OUTPUT,
2537
+ annotations: CHANGES_A_SITE
2538
+ },
2539
+ ({ site, name, mode, password, forms, expiresAt, expiryAction, folder, confirm }) => withCredentials(async (credentials) => {
2540
+ const found = await findSite(credentials, site);
2541
+ if (expiryAction !== void 0 && expiresAt === void 0) {
2542
+ throw new Error("`expiryAction` only applies with `expiresAt` — send the date too.");
2543
+ }
2544
+ if (expiryAction === "delete" && expiresAt !== "" && confirm?.trim().toLowerCase() !== found.subdomain.toLowerCase()) {
2545
+ throw new Error(
2546
+ `Refusing to schedule ${found.subdomain} for deletion: \`confirm\` said "${confirm ?? ""}". Repeat the subdomain exactly to go ahead, or use \`expiryAction: pause\`, which keeps the files.`
2547
+ );
2548
+ }
2549
+ const change = {
2550
+ name,
2551
+ mode,
2552
+ password,
2553
+ expiresAt,
2554
+ // Dropped on a cancellation: the API reads an action only with a date, and sending
2555
+ // `delete` alongside an empty date would still run its owner-only check for nothing.
2556
+ expiryAction: expiresAt === "" ? void 0 : expiryAction,
2557
+ formsEnabled: forms,
2558
+ folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder)
2559
+ };
2560
+ const settings = await updateSiteSettings(credentials, found.siteId, change);
2561
+ const lines = [
2562
+ `Updated ${found.subdomain}.`,
2563
+ ` access ${accessOf(settings)}`,
2564
+ ` mode ${servingModeOf$1(settings)}`,
2565
+ ` forms ${settings.formsEnabled ? "on" : "off"}`,
2566
+ ` takedown ${settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`}`,
2567
+ ...settings.replacedInviteOnly ? [
2568
+ "The password replaced invite-only: the people on its list can no longer get in without it. The list is kept for switching back in the dashboard."
2569
+ ] : [],
2570
+ ...settings.live ? ["Reaching every edge takes up to about a minute."] : ["Nothing is published yet, so this applies from the first publish."]
2571
+ ];
2572
+ return report(lines.join("\n"), {
2573
+ siteId: settings.siteId,
2574
+ name: settings.name,
2575
+ mode: servingModeOf$1(settings),
2576
+ passwordProtected: settings.passwordProtected,
2577
+ invitedOnly: settings.invitedOnly,
2578
+ formsEnabled: settings.formsEnabled,
2579
+ expiresAt: settings.expiresAt,
2580
+ expiryAction: settings.expiryAction,
2581
+ folderId: settings.folderId
2582
+ });
2583
+ })
2584
+ );
2585
+ server.registerTool(
2586
+ "pause_site",
2587
+ {
2588
+ title: "Pause a site",
2589
+ description: "Takes a site off the air without deleting anything: visitors see that it is paused, and its files, versions and subdomain are kept. resume_site puts it back.",
2590
+ inputSchema: { site: SITE_INPUT },
2591
+ outputSchema: PAUSE_OUTPUT,
2592
+ annotations: CHANGES_A_SITE
2593
+ },
2594
+ ({ site }) => withCredentials(async (credentials) => {
2595
+ const found = await findSite(credentials, site);
2596
+ const state = await setSitePaused(credentials, found.siteId, "pause");
2597
+ return report(
2598
+ state.status === "paused" ? `Paused ${state.subdomain}. Its files and subdomain are kept; resume_site puts it back.` : `${state.subdomain} is ${state.status}, not paused by its owner, so resume_site cannot bring it back. The pause is recorded and applies if that is lifted.`,
2599
+ { ...state }
2600
+ );
2601
+ })
2602
+ );
2603
+ server.registerTool(
2604
+ "resume_site",
2605
+ {
2606
+ title: "Resume a paused site",
2607
+ description: "Puts a paused site back on the air. Refused, with the reason, when the plan has no room for another active site, the account is past due, or the site was suspended by Drop2Run.",
2608
+ inputSchema: { site: SITE_INPUT },
2609
+ outputSchema: PAUSE_OUTPUT,
2610
+ annotations: RESTORES_A_SITE
2611
+ },
2612
+ ({ site }) => withCredentials(async (credentials) => {
2613
+ const found = await findSite(credentials, site);
2614
+ const state = await setSitePaused(credentials, found.siteId, "resume");
2615
+ return report(`${state.subdomain} is back on the air.
2616
+ ${found.url}`, { ...state });
2617
+ })
2618
+ );
2619
+ server.registerTool(
2620
+ "rollback_site",
2621
+ {
2622
+ title: "Roll a site back",
2623
+ description: "Serves an earlier version of a site again, without publishing anything. get_site lists the versions; one whose files were collected cannot be rolled back to.",
2624
+ inputSchema: {
2625
+ site: SITE_INPUT,
2626
+ deployId: z.string().min(1).describe("The version to serve, from get_site's `versions`.")
2627
+ },
2628
+ outputSchema: {
2629
+ deployId: z.string().describe("The version now being served."),
2630
+ url: z.string().describe("The URL it is served on.")
2631
+ },
2632
+ annotations: CHANGES_A_SITE
2633
+ },
2634
+ ({ site, deployId }) => withCredentials(async (credentials) => {
2635
+ const found = await findSite(credentials, site);
2636
+ const promoted = await promoteDeploy(credentials, found.siteId, deployId);
2637
+ return report(
2638
+ `${found.subdomain} is back on ${promoted.deployId}. Reaching every edge takes up to about a minute.
2639
+ ${promoted.url}`,
2640
+ { deployId: promoted.deployId, url: promoted.url }
2641
+ );
2642
+ })
2643
+ );
2191
2644
  return server;
2192
2645
  }
2193
2646
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -46,14 +46,14 @@
46
46
  "build:mcpb": "node scripts/build-mcpb.mjs"
47
47
  },
48
48
  "dependencies": {
49
- "@modelcontextprotocol/sdk": "^1.30.0",
49
+ "@modelcontextprotocol/sdk": "^1.32.1",
50
50
  "zod": "^4.1.12"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@biomejs/biome": "^2.5.11",
54
54
  "@types/node": "^26.4.0",
55
55
  "typescript": "^5.9.3",
56
- "vitest": "^4.1.11",
57
- "vite": "^7.2.1"
56
+ "vite": "^7.2.1",
57
+ "vitest": "^4.1.11"
58
58
  }
59
59
  }