@drop2run/mcp 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +12 -4
  2. package/dist/index.js +710 -134
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @drop2run/mcp
2
2
 
3
- An MCP server that lets Claude — or any MCP client — publish a static site to
4
- [Drop2Run](https://dropto.run). Give it a folder or an HTML document; it returns a live HTTPS URL.
3
+ An MCP server that lets Claude — or any MCP client — publish what it made to
4
+ [Drop2Run](https://dropto.run): an HTML page, a markdown note, a folder of documents or a built site. It
5
+ returns a live HTTPS URL.
5
6
 
6
7
  Full documentation at [dropto.run/docs/mcp](https://dropto.run/docs/mcp).
7
8
 
@@ -66,6 +67,7 @@ lose it.
66
67
  | `publish_files` | Publishes files Claude wrote — a page, a markdown note, several files together |
67
68
  | `publish_dir` | Publishes a folder, given its absolute path |
68
69
  | `list_sites` | Lists the sites on the account |
70
+ | `list_folders` | Lists the account's folders, each with the path `folder` accepts |
69
71
  | `delete_site` | Deletes a site permanently, subdomain included — requires the subdomain repeated as `confirm` |
70
72
 
71
73
  Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
@@ -77,6 +79,11 @@ afterwards, and a site made under a guessed one has to be deleted by hand. `site
77
79
  opposites — one publishes over a site that exists, the other creates one that does not — so passing both
78
80
  is refused rather than resolved.
79
81
 
82
+ And both take an optional `folder`, which files the new site in one of the account's dashboard folders —
83
+ by path such as `Clients/Acme`, or by the id `list_folders` shows. Like `subdomain` it only describes a new
84
+ site, so it cannot be combined with `site`. It never creates a folder: one that does not exist is refused
85
+ with the list of those that do, and nothing is published.
86
+
80
87
  Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
81
88
  again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
82
89
  to with `replace`.
@@ -89,8 +96,9 @@ it as `subdomain`; say nothing and the name is generated, which is the right def
89
96
  nobody is going to link to.
90
97
 
91
98
  **A site can be documents instead of a built site.** A publish needs an `index.html` at the top level,
92
- or at least one `.md`, `.markdown` or `.pdf` file — those are served through the reader. So a single
93
- note is a whole site, and Claude does not have to wrap it in HTML to publish it.
99
+ or at least one document — `.md`, `.markdown`, `.pdf`, `.docx`, `.xlsx` or `.epub` — and those are
100
+ served through the reader. So a single note is a whole site, and Claude does not have to wrap it in HTML
101
+ to publish it.
94
102
 
95
103
  **`publish_files` is text.** A PDF or an image has to come off a disk with `publish_dir`, because these
96
104
  files arrive as JSON strings.
package/dist/index.js CHANGED
@@ -8,59 +8,6 @@ import { createServer as createServer$1 } from "node:http";
8
8
  import { realpath, readdir, stat, readFile } from "node:fs/promises";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
- async function call(credentials, path, init = {}) {
12
- const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
13
- ...init,
14
- headers: {
15
- ...init.headers,
16
- Authorization: `Bearer ${credentials.token}`,
17
- ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
18
- }
19
- });
20
- if (!response.ok) {
21
- const problem = await response.json().catch(() => null);
22
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
23
- }
24
- return await response.json();
25
- }
26
- async function findSite(credentials, site) {
27
- const wanted = site.trim().toLowerCase();
28
- const found = (await listSites(credentials)).find(
29
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
30
- );
31
- if (found === void 0) {
32
- throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
33
- }
34
- return found;
35
- }
36
- async function deleteSite(credentials, siteId) {
37
- const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
38
- method: "DELETE",
39
- headers: { Authorization: `Bearer ${credentials.token}` }
40
- });
41
- if (!response.ok) {
42
- const problem = await response.json().catch(() => null);
43
- throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
44
- }
45
- }
46
- async function listSites(credentials) {
47
- const body = await call(credentials, "sites");
48
- return body.sites;
49
- }
50
- async function createSite(credentials, subdomain) {
51
- const created = await call(
52
- credentials,
53
- "sites",
54
- {
55
- method: "POST",
56
- // An absent field rather than a null one, because the API reads null, empty and missing the
57
- // same way and a body carrying only what was asked for is the one that reads correctly in a
58
- // log.
59
- body: JSON.stringify(subdomain === void 0 ? {} : { subdomain })
60
- }
61
- );
62
- return { ...created, name: null };
63
- }
64
11
  const TOKEN_VARIABLE = "DROP2RUN_TOKEN";
65
12
  function configPath() {
66
13
  return join(homedir(), ".config", "drop2run", "config.json");
@@ -137,6 +84,131 @@ function missingCredentialsMessage(surface = "cli") {
137
84
  "The token is shown once when it is created and cannot be recovered afterwards."
138
85
  ].join("\n");
139
86
  }
87
+ async function call(credentials, path, init = {}) {
88
+ const response = await fetch(`${credentials.apiBaseUrl}/${path}`, {
89
+ ...init,
90
+ headers: {
91
+ ...init.headers,
92
+ Authorization: `Bearer ${credentials.token}`,
93
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
94
+ }
95
+ });
96
+ if (!response.ok) {
97
+ const problem = await response.json().catch(() => null);
98
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
99
+ }
100
+ return await response.json();
101
+ }
102
+ async function findSite(credentials, site) {
103
+ const found = await lookUpSite(credentials, site);
104
+ if (found === void 0) {
105
+ throw new Error(`No site of yours is called "${site}". \`drop2run ls\` shows what exists.`);
106
+ }
107
+ return found;
108
+ }
109
+ const SITE_ID_SHAPE = /^[0-9a-z]{26}$/i;
110
+ async function lookUpSite(credentials, site) {
111
+ const wanted = site.trim().toLowerCase();
112
+ if (wanted === "") return void 0;
113
+ const { sites } = await call(
114
+ credentials,
115
+ `sites?pageSize=${SITES_PER_REQUEST}&q=${encodeURIComponent(wanted)}`
116
+ );
117
+ const bySubdomain = sites.find((candidate) => candidate.subdomain.toLowerCase() === wanted);
118
+ if (bySubdomain !== void 0) return bySubdomain;
119
+ if (!SITE_ID_SHAPE.test(wanted)) return void 0;
120
+ const response = await fetch(
121
+ `${credentials.apiBaseUrl}/sites/${encodeURIComponent(wanted.toUpperCase())}`,
122
+ { headers: { Authorization: `Bearer ${credentials.token}` } }
123
+ );
124
+ if (response.status === 404) return void 0;
125
+ if (!response.ok) {
126
+ const problem = await response.json().catch(() => null);
127
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
128
+ }
129
+ const detail = await response.json();
130
+ return { siteId: detail.siteId, subdomain: detail.subdomain, url: detail.url, name: detail.name };
131
+ }
132
+ async function deleteSite(credentials, siteId) {
133
+ const response = await fetch(`${credentials.apiBaseUrl}/sites/${encodeURIComponent(siteId)}`, {
134
+ method: "DELETE",
135
+ headers: { Authorization: `Bearer ${credentials.token}` }
136
+ });
137
+ if (!response.ok) {
138
+ const problem = await response.json().catch(() => null);
139
+ throw new Error(problem?.detail ?? `The API answered ${response.status}.`);
140
+ }
141
+ }
142
+ const SITES_PER_REQUEST = 100;
143
+ async function listSites(credentials) {
144
+ const body = await call(
145
+ credentials,
146
+ `sites?pageSize=${SITES_PER_REQUEST}`
147
+ );
148
+ return { sites: body.sites, total: body.total };
149
+ }
150
+ async function createSite(credentials, subdomain, folderId) {
151
+ const created = await call(
152
+ credentials,
153
+ "sites",
154
+ {
155
+ method: "POST",
156
+ // Absent fields rather than null ones, because the API reads null, empty and missing the same
157
+ // way and a body carrying only what was asked for is the one that reads correctly in a log.
158
+ body: JSON.stringify({
159
+ ...subdomain === void 0 ? {} : { subdomain },
160
+ ...folderId === void 0 ? {} : { folderId }
161
+ })
162
+ }
163
+ );
164
+ return { ...created, name: null };
165
+ }
166
+ async function listFolders(credentials) {
167
+ const body = await call(credentials, "site-folders");
168
+ const byId = new Map(body.folders.map((folder) => [folder.folderId, folder]));
169
+ const pathOf = (folderId) => {
170
+ const names = [];
171
+ let cursor = byId.get(folderId);
172
+ while (cursor !== void 0 && names.length <= byId.size) {
173
+ names.unshift(cursor.name);
174
+ cursor = cursor.parentId === null ? void 0 : byId.get(cursor.parentId);
175
+ }
176
+ return names.join("/");
177
+ };
178
+ return body.folders.map((folder) => ({ folderId: folder.folderId, path: pathOf(folder.folderId) })).sort((a, b) => a.path.localeCompare(b.path));
179
+ }
180
+ function comparablePath(path) {
181
+ return path.split("/").map((name) => name.trim()).filter((name) => name !== "").join("/").toLowerCase();
182
+ }
183
+ const FOLDERS_LISTED_ON_MISS = 20;
184
+ async function resolveFolder(credentials, folder) {
185
+ const wanted = comparablePath(folder);
186
+ if (wanted === "") {
187
+ throw new Error(
188
+ `"${folder}" does not name a folder. Leave the folder out to put the site at the top level.`
189
+ );
190
+ }
191
+ const folders = await listFolders(credentials);
192
+ const byId = folders.find((candidate) => candidate.folderId.toLowerCase() === wanted);
193
+ if (byId !== void 0) return byId.folderId;
194
+ const matches = folders.filter((candidate) => comparablePath(candidate.path) === wanted);
195
+ if (matches.length === 1 && matches[0] !== void 0) return matches[0].folderId;
196
+ if (matches.length > 1) {
197
+ throw new Error(
198
+ `More than one folder is at "${folder}", because a folder name can contain "/". Name it by id instead: ${matches.map((match) => match.folderId).join(", ")}.`
199
+ );
200
+ }
201
+ if (folders.length === 0) {
202
+ throw new Error(
203
+ `No folder of yours is called "${folder}" — this account has no folders yet. Make one in the dashboard at ${dashboardUrlFor(credentials.apiBaseUrl)}, or leave the folder out.`
204
+ );
205
+ }
206
+ const listed = folders.slice(0, FOLDERS_LISTED_ON_MISS).map((candidate) => candidate.path);
207
+ const more = folders.length - listed.length;
208
+ throw new Error(
209
+ `No folder of yours is called "${folder}". Your folders: ${listed.join(", ")}${more > 0 ? `, and ${more} more` : ""}.`
210
+ );
211
+ }
140
212
  function newAttempt() {
141
213
  const verifier = randomBytes(32).toString("base64url");
142
214
  return {
@@ -374,6 +446,485 @@ async function problemToError(response) {
374
446
  const message = typeof problem.detail === "string" ? problem.detail : typeof problem.title === "string" ? problem.title : `The server responded with ${response.status}.`;
375
447
  return new DeployError(code, message, problem);
376
448
  }
449
+ const FILE_TYPES = [
450
+ // Documents. The three extensions that make a folder a documents site.
451
+ {
452
+ ext: ".md",
453
+ mime: "text/markdown; charset=utf-8",
454
+ kind: "markdown",
455
+ document: true,
456
+ reader: true,
457
+ editable: true
458
+ },
459
+ {
460
+ ext: ".markdown",
461
+ mime: "text/markdown; charset=utf-8",
462
+ kind: "markdown",
463
+ document: true,
464
+ reader: true,
465
+ editable: true
466
+ },
467
+ {
468
+ ext: ".pdf",
469
+ mime: "application/pdf",
470
+ kind: "pdf",
471
+ document: true,
472
+ reader: true,
473
+ editable: false
474
+ },
475
+ // Office documents and ebooks. Each has its own kind because each needs its own renderer, loaded
476
+ // only when one is opened — `.docx` through mammoth, `.xlsx` through read-excel-file, `.epub`
477
+ // through foliate-js.
478
+ {
479
+ ext: ".docx",
480
+ mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
481
+ kind: "docx",
482
+ document: true,
483
+ reader: true,
484
+ editable: false
485
+ },
486
+ {
487
+ ext: ".xlsx",
488
+ mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
489
+ kind: "xlsx",
490
+ document: true,
491
+ reader: true,
492
+ editable: false
493
+ },
494
+ {
495
+ ext: ".epub",
496
+ mime: "application/epub+zip",
497
+ kind: "epub",
498
+ document: true,
499
+ reader: true,
500
+ editable: false
501
+ },
502
+ // Prose the reader shows preformatted.
503
+ {
504
+ ext: ".txt",
505
+ mime: "text/plain; charset=utf-8",
506
+ kind: "text",
507
+ document: false,
508
+ reader: true,
509
+ editable: true
510
+ },
511
+ {
512
+ ext: ".log",
513
+ mime: "text/plain; charset=utf-8",
514
+ kind: "text",
515
+ document: false,
516
+ reader: true,
517
+ editable: false
518
+ },
519
+ {
520
+ ext: ".csv",
521
+ mime: "text/csv; charset=utf-8",
522
+ kind: "text",
523
+ document: false,
524
+ reader: true,
525
+ editable: true
526
+ },
527
+ // Structured text. Read through the reader inside a folder of notes, which is why these carry
528
+ // `reader: true` while the source formats below do not.
529
+ {
530
+ ext: ".json",
531
+ mime: "application/json",
532
+ kind: "code",
533
+ document: false,
534
+ reader: true,
535
+ editable: true
536
+ },
537
+ {
538
+ ext: ".yaml",
539
+ mime: "text/yaml; charset=utf-8",
540
+ kind: "code",
541
+ document: false,
542
+ reader: true,
543
+ editable: true
544
+ },
545
+ {
546
+ ext: ".yml",
547
+ mime: "text/yaml; charset=utf-8",
548
+ kind: "code",
549
+ document: false,
550
+ reader: true,
551
+ editable: true
552
+ },
553
+ {
554
+ // No registered media type, so it takes text/plain rather than an invented one. The point is
555
+ // only that a browser shows it instead of downloading it.
556
+ ext: ".toml",
557
+ mime: "text/plain; charset=utf-8",
558
+ kind: "code",
559
+ document: false,
560
+ reader: true,
561
+ editable: true
562
+ },
563
+ // Source the viewer highlights. A link straight to one of these hands over the file.
564
+ {
565
+ ext: ".css",
566
+ mime: "text/css; charset=utf-8",
567
+ kind: "code",
568
+ document: false,
569
+ reader: false,
570
+ editable: true
571
+ },
572
+ {
573
+ ext: ".js",
574
+ mime: "text/javascript; charset=utf-8",
575
+ kind: "code",
576
+ document: false,
577
+ reader: false,
578
+ editable: true
579
+ },
580
+ {
581
+ ext: ".ts",
582
+ mime: null,
583
+ kind: "code",
584
+ document: false,
585
+ reader: false,
586
+ editable: false
587
+ },
588
+ {
589
+ ext: ".tsx",
590
+ mime: null,
591
+ kind: "code",
592
+ document: false,
593
+ reader: false,
594
+ editable: false
595
+ },
596
+ {
597
+ ext: ".jsx",
598
+ mime: null,
599
+ kind: "code",
600
+ document: false,
601
+ reader: false,
602
+ editable: false
603
+ },
604
+ {
605
+ ext: ".sh",
606
+ mime: null,
607
+ kind: "code",
608
+ document: false,
609
+ reader: false,
610
+ editable: false
611
+ },
612
+ // The languages an assistant writes most, beyond the web's own. Same row as `.ts`: shown as source
613
+ // when somebody opens one in the reader or publishes it on its own, handed over as a file when a
614
+ // link names it directly, and no media type — adding one changes what a browser does with a
615
+ // published file, which I10 makes a decision of its own rather than a side effect of this list.
616
+ {
617
+ ext: ".py",
618
+ mime: null,
619
+ kind: "code",
620
+ document: false,
621
+ reader: false,
622
+ editable: false
623
+ },
624
+ {
625
+ ext: ".sql",
626
+ mime: null,
627
+ kind: "code",
628
+ document: false,
629
+ reader: false,
630
+ editable: false
631
+ },
632
+ {
633
+ ext: ".go",
634
+ mime: null,
635
+ kind: "code",
636
+ document: false,
637
+ reader: false,
638
+ editable: false
639
+ },
640
+ {
641
+ ext: ".rs",
642
+ mime: null,
643
+ kind: "code",
644
+ document: false,
645
+ reader: false,
646
+ editable: false
647
+ },
648
+ {
649
+ ext: ".java",
650
+ mime: null,
651
+ kind: "code",
652
+ document: false,
653
+ reader: false,
654
+ editable: false
655
+ },
656
+ {
657
+ ext: ".kt",
658
+ mime: null,
659
+ kind: "code",
660
+ document: false,
661
+ reader: false,
662
+ editable: false
663
+ },
664
+ {
665
+ ext: ".swift",
666
+ mime: null,
667
+ kind: "code",
668
+ document: false,
669
+ reader: false,
670
+ editable: false
671
+ },
672
+ {
673
+ ext: ".rb",
674
+ mime: null,
675
+ kind: "code",
676
+ document: false,
677
+ reader: false,
678
+ editable: false
679
+ },
680
+ {
681
+ ext: ".php",
682
+ mime: null,
683
+ kind: "code",
684
+ document: false,
685
+ reader: false,
686
+ editable: false
687
+ },
688
+ {
689
+ ext: ".c",
690
+ mime: null,
691
+ kind: "code",
692
+ document: false,
693
+ reader: false,
694
+ editable: false
695
+ },
696
+ {
697
+ ext: ".h",
698
+ mime: null,
699
+ kind: "code",
700
+ document: false,
701
+ reader: false,
702
+ editable: false
703
+ },
704
+ {
705
+ ext: ".cpp",
706
+ mime: null,
707
+ kind: "code",
708
+ document: false,
709
+ reader: false,
710
+ editable: false
711
+ },
712
+ {
713
+ ext: ".cs",
714
+ mime: null,
715
+ kind: "code",
716
+ document: false,
717
+ reader: false,
718
+ editable: false
719
+ },
720
+ {
721
+ ext: ".r",
722
+ mime: null,
723
+ kind: "code",
724
+ document: false,
725
+ reader: false,
726
+ editable: false
727
+ },
728
+ {
729
+ ext: ".xml",
730
+ mime: "application/xml",
731
+ kind: "code",
732
+ document: false,
733
+ reader: false,
734
+ editable: true
735
+ },
736
+ // Pictures, shown with an img element.
737
+ {
738
+ ext: ".png",
739
+ mime: "image/png",
740
+ kind: "image",
741
+ document: false,
742
+ reader: false,
743
+ editable: false
744
+ },
745
+ {
746
+ ext: ".jpg",
747
+ mime: "image/jpeg",
748
+ kind: "image",
749
+ document: false,
750
+ reader: false,
751
+ editable: false
752
+ },
753
+ {
754
+ ext: ".jpeg",
755
+ mime: "image/jpeg",
756
+ kind: "image",
757
+ document: false,
758
+ reader: false,
759
+ editable: false
760
+ },
761
+ {
762
+ ext: ".gif",
763
+ mime: "image/gif",
764
+ kind: "image",
765
+ document: false,
766
+ reader: false,
767
+ editable: false
768
+ },
769
+ {
770
+ ext: ".webp",
771
+ mime: "image/webp",
772
+ kind: "image",
773
+ document: false,
774
+ reader: false,
775
+ editable: false
776
+ },
777
+ {
778
+ ext: ".avif",
779
+ mime: "image/avif",
780
+ kind: "image",
781
+ document: false,
782
+ reader: false,
783
+ editable: false
784
+ },
785
+ {
786
+ // Text, hand-edited in practice, and script-carrying. Editable anyway: the Worker derives the
787
+ // type at serve time (I10) and already serves whatever was uploaded, so withholding the edit
788
+ // would withhold nothing else.
789
+ ext: ".svg",
790
+ mime: "image/svg+xml",
791
+ kind: "image",
792
+ document: false,
793
+ reader: false,
794
+ editable: true
795
+ },
796
+ {
797
+ ext: ".ico",
798
+ mime: "image/x-icon",
799
+ kind: "image",
800
+ document: false,
801
+ reader: false,
802
+ editable: false
803
+ },
804
+ // A customer's own pages. Never rendered inside the reader.
805
+ {
806
+ ext: ".html",
807
+ mime: "text/html; charset=utf-8",
808
+ kind: "html",
809
+ document: false,
810
+ reader: false,
811
+ editable: true
812
+ },
813
+ {
814
+ // ⚠️ No media type, and that is the state of the code rather than a decision: `TYPES` in
815
+ // `headers.ts` has never held `htm`, so a published `.htm` is served as octet-stream and
816
+ // downloads instead of rendering. Recorded here rather than fixed, because this table was
817
+ // introduced as a refactor and a refactor that changes what a URL answers is not one.
818
+ ext: ".htm",
819
+ mime: null,
820
+ kind: "html",
821
+ document: false,
822
+ reader: false,
823
+ editable: true
824
+ },
825
+ // Everything below renders as nothing — the viewer offers a download — but still needs a media
826
+ // type, because these are the files a static site is built out of.
827
+ {
828
+ ext: ".mjs",
829
+ mime: "text/javascript; charset=utf-8",
830
+ kind: "binary",
831
+ document: false,
832
+ reader: false,
833
+ editable: true
834
+ },
835
+ {
836
+ ext: ".cjs",
837
+ mime: null,
838
+ kind: "binary",
839
+ document: false,
840
+ reader: false,
841
+ editable: true
842
+ },
843
+ {
844
+ ext: ".map",
845
+ mime: "application/json",
846
+ kind: "binary",
847
+ document: false,
848
+ reader: false,
849
+ editable: false
850
+ },
851
+ {
852
+ ext: ".wasm",
853
+ mime: "application/wasm",
854
+ kind: "binary",
855
+ document: false,
856
+ reader: false,
857
+ editable: false
858
+ },
859
+ {
860
+ ext: ".woff2",
861
+ mime: "font/woff2",
862
+ kind: "binary",
863
+ document: false,
864
+ reader: false,
865
+ editable: false
866
+ },
867
+ {
868
+ ext: ".woff",
869
+ mime: "font/woff",
870
+ kind: "binary",
871
+ document: false,
872
+ reader: false,
873
+ editable: false
874
+ },
875
+ {
876
+ ext: ".ttf",
877
+ mime: "font/ttf",
878
+ kind: "binary",
879
+ document: false,
880
+ reader: false,
881
+ editable: false
882
+ },
883
+ {
884
+ ext: ".mp4",
885
+ mime: "video/mp4",
886
+ kind: "binary",
887
+ document: false,
888
+ reader: false,
889
+ editable: false
890
+ },
891
+ {
892
+ ext: ".webm",
893
+ mime: "video/webm",
894
+ kind: "binary",
895
+ document: false,
896
+ reader: false,
897
+ editable: false
898
+ }
899
+ ];
900
+ function extensionsWhere(include) {
901
+ return FILE_TYPES.filter(include).map((type) => type.ext);
902
+ }
903
+ const DOCUMENT_EXTENSIONS$1 = extensionsWhere((type) => type.document);
904
+ const VIEWABLE_EXTENSIONS$1 = extensionsWhere(
905
+ (type) => type.kind !== "binary"
906
+ );
907
+ extensionsWhere((type) => type.reader);
908
+ extensionsWhere((type) => type.editable);
909
+ const WEB_ASSET_EXTENSIONS = [
910
+ ".html",
911
+ ".htm",
912
+ ".js",
913
+ ".mjs",
914
+ ".cjs",
915
+ ".css"
916
+ ];
917
+ const GALLERY_EXTENSIONS = extensionsWhere(
918
+ (type) => type.kind === "image"
919
+ );
920
+ Object.fromEntries(
921
+ FILE_TYPES.filter((type) => type.mime !== null).map((type) => [type.ext, type.mime])
922
+ );
923
+ function extensionOf(path) {
924
+ const name = path.slice(path.lastIndexOf("/") + 1);
925
+ const dot = name.lastIndexOf(".");
926
+ return dot <= 0 ? "" : name.slice(dot).toLowerCase();
927
+ }
377
928
  async function sha256Hex(bytes) {
378
929
  const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
379
930
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -405,52 +956,29 @@ function shouldIgnore(path) {
405
956
  return segments.some((segment) => IGNORED_DIRECTORIES.includes(segment));
406
957
  }
407
958
  const REQUIRED_INDEX = "index.html";
408
- const DOCUMENT_EXTENSIONS = [".md", ".markdown", ".pdf"];
959
+ const DOCUMENT_EXTENSIONS = DOCUMENT_EXTENSIONS$1;
409
960
  function isDocumentPath(path) {
410
961
  const lower = path.toLowerCase();
411
962
  return DOCUMENT_EXTENSIONS.some((extension) => lower.endsWith(extension));
412
963
  }
413
- const VIEWABLE_EXTENSIONS = [
414
- // Documents — these also count at any depth, via DOCUMENT_EXTENSIONS.
415
- ".md",
416
- ".markdown",
417
- ".pdf",
418
- // Prose the reader shows preformatted.
419
- ".txt",
420
- ".log",
421
- ".csv",
422
- // Source the reader shows with highlighting.
423
- ".json",
424
- ".yaml",
425
- ".yml",
426
- ".toml",
427
- ".css",
428
- ".js",
429
- ".ts",
430
- ".tsx",
431
- ".jsx",
432
- ".sh",
433
- ".xml",
434
- // Pictures the reader shows with an img element.
435
- ".png",
436
- ".jpg",
437
- ".jpeg",
438
- ".gif",
439
- ".webp",
440
- ".avif",
441
- ".svg",
442
- ".ico",
443
- // See the note above: a floor for a caller that did not rename it.
444
- ".html",
445
- ".htm"
446
- ];
964
+ const VIEWABLE_EXTENSIONS = VIEWABLE_EXTENSIONS$1;
447
965
  function isViewablePath(path) {
448
966
  const lower = path.toLowerCase();
449
967
  return VIEWABLE_EXTENSIONS.some((extension) => lower.endsWith(extension));
450
968
  }
969
+ function isImageFolder(paths) {
970
+ let pictures = false;
971
+ for (const path of paths) {
972
+ const ext = extensionOf(path);
973
+ if (WEB_ASSET_EXTENSIONS.includes(ext)) return false;
974
+ if (GALLERY_EXTENSIONS.includes(ext)) pictures = true;
975
+ }
976
+ return pictures;
977
+ }
451
978
  function canPublish(paths) {
452
979
  if (paths.some((path) => path === REQUIRED_INDEX)) return true;
453
980
  if (paths.some(isDocumentPath)) return true;
981
+ if (isImageFolder(paths)) return true;
454
982
  return paths.length === 1 && paths[0] !== void 0 && isViewablePath(paths[0]);
455
983
  }
456
984
  function renameOfLoneHtmlPage(paths) {
@@ -479,37 +1007,42 @@ function checkLimits(files, limits) {
479
1007
  `A site needs an ${REQUIRED_INDEX} at its top level, or at least one .md or .pdf file to publish as a documents site — or be a single file that can be displayed on its own, such as a note, a PDF, a page or an image. Drop the folder that contains it, not the folder above.`
480
1008
  );
481
1009
  }
482
- if (!limits) return;
1010
+ const refusal = sizeRefusal(
1011
+ files.map((file) => ({ path: file.path, size: file.bytes.length })),
1012
+ limits
1013
+ );
1014
+ if (refusal) throw refusal;
1015
+ }
1016
+ function sizeRefusal(files, limits) {
1017
+ if (!limits) return null;
483
1018
  if (files.length > limits.maxFiles) {
484
- throw new DeployError(
1019
+ return new DeployError(
485
1020
  ClientErrorCode.TooManyFiles,
486
1021
  `That is ${files.length} files, and this plan allows ${limits.maxFiles}.`,
487
1022
  { limit: limits.maxFiles, actual: files.length }
488
1023
  );
489
1024
  }
490
- const oversized = files.find((file) => file.bytes.length > limits.maxFileBytes);
1025
+ const oversized = files.find((file) => file.size > limits.maxFileBytes);
491
1026
  if (oversized) {
492
- throw new DeployError(
1027
+ return new DeployError(
493
1028
  ClientErrorCode.FileTooLarge,
494
- `${oversized.path} is ${formatBytes(oversized.bytes.length)}, and this plan allows ${formatBytes(limits.maxFileBytes)} per file.`,
1029
+ `${oversized.path} is ${formatBytes(oversized.size)}, and this plan allows ${formatBytes(limits.maxFileBytes)} per file.`,
495
1030
  {
496
1031
  path: oversized.path,
497
1032
  limit: limits.maxFileBytes,
498
- actual: oversized.bytes.length
1033
+ actual: oversized.size
499
1034
  }
500
1035
  );
501
1036
  }
502
- const total = totalBytes(files);
1037
+ const total = files.reduce((sum, file) => sum + file.size, 0);
503
1038
  if (limits.maxSiteBytes !== null && total > limits.maxSiteBytes) {
504
- throw new DeployError(
1039
+ return new DeployError(
505
1040
  ClientErrorCode.TooLarge,
506
1041
  `That drop is ${formatBytes(total)}, and this plan allows ${formatBytes(limits.maxSiteBytes)}.`,
507
1042
  { limit: limits.maxSiteBytes, actual: total }
508
1043
  );
509
1044
  }
510
- }
511
- function totalBytes(files) {
512
- return files.reduce((sum, file) => sum + file.bytes.length, 0);
1045
+ return null;
513
1046
  }
514
1047
  const MAX_NAME_LENGTH = 60;
515
1048
  const HEAD_BYTES = 64 * 1024;
@@ -808,20 +1341,23 @@ function escapesRoot(root, candidate) {
808
1341
  const inside = relative(root, candidate);
809
1342
  return inside === "" || inside.startsWith("..");
810
1343
  }
811
- async function resolveSite(credentials, site, subdomain) {
812
- if (subdomain !== void 0) {
813
- if (site !== void 0) {
814
- throw new Error(
815
- "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
816
- );
817
- }
818
- return await createSite(credentials, subdomain);
1344
+ async function resolveSite(credentials, site, newSite) {
1345
+ const { subdomain, folder } = newSite;
1346
+ if (site !== void 0 && subdomain !== void 0) {
1347
+ throw new Error(
1348
+ "Give either an existing site or a subdomain for a new one, not both: publishing over a site and creating one are different requests."
1349
+ );
819
1350
  }
820
- if (site === void 0) return await createSite(credentials);
821
- const wanted = site.trim().toLowerCase();
822
- const found = (await listSites(credentials)).find(
823
- (candidate) => candidate.siteId.toLowerCase() === wanted || candidate.subdomain.toLowerCase() === wanted
824
- );
1351
+ if (site !== void 0 && folder !== void 0) {
1352
+ throw new Error(
1353
+ "A folder only says where a new site goes, so it cannot be given with an existing site. Leave the site out to create one in that folder, or move the existing site from the dashboard."
1354
+ );
1355
+ }
1356
+ if (site === void 0) {
1357
+ const folderId = folder === void 0 ? void 0 : await resolveFolder(credentials, folder);
1358
+ return await createSite(credentials, subdomain, folderId);
1359
+ }
1360
+ const found = await lookUpSite(credentials, site);
825
1361
  if (found === void 0) {
826
1362
  throw new Error(
827
1363
  `No site of yours is called "${site}". Leave the site out to publish to a new one, or list your sites to see what exists.`
@@ -829,8 +1365,8 @@ async function resolveSite(credentials, site, subdomain) {
829
1365
  }
830
1366
  return found;
831
1367
  }
832
- async function publish(credentials, source, site, onProgress, subdomain) {
833
- const target = await resolveSite(credentials, site, subdomain);
1368
+ async function publish(credentials, source, site, onProgress, newSite = {}) {
1369
+ const target = await resolveSite(credentials, site, newSite);
834
1370
  let files = 0;
835
1371
  let url = target.url;
836
1372
  let unchanged = false;
@@ -860,11 +1396,11 @@ async function publish(credentials, source, site, onProgress, subdomain) {
860
1396
  });
861
1397
  return { url, siteId: target.siteId, subdomain: target.subdomain, files, unchanged };
862
1398
  }
863
- async function publishDirectory(credentials, directory, site, onProgress, subdomain) {
1399
+ async function publishDirectory(credentials, directory, site, onProgress, newSite = {}) {
864
1400
  const found = await stat(directory).catch(() => null);
865
1401
  if (found === null) throw new Error(`There is no folder at ${directory}.`);
866
1402
  if (!found.isDirectory()) throw new Error(`${directory} is a file, not a folder to publish.`);
867
- return await publish(credentials, directorySource(directory), site, onProgress, subdomain);
1403
+ return await publish(credentials, directorySource(directory), site, onProgress, newSite);
868
1404
  }
869
1405
  function cleanPath(path) {
870
1406
  const cleaned = path.trim().replaceAll("\\", "/").replace(/^\.?\//, "");
@@ -878,7 +1414,7 @@ function cleanPath(path) {
878
1414
  }
879
1415
  return cleaned;
880
1416
  }
881
- async function publishFiles(credentials, files, site, subdomain) {
1417
+ async function publishFiles(credentials, files, site, newSite = {}) {
882
1418
  const encoder = new TextEncoder();
883
1419
  const collected = files.map((file) => ({
884
1420
  path: cleanPath(file.path),
@@ -889,7 +1425,7 @@ async function publishFiles(credentials, files, site, subdomain) {
889
1425
  if (seen.has(file.path)) throw new Error(`Two files were given the same path: ${file.path}`);
890
1426
  seen.add(file.path);
891
1427
  }
892
- return publish(credentials, async () => collected, site, void 0, subdomain);
1428
+ return publish(credentials, async () => collected, site, void 0, newSite);
893
1429
  }
894
1430
  const DEFAULT_WAIT_SECONDS = 120;
895
1431
  const MAX_WAIT_SECONDS = 240;
@@ -1067,7 +1603,7 @@ async function signInWithCode(waitSeconds, replace = false) {
1067
1603
  "Call this tool again to keep waiting. Nothing has been changed yet."
1068
1604
  ].join("\n");
1069
1605
  }
1070
- const SERVER_VERSION = "0.5.1";
1606
+ const SERVER_VERSION = "0.7.0";
1071
1607
  const CREDENTIAL_WRITE = {
1072
1608
  readOnlyHint: false,
1073
1609
  destructiveHint: false,
@@ -1105,6 +1641,15 @@ const SITE_OUTPUT = z.object({
1105
1641
  url: z.string().describe("Its live URL."),
1106
1642
  name: z.string().nullable().describe("What it is called, or null when it has no name.")
1107
1643
  });
1644
+ const FOLDER_OUTPUT = z.object({
1645
+ folderId: z.string().describe("Identifier of the folder, which `folder` accepts."),
1646
+ path: z.string().describe(
1647
+ "Every name from the top level down to it, joined with /, which `folder` accepts too."
1648
+ )
1649
+ });
1650
+ const FOLDER_INPUT = z.string().optional().describe(
1651
+ "Folder to file the new site in, when the person asked for one: its path of names such as Clients/Acme, or its id from list_folders. Only for a new site — do not pass it together with `site`; moving an existing site is done in the dashboard. It never creates a folder: a folder that does not exist is refused with the list of those that do, and nothing is published."
1652
+ );
1108
1653
  function say(text, isError = false) {
1109
1654
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
1110
1655
  }
@@ -1139,7 +1684,7 @@ function createServer() {
1139
1684
  { name: "drop2run", version: SERVER_VERSION },
1140
1685
  {
1141
1686
  instructions: [
1142
- "Publishes static sites to Drop2Run.",
1687
+ "Publishes pages, documents and static sites to Drop2Run, and returns a URL.",
1143
1688
  "",
1144
1689
  "Use publish_files for anything you wrote here — a page, a markdown note, several files",
1145
1690
  "together — and publish_dir for a folder that already exists on disk. Leave `site` unset to",
@@ -1152,9 +1697,14 @@ function createServer() {
1152
1697
  "be deleted by hand. `site` and `subdomain` are opposites: one publishes over a site that",
1153
1698
  "exists, the other creates one that does not. Never pass both.",
1154
1699
  "",
1155
- "A site needs an index.html at its top level, or at least one .md, .markdown or .pdf file,",
1156
- "which publishes as a documents site and is read through a viewer. So one page goes at",
1157
- "index.html, and a single note is a whole publish that needs no wrapping in HTML.",
1700
+ "A new site goes at the top level of the dashboard unless `folder` files it somewhere. Pass",
1701
+ "it when the person named a folder, as its path (Clients/Acme) or its id; list_folders shows",
1702
+ "what exists. Like `subdomain`, it only describes a new site, so never pass it with `site`.",
1703
+ "",
1704
+ "A site needs an index.html at its top level, or at least one document (.md, .markdown,",
1705
+ ".pdf, .docx, .xlsx or .epub), which publishes as a documents site and is read through a",
1706
+ "viewer. So one page goes at index.html, and a single note is a whole publish that needs",
1707
+ "no wrapping in HTML.",
1158
1708
  "",
1159
1709
  "Publishing needs an access token. If there is none, call login — it opens a browser and",
1160
1710
  "stores one, and no command line or manual token is involved. Where no browser can be",
@@ -1212,13 +1762,14 @@ function createServer() {
1212
1762
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1213
1763
  subdomain: z.string().optional().describe(
1214
1764
  "Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
1215
- )
1765
+ ),
1766
+ folder: FOLDER_INPUT
1216
1767
  },
1217
1768
  outputSchema: PUBLISH_OUTPUT,
1218
1769
  annotations: REPLACES_A_SITE
1219
1770
  },
1220
- ({ files, site, subdomain }) => withCredentials(async (credentials) => {
1221
- const result = await publishFiles(credentials, files, site, subdomain);
1771
+ ({ files, site, subdomain, folder }) => withCredentials(async (credentials) => {
1772
+ const result = await publishFiles(credentials, files, site, { subdomain, folder });
1222
1773
  return report(describe(result), { ...result });
1223
1774
  })
1224
1775
  );
@@ -1226,19 +1777,23 @@ function createServer() {
1226
1777
  "publish_dir",
1227
1778
  {
1228
1779
  title: "Publish a folder",
1229
- description: "Publishes a folder of static files and returns its URL. The folder needs an index.html at its root, or .md and .pdf files, which are served through the reader.",
1780
+ description: "Publishes a folder from disk and returns its URL. The folder needs an index.html at its root, or documents (.md, .pdf, .docx, .xlsx, .epub), which are served through the reader.",
1230
1781
  inputSchema: {
1231
1782
  path: z.string().min(1).describe("Absolute path of the folder to publish."),
1232
1783
  site: z.string().optional().describe("Subdomain or site id to publish over. Omit to create a new site."),
1233
1784
  subdomain: z.string().optional().describe(
1234
1785
  "Subdomain to create the new site under, when the person asked for a particular address. Only for a new site: leave it out to publish over an existing one, and do not pass it together with `site`. Ask rather than inventing one — the name is the URL somebody will be given, it cannot be changed afterwards, and an unused site made under a guessed name has to be deleted by hand."
1235
- )
1786
+ ),
1787
+ folder: FOLDER_INPUT
1236
1788
  },
1237
1789
  outputSchema: PUBLISH_OUTPUT,
1238
1790
  annotations: REPLACES_A_SITE
1239
1791
  },
1240
- ({ path, site, subdomain }) => withCredentials(async (credentials) => {
1241
- const result = await publishDirectory(credentials, path, site, void 0, subdomain);
1792
+ ({ path, site, subdomain, folder }) => withCredentials(async (credentials) => {
1793
+ const result = await publishDirectory(credentials, path, site, void 0, {
1794
+ subdomain,
1795
+ folder
1796
+ });
1242
1797
  return report(describe(result), { ...result });
1243
1798
  })
1244
1799
  );
@@ -1279,15 +1834,36 @@ function createServer() {
1279
1834
  title: "List sites",
1280
1835
  description: "Lists the sites on this account, so a publish can go to one of them.",
1281
1836
  inputSchema: {},
1282
- outputSchema: { sites: z.array(SITE_OUTPUT).describe("Every site on this account.") },
1837
+ outputSchema: {
1838
+ sites: z.array(SITE_OUTPUT).describe("The account's sites, newest first — every one unless `total` is larger."),
1839
+ total: z.number().int().describe("How many sites the account holds. Larger than `sites` only past 100.")
1840
+ },
1283
1841
  annotations: READS_ONLY
1284
1842
  },
1285
1843
  () => withCredentials(async (credentials) => {
1286
- const sites = await listSites(credentials);
1844
+ const { sites, total } = await listSites(credentials);
1287
1845
  const text = sites.length === 0 ? "No sites on this account yet. Publishing without a `site` creates one." : sites.map(
1288
1846
  (site) => `${site.subdomain} — ${site.url}${site.name ? ` — ${site.name}` : ""}`
1289
- ).join("\n");
1290
- return report(text, { sites: sites.map((site) => ({ ...site })) });
1847
+ ).join("\n") + (total > sites.length ? `
1848
+ … and ${total - sites.length} more; these are the newest ${sites.length} of ${total}.` : "");
1849
+ return report(text, { sites: sites.map((site) => ({ ...site })), total });
1850
+ })
1851
+ );
1852
+ server.registerTool(
1853
+ "list_folders",
1854
+ {
1855
+ title: "List folders",
1856
+ description: "Lists the folders on this account, each with the path a publish's `folder` accepts. Folders are made and rearranged in the dashboard; no tool here creates one.",
1857
+ inputSchema: {},
1858
+ outputSchema: {
1859
+ folders: z.array(FOLDER_OUTPUT).describe("Every folder on this account, ordered by path.")
1860
+ },
1861
+ annotations: READS_ONLY
1862
+ },
1863
+ () => withCredentials(async (credentials) => {
1864
+ const folders = await listFolders(credentials);
1865
+ const text = folders.length === 0 ? "No folders on this account. A new site goes at the top level; folders are made in the dashboard." : folders.map((folder) => `${folder.path} — ${folder.folderId}`).join("\n");
1866
+ return report(text, { folders: folders.map((folder) => ({ ...folder })) });
1291
1867
  })
1292
1868
  );
1293
1869
  return server;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@drop2run/mcp",
3
- "version": "0.5.1",
4
- "description": "MCP server that publishes a folder or an HTML page to Drop2Run and returns a live URL.",
3
+ "version": "0.7.0",
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",
7
7
  "type": "module",