@drop2run/mcp 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +10 -1
  2. package/dist/index.js +497 -92
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -88,7 +88,10 @@ lose it.
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
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 |
91
+ | `update_site` | Changes a site's name, serving mode, password, invite-only, comments, forms, scheduled takedown or folder |
92
+ | `list_people` | Lists who a site is shared with, what each may do, and whether only they can open it |
93
+ | `invite_people` | Invites addresses the person gave — to view, or to view and comment — and emails each one |
94
+ | `remove_person` | Takes one address off a site's list |
92
95
  | `pause_site` | Takes a site off the air, keeping its files, versions and subdomain |
93
96
  | `resume_site` | Puts a paused site back on the air |
94
97
  | `rollback_site` | Serves an earlier version again, by its id from `get_site` |
@@ -117,6 +120,12 @@ default; `expiryAction: "delete"` removes it instead, and asks for the subdomain
117
120
  `delete_site` does. Settings your plan does not include are refused with the reason, and `get_site` says
118
121
  which ones those are.
119
122
 
123
+ To share a site for review, `invite_people` puts the addresses the person gave on its list and emails each
124
+ one; `update_site` with `invitedOnly: true` makes the list the only way in, and `comments` decides who may
125
+ comment — `invited` for the people invited with the comment role, `anyone` for anybody signed in. Both are
126
+ plan features, refused with the reason where the plan lacks them. The tool description tells the model never
127
+ to invite an address the person did not give it.
128
+
120
129
  The comment tools close the loop on a site somebody is reviewing: `list_comments` gives each thread's page
121
130
  and the words or element it points at, so the source can be found; after the fix is published,
122
131
  `reply_comment` answers on the page. Publish first and reply second, or the commenter opens the old
package/dist/index.js CHANGED
@@ -177,10 +177,7 @@ async function folderFromListing(credentials, siteId, subdomain) {
177
177
  }
178
178
  async function getSite(credentials, siteId) {
179
179
  const [site, invitedOnly] = await Promise.all([
180
- call(
181
- credentials,
182
- `sites/${encodeURIComponent(siteId)}`
183
- ),
180
+ call(credentials, `sites/${encodeURIComponent(siteId)}`),
184
181
  readInvitedOnly(credentials, siteId)
185
182
  ]);
186
183
  const folderId = site.folderId !== void 0 ? site.folderId : await folderFromListing(credentials, site.siteId, site.subdomain);
@@ -202,6 +199,8 @@ async function getSite(credentials, siteId) {
202
199
  expiresAt: site.expiresAt,
203
200
  expiryAction: actionOf(site.expiresAt, site.expiryAction ?? ""),
204
201
  scheduledExpiryAvailable: site.scheduledExpiryAvailable,
202
+ comments: audienceOf(site.commentAudience),
203
+ commentsAvailable: site.feedbackAvailable === true,
205
204
  folderId,
206
205
  deploys: site.deploys.map((deploy2) => ({
207
206
  deployId: deploy2.deployId,
@@ -226,9 +225,11 @@ async function updateSiteSettings(credentials, siteId, change) {
226
225
  if (change.expiryAction !== void 0) body.expiryAction = change.expiryAction;
227
226
  if (change.formsEnabled !== void 0) body.formsEnabled = change.formsEnabled;
228
227
  if (change.folderId !== void 0) body.folderId = change.folderId;
228
+ if (change.invitedOnly !== void 0) body.invitedOnly = change.invitedOnly;
229
+ if (change.comments !== void 0) body.commentAudience = API_AUDIENCE[change.comments];
229
230
  if (Object.keys(body).length === 0) throw new Error("Nothing to change — name a setting.");
230
231
  const wasInvitedOnly = change.password !== void 0 && change.password !== "" ? await readInvitedOnly(credentials, siteId) : null;
231
- const settings = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
232
+ const { commentAudience, ...settings } = await call(credentials, `sites/${encodeURIComponent(siteId)}`, {
232
233
  method: "PATCH",
233
234
  body: JSON.stringify(body)
234
235
  });
@@ -237,9 +238,19 @@ async function updateSiteSettings(credentials, siteId, change) {
237
238
  ...settings,
238
239
  expiryAction: actionOf(settings.expiresAt, settings.expiryAction ?? ""),
239
240
  invitedOnly,
241
+ comments: audienceOf(commentAudience),
240
242
  replacedInviteOnly: wasInvitedOnly === true && invitedOnly === false
241
243
  };
242
244
  }
245
+ const API_AUDIENCE = {
246
+ off: "Off",
247
+ invited: "Invited",
248
+ anyone: "Anyone"
249
+ };
250
+ function audienceOf(value) {
251
+ const lower = value?.toLowerCase();
252
+ return lower === "off" || lower === "invited" || lower === "anyone" ? lower : null;
253
+ }
243
254
  async function setSitePaused(credentials, siteId, action) {
244
255
  const state = await call(
245
256
  credentials,
@@ -354,6 +365,34 @@ async function setCommentResolved(credentials, siteId, threadId, resolved) {
354
365
  { method: "POST" }
355
366
  );
356
367
  }
368
+ const PEOPLE_PER_REQUEST = 50;
369
+ async function listPeople(credentials, siteId) {
370
+ const page = await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers?pageSize=${PEOPLE_PER_REQUEST}`);
371
+ return {
372
+ invitedOnly: page.invitedOnly,
373
+ total: page.total,
374
+ limit: page.limit,
375
+ people: page.viewers.map((row) => ({
376
+ email: row.email,
377
+ role: enumName(row.role),
378
+ owner: row.owner,
379
+ status: enumName(row.status)
380
+ }))
381
+ };
382
+ }
383
+ async function invitePeople(credentials, siteId, emails, role) {
384
+ return await call(credentials, `sites/${encodeURIComponent(siteId)}/viewers`, {
385
+ method: "POST",
386
+ body: JSON.stringify({ emails, role: role === "comment" ? "Comment" : "View", notify: true })
387
+ });
388
+ }
389
+ async function removePerson(credentials, siteId, email) {
390
+ await call(
391
+ credentials,
392
+ `sites/${encodeURIComponent(siteId)}/viewers?email=${encodeURIComponent(email)}`,
393
+ { method: "DELETE" }
394
+ );
395
+ }
357
396
  const VERSIONS_LISTED = 10;
358
397
  function servingModeOf$1(site) {
359
398
  if (site.spaMode) return "spa";
@@ -382,6 +421,7 @@ function describeSite(site, folderPath) {
382
421
  ` mode ${servingModeOf$1(site)}${site.modeIsManual ? "" : " (detected on each publish)"}`,
383
422
  ` password ${site.passwordProtected ? "on" : "off"}${unavailable(site.passwordProtectionAvailable)}`,
384
423
  ` forms ${site.formsEnabled ? "on" : "off"}${unavailable(site.formsAvailable)}`,
424
+ ` comments ${site.comments ?? "unknown"}${unavailable(site.commentsAvailable)}`,
385
425
  ` takedown ${site.expiresAt === null ? "none" : `${site.expiresAt} (${site.expiryAction})`}${unavailable(site.scheduledExpiryAvailable)}`,
386
426
  ` folder ${site.folderId === null ? "(top level)" : folderPath ?? site.folderId}`,
387
427
  "",
@@ -613,7 +653,9 @@ async function prepareDeploy(siteId, files, options, signal) {
613
653
  }));
614
654
  const prepared = await request(
615
655
  `sites/${encodeURIComponent(siteId)}/deploys/prepare`,
616
- { files: manifest },
656
+ // `batches` asks for batch permits; an API that predates them ignores the field, and one that
657
+ // knows them answers with single permits until they are switched on.
658
+ { files: manifest, batches: true },
617
659
  options,
618
660
  signal
619
661
  );
@@ -702,6 +744,47 @@ async function problemToError(response) {
702
744
  const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
703
745
  return new DeployError(code, message, problem);
704
746
  }
747
+ function batchLayout(members) {
748
+ return members.map((member) => `${member.sha256} ${member.size}
749
+ `).join("");
750
+ }
751
+ function isBatch(target) {
752
+ return "files" in target;
753
+ }
754
+ function parseBatchResult(body) {
755
+ try {
756
+ const parsed = JSON.parse(body);
757
+ if (parsed === null || typeof parsed !== "object") return null;
758
+ const { stored, present, failed } = parsed;
759
+ if (typeof stored !== "number" || typeof present !== "number" || !Array.isArray(failed))
760
+ return null;
761
+ return { stored, present, failed };
762
+ } catch {
763
+ return null;
764
+ }
765
+ }
766
+ async function mapInOrder(items, limit, fn, signal) {
767
+ const results = new Array(items.length);
768
+ let next = 0;
769
+ let failure = null;
770
+ const lane = async () => {
771
+ for (; ; ) {
772
+ if (failure !== null) return;
773
+ const index = next++;
774
+ if (index >= items.length) return;
775
+ try {
776
+ results[index] = await fn(items[index], index);
777
+ } catch (error) {
778
+ failure ??= { error };
779
+ return;
780
+ }
781
+ }
782
+ };
783
+ const lanes = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, lane);
784
+ await Promise.all(lanes);
785
+ if (failure !== null) throw failure.error;
786
+ return results;
787
+ }
705
788
  const FILE_TYPES = [
706
789
  // Documents. The three extensions that make a folder a documents site.
707
790
  {
@@ -816,13 +899,14 @@ const FILE_TYPES = [
816
899
  reader: true,
817
900
  editable: true
818
901
  },
819
- // Source the viewer highlights. A link straight to one of these hands over the file.
902
+ // Source the viewer highlights. Inside a documents site a link straight to one opens it in the
903
+ // reader; on an ordinary site, and with `?raw`, it hands over the file.
820
904
  {
821
905
  ext: ".css",
822
906
  mime: "text/css; charset=utf-8",
823
907
  kind: "code",
824
908
  document: false,
825
- reader: false,
909
+ reader: true,
826
910
  editable: true
827
911
  },
828
912
  {
@@ -830,7 +914,7 @@ const FILE_TYPES = [
830
914
  mime: "text/javascript; charset=utf-8",
831
915
  kind: "code",
832
916
  document: false,
833
- reader: false,
917
+ reader: true,
834
918
  editable: true
835
919
  },
836
920
  {
@@ -838,7 +922,7 @@ const FILE_TYPES = [
838
922
  mime: null,
839
923
  kind: "code",
840
924
  document: false,
841
- reader: false,
925
+ reader: true,
842
926
  editable: false
843
927
  },
844
928
  {
@@ -846,7 +930,7 @@ const FILE_TYPES = [
846
930
  mime: null,
847
931
  kind: "code",
848
932
  document: false,
849
- reader: false,
933
+ reader: true,
850
934
  editable: false
851
935
  },
852
936
  {
@@ -854,7 +938,7 @@ const FILE_TYPES = [
854
938
  mime: null,
855
939
  kind: "code",
856
940
  document: false,
857
- reader: false,
941
+ reader: true,
858
942
  editable: false
859
943
  },
860
944
  {
@@ -862,19 +946,18 @@ const FILE_TYPES = [
862
946
  mime: null,
863
947
  kind: "code",
864
948
  document: false,
865
- reader: false,
949
+ reader: true,
866
950
  editable: false
867
951
  },
868
952
  // The languages an assistant writes most, beyond the web's own. Same row as `.ts`: shown as source
869
- // when somebody opens one in the reader or publishes it on its own, handed over as a file when a
870
- // link names it directly, and no media type — adding one changes what a browser does with a
953
+ // in the reader, handed over as a file with `?raw` or on an ordinary site, and no media type — adding one changes what a browser does with a
871
954
  // published file, which I10 makes a decision of its own rather than a side effect of this list.
872
955
  {
873
956
  ext: ".py",
874
957
  mime: null,
875
958
  kind: "code",
876
959
  document: false,
877
- reader: false,
960
+ reader: true,
878
961
  editable: false
879
962
  },
880
963
  {
@@ -882,7 +965,7 @@ const FILE_TYPES = [
882
965
  mime: null,
883
966
  kind: "code",
884
967
  document: false,
885
- reader: false,
968
+ reader: true,
886
969
  editable: false
887
970
  },
888
971
  {
@@ -890,7 +973,7 @@ const FILE_TYPES = [
890
973
  mime: null,
891
974
  kind: "code",
892
975
  document: false,
893
- reader: false,
976
+ reader: true,
894
977
  editable: false
895
978
  },
896
979
  {
@@ -898,7 +981,7 @@ const FILE_TYPES = [
898
981
  mime: null,
899
982
  kind: "code",
900
983
  document: false,
901
- reader: false,
984
+ reader: true,
902
985
  editable: false
903
986
  },
904
987
  {
@@ -906,7 +989,7 @@ const FILE_TYPES = [
906
989
  mime: null,
907
990
  kind: "code",
908
991
  document: false,
909
- reader: false,
992
+ reader: true,
910
993
  editable: false
911
994
  },
912
995
  {
@@ -914,7 +997,7 @@ const FILE_TYPES = [
914
997
  mime: null,
915
998
  kind: "code",
916
999
  document: false,
917
- reader: false,
1000
+ reader: true,
918
1001
  editable: false
919
1002
  },
920
1003
  {
@@ -922,7 +1005,7 @@ const FILE_TYPES = [
922
1005
  mime: null,
923
1006
  kind: "code",
924
1007
  document: false,
925
- reader: false,
1008
+ reader: true,
926
1009
  editable: false
927
1010
  },
928
1011
  {
@@ -930,7 +1013,7 @@ const FILE_TYPES = [
930
1013
  mime: null,
931
1014
  kind: "code",
932
1015
  document: false,
933
- reader: false,
1016
+ reader: true,
934
1017
  editable: false
935
1018
  },
936
1019
  {
@@ -938,7 +1021,7 @@ const FILE_TYPES = [
938
1021
  mime: null,
939
1022
  kind: "code",
940
1023
  document: false,
941
- reader: false,
1024
+ reader: true,
942
1025
  editable: false
943
1026
  },
944
1027
  {
@@ -946,7 +1029,7 @@ const FILE_TYPES = [
946
1029
  mime: null,
947
1030
  kind: "code",
948
1031
  document: false,
949
- reader: false,
1032
+ reader: true,
950
1033
  editable: false
951
1034
  },
952
1035
  {
@@ -954,7 +1037,7 @@ const FILE_TYPES = [
954
1037
  mime: null,
955
1038
  kind: "code",
956
1039
  document: false,
957
- reader: false,
1040
+ reader: true,
958
1041
  editable: false
959
1042
  },
960
1043
  {
@@ -962,7 +1045,7 @@ const FILE_TYPES = [
962
1045
  mime: null,
963
1046
  kind: "code",
964
1047
  document: false,
965
- reader: false,
1048
+ reader: true,
966
1049
  editable: false
967
1050
  },
968
1051
  {
@@ -970,7 +1053,7 @@ const FILE_TYPES = [
970
1053
  mime: null,
971
1054
  kind: "code",
972
1055
  document: false,
973
- reader: false,
1056
+ reader: true,
974
1057
  editable: false
975
1058
  },
976
1059
  {
@@ -978,7 +1061,7 @@ const FILE_TYPES = [
978
1061
  mime: null,
979
1062
  kind: "code",
980
1063
  document: false,
981
- reader: false,
1064
+ reader: true,
982
1065
  editable: false
983
1066
  },
984
1067
  {
@@ -986,7 +1069,7 @@ const FILE_TYPES = [
986
1069
  mime: "application/xml",
987
1070
  kind: "code",
988
1071
  document: false,
989
- reader: false,
1072
+ reader: true,
990
1073
  editable: true
991
1074
  },
992
1075
  // Pictures, shown with an img element.
@@ -1057,25 +1140,25 @@ const FILE_TYPES = [
1057
1140
  reader: false,
1058
1141
  editable: false
1059
1142
  },
1060
- // A customer's own pages. Never rendered inside the reader.
1143
+ // A customer's own pages. Inside a documents site a navigation opens the reader, which frames the
1144
+ // page; `?raw` and every ordinary site serve the page itself.
1061
1145
  {
1062
1146
  ext: ".html",
1063
1147
  mime: "text/html; charset=utf-8",
1064
1148
  kind: "html",
1065
1149
  document: false,
1066
- reader: false,
1150
+ reader: true,
1067
1151
  editable: true
1068
1152
  },
1069
1153
  {
1070
- // ⚠️ No media type, and that is the state of the code rather than a decision: `TYPES` in
1071
- // `headers.ts` has never held `htm`, so a published `.htm` is served as octet-stream and
1072
- // downloads instead of rendering. Recorded here rather than fixed, because this table was
1073
- // introduced as a refactor and a refactor that changes what a URL answers is not one.
1154
+ // An HTML page in every respect the Worker decides by extension: media type, the revalidated
1155
+ // cache policy invariant I7 requires, and the badge's `isHtml`. Missing from `TYPES` it used to
1156
+ // download, which inside the reader meant a frame that downloaded instead of showing a page.
1074
1157
  ext: ".htm",
1075
- mime: null,
1158
+ mime: "text/html; charset=utf-8",
1076
1159
  kind: "html",
1077
1160
  document: false,
1078
- reader: false,
1161
+ reader: true,
1079
1162
  editable: true
1080
1163
  },
1081
1164
  // Everything below renders as nothing — the viewer offers a download — but still needs a media
@@ -1185,13 +1268,46 @@ async function sha256Hex(bytes) {
1185
1268
  const digest = await crypto.subtle.digest("SHA-256", bytes);
1186
1269
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
1187
1270
  }
1271
+ const HASH_CONCURRENCY = 4;
1272
+ const HASH_BYTES_IN_FLIGHT = 16 * 1024 * 1024;
1188
1273
  async function hashAll(files, onProgress, signal) {
1189
- const hashed = [];
1190
- for (const [index, file] of files.entries()) {
1191
- hashed.push({ ...file, sha256: await sha256Hex(await readForHash(file)) });
1192
- onProgress?.(index + 1, files.length);
1193
- }
1194
- return hashed;
1274
+ const budget = byteBudget(HASH_BYTES_IN_FLIGHT);
1275
+ let done = 0;
1276
+ return mapInOrder(
1277
+ files,
1278
+ HASH_CONCURRENCY,
1279
+ async (file) => {
1280
+ const size = sizeOf(file.bytes);
1281
+ await budget.acquire(size);
1282
+ try {
1283
+ signal?.throwIfAborted();
1284
+ const hashed = { ...file, sha256: await sha256Hex(await readForHash(file)) };
1285
+ done += 1;
1286
+ onProgress?.(done, files.length);
1287
+ return hashed;
1288
+ } finally {
1289
+ budget.release(size);
1290
+ }
1291
+ }
1292
+ );
1293
+ }
1294
+ function byteBudget(limit) {
1295
+ let held = 0;
1296
+ let waiting = [];
1297
+ return {
1298
+ async acquire(size) {
1299
+ while (held > 0 && held + size > limit) {
1300
+ await new Promise((resolve) => waiting.push(resolve));
1301
+ }
1302
+ held += size;
1303
+ },
1304
+ release(size) {
1305
+ held -= size;
1306
+ const woken = waiting;
1307
+ waiting = [];
1308
+ for (const wake of woken) wake();
1309
+ }
1310
+ };
1195
1311
  }
1196
1312
  async function readForHash(file) {
1197
1313
  try {
@@ -1220,10 +1336,13 @@ const IGNORED_DIRECTORIES = [
1220
1336
  ".codex",
1221
1337
  ".gemini"
1222
1338
  ];
1339
+ function isEnvironmentFile(name) {
1340
+ return name === ".env" || name === ".envrc" || name.startsWith(".env.");
1341
+ }
1223
1342
  function shouldIgnore(path) {
1224
1343
  const segments = path.split("/");
1225
1344
  const name = segments.at(-1) ?? "";
1226
- if (name === "" || IGNORED_EXACT.has(name)) return true;
1345
+ if (name === "" || IGNORED_EXACT.has(name) || isEnvironmentFile(name)) return true;
1227
1346
  if (name.startsWith("._")) return true;
1228
1347
  return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
1229
1348
  }
@@ -1358,9 +1477,7 @@ async function readSiteName(files) {
1358
1477
  }
1359
1478
  }
1360
1479
  function decodeHead(bytes) {
1361
- return new TextDecoder("utf-8", { fatal: false }).decode(
1362
- bytes.subarray(0, HEAD_BYTES)
1363
- );
1480
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, HEAD_BYTES));
1364
1481
  }
1365
1482
  function nameFromHtml(html) {
1366
1483
  const document = parse(html);
@@ -1385,9 +1502,7 @@ function parse(html) {
1385
1502
  }
1386
1503
  }
1387
1504
  function matchTag(html, tag) {
1388
- const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(
1389
- html
1390
- );
1505
+ const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}\\s*>`, "i").exec(html);
1391
1506
  return match?.[1] === void 0 ? null : decodeEntities(match[1]);
1392
1507
  }
1393
1508
  function matchOgSiteName(html) {
@@ -1488,6 +1603,7 @@ function trimSuffix(title) {
1488
1603
  return title;
1489
1604
  }
1490
1605
  const DEFAULT_CONCURRENCY = 8;
1606
+ const MAX_BATCH_CONCURRENCY = 64;
1491
1607
  const DEFAULT_BYTES_IN_FLIGHT = 64 * 1024 * 1024;
1492
1608
  const DEFAULT_RETRIES = 3;
1493
1609
  const BACKOFF_MS = [1e3, 2e3, 4e3];
@@ -1504,14 +1620,31 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1504
1620
  const put = deps.put ?? defaultTransport(deps.fetch);
1505
1621
  const delay = deps.delay ?? defaultDelay;
1506
1622
  const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
1623
+ const batchConcurrency = Math.max(
1624
+ 1,
1625
+ Math.min(MAX_BATCH_CONCURRENCY, Math.floor(deps.batchConcurrency ?? concurrency))
1626
+ );
1507
1627
  const retries = deps.retries ?? DEFAULT_RETRIES;
1508
1628
  const stallMs = deps.stallMs ?? DEFAULT_STALL_MS;
1509
1629
  const now = deps.now ?? Date.now;
1510
1630
  const maxBytesInFlight = deps.maxBytesInFlight ?? DEFAULT_BYTES_IN_FLIGHT;
1511
1631
  const byPath = new Map(files.map((file) => [file.path, file]));
1632
+ const fileCount = targets.reduce(
1633
+ (sum, target) => sum + (isBatch(target) ? target.files.length : 1),
1634
+ 0
1635
+ );
1512
1636
  let nextIndex = 0;
1513
1637
  let done = 0;
1514
1638
  let failed = false;
1639
+ let firstFailure;
1640
+ const local = new AbortController();
1641
+ const inner = local.signal;
1642
+ const fail = (error) => {
1643
+ if (failed) return;
1644
+ failed = true;
1645
+ firstFailure = error;
1646
+ local.abort();
1647
+ };
1515
1648
  let reserved = 0;
1516
1649
  let waiting = [];
1517
1650
  const acquire = async (size) => {
@@ -1526,17 +1659,32 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1526
1659
  waiting = [];
1527
1660
  for (const wake of woken) wake();
1528
1661
  };
1662
+ const running = { single: 0, batch: 0 };
1663
+ const limit = { single: concurrency, batch: batchConcurrency };
1664
+ let slotWaiting = [];
1665
+ const takeSlot = async (kind) => {
1666
+ while (running[kind] >= limit[kind]) {
1667
+ await new Promise((resolve) => slotWaiting.push(resolve));
1668
+ }
1669
+ running[kind] += 1;
1670
+ };
1671
+ const freeSlot = (kind) => {
1672
+ running[kind] -= 1;
1673
+ const woken = slotWaiting;
1674
+ slotWaiting = [];
1675
+ for (const wake of woken) wake();
1676
+ };
1529
1677
  let landedBytes = 0;
1530
1678
  const inFlight = /* @__PURE__ */ new Map();
1531
1679
  let lastReport = Number.NEGATIVE_INFINITY;
1532
1680
  const report2 = (path) => {
1533
- if (onProgress === void 0) return;
1681
+ if (onProgress === void 0 || failed) return;
1534
1682
  const at = now();
1535
1683
  if (path === void 0 && at - lastReport < PROGRESS_INTERVAL_MS) return;
1536
1684
  lastReport = at;
1537
1685
  let bytes = landedBytes;
1538
1686
  for (const sent of inFlight.values()) bytes += sent;
1539
- onProgress(done, targets.length, bytes, path);
1687
+ onProgress(done, fileCount, bytes, path);
1540
1688
  };
1541
1689
  const worker = async () => {
1542
1690
  for (; ; ) {
@@ -1544,25 +1692,49 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1544
1692
  const index = nextIndex++;
1545
1693
  const target = targets[index];
1546
1694
  if (target === void 0) return;
1547
- const file = byPath.get(target.path);
1548
- if (file === void 0) {
1549
- failed = true;
1550
- throw new DeployError(
1551
- ClientErrorCode.UploadFailed,
1552
- `The server asked for ${target.path}, which is not in the manifest.`,
1553
- { path: target.path }
1554
- );
1695
+ inner.throwIfAborted();
1696
+ const refs = isBatch(target) ? target.files : [target];
1697
+ const members = [];
1698
+ for (const ref of refs) {
1699
+ const file = byPath.get(ref.path);
1700
+ if (file === void 0) {
1701
+ const missing = new DeployError(
1702
+ ClientErrorCode.UploadFailed,
1703
+ `The server asked for ${ref.path}, which is not in the manifest.`,
1704
+ { path: ref.path }
1705
+ );
1706
+ fail(missing);
1707
+ throw missing;
1708
+ }
1709
+ members.push(file);
1555
1710
  }
1556
- const size = sizeOf(file.bytes);
1557
- await acquire(size);
1711
+ const layout = isBatch(target) ? new TextEncoder().encode(
1712
+ batchLayout(
1713
+ target.files.map((ref, i) => ({
1714
+ sha256: ref.sha256,
1715
+ size: sizeOf(members[i].bytes)
1716
+ }))
1717
+ )
1718
+ ) : null;
1719
+ const size = members.reduce((sum, file) => sum + sizeOf(file.bytes), layout?.length ?? 0);
1720
+ const copied = isBatch(target) ? members.reduce(
1721
+ (sum, file) => sum + (file.bytes instanceof Blob ? 0 : sizeOf(file.bytes)),
1722
+ 0
1723
+ ) : 0;
1724
+ const reservation = size + copied;
1725
+ const first = members[0].path;
1726
+ const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1727
+ const kind = isBatch(target) ? "batch" : "single";
1728
+ await takeSlot(kind);
1729
+ await acquire(reservation);
1558
1730
  try {
1559
1731
  if (failed) return;
1560
- signal?.throwIfAborted();
1561
- await uploadOne(target, file, {
1732
+ inner.throwIfAborted();
1733
+ const context = {
1562
1734
  put,
1563
1735
  delay,
1564
1736
  retries,
1565
- signal,
1737
+ signal: inner,
1566
1738
  uploadUrl,
1567
1739
  stallMs,
1568
1740
  onSent: (sent) => {
@@ -1571,26 +1743,34 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
1571
1743
  },
1572
1744
  onRetry: (attempt2, stalled) => {
1573
1745
  inFlight.set(index, 0);
1574
- onRetry?.(target.path, attempt2, retries + 1, stalled);
1746
+ onRetry?.(label, attempt2, retries + 1, stalled);
1575
1747
  }
1576
- });
1748
+ };
1749
+ if (isBatch(target) && layout !== null)
1750
+ await uploadBatchOne(target, members, layout, context);
1751
+ else await uploadOne(target, members[0], context);
1577
1752
  } catch (error) {
1578
- failed = true;
1753
+ fail(error);
1579
1754
  throw error;
1580
1755
  } finally {
1581
- release(size);
1756
+ release(reservation);
1757
+ freeSlot(kind);
1582
1758
  }
1583
1759
  inFlight.delete(index);
1584
- done += 1;
1760
+ done += members.length;
1585
1761
  landedBytes += size;
1586
- report2(file.path);
1762
+ report2(members.at(-1).path);
1587
1763
  }
1588
1764
  };
1589
- const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
1765
+ const workers = Array.from(
1766
+ { length: Math.min(Math.max(concurrency, batchConcurrency), targets.length) },
1767
+ worker
1768
+ );
1590
1769
  try {
1591
1770
  await Promise.all(workers);
1592
1771
  } catch (error) {
1593
- throw asDeployError(error);
1772
+ throw asDeployError(firstFailure ?? error);
1773
+ } finally {
1594
1774
  }
1595
1775
  }
1596
1776
  async function uploadOne(target, file, context) {
@@ -1603,7 +1783,7 @@ async function uploadOne(target, file, context) {
1603
1783
  context.signal?.throwIfAborted();
1604
1784
  }
1605
1785
  try {
1606
- const status = await context.put({
1786
+ const answer = await context.put({
1607
1787
  url: context.uploadUrl,
1608
1788
  token: target.token,
1609
1789
  body: untyped(file.bytes),
@@ -1611,6 +1791,7 @@ async function uploadOne(target, file, context) {
1611
1791
  onSent: context.onSent,
1612
1792
  stallMs: context.stallMs
1613
1793
  });
1794
+ const status = typeof answer === "number" ? answer : answer.status;
1614
1795
  if (status >= 200 && status < 300) return;
1615
1796
  if (status === 401 || status === 403 || status === 429) {
1616
1797
  throw new DeployError(
@@ -1639,6 +1820,85 @@ async function uploadOne(target, file, context) {
1639
1820
  cause: describeError(lastError)
1640
1821
  });
1641
1822
  }
1823
+ async function uploadBatchOne(batch, members, layout, context) {
1824
+ const first = members[0].path;
1825
+ const label = members.length === 1 ? first : `${first} and ${members.length - 1} more`;
1826
+ let lastError;
1827
+ for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
1828
+ context.signal?.throwIfAborted();
1829
+ if (attempt2 > 0) {
1830
+ context.onRetry(attempt2 + 1, lastError instanceof StalledError);
1831
+ await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
1832
+ context.signal?.throwIfAborted();
1833
+ }
1834
+ try {
1835
+ const body = new Blob([layout, ...members.map((file) => file.bytes)]);
1836
+ const answer = await context.put({
1837
+ url: context.uploadUrl,
1838
+ token: batch.token,
1839
+ body,
1840
+ signal: context.signal,
1841
+ onSent: context.onSent,
1842
+ stallMs: context.stallMs,
1843
+ wantsBody: true
1844
+ });
1845
+ const status = typeof answer === "number" ? answer : answer.status;
1846
+ if (status >= 200 && status < 300) {
1847
+ const result = typeof answer === "number" ? null : parseBatchResult(answer.body);
1848
+ if (result === null) {
1849
+ lastError = new Error(
1850
+ "The upload service answered with something that is not a batch result."
1851
+ );
1852
+ continue;
1853
+ }
1854
+ if (result.failed.length === 0) return;
1855
+ const changed = result.failed.find((failure) => failure.reason === "hash_mismatch");
1856
+ if (changed !== void 0) {
1857
+ const path = members[changed.i]?.path ?? first;
1858
+ throw new DeployError(
1859
+ ClientErrorCode.ReadFailed,
1860
+ `${path} changed after it was read, so it no longer matches what was checked. Drop the folder again.`,
1861
+ { path }
1862
+ );
1863
+ }
1864
+ lastError = new Error(
1865
+ `${result.failed.length} of ${members.length} files were not stored.`
1866
+ );
1867
+ continue;
1868
+ }
1869
+ if ([400, 401, 403, 411, 413, 429].includes(status)) {
1870
+ throw new DeployError(ClientErrorCode.UploadFailed, refusalMessage(status, label), {
1871
+ path: first,
1872
+ status
1873
+ });
1874
+ }
1875
+ lastError = new Error(`HTTP ${status}`);
1876
+ } catch (error) {
1877
+ if (error instanceof DeployError) throw error;
1878
+ if (isAbort(error)) throw cancelled();
1879
+ lastError = error;
1880
+ }
1881
+ }
1882
+ const message = lastError instanceof StalledError ? `Upload of ${label} stopped moving and did not recover after ${context.retries + 1} attempts. Check your connection and try again.` : `Could not upload ${label} after ${context.retries + 1} attempts.`;
1883
+ throw new DeployError(ClientErrorCode.UploadFailed, message, {
1884
+ path: first,
1885
+ cause: describeError(lastError)
1886
+ });
1887
+ }
1888
+ function refusalMessage(status, label) {
1889
+ switch (status) {
1890
+ case 401:
1891
+ return `Upload of ${label} was refused because its upload window expired. Try deploying again.`;
1892
+ case 429:
1893
+ return `Upload of ${label} was refused: this deploy has used every upload it was given. Try deploying again.`;
1894
+ case 400:
1895
+ case 411:
1896
+ case 413:
1897
+ return `Upload of ${label} was refused because it no longer matches what was checked — a file may have changed since you dropped it. Drop the folder again.`;
1898
+ default:
1899
+ return `Upload of ${label} was refused by the upload service. Try deploying again.`;
1900
+ }
1901
+ }
1642
1902
  function untyped(content) {
1643
1903
  if (ArrayBuffer.isView(content) || content.type === "") return content;
1644
1904
  return content.slice(0, content.size);
@@ -1649,7 +1909,8 @@ function defaultTransport(doFetch) {
1649
1909
  return Xhr === void 0 ? fetchTransport(globalFetch()) : xhrTransport(Xhr);
1650
1910
  }
1651
1911
  function fetchTransport(doFetch) {
1652
- return async ({ url, token, body, signal }) => {
1912
+ return async (request2) => {
1913
+ const { url, token, body, signal } = request2;
1653
1914
  const response = await doFetch(url, {
1654
1915
  method: "PUT",
1655
1916
  // The view, not a copy. Wrapping it in `new Blob([body.slice().buffer])` made two extra
@@ -1664,11 +1925,11 @@ function fetchTransport(doFetch) {
1664
1925
  headers: { Authorization: `Bearer ${token}` },
1665
1926
  ...signal ? { signal } : {}
1666
1927
  });
1667
- return response.status;
1928
+ return request2.wantsBody === true ? { status: response.status, body: await response.text() } : response.status;
1668
1929
  };
1669
1930
  }
1670
1931
  function xhrTransport(Xhr) {
1671
- return ({ url, token, body, signal, onSent, stallMs }) => new Promise((resolve, reject) => {
1932
+ return ({ url, token, body, signal, onSent, stallMs, wantsBody }) => new Promise((resolve, reject) => {
1672
1933
  const xhr = new Xhr();
1673
1934
  let settled = false;
1674
1935
  let timer;
@@ -1702,7 +1963,11 @@ function xhrTransport(Xhr) {
1702
1963
  onSent(event.loaded);
1703
1964
  };
1704
1965
  xhr.onprogress = arm;
1705
- xhr.onload = () => settle(() => resolve(xhr.status));
1966
+ xhr.onload = () => settle(
1967
+ () => resolve(
1968
+ wantsBody === true ? { status: xhr.status, body: xhr.responseText ?? "" } : xhr.status
1969
+ )
1970
+ );
1706
1971
  xhr.onerror = () => settle(() => reject(new TypeError("Network error during upload.")));
1707
1972
  arm();
1708
1973
  xhr.send(body);
@@ -1767,11 +2032,16 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1767
2032
  return;
1768
2033
  }
1769
2034
  const sizes = new Map(hashed.map((file) => [file.path, sizeOf(file.bytes)]));
1770
- const totalBytes = prepared.upload.reduce(
1771
- (sum, target) => sum + (sizes.get(target.path) ?? 0),
1772
- 0
1773
- );
1774
- if (prepared.upload.length > 0) {
2035
+ const batches = prepared.batches ?? [];
2036
+ const targets = [...prepared.upload, ...batches];
2037
+ const totalBytes = prepared.upload.reduce((sum, target) => sum + (sizes.get(target.path) ?? 0), 0) + batches.reduce((sum, batch) => {
2038
+ const members = batch.files.map((ref) => ({
2039
+ sha256: ref.sha256,
2040
+ size: sizes.get(ref.path) ?? 0
2041
+ }));
2042
+ return sum + batchLayout(members).length + members.reduce((n, member) => n + member.size, 0);
2043
+ }, 0);
2044
+ if (targets.length > 0) {
1775
2045
  onProgress({
1776
2046
  type: "uploading",
1777
2047
  done: prepared.reused,
@@ -1782,7 +2052,7 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1782
2052
  });
1783
2053
  }
1784
2054
  await uploadAll(
1785
- prepared.upload,
2055
+ targets,
1786
2056
  prepared.uploadUrl,
1787
2057
  hashed,
1788
2058
  (done, _total, bytes, path) => onProgress({
@@ -1795,7 +2065,9 @@ async function deploy(source, siteId, onProgress, signal, options = {}) {
1795
2065
  ...path === void 0 ? {} : { path }
1796
2066
  }),
1797
2067
  signal,
1798
- options.upload,
2068
+ // The server's number unless the caller set one: it is tuned on the server so a deploy of many
2069
+ // small files speeds up without a CLI or MCP release.
2070
+ typeof prepared.batchConcurrency === "number" && options.upload?.batchConcurrency === void 0 ? { ...options.upload, batchConcurrency: prepared.batchConcurrency } : options.upload,
1799
2071
  (path, attempt2, attempts, stalled) => onProgress({ type: "retrying", path, attempt: attempt2, attempts, stalled })
1800
2072
  );
1801
2073
  onProgress({ type: "completing", reused: prepared.reused });
@@ -2154,7 +2426,7 @@ async function signInWithCode(waitSeconds, replace = false) {
2154
2426
  "Call this tool again to keep waiting. Nothing has been changed yet."
2155
2427
  ].join("\n");
2156
2428
  }
2157
- const SERVER_VERSION = "0.10.0";
2429
+ const SERVER_VERSION = "0.12.0";
2158
2430
  const CREDENTIAL_WRITE = {
2159
2431
  readOnlyHint: false,
2160
2432
  destructiveHint: false,
@@ -2204,6 +2476,12 @@ const ANSWERS_A_COMMENT = {
2204
2476
  idempotentHint: false,
2205
2477
  openWorldHint: true
2206
2478
  };
2479
+ const INVITES_PEOPLE = {
2480
+ readOnlyHint: false,
2481
+ destructiveHint: false,
2482
+ idempotentHint: true,
2483
+ openWorldHint: true
2484
+ };
2207
2485
  const MARKS_A_THREAD = {
2208
2486
  readOnlyHint: false,
2209
2487
  destructiveHint: false,
@@ -2292,6 +2570,9 @@ const SETTINGS_OUTPUT = {
2292
2570
  "Whether only invited people can open it, or null when that could not be read. Null does not mean public."
2293
2571
  ),
2294
2572
  formsEnabled: z.boolean().describe("Whether it accepts form submissions."),
2573
+ comments: z.enum(["off", "invited", "anyone"]).nullable().describe(
2574
+ "Who may comment on its pages: nobody, the people invited to comment (only while it is invite-only), or anybody signed in. Null when that could not be read."
2575
+ ),
2295
2576
  expiresAt: z.string().nullable().describe("When it is scheduled to come down, or null."),
2296
2577
  expiryAction: z.string().nullable().describe("What the takedown does: `pause` or `delete`, or null when none is scheduled."),
2297
2578
  folderId: z.string().nullable().describe("The folder it is filed in, or null at the top level.")
@@ -2320,10 +2601,12 @@ function structuredSite(site) {
2320
2601
  expiresAt: site.expiresAt,
2321
2602
  expiryAction: site.expiryAction,
2322
2603
  folderId: site.folderId,
2604
+ comments: site.comments,
2323
2605
  available: {
2324
2606
  password: site.passwordProtectionAvailable,
2325
2607
  forms: site.formsAvailable,
2326
- scheduledTakedown: site.scheduledExpiryAvailable
2608
+ scheduledTakedown: site.scheduledExpiryAvailable,
2609
+ comments: site.commentsAvailable
2327
2610
  },
2328
2611
  versions: site.deploys.map((deploy2) => ({
2329
2612
  deployId: deploy2.deployId,
@@ -2426,6 +2709,12 @@ function createServer() {
2426
2709
  "a password or a date only when the person gave one, and scheduling a deletion asks for",
2427
2710
  "`confirm` the way delete_site does.",
2428
2711
  "",
2712
+ "To share a site for review: invite_people with the addresses the person gave (each is",
2713
+ "emailed), update_site `invitedOnly: true` so only they can open it, and `comments` to let",
2714
+ "them comment — `invited` for the people invited with the comment role, `anyone` for",
2715
+ "anybody signed in. list_people shows the list; remove_person takes someone off it.",
2716
+ "Never invite an address the person did not give you.",
2717
+ "",
2429
2718
  "People viewing a site can leave comments pinned to its pages. list_comments reads them,",
2430
2719
  "with the page and the words each one points at, so the source can be found and changed.",
2431
2720
  'After changing it, publish first and reply second — a reply that says "fixed" before the',
@@ -2597,7 +2886,8 @@ function createServer() {
2597
2886
  available: z.object({
2598
2887
  password: z.boolean(),
2599
2888
  forms: z.boolean(),
2600
- scheduledTakedown: z.boolean()
2889
+ scheduledTakedown: z.boolean(),
2890
+ comments: z.boolean()
2601
2891
  }).describe("Which settings the account's plan includes."),
2602
2892
  versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first.")
2603
2893
  },
@@ -2636,6 +2926,12 @@ function createServer() {
2636
2926
  folder: z.string().optional().describe(
2637
2927
  "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."
2638
2928
  ),
2929
+ invitedOnly: z.boolean().optional().describe(
2930
+ "True: only the people on the site's list (invite_people) can open it, each signed in. It replaces a password. False: anybody with the link can open it again. Change it only when the person asked who should see the site."
2931
+ ),
2932
+ comments: z.enum(["off", "invited", "anyone"]).optional().describe(
2933
+ "Who may comment on the site's pages: `off`; `invited` — the people invited with the comment role, on an invite-only site; or `anyone` signed in. Only on plans that include comments."
2934
+ ),
2639
2935
  confirm: z.string().optional().describe(
2640
2936
  "Required only with `expiryAction: delete`: the site's subdomain, repeated exactly as the person said it. Ask for it rather than filling it in."
2641
2937
  )
@@ -2643,7 +2939,19 @@ function createServer() {
2643
2939
  outputSchema: SETTINGS_OUTPUT,
2644
2940
  annotations: CHANGES_A_SITE
2645
2941
  },
2646
- ({ site, name, mode, password, forms, expiresAt, expiryAction, folder, confirm }) => withCredentials(async (credentials) => {
2942
+ ({
2943
+ site,
2944
+ name,
2945
+ mode,
2946
+ password,
2947
+ forms,
2948
+ expiresAt,
2949
+ expiryAction,
2950
+ folder,
2951
+ invitedOnly,
2952
+ comments,
2953
+ confirm
2954
+ }) => withCredentials(async (credentials) => {
2647
2955
  const found = await findSite(credentials, site);
2648
2956
  if (expiryAction !== void 0 && expiresAt === void 0) {
2649
2957
  throw new Error("`expiryAction` only applies with `expiresAt` — send the date too.");
@@ -2662,7 +2970,9 @@ function createServer() {
2662
2970
  // `delete` alongside an empty date would still run its owner-only check for nothing.
2663
2971
  expiryAction: expiresAt === "" ? void 0 : expiryAction,
2664
2972
  formsEnabled: forms,
2665
- folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder)
2973
+ folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder),
2974
+ invitedOnly,
2975
+ comments
2666
2976
  };
2667
2977
  const settings = await updateSiteSettings(credentials, found.siteId, change);
2668
2978
  const lines = [
@@ -2670,9 +2980,15 @@ function createServer() {
2670
2980
  ` access ${accessOf(settings)}`,
2671
2981
  ` mode ${servingModeOf$1(settings)}`,
2672
2982
  ` forms ${settings.formsEnabled ? "on" : "off"}`,
2983
+ ` comments ${settings.comments ?? "unknown"}`,
2984
+ // The one combination that reads as on and does nothing: invited commenters on a site anybody
2985
+ // can open, which the API keeps (it is the owner's choice for when the site goes private).
2986
+ ...settings.comments === "invited" && settings.invitedOnly === false ? [
2987
+ "Comments are set to invited people, but the site is not invite-only, so nobody can comment yet. Set `invitedOnly: true`, or `comments: anyone`."
2988
+ ] : [],
2673
2989
  ` takedown ${settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`}`,
2674
2990
  ...settings.replacedInviteOnly ? [
2675
- "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."
2991
+ "The password replaced invite-only: the people on its list can no longer get in without it. The list is kept; `invitedOnly: true` switches back."
2676
2992
  ] : [],
2677
2993
  ...settings.live ? ["Reaching every edge takes up to about a minute."] : ["Nothing is published yet, so this applies from the first publish."]
2678
2994
  ];
@@ -2683,6 +2999,7 @@ function createServer() {
2683
2999
  passwordProtected: settings.passwordProtected,
2684
3000
  invitedOnly: settings.invitedOnly,
2685
3001
  formsEnabled: settings.formsEnabled,
3002
+ comments: settings.comments,
2686
3003
  expiresAt: settings.expiresAt,
2687
3004
  expiryAction: settings.expiryAction,
2688
3005
  folderId: settings.folderId
@@ -2748,6 +3065,94 @@ ${promoted.url}`,
2748
3065
  );
2749
3066
  })
2750
3067
  );
3068
+ server.registerTool(
3069
+ "list_people",
3070
+ {
3071
+ title: "List who a site is shared with",
3072
+ description: "Lists the people on a site's list — the ones invite_people added — with what each may do (`view` or `comment`) and whether they have opened it yet. Says whether the list decides who can open the site, which it does only while the site is invite-only.",
3073
+ inputSchema: { site: SITE_INPUT },
3074
+ outputSchema: {
3075
+ invitedOnly: z.boolean().describe("Whether only the people on the list can open the site now."),
3076
+ total: z.number().int().describe("How many are on the list."),
3077
+ limit: z.number().int().describe("The most the plan allows on one site's list."),
3078
+ people: z.array(
3079
+ z.object({
3080
+ email: z.string(),
3081
+ role: z.string().describe("`view` or `comment`."),
3082
+ owner: z.boolean().describe("Whether this is the site's owner."),
3083
+ status: z.string().describe("`opened` or `not-opened`.")
3084
+ })
3085
+ )
3086
+ },
3087
+ annotations: READS_ONLY
3088
+ },
3089
+ ({ site }) => withCredentials(async (credentials) => {
3090
+ const found = await findSite(credentials, site);
3091
+ const list = await listPeople(credentials, found.siteId);
3092
+ const rows = list.people.map(
3093
+ (person) => ` ${person.email} ${person.role}${person.owner ? ", owner" : ""}, ${person.status}`
3094
+ );
3095
+ return report(
3096
+ [
3097
+ `${list.total} of ${list.limit} on ${found.subdomain}'s list. ` + (list.invitedOnly ? "Only they can open it." : "It is not invite-only, so anybody with the link can open it."),
3098
+ ...rows,
3099
+ ...list.total > list.people.length ? [` … and ${list.total - list.people.length} more, in the dashboard`] : []
3100
+ ].join("\n"),
3101
+ { ...list }
3102
+ );
3103
+ })
3104
+ );
3105
+ server.registerTool(
3106
+ "invite_people",
3107
+ {
3108
+ title: "Invite people to a site",
3109
+ description: "Adds email addresses to a site's list and emails each new one an invitation from the account. Use only addresses the person gave you — never guess, infer or look one up. `comment` lets them comment as well as open it. Inviting does not make the site private; update_site's `invitedOnly: true` does. On plans with private sharing; all or nothing — one bad address, or a list past the plan's limit, adds nobody.",
3110
+ inputSchema: {
3111
+ site: SITE_INPUT,
3112
+ emails: z.array(z.string().min(3)).min(1).max(50).describe("The addresses, exactly as the person gave them."),
3113
+ role: z.enum(["view", "comment"]).optional().describe("`view` (the default) to open the site, `comment` to open it and comment.")
3114
+ },
3115
+ outputSchema: {
3116
+ added: z.array(z.string()).describe("Addresses added and emailed."),
3117
+ alreadyListed: z.array(z.string()).describe("Addresses already on the list, left as they were and not emailed again."),
3118
+ total: z.number().int().describe("How many are on the list now.")
3119
+ },
3120
+ annotations: INVITES_PEOPLE
3121
+ },
3122
+ ({ site, emails, role }) => withCredentials(async (credentials) => {
3123
+ const found = await findSite(credentials, site);
3124
+ const invited = await invitePeople(credentials, found.siteId, emails, role ?? "view");
3125
+ const detail = await getSite(credentials, found.siteId);
3126
+ return report(
3127
+ [
3128
+ invited.added.length === 0 ? `Nobody new on ${found.subdomain}'s list.` : `Invited ${invited.added.join(", ")} to ${found.subdomain} — each was emailed.`,
3129
+ ...invited.alreadyListed.length === 0 ? [] : [`Already on the list, not emailed again: ${invited.alreadyListed.join(", ")}.`],
3130
+ ...detail.invitedOnly === false ? [
3131
+ "The site is not invite-only yet, so the list does not decide who opens it. update_site with `invitedOnly: true` does."
3132
+ ] : []
3133
+ ].join("\n"),
3134
+ { ...invited }
3135
+ );
3136
+ })
3137
+ );
3138
+ server.registerTool(
3139
+ "remove_person",
3140
+ {
3141
+ title: "Remove someone from a site's list",
3142
+ description: "Takes one address off a site's list. On an invite-only site their access ends within a minute; putting them back is another invitation, with another email.",
3143
+ inputSchema: {
3144
+ site: SITE_INPUT,
3145
+ email: z.string().min(3).describe("The address to remove, from list_people.")
3146
+ },
3147
+ outputSchema: { email: z.string().describe("The address removed.") },
3148
+ annotations: CHANGES_A_SITE
3149
+ },
3150
+ ({ site, email }) => withCredentials(async (credentials) => {
3151
+ const found = await findSite(credentials, site);
3152
+ await removePerson(credentials, found.siteId, email);
3153
+ return report(`Removed ${email} from ${found.subdomain}'s list.`, { email });
3154
+ })
3155
+ );
2751
3156
  server.registerTool(
2752
3157
  "list_comments",
2753
3158
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",