@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.
- package/README.md +11 -0
- package/dist/index.js +462 -9
- 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(
|
|
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({
|
|
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
|
|
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")
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
57
|
-
"
|
|
56
|
+
"vite": "^7.2.1",
|
|
57
|
+
"vitest": "^4.1.11"
|
|
58
58
|
}
|
|
59
59
|
}
|