@drop2run/mcp 0.10.0 → 0.11.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 +216 -43
  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
  "",
@@ -816,13 +856,14 @@ const FILE_TYPES = [
816
856
  reader: true,
817
857
  editable: true
818
858
  },
819
- // Source the viewer highlights. A link straight to one of these hands over the file.
859
+ // Source the viewer highlights. Inside a documents site a link straight to one opens it in the
860
+ // reader; on an ordinary site, and with `?raw`, it hands over the file.
820
861
  {
821
862
  ext: ".css",
822
863
  mime: "text/css; charset=utf-8",
823
864
  kind: "code",
824
865
  document: false,
825
- reader: false,
866
+ reader: true,
826
867
  editable: true
827
868
  },
828
869
  {
@@ -830,7 +871,7 @@ const FILE_TYPES = [
830
871
  mime: "text/javascript; charset=utf-8",
831
872
  kind: "code",
832
873
  document: false,
833
- reader: false,
874
+ reader: true,
834
875
  editable: true
835
876
  },
836
877
  {
@@ -838,7 +879,7 @@ const FILE_TYPES = [
838
879
  mime: null,
839
880
  kind: "code",
840
881
  document: false,
841
- reader: false,
882
+ reader: true,
842
883
  editable: false
843
884
  },
844
885
  {
@@ -846,7 +887,7 @@ const FILE_TYPES = [
846
887
  mime: null,
847
888
  kind: "code",
848
889
  document: false,
849
- reader: false,
890
+ reader: true,
850
891
  editable: false
851
892
  },
852
893
  {
@@ -854,7 +895,7 @@ const FILE_TYPES = [
854
895
  mime: null,
855
896
  kind: "code",
856
897
  document: false,
857
- reader: false,
898
+ reader: true,
858
899
  editable: false
859
900
  },
860
901
  {
@@ -862,19 +903,18 @@ const FILE_TYPES = [
862
903
  mime: null,
863
904
  kind: "code",
864
905
  document: false,
865
- reader: false,
906
+ reader: true,
866
907
  editable: false
867
908
  },
868
909
  // 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
910
+ // 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
911
  // published file, which I10 makes a decision of its own rather than a side effect of this list.
872
912
  {
873
913
  ext: ".py",
874
914
  mime: null,
875
915
  kind: "code",
876
916
  document: false,
877
- reader: false,
917
+ reader: true,
878
918
  editable: false
879
919
  },
880
920
  {
@@ -882,7 +922,7 @@ const FILE_TYPES = [
882
922
  mime: null,
883
923
  kind: "code",
884
924
  document: false,
885
- reader: false,
925
+ reader: true,
886
926
  editable: false
887
927
  },
888
928
  {
@@ -890,7 +930,7 @@ const FILE_TYPES = [
890
930
  mime: null,
891
931
  kind: "code",
892
932
  document: false,
893
- reader: false,
933
+ reader: true,
894
934
  editable: false
895
935
  },
896
936
  {
@@ -898,7 +938,7 @@ const FILE_TYPES = [
898
938
  mime: null,
899
939
  kind: "code",
900
940
  document: false,
901
- reader: false,
941
+ reader: true,
902
942
  editable: false
903
943
  },
904
944
  {
@@ -906,7 +946,7 @@ const FILE_TYPES = [
906
946
  mime: null,
907
947
  kind: "code",
908
948
  document: false,
909
- reader: false,
949
+ reader: true,
910
950
  editable: false
911
951
  },
912
952
  {
@@ -914,7 +954,7 @@ const FILE_TYPES = [
914
954
  mime: null,
915
955
  kind: "code",
916
956
  document: false,
917
- reader: false,
957
+ reader: true,
918
958
  editable: false
919
959
  },
920
960
  {
@@ -922,7 +962,7 @@ const FILE_TYPES = [
922
962
  mime: null,
923
963
  kind: "code",
924
964
  document: false,
925
- reader: false,
965
+ reader: true,
926
966
  editable: false
927
967
  },
928
968
  {
@@ -930,7 +970,7 @@ const FILE_TYPES = [
930
970
  mime: null,
931
971
  kind: "code",
932
972
  document: false,
933
- reader: false,
973
+ reader: true,
934
974
  editable: false
935
975
  },
936
976
  {
@@ -938,7 +978,7 @@ const FILE_TYPES = [
938
978
  mime: null,
939
979
  kind: "code",
940
980
  document: false,
941
- reader: false,
981
+ reader: true,
942
982
  editable: false
943
983
  },
944
984
  {
@@ -946,7 +986,7 @@ const FILE_TYPES = [
946
986
  mime: null,
947
987
  kind: "code",
948
988
  document: false,
949
- reader: false,
989
+ reader: true,
950
990
  editable: false
951
991
  },
952
992
  {
@@ -954,7 +994,7 @@ const FILE_TYPES = [
954
994
  mime: null,
955
995
  kind: "code",
956
996
  document: false,
957
- reader: false,
997
+ reader: true,
958
998
  editable: false
959
999
  },
960
1000
  {
@@ -962,7 +1002,7 @@ const FILE_TYPES = [
962
1002
  mime: null,
963
1003
  kind: "code",
964
1004
  document: false,
965
- reader: false,
1005
+ reader: true,
966
1006
  editable: false
967
1007
  },
968
1008
  {
@@ -970,7 +1010,7 @@ const FILE_TYPES = [
970
1010
  mime: null,
971
1011
  kind: "code",
972
1012
  document: false,
973
- reader: false,
1013
+ reader: true,
974
1014
  editable: false
975
1015
  },
976
1016
  {
@@ -978,7 +1018,7 @@ const FILE_TYPES = [
978
1018
  mime: null,
979
1019
  kind: "code",
980
1020
  document: false,
981
- reader: false,
1021
+ reader: true,
982
1022
  editable: false
983
1023
  },
984
1024
  {
@@ -986,7 +1026,7 @@ const FILE_TYPES = [
986
1026
  mime: "application/xml",
987
1027
  kind: "code",
988
1028
  document: false,
989
- reader: false,
1029
+ reader: true,
990
1030
  editable: true
991
1031
  },
992
1032
  // Pictures, shown with an img element.
@@ -1057,25 +1097,25 @@ const FILE_TYPES = [
1057
1097
  reader: false,
1058
1098
  editable: false
1059
1099
  },
1060
- // A customer's own pages. Never rendered inside the reader.
1100
+ // A customer's own pages. Inside a documents site a navigation opens the reader, which frames the
1101
+ // page; `?raw` and every ordinary site serve the page itself.
1061
1102
  {
1062
1103
  ext: ".html",
1063
1104
  mime: "text/html; charset=utf-8",
1064
1105
  kind: "html",
1065
1106
  document: false,
1066
- reader: false,
1107
+ reader: true,
1067
1108
  editable: true
1068
1109
  },
1069
1110
  {
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.
1111
+ // An HTML page in every respect the Worker decides by extension: media type, the revalidated
1112
+ // cache policy invariant I7 requires, and the badge's `isHtml`. Missing from `TYPES` it used to
1113
+ // download, which inside the reader meant a frame that downloaded instead of showing a page.
1074
1114
  ext: ".htm",
1075
- mime: null,
1115
+ mime: "text/html; charset=utf-8",
1076
1116
  kind: "html",
1077
1117
  document: false,
1078
- reader: false,
1118
+ reader: true,
1079
1119
  editable: true
1080
1120
  },
1081
1121
  // Everything below renders as nothing — the viewer offers a download — but still needs a media
@@ -2154,7 +2194,7 @@ async function signInWithCode(waitSeconds, replace = false) {
2154
2194
  "Call this tool again to keep waiting. Nothing has been changed yet."
2155
2195
  ].join("\n");
2156
2196
  }
2157
- const SERVER_VERSION = "0.10.0";
2197
+ const SERVER_VERSION = "0.11.0";
2158
2198
  const CREDENTIAL_WRITE = {
2159
2199
  readOnlyHint: false,
2160
2200
  destructiveHint: false,
@@ -2204,6 +2244,12 @@ const ANSWERS_A_COMMENT = {
2204
2244
  idempotentHint: false,
2205
2245
  openWorldHint: true
2206
2246
  };
2247
+ const INVITES_PEOPLE = {
2248
+ readOnlyHint: false,
2249
+ destructiveHint: false,
2250
+ idempotentHint: true,
2251
+ openWorldHint: true
2252
+ };
2207
2253
  const MARKS_A_THREAD = {
2208
2254
  readOnlyHint: false,
2209
2255
  destructiveHint: false,
@@ -2292,6 +2338,9 @@ const SETTINGS_OUTPUT = {
2292
2338
  "Whether only invited people can open it, or null when that could not be read. Null does not mean public."
2293
2339
  ),
2294
2340
  formsEnabled: z.boolean().describe("Whether it accepts form submissions."),
2341
+ comments: z.enum(["off", "invited", "anyone"]).nullable().describe(
2342
+ "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."
2343
+ ),
2295
2344
  expiresAt: z.string().nullable().describe("When it is scheduled to come down, or null."),
2296
2345
  expiryAction: z.string().nullable().describe("What the takedown does: `pause` or `delete`, or null when none is scheduled."),
2297
2346
  folderId: z.string().nullable().describe("The folder it is filed in, or null at the top level.")
@@ -2320,10 +2369,12 @@ function structuredSite(site) {
2320
2369
  expiresAt: site.expiresAt,
2321
2370
  expiryAction: site.expiryAction,
2322
2371
  folderId: site.folderId,
2372
+ comments: site.comments,
2323
2373
  available: {
2324
2374
  password: site.passwordProtectionAvailable,
2325
2375
  forms: site.formsAvailable,
2326
- scheduledTakedown: site.scheduledExpiryAvailable
2376
+ scheduledTakedown: site.scheduledExpiryAvailable,
2377
+ comments: site.commentsAvailable
2327
2378
  },
2328
2379
  versions: site.deploys.map((deploy2) => ({
2329
2380
  deployId: deploy2.deployId,
@@ -2426,6 +2477,12 @@ function createServer() {
2426
2477
  "a password or a date only when the person gave one, and scheduling a deletion asks for",
2427
2478
  "`confirm` the way delete_site does.",
2428
2479
  "",
2480
+ "To share a site for review: invite_people with the addresses the person gave (each is",
2481
+ "emailed), update_site `invitedOnly: true` so only they can open it, and `comments` to let",
2482
+ "them comment — `invited` for the people invited with the comment role, `anyone` for",
2483
+ "anybody signed in. list_people shows the list; remove_person takes someone off it.",
2484
+ "Never invite an address the person did not give you.",
2485
+ "",
2429
2486
  "People viewing a site can leave comments pinned to its pages. list_comments reads them,",
2430
2487
  "with the page and the words each one points at, so the source can be found and changed.",
2431
2488
  'After changing it, publish first and reply second — a reply that says "fixed" before the',
@@ -2597,7 +2654,8 @@ function createServer() {
2597
2654
  available: z.object({
2598
2655
  password: z.boolean(),
2599
2656
  forms: z.boolean(),
2600
- scheduledTakedown: z.boolean()
2657
+ scheduledTakedown: z.boolean(),
2658
+ comments: z.boolean()
2601
2659
  }).describe("Which settings the account's plan includes."),
2602
2660
  versions: z.array(VERSION_OUTPUT).describe("Its versions, newest first.")
2603
2661
  },
@@ -2636,6 +2694,12 @@ function createServer() {
2636
2694
  folder: z.string().optional().describe(
2637
2695
  "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
2696
  ),
2697
+ invitedOnly: z.boolean().optional().describe(
2698
+ "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."
2699
+ ),
2700
+ comments: z.enum(["off", "invited", "anyone"]).optional().describe(
2701
+ "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."
2702
+ ),
2639
2703
  confirm: z.string().optional().describe(
2640
2704
  "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
2705
  )
@@ -2643,7 +2707,19 @@ function createServer() {
2643
2707
  outputSchema: SETTINGS_OUTPUT,
2644
2708
  annotations: CHANGES_A_SITE
2645
2709
  },
2646
- ({ site, name, mode, password, forms, expiresAt, expiryAction, folder, confirm }) => withCredentials(async (credentials) => {
2710
+ ({
2711
+ site,
2712
+ name,
2713
+ mode,
2714
+ password,
2715
+ forms,
2716
+ expiresAt,
2717
+ expiryAction,
2718
+ folder,
2719
+ invitedOnly,
2720
+ comments,
2721
+ confirm
2722
+ }) => withCredentials(async (credentials) => {
2647
2723
  const found = await findSite(credentials, site);
2648
2724
  if (expiryAction !== void 0 && expiresAt === void 0) {
2649
2725
  throw new Error("`expiryAction` only applies with `expiresAt` — send the date too.");
@@ -2662,7 +2738,9 @@ function createServer() {
2662
2738
  // `delete` alongside an empty date would still run its owner-only check for nothing.
2663
2739
  expiryAction: expiresAt === "" ? void 0 : expiryAction,
2664
2740
  formsEnabled: forms,
2665
- folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder)
2741
+ folderId: folder === void 0 ? void 0 : await resolveFolderTarget(credentials, folder),
2742
+ invitedOnly,
2743
+ comments
2666
2744
  };
2667
2745
  const settings = await updateSiteSettings(credentials, found.siteId, change);
2668
2746
  const lines = [
@@ -2670,9 +2748,15 @@ function createServer() {
2670
2748
  ` access ${accessOf(settings)}`,
2671
2749
  ` mode ${servingModeOf$1(settings)}`,
2672
2750
  ` forms ${settings.formsEnabled ? "on" : "off"}`,
2751
+ ` comments ${settings.comments ?? "unknown"}`,
2752
+ // The one combination that reads as on and does nothing: invited commenters on a site anybody
2753
+ // can open, which the API keeps (it is the owner's choice for when the site goes private).
2754
+ ...settings.comments === "invited" && settings.invitedOnly === false ? [
2755
+ "Comments are set to invited people, but the site is not invite-only, so nobody can comment yet. Set `invitedOnly: true`, or `comments: anyone`."
2756
+ ] : [],
2673
2757
  ` takedown ${settings.expiresAt === null ? "none" : `${settings.expiresAt} (${settings.expiryAction})`}`,
2674
2758
  ...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."
2759
+ "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
2760
  ] : [],
2677
2761
  ...settings.live ? ["Reaching every edge takes up to about a minute."] : ["Nothing is published yet, so this applies from the first publish."]
2678
2762
  ];
@@ -2683,6 +2767,7 @@ function createServer() {
2683
2767
  passwordProtected: settings.passwordProtected,
2684
2768
  invitedOnly: settings.invitedOnly,
2685
2769
  formsEnabled: settings.formsEnabled,
2770
+ comments: settings.comments,
2686
2771
  expiresAt: settings.expiresAt,
2687
2772
  expiryAction: settings.expiryAction,
2688
2773
  folderId: settings.folderId
@@ -2748,6 +2833,94 @@ ${promoted.url}`,
2748
2833
  );
2749
2834
  })
2750
2835
  );
2836
+ server.registerTool(
2837
+ "list_people",
2838
+ {
2839
+ title: "List who a site is shared with",
2840
+ 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.",
2841
+ inputSchema: { site: SITE_INPUT },
2842
+ outputSchema: {
2843
+ invitedOnly: z.boolean().describe("Whether only the people on the list can open the site now."),
2844
+ total: z.number().int().describe("How many are on the list."),
2845
+ limit: z.number().int().describe("The most the plan allows on one site's list."),
2846
+ people: z.array(
2847
+ z.object({
2848
+ email: z.string(),
2849
+ role: z.string().describe("`view` or `comment`."),
2850
+ owner: z.boolean().describe("Whether this is the site's owner."),
2851
+ status: z.string().describe("`opened` or `not-opened`.")
2852
+ })
2853
+ )
2854
+ },
2855
+ annotations: READS_ONLY
2856
+ },
2857
+ ({ site }) => withCredentials(async (credentials) => {
2858
+ const found = await findSite(credentials, site);
2859
+ const list = await listPeople(credentials, found.siteId);
2860
+ const rows = list.people.map(
2861
+ (person) => ` ${person.email} ${person.role}${person.owner ? ", owner" : ""}, ${person.status}`
2862
+ );
2863
+ return report(
2864
+ [
2865
+ `${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."),
2866
+ ...rows,
2867
+ ...list.total > list.people.length ? [` … and ${list.total - list.people.length} more, in the dashboard`] : []
2868
+ ].join("\n"),
2869
+ { ...list }
2870
+ );
2871
+ })
2872
+ );
2873
+ server.registerTool(
2874
+ "invite_people",
2875
+ {
2876
+ title: "Invite people to a site",
2877
+ 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.",
2878
+ inputSchema: {
2879
+ site: SITE_INPUT,
2880
+ emails: z.array(z.string().min(3)).min(1).max(50).describe("The addresses, exactly as the person gave them."),
2881
+ role: z.enum(["view", "comment"]).optional().describe("`view` (the default) to open the site, `comment` to open it and comment.")
2882
+ },
2883
+ outputSchema: {
2884
+ added: z.array(z.string()).describe("Addresses added and emailed."),
2885
+ alreadyListed: z.array(z.string()).describe("Addresses already on the list, left as they were and not emailed again."),
2886
+ total: z.number().int().describe("How many are on the list now.")
2887
+ },
2888
+ annotations: INVITES_PEOPLE
2889
+ },
2890
+ ({ site, emails, role }) => withCredentials(async (credentials) => {
2891
+ const found = await findSite(credentials, site);
2892
+ const invited = await invitePeople(credentials, found.siteId, emails, role ?? "view");
2893
+ const detail = await getSite(credentials, found.siteId);
2894
+ return report(
2895
+ [
2896
+ invited.added.length === 0 ? `Nobody new on ${found.subdomain}'s list.` : `Invited ${invited.added.join(", ")} to ${found.subdomain} — each was emailed.`,
2897
+ ...invited.alreadyListed.length === 0 ? [] : [`Already on the list, not emailed again: ${invited.alreadyListed.join(", ")}.`],
2898
+ ...detail.invitedOnly === false ? [
2899
+ "The site is not invite-only yet, so the list does not decide who opens it. update_site with `invitedOnly: true` does."
2900
+ ] : []
2901
+ ].join("\n"),
2902
+ { ...invited }
2903
+ );
2904
+ })
2905
+ );
2906
+ server.registerTool(
2907
+ "remove_person",
2908
+ {
2909
+ title: "Remove someone from a site's list",
2910
+ 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.",
2911
+ inputSchema: {
2912
+ site: SITE_INPUT,
2913
+ email: z.string().min(3).describe("The address to remove, from list_people.")
2914
+ },
2915
+ outputSchema: { email: z.string().describe("The address removed.") },
2916
+ annotations: CHANGES_A_SITE
2917
+ },
2918
+ ({ site, email }) => withCredentials(async (credentials) => {
2919
+ const found = await findSite(credentials, site);
2920
+ await removePerson(credentials, found.siteId, email);
2921
+ return report(`Removed ${email} from ${found.subdomain}'s list.`, { email });
2922
+ })
2923
+ );
2751
2924
  server.registerTool(
2752
2925
  "list_comments",
2753
2926
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",