proton-drive-mcp 1.0.28 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +8 -8
- package/dist/cli.js +31 -20
- package/dist/index.js +57 -31
- package/dist/services/drive.js +101 -41
- package/dist/utils/subprocess.js +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.30 — 2026-08-21
|
|
4
|
+
|
|
5
|
+
**Root cause of this release:** every prior "verification" of this package (v1.0.24 through v1.0.29) diffed the CLI's *command source* against GitHub tags — confirming the right argv gets built — but never actually ran the CLI and inspected its *response* JSON. This release does that, live, against a real authenticated account. It found that several response-shape assumptions were wrong from day one, in ways no amount of source-reading would have caught, because the exact wire format (Result-wrapped names, nested fields, a CLI-side `undefined`-text quirk) isn't visible from the command classes — it only shows up in the actual output.
|
|
6
|
+
|
|
7
|
+
### Fixed — confirmed live against CLI v0.8.0 + a real Proton Drive account
|
|
8
|
+
|
|
9
|
+
- **`drive_list` (and `drive_list_trash`, which reuses it) — most severe.** Every item's `name` rendered as the literal string `"[object Object]"`, and every item's `path` showed the *same* value (the parent folder) regardless of the actual file. Root cause: the CLI wraps `name` in a cryptographic-verification result (`{ok, value}`), not a plain string, and doesn't return a `path` field at all. Now unwraps the name correctly and computes the path by joining the listed folder with the (correct) name. Also fixed: `size` was always `undefined` (real field is `totalStorageSize`, not `size`), `modifiedAt` was always `undefined` (real field is `modificationTime`), `mimeType` was always `undefined` (real field is `mediaType`).
|
|
10
|
+
- **`drive_share_status` — most severe.** Threw `DriveParseError` on any item with no sharing at all — extremely common — because the CLI prints the literal 5 characters `undefined` (not valid JSON) when there's no share record, and `JSON.parse("undefined")` throws. Fixed at the subprocess layer: that literal text is now treated the same as empty output. Also fixed, for the case where an item *is* shared: `members[].email` and `members[].addedAt` read from fields that don't exist (real names: `inviteeEmail`, `invitationTime`); `isShared` and `shareUrl` were read from fields that don't exist on the real `ShareResult` type at all (`shareUrl` is nested under `urlAccess.url`; `isShared` must be computed from whether any members/invitations/urlAccess exist).
|
|
11
|
+
- **`drive_share_remove_url` — threw on success.** Same literal-`undefined` CLI quirk as above; a successful removal was reported as a parse error. Fixed by the same subprocess-layer change.
|
|
12
|
+
- **`drive_upload` / `drive_download` — silently misreported failures.** Parsed `{uploaded, skipped, failed}`/`{downloaded}` field names that don't exist on the real response (`TransferSummary`: `transferredItems`, `transferredBytes`, `skippedItems`, `failedItems`). Because `failed`/`downloaded` always read as `0`, a genuinely failed upload or download could be reported as a full success — the worst kind of bug for these two tools. Now reads the real fields.
|
|
13
|
+
- **`photos_list_albums`** — `name` had the same `{ok,value}` unwrap bug as `drive_list`. `photoCount` was always `0` regardless of how many photos an album actually had — real field is nested under `album.photoCount`, not top-level.
|
|
14
|
+
- **`drive_list_invitations`** — `invitedByEmail` had the same unwrap bug (`addedByEmail` is also a verified `{ok,value}` result, not a plain string).
|
|
15
|
+
- **`drive_version`** — returned the raw token straight from CLI output, e.g. `cli-drive@0.8.0+06e8c605`, instead of a clean `0.8.0`. Any comparison against a plain semver string would never match. Now strips the package-name prefix and build hash, the same way the CLI's own update-check does internally.
|
|
16
|
+
|
|
17
|
+
### Verified correct, no change needed
|
|
18
|
+
`drive_auth_status`, `drive_info`, `drive_rename`, `photos_list_timeline`, `photos_list_album_photos`, `photos_download`, `photos_upload`, `drive_share_set_url`, `drive_copy`, `drive_move` (including its move+rename composition), `drive_mkdir`, `drive_trash`/`restore`/`delete`/`empty_trash`, `photos_create_album`/`update_album`/`delete_album`/`add_to_album`/`remove_from_album` — all exercised live end-to-end (create → verify → clean up) against the real account, no discrepancies found.
|
|
19
|
+
|
|
20
|
+
### Testing
|
|
21
|
+
120 tests passing (up from 113). Every rewritten test now uses JSON fixtures captured verbatim from real CLI output, not hand-written guesses — the exact class of mistake that caused this release.
|
|
22
|
+
|
|
23
|
+
## 1.0.29 — 2026-08-13
|
|
24
|
+
|
|
25
|
+
Tracks the Proton Drive CLI v0.8.0 release, which reworked upload/download conflict strategies for clarity (per Proton's release notes: general terms like "overwrite" were leading to confusion, so strategies are now explicit and set separately per file/folder). Verified against the `cli/v0.8.0` tag — a full tree diff against `cli/v0.7.0` (what 1.0.27/1.0.28 were built against) confirmed this is the *only* functional change; every other command this package wraps is untouched.
|
|
26
|
+
|
|
27
|
+
### Fixed — breaking upstream change, adapted
|
|
28
|
+
- **`drive_upload`** — the CLI removed the unified `--conflict-strategy` flag entirely. It's now `fileConflictStrategy` (skip/create-new-revision/rename/replace) and `folderConflictStrategy` (skip/merge/rename/replace), set independently. `create-new-revision` is new — uploads as a new version of the existing file instead of forcing a choice between overwrite and skip.
|
|
29
|
+
- **`drive_download`** — same split: `fileConflictStrategy` (skip/rename/remove) and `folderConflictStrategy` (skip/merge/rename/remove). `remove` replaces the old `replace`/`overwrite` naming — it deletes the local copy and downloads the remote one in its place.
|
|
30
|
+
- **`photos_upload`** — flag name (`--conflict-strategy`) unchanged, but `keep-both` was renamed to `rename`; `merge` was never valid here (photos have no folder concept) and remains excluded.
|
|
31
|
+
- **`photos_download`** — flag name unchanged; values changed from skip/replace/keep-both to skip/remove/rename (folder handling is auto-merged internally by the CLI, not user-settable, since a photo download's only folder is the root).
|
|
32
|
+
|
|
33
|
+
### Unaffected
|
|
34
|
+
`drive_version`'s plain-text output format is unchanged (verified against the new dedicated `version.ts`, which also added the "you're up to date" / update-available check — no parsing changes needed on this end). Every other tool — filesystem, trash, sharing, invitations, albums — calls commands untouched by v0.8.0.
|
|
35
|
+
|
|
3
36
|
## 1.0.28 — 2026-08-11
|
|
4
37
|
|
|
5
38
|
Closes the last 2 gaps found in the full CLI command audit — every scriptable Proton Drive CLI command now has a matching tool.
|
package/README.md
CHANGED
|
@@ -163,10 +163,10 @@ proton-drive-cli info /my-files/report.pdf # full metadata, incl. revision
|
|
|
163
163
|
proton-drive-cli mkdir /my-files/NewFolder
|
|
164
164
|
|
|
165
165
|
proton-drive-cli upload ./report.pdf /my-files/Reports
|
|
166
|
-
proton-drive-cli upload ./dist /my-files/Releases --conflict replace
|
|
166
|
+
proton-drive-cli upload ./dist /my-files/Releases --file-conflict replace --folder-conflict merge
|
|
167
167
|
|
|
168
168
|
proton-drive-cli download /my-files/report.pdf ./local/report.pdf
|
|
169
|
-
proton-drive-cli download /my-files/Reports ./local/Reports --conflict
|
|
169
|
+
proton-drive-cli download /my-files/Reports ./local/Reports --file-conflict rename --folder-conflict merge
|
|
170
170
|
|
|
171
171
|
proton-drive-cli rename /my-files/old-name.pdf new-name.pdf # in place, no move
|
|
172
172
|
proton-drive-cli move /my-files/old-name.pdf /my-files/new-name.pdf
|
|
@@ -208,7 +208,7 @@ proton-drive-cli album update /albums/Summer2026 --name "Summer Trip"
|
|
|
208
208
|
proton-drive-cli album add-photo /albums/Summer2026 /photos/IMG_001.jpg
|
|
209
209
|
|
|
210
210
|
proton-drive-cli photo timeline
|
|
211
|
-
proton-drive-cli photo download /photos/IMG_001.jpg ./local/photos --conflict
|
|
211
|
+
proton-drive-cli photo download /photos/IMG_001.jpg ./local/photos --conflict rename
|
|
212
212
|
proton-drive-cli photo upload ./camera-roll --conflict skip
|
|
213
213
|
```
|
|
214
214
|
|
|
@@ -216,7 +216,7 @@ proton-drive-cli photo upload ./camera-roll --conflict skip
|
|
|
216
216
|
|
|
217
217
|
```bash
|
|
218
218
|
# Backup build output after CI
|
|
219
|
-
proton-drive-cli upload ./dist /my-files/Releases/$(date +%Y-%m-%d) --conflict
|
|
219
|
+
proton-drive-cli upload ./dist /my-files/Releases/$(date +%Y-%m-%d) --file-conflict rename --folder-conflict rename
|
|
220
220
|
|
|
221
221
|
# Download all contracts for audit
|
|
222
222
|
proton-drive-cli download /my-files/Contracts ./audit/contracts
|
|
@@ -268,8 +268,8 @@ proton-drive-cli share status /my-files/Projects
|
|
|
268
268
|
| `drive_list` | List files and folders at a path | `path` |
|
|
269
269
|
| `drive_info` | Get full metadata for one file/folder, including revision details | `path` |
|
|
270
270
|
| `drive_mkdir` | Create a new empty folder | `path` |
|
|
271
|
-
| `drive_upload` | Upload local file or folder | `localPath`, `remotePath`, `
|
|
272
|
-
| `drive_download` | Download to local path | `remotePath`, `localPath`, `
|
|
271
|
+
| `drive_upload` | Upload local file or folder | `localPath`, `remotePath`, `fileConflictStrategy?` (skip/create-new-revision/rename/replace), `folderConflictStrategy?` (skip/merge/rename/replace) |
|
|
272
|
+
| `drive_download` | Download to local path | `remotePath`, `localPath`, `fileConflictStrategy?` (skip/rename/remove), `folderConflictStrategy?` (skip/merge/rename/remove) |
|
|
273
273
|
| `drive_rename` | Rename in place, no move | `path`, `newName` |
|
|
274
274
|
| `drive_move` | Move and/or rename | `sourcePath`, `destinationPath` |
|
|
275
275
|
| `drive_copy` | Copy file or folder to another Drive location | `sourcePath`, `destinationPath` |
|
|
@@ -298,8 +298,8 @@ proton-drive-cli share status /my-files/Projects
|
|
|
298
298
|
| `photos_add_to_album` | Add a photo from your library to an album | `albumPath`, `photoPath` |
|
|
299
299
|
| `photos_remove_from_album` | Remove a photo from an album (keeps it in library) ⚠️ | `albumPath`, `photoPath` |
|
|
300
300
|
| `photos_list_timeline` | List photos in your full library timeline | `loadDetails?` |
|
|
301
|
-
| `photos_download` | Download photos to a local folder | `photoPaths`, `localFolder`, `conflictStrategy?` (skip/
|
|
302
|
-
| `photos_upload` | Upload local files directly into your Photos library | `localPaths`, `conflictStrategy?` (skip/
|
|
301
|
+
| `photos_download` | Download photos to a local folder | `photoPaths`, `localFolder`, `conflictStrategy?` (skip/rename/remove) |
|
|
302
|
+
| `photos_upload` | Upload local files directly into your Photos library | `localPaths`, `conflictStrategy?` (skip/rename) |
|
|
303
303
|
|
|
304
304
|
> ⚠️ **Destructive tools** require `confirmed: true`. Use `drive_list_trash` first so you know what will be deleted, then pass `confirmed: true` to proceed.
|
|
305
305
|
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Usage: proton-drive-cli <command> [args]
|
|
7
7
|
* Run with --help (or see usage() below) for the full command list.
|
|
8
8
|
*/
|
|
9
|
-
import { DriveService } from "./services/drive.js";
|
|
9
|
+
import { DriveService, } from "./services/drive.js";
|
|
10
10
|
import { DriveCliError, DriveCliNotFoundError, DriveNotAuthenticatedError, DriveParseError } from "./utils/errors.js";
|
|
11
11
|
import { checkCliAvailable } from "./utils/subprocess.js";
|
|
12
12
|
import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName } from "./utils/validation.js";
|
|
@@ -24,8 +24,10 @@ Commands:
|
|
|
24
24
|
list <path> List files at path
|
|
25
25
|
info <path> Show full node metadata for one path
|
|
26
26
|
mkdir <path> Create a new folder
|
|
27
|
-
upload <local> <remote> [--conflict X]
|
|
28
|
-
|
|
27
|
+
upload <local> <remote> [--file-conflict X] [--folder-conflict X]
|
|
28
|
+
Upload file/folder (files: skip/create-new-revision/rename/replace; folders: skip/merge/rename/replace)
|
|
29
|
+
download <remote> <local> [--file-conflict X] [--folder-conflict X]
|
|
30
|
+
Download file/folder (files: skip/rename/remove; folders: skip/merge/rename/remove)
|
|
29
31
|
rename <path> <new-name> Rename in place, no move
|
|
30
32
|
move <src> <dst> Move and/or rename
|
|
31
33
|
delete <path> --confirm Delete a file/folder already in trash, permanently
|
|
@@ -52,8 +54,8 @@ Commands:
|
|
|
52
54
|
album add-photo <album> <photo> Add a photo to an album
|
|
53
55
|
album remove-photo <album> <photo> Remove a photo from an album
|
|
54
56
|
photo timeline [--load-details] List photos in your timeline
|
|
55
|
-
photo download <photo>... <local> [--conflict X] Download photos (skip/
|
|
56
|
-
photo upload <local>... [--conflict X] Upload photos to your library (skip/
|
|
57
|
+
photo download <photo>... <local> [--conflict X] Download photos (skip/rename/remove)
|
|
58
|
+
photo upload <local>... [--conflict X] Upload photos to your library (skip/rename)
|
|
57
59
|
|
|
58
60
|
Flags:
|
|
59
61
|
--json Machine-readable JSON output (one line)
|
|
@@ -145,7 +147,7 @@ async function run() {
|
|
|
145
147
|
case "upload": {
|
|
146
148
|
const rawLocal = sub;
|
|
147
149
|
if (!rawLocal) {
|
|
148
|
-
console.error("Usage: upload <local> <remote>");
|
|
150
|
+
console.error("Usage: upload <local> <remote> [--file-conflict X] [--folder-conflict X]");
|
|
149
151
|
process.exit(1);
|
|
150
152
|
}
|
|
151
153
|
let local;
|
|
@@ -158,17 +160,21 @@ async function run() {
|
|
|
158
160
|
return;
|
|
159
161
|
}
|
|
160
162
|
const remote = requirePath(rest[0], "upload <local> <remote>");
|
|
161
|
-
const
|
|
162
|
-
if (!["skip", "
|
|
163
|
-
console.error(`Invalid --conflict value: ${
|
|
163
|
+
const fileConflictRaw = getFlag("--file-conflict") ?? "skip";
|
|
164
|
+
if (!["skip", "create-new-revision", "rename", "replace"].includes(fileConflictRaw)) {
|
|
165
|
+
console.error(`Invalid --file-conflict value: ${fileConflictRaw}. Must be skip, create-new-revision, rename, or replace.`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const folderConflictRaw = getFlag("--folder-conflict") ?? "skip";
|
|
169
|
+
if (!["skip", "merge", "rename", "replace"].includes(folderConflictRaw)) {
|
|
170
|
+
console.error(`Invalid --folder-conflict value: ${folderConflictRaw}. Must be skip, merge, rename, or replace.`);
|
|
164
171
|
process.exit(1);
|
|
165
172
|
}
|
|
166
|
-
|
|
167
|
-
print(await drive.upload(local, remote, conflict));
|
|
173
|
+
print(await drive.upload(local, remote, fileConflictRaw, folderConflictRaw));
|
|
168
174
|
break;
|
|
169
175
|
}
|
|
170
176
|
case "download": {
|
|
171
|
-
const remote = requirePath(sub, "download <remote> <local>");
|
|
177
|
+
const remote = requirePath(sub, "download <remote> <local> [--file-conflict X] [--folder-conflict X]");
|
|
172
178
|
const rawLocal2 = rest[0];
|
|
173
179
|
if (!rawLocal2) {
|
|
174
180
|
console.error("Usage: download <remote> <local>");
|
|
@@ -184,12 +190,17 @@ async function run() {
|
|
|
184
190
|
process.exit(1);
|
|
185
191
|
return;
|
|
186
192
|
}
|
|
187
|
-
const
|
|
188
|
-
if (!["skip", "
|
|
189
|
-
console.error(`Invalid --conflict value: ${
|
|
193
|
+
const fileConflictRaw = getFlag("--file-conflict") ?? "skip";
|
|
194
|
+
if (!["skip", "rename", "remove"].includes(fileConflictRaw)) {
|
|
195
|
+
console.error(`Invalid --file-conflict value: ${fileConflictRaw}. Must be skip, rename, or remove.`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const folderConflictRaw = getFlag("--folder-conflict") ?? "skip";
|
|
199
|
+
if (!["skip", "merge", "rename", "remove"].includes(folderConflictRaw)) {
|
|
200
|
+
console.error(`Invalid --folder-conflict value: ${folderConflictRaw}. Must be skip, merge, rename, or remove.`);
|
|
190
201
|
process.exit(1);
|
|
191
202
|
}
|
|
192
|
-
print(await drive.download(remote, local,
|
|
203
|
+
print(await drive.download(remote, local, fileConflictRaw, folderConflictRaw));
|
|
193
204
|
break;
|
|
194
205
|
}
|
|
195
206
|
case "rename": {
|
|
@@ -436,8 +447,8 @@ async function run() {
|
|
|
436
447
|
process.exit(1);
|
|
437
448
|
return;
|
|
438
449
|
}
|
|
439
|
-
if (!["skip", "
|
|
440
|
-
console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip,
|
|
450
|
+
if (!["skip", "rename", "remove"].includes(conflictRaw)) {
|
|
451
|
+
console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, rename, or remove.`);
|
|
441
452
|
process.exit(1);
|
|
442
453
|
}
|
|
443
454
|
print(await drive.photoDownload(photoPaths, localFolderValidated, conflictRaw));
|
|
@@ -459,8 +470,8 @@ async function run() {
|
|
|
459
470
|
return;
|
|
460
471
|
}
|
|
461
472
|
}
|
|
462
|
-
if (!["skip", "
|
|
463
|
-
console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip or
|
|
473
|
+
if (!["skip", "rename"].includes(conflictRaw)) {
|
|
474
|
+
console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip or rename.`);
|
|
464
475
|
process.exit(1);
|
|
465
476
|
}
|
|
466
477
|
print(await drive.photoUpload(localPaths, conflictRaw));
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { DriveService } from "./services/drive.js";
|
|
6
|
+
import { DriveService, } from "./services/drive.js";
|
|
7
7
|
import { isMainModule } from "./utils/isMainModule.js";
|
|
8
8
|
import { checkCliAvailable } from "./utils/subprocess.js";
|
|
9
9
|
import { DriveCliNotFoundError, DriveCliError, DriveNotAuthenticatedError, DriveParseError, } from "./utils/errors.js";
|
|
@@ -115,7 +115,7 @@ const TOOLS = [
|
|
|
115
115
|
description: "Upload a local file or folder to Proton Drive with end-to-end encryption. Requires authentication. " +
|
|
116
116
|
"For folders, uploads recursively and preserves directory structure. " +
|
|
117
117
|
"Returns {uploaded, skipped, failed} counts — fails the call if failed > 0 (common causes: quota exceeded, destination path not found, permission denied). " +
|
|
118
|
-
"
|
|
118
|
+
"Conflict strategies are set separately for files and folders (CLI v0.8.0+) — both default to 'skip'. " +
|
|
119
119
|
"Do not use to move files already on Drive (use drive_move) or to write text content directly (use drive_write_file if PROTON_DRIVE_SYNC_PATH is set). " +
|
|
120
120
|
"Ensure destination folder exists first with drive_list; create it with drive_mkdir if needed.",
|
|
121
121
|
annotations: { openWorldHint: true },
|
|
@@ -130,13 +130,21 @@ const TOOLS = [
|
|
|
130
130
|
type: "string",
|
|
131
131
|
description: "Absolute remote Drive destination folder path (must start with '/'). E.g. /my-files/Reports",
|
|
132
132
|
},
|
|
133
|
-
|
|
133
|
+
fileConflictStrategy: {
|
|
134
|
+
type: "string",
|
|
135
|
+
enum: ["skip", "create-new-revision", "rename", "replace"],
|
|
136
|
+
description: "'skip' leaves an existing remote file unchanged (default). " +
|
|
137
|
+
"'create-new-revision' uploads as a new version of the existing file, keeping history. " +
|
|
138
|
+
"'rename' adds a unique suffix to the uploaded file's name. " +
|
|
139
|
+
"'replace' trashes the remote file and uploads the local copy in its place — confirm with user first.",
|
|
140
|
+
},
|
|
141
|
+
folderConflictStrategy: {
|
|
134
142
|
type: "string",
|
|
135
|
-
enum: ["skip", "
|
|
136
|
-
description: "'skip' leaves existing remote
|
|
137
|
-
"'
|
|
138
|
-
"'
|
|
139
|
-
"'
|
|
143
|
+
enum: ["skip", "merge", "rename", "replace"],
|
|
144
|
+
description: "'skip' leaves an existing remote folder unchanged (default). " +
|
|
145
|
+
"'merge' merges the uploaded folder's contents into the existing one. " +
|
|
146
|
+
"'rename' adds a unique suffix to the uploaded folder's name. " +
|
|
147
|
+
"'replace' trashes the remote folder and uploads the local copy in its place — confirm with user first.",
|
|
140
148
|
},
|
|
141
149
|
},
|
|
142
150
|
required: ["localPath", "remotePath"],
|
|
@@ -147,7 +155,7 @@ const TOOLS = [
|
|
|
147
155
|
name: "drive_download",
|
|
148
156
|
description: "Download a file or folder from Proton Drive to the local filesystem. Requires authentication. " +
|
|
149
157
|
"For folders, downloads recursively. " +
|
|
150
|
-
"
|
|
158
|
+
"Conflict strategies are set separately for files and folders (CLI v0.8.0+) — both default to 'skip'. " +
|
|
151
159
|
"Fails if the local parent directory does not exist. " +
|
|
152
160
|
"Returns {downloaded} count. " +
|
|
153
161
|
"Do not use to move files within Drive (use drive_move) or to read a small text file's contents (use drive_read_file if PROTON_DRIVE_SYNC_PATH is set).",
|
|
@@ -163,12 +171,20 @@ const TOOLS = [
|
|
|
163
171
|
type: "string",
|
|
164
172
|
description: "Absolute local destination path (must start with '/'). Parent directory must already exist.",
|
|
165
173
|
},
|
|
166
|
-
|
|
174
|
+
fileConflictStrategy: {
|
|
167
175
|
type: "string",
|
|
168
|
-
enum: ["skip", "
|
|
176
|
+
enum: ["skip", "rename", "remove"],
|
|
169
177
|
description: "'skip' leaves an existing local file unchanged (default). " +
|
|
170
|
-
"'
|
|
171
|
-
"'
|
|
178
|
+
"'rename' downloads under a unique name. " +
|
|
179
|
+
"'remove' deletes the local file and downloads the remote copy in its place — confirm with user first.",
|
|
180
|
+
},
|
|
181
|
+
folderConflictStrategy: {
|
|
182
|
+
type: "string",
|
|
183
|
+
enum: ["skip", "merge", "rename", "remove"],
|
|
184
|
+
description: "'skip' leaves an existing local folder unchanged (default). " +
|
|
185
|
+
"'merge' merges the downloaded folder's contents into the existing one. " +
|
|
186
|
+
"'rename' downloads under a unique name. " +
|
|
187
|
+
"'remove' deletes the local folder and downloads the remote copy in its place — confirm with user first.",
|
|
172
188
|
},
|
|
173
189
|
},
|
|
174
190
|
required: ["remotePath", "localPath"],
|
|
@@ -743,7 +759,7 @@ const TOOLS = [
|
|
|
743
759
|
{
|
|
744
760
|
name: "photos_download",
|
|
745
761
|
description: "Download one or more photos from Proton Photos (timeline, an album, or shared-with-me) to a local folder. Requires authentication. " +
|
|
746
|
-
"Multiple timeline photos can share the same filename — with conflictStrategy '
|
|
762
|
+
"Multiple timeline photos can share the same filename — with conflictStrategy 'remove' or 'skip' only one copy survives locally; use 'rename' to keep all. " +
|
|
747
763
|
"Fails if any item fails to download. " +
|
|
748
764
|
"Do not use for regular Drive files — use drive_download instead.",
|
|
749
765
|
annotations: { openWorldHint: true },
|
|
@@ -761,8 +777,10 @@ const TOOLS = [
|
|
|
761
777
|
},
|
|
762
778
|
conflictStrategy: {
|
|
763
779
|
type: "string",
|
|
764
|
-
enum: ["skip", "
|
|
765
|
-
description: "
|
|
780
|
+
enum: ["skip", "rename", "remove"],
|
|
781
|
+
description: "'skip' leaves an existing local file unchanged (default). " +
|
|
782
|
+
"'rename' downloads under a unique name. " +
|
|
783
|
+
"'remove' deletes the local file and downloads the remote copy in its place — confirm with user first.",
|
|
766
784
|
},
|
|
767
785
|
},
|
|
768
786
|
required: ["photoPaths", "localFolder"],
|
|
@@ -773,7 +791,7 @@ const TOOLS = [
|
|
|
773
791
|
name: "photos_upload",
|
|
774
792
|
description: "Upload one or more local photo or video files directly into your Proton Photos library (My Photos timeline). Requires authentication. " +
|
|
775
793
|
"Non-photo/video files are silently skipped. Folders are recursed but flattened into My Photos — folder structure is not preserved. " +
|
|
776
|
-
"Never overwrites — duplicates (matched by name + content hash) resolve to '
|
|
794
|
+
"Never overwrites — duplicates (matched by name + content hash) resolve to 'rename' or 'skip' only. " +
|
|
777
795
|
"Do not use for regular Drive files — use drive_upload instead.",
|
|
778
796
|
annotations: { openWorldHint: true },
|
|
779
797
|
inputSchema: {
|
|
@@ -786,8 +804,8 @@ const TOOLS = [
|
|
|
786
804
|
},
|
|
787
805
|
conflictStrategy: {
|
|
788
806
|
type: "string",
|
|
789
|
-
enum: ["skip", "
|
|
790
|
-
description: "How to handle duplicate photos (matched by name + content hash).
|
|
807
|
+
enum: ["skip", "rename"],
|
|
808
|
+
description: "How to handle duplicate photos (matched by name + content hash). 'skip' leaves the existing photo unchanged (default); 'rename' uploads under a unique name.",
|
|
791
809
|
},
|
|
792
810
|
},
|
|
793
811
|
required: ["localPaths"],
|
|
@@ -883,22 +901,30 @@ export async function main() {
|
|
|
883
901
|
return ok({ message: `Folder created: ${mkdirPath}` });
|
|
884
902
|
}
|
|
885
903
|
case "drive_upload": {
|
|
886
|
-
const
|
|
887
|
-
if (!["skip", "
|
|
888
|
-
return fail(`
|
|
904
|
+
const fcs = typeof a.fileConflictStrategy === "string" ? a.fileConflictStrategy : "skip";
|
|
905
|
+
if (!["skip", "create-new-revision", "rename", "replace"].includes(fcs)) {
|
|
906
|
+
return fail(`fileConflictStrategy must be skip, create-new-revision, rename, or replace`);
|
|
889
907
|
}
|
|
890
|
-
const
|
|
908
|
+
const dcs2 = typeof a.folderConflictStrategy === "string" ? a.folderConflictStrategy : "skip";
|
|
909
|
+
if (!["skip", "merge", "rename", "replace"].includes(dcs2)) {
|
|
910
|
+
return fail(`folderConflictStrategy must be skip, merge, rename, or replace`);
|
|
911
|
+
}
|
|
912
|
+
const uploadResult = await drive.upload(validateLocalPath(a.localPath), validateRemotePath(a.remotePath), fcs, dcs2);
|
|
891
913
|
if (uploadResult.failed > 0) {
|
|
892
914
|
return fail(`Upload completed with ${uploadResult.failed} failed file(s). uploaded=${uploadResult.uploaded} skipped=${uploadResult.skipped}`);
|
|
893
915
|
}
|
|
894
916
|
return ok(uploadResult);
|
|
895
917
|
}
|
|
896
918
|
case "drive_download": {
|
|
897
|
-
const
|
|
898
|
-
if (!["skip", "
|
|
899
|
-
return fail(`
|
|
919
|
+
const fdcs = typeof a.fileConflictStrategy === "string" ? a.fileConflictStrategy : "skip";
|
|
920
|
+
if (!["skip", "rename", "remove"].includes(fdcs)) {
|
|
921
|
+
return fail(`fileConflictStrategy must be skip, rename, or remove`);
|
|
922
|
+
}
|
|
923
|
+
const fodcs = typeof a.folderConflictStrategy === "string" ? a.folderConflictStrategy : "skip";
|
|
924
|
+
if (!["skip", "merge", "rename", "remove"].includes(fodcs)) {
|
|
925
|
+
return fail(`folderConflictStrategy must be skip, merge, rename, or remove`);
|
|
900
926
|
}
|
|
901
|
-
return ok(await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath),
|
|
927
|
+
return ok(await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath), fdcs, fodcs));
|
|
902
928
|
}
|
|
903
929
|
case "drive_rename": {
|
|
904
930
|
const renamePath = validateRemotePath(a.path);
|
|
@@ -1070,8 +1096,8 @@ export async function main() {
|
|
|
1070
1096
|
const downloadPaths = a.photoPaths.map((p) => validateRemotePath(p));
|
|
1071
1097
|
const downloadFolder = validateLocalPath(a.localFolder);
|
|
1072
1098
|
const pdcs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
|
|
1073
|
-
if (!["skip", "
|
|
1074
|
-
return fail("conflictStrategy must be skip,
|
|
1099
|
+
if (!["skip", "rename", "remove"].includes(pdcs))
|
|
1100
|
+
return fail("conflictStrategy must be skip, rename, or remove");
|
|
1075
1101
|
const downloadSummary = await drive.photoDownload(downloadPaths, downloadFolder, pdcs);
|
|
1076
1102
|
if (downloadSummary.failedItems > 0) {
|
|
1077
1103
|
return fail(`Download completed with ${downloadSummary.failedItems} failed item(s). transferred=${downloadSummary.transferredItems}`);
|
|
@@ -1083,8 +1109,8 @@ export async function main() {
|
|
|
1083
1109
|
return fail("localPaths must be a non-empty array of strings");
|
|
1084
1110
|
const uploadPaths = a.localPaths.map((p) => validateLocalPath(p));
|
|
1085
1111
|
const pucs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
|
|
1086
|
-
if (!["skip", "
|
|
1087
|
-
return fail("conflictStrategy must be skip or
|
|
1112
|
+
if (!["skip", "rename"].includes(pucs))
|
|
1113
|
+
return fail("conflictStrategy must be skip or rename");
|
|
1088
1114
|
const uploadSummary = await drive.photoUpload(uploadPaths, pucs);
|
|
1089
1115
|
if (uploadSummary.failedItems > 0) {
|
|
1090
1116
|
return fail(`Upload completed with ${uploadSummary.failedItems} failed item(s). transferred=${uploadSummary.transferredItems}`);
|
package/dist/services/drive.js
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { posix as posixPath } from "node:path";
|
|
2
2
|
import { runDrive as defaultRunDrive, runDriveRaw as defaultRunDriveRaw } from "../utils/subprocess.js";
|
|
3
3
|
import { DriveNotAuthenticatedError, DriveParseError } from "../utils/errors.js";
|
|
4
|
+
// The SDK verifies the author of every name (and several other fields)
|
|
5
|
+
// cryptographically and returns a `Result<string, Error>`-shaped object —
|
|
6
|
+
// {ok:true, value:"name"} on success, {ok:false, error:{...}} if the
|
|
7
|
+
// signature couldn't be verified — never a plain string. Confirmed live
|
|
8
|
+
// against the CLI's `filesystem list`, `filesystem info`, `album list`,
|
|
9
|
+
// `invitation list` output. A naive `String(item.name)` on this object
|
|
10
|
+
// produces the literal text "[object Object]".
|
|
11
|
+
function unwrapResult(value, fallback = "") {
|
|
12
|
+
if (value && typeof value === "object" && "ok" in value) {
|
|
13
|
+
const r = value;
|
|
14
|
+
if (r.ok && typeof r.value === "string")
|
|
15
|
+
return r.value;
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
return typeof value === "string" ? value : fallback;
|
|
19
|
+
}
|
|
20
|
+
// Strips "<package-name>@" and "+<hash>" from a version token like
|
|
21
|
+
// "cli-drive@0.8.0+06e8c605", leaving "0.8.0". Falls back to the raw
|
|
22
|
+
// token if it doesn't match, so an unexpected future format still shows
|
|
23
|
+
// something rather than silently becoming "unknown".
|
|
24
|
+
function extractSemver(token) {
|
|
25
|
+
if (!token)
|
|
26
|
+
return "unknown";
|
|
27
|
+
const afterAt = token.includes("@") ? token.slice(token.indexOf("@") + 1) : token;
|
|
28
|
+
const semverMatch = afterAt.match(/^(\d+\.\d+\.\d+)/);
|
|
29
|
+
return semverMatch ? semverMatch[1] : token;
|
|
30
|
+
}
|
|
4
31
|
export class DriveService {
|
|
5
32
|
run;
|
|
6
33
|
runRaw;
|
|
@@ -29,62 +56,81 @@ export class DriveService {
|
|
|
29
56
|
await this.run(["auth", "logout"]);
|
|
30
57
|
}
|
|
31
58
|
// `version` ignores --json entirely and always prints plain text:
|
|
32
|
-
// Proton Drive CLI
|
|
33
|
-
// Proton Drive SDK
|
|
59
|
+
// Proton Drive CLI cli-drive@0.8.0+06e8c605
|
|
60
|
+
// Proton Drive SDK js@0.21.0+06e8c605
|
|
34
61
|
// ...update-check line...
|
|
62
|
+
// Confirmed live: the token after "CLI"/"SDK" is <package-name>@<semver>+<hash>,
|
|
63
|
+
// not a bare semver — the CLI's own version.ts extracts semver the same way
|
|
64
|
+
// (slice after '@', match leading \d+.\d+.\d+) before comparing versions.
|
|
35
65
|
async version() {
|
|
36
66
|
const text = await this.runRaw(["version"]);
|
|
37
67
|
const cliMatch = text.match(/Proton Drive CLI\s+(\S+)/);
|
|
38
68
|
const sdkMatch = text.match(/Proton Drive SDK\s+(\S+)/);
|
|
39
69
|
return {
|
|
40
|
-
cli: cliMatch
|
|
41
|
-
sdk: sdkMatch
|
|
70
|
+
cli: extractSemver(cliMatch?.[1]),
|
|
71
|
+
sdk: extractSemver(sdkMatch?.[1]),
|
|
42
72
|
};
|
|
43
73
|
}
|
|
44
74
|
// Filesystem
|
|
75
|
+
//
|
|
76
|
+
// The CLI's list output has no `path` field at all — only `uid`/`parentUid`.
|
|
77
|
+
// We compute a usable path by joining the listed folder with each item's
|
|
78
|
+
// (unwrapped) name; confirmed live that /parent/name round-trips correctly
|
|
79
|
+
// through the CLI's own name-based path resolver for every other command.
|
|
45
80
|
async list(remotePath) {
|
|
46
81
|
const result = await this.run(["filesystem", "list", remotePath]);
|
|
47
82
|
if (result === null)
|
|
48
83
|
return [];
|
|
49
84
|
if (!Array.isArray(result))
|
|
50
85
|
throw new DriveParseError(`Expected array from list, got: ${JSON.stringify(result).slice(0, 100)}`);
|
|
51
|
-
return result.map((item) =>
|
|
52
|
-
name
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
86
|
+
return result.map((item) => {
|
|
87
|
+
const name = unwrapResult(item.name, "[unnamed]");
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
path: posixPath.join(remotePath, name),
|
|
91
|
+
type: item.type === "folder" || item.type === "album" ? "folder" : "file",
|
|
92
|
+
size: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
|
|
93
|
+
modifiedAt: typeof item.modificationTime === "string" ? item.modificationTime : undefined,
|
|
94
|
+
mimeType: typeof item.mediaType === "string" ? item.mediaType : undefined,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
59
97
|
}
|
|
60
|
-
async upload(localPath, remotePath,
|
|
98
|
+
async upload(localPath, remotePath, fileConflictStrategy = "skip", folderConflictStrategy = "skip") {
|
|
61
99
|
const result = await this.run([
|
|
62
100
|
"filesystem",
|
|
63
101
|
"upload",
|
|
64
102
|
localPath,
|
|
65
103
|
remotePath,
|
|
66
|
-
"--conflict-strategy",
|
|
67
|
-
|
|
104
|
+
"--file-conflict-strategy",
|
|
105
|
+
fileConflictStrategy,
|
|
106
|
+
"--folder-conflict-strategy",
|
|
107
|
+
folderConflictStrategy,
|
|
68
108
|
"--skip-thumbnails",
|
|
69
109
|
]);
|
|
70
|
-
|
|
110
|
+
// Real shape is TransferSummary: {transferredItems, transferredBytes,
|
|
111
|
+
// skippedItems, failedItems, failures}. Confirmed live — the old
|
|
112
|
+
// {uploaded, skipped, failed} field names never existed, so failures
|
|
113
|
+
// were silently reported as 0 regardless of what actually happened.
|
|
114
|
+
const summary = this.parseTransferSummary(result);
|
|
71
115
|
return {
|
|
72
116
|
path: remotePath,
|
|
73
|
-
uploaded:
|
|
74
|
-
skipped:
|
|
75
|
-
failed:
|
|
117
|
+
uploaded: summary.transferredItems,
|
|
118
|
+
skipped: summary.skippedItems,
|
|
119
|
+
failed: summary.failedItems,
|
|
76
120
|
};
|
|
77
121
|
}
|
|
78
|
-
async download(remotePath, localPath,
|
|
122
|
+
async download(remotePath, localPath, fileConflictStrategy = "skip", folderConflictStrategy = "skip") {
|
|
79
123
|
const result = await this.run([
|
|
80
124
|
"filesystem", "download", remotePath, localPath,
|
|
81
|
-
"--conflict-strategy",
|
|
125
|
+
"--file-conflict-strategy", fileConflictStrategy,
|
|
126
|
+
"--folder-conflict-strategy", folderConflictStrategy,
|
|
82
127
|
]);
|
|
83
|
-
|
|
128
|
+
// Same real shape as upload — TransferSummary, not {downloaded}.
|
|
129
|
+
const summary = this.parseTransferSummary(result);
|
|
84
130
|
return {
|
|
85
131
|
path: remotePath,
|
|
86
132
|
localPath,
|
|
87
|
-
downloaded:
|
|
133
|
+
downloaded: summary.transferredItems,
|
|
88
134
|
};
|
|
89
135
|
}
|
|
90
136
|
// The CLI has no `mkdir` — it's `create-folder <parentPath> <name>`.
|
|
@@ -136,24 +182,31 @@ export class DriveService {
|
|
|
136
182
|
await this.run(["filesystem", "delete", remotePath]);
|
|
137
183
|
}
|
|
138
184
|
// Sharing
|
|
185
|
+
//
|
|
186
|
+
// Real shape is the SDK's ShareResult: {protonInvitations, nonProtonInvitations,
|
|
187
|
+
// members, urlAccess?, editorsCanShare} — confirmed live. There is no
|
|
188
|
+
// isShared/email/addedAt/shareUrl field; those were all wrong names.
|
|
189
|
+
// When nothing is shared, the CLI prints literal "undefined" (see
|
|
190
|
+
// subprocess.ts) which now resolves to `result === null` here.
|
|
139
191
|
async shareStatus(remotePath) {
|
|
140
192
|
const result = await this.run(["sharing", "status", remotePath]);
|
|
141
|
-
|
|
142
|
-
throw new DriveParseError("sharing status returned empty response");
|
|
143
|
-
const r = result;
|
|
193
|
+
const r = (result ?? {});
|
|
144
194
|
const VALID_ROLES = new Set(["viewer", "editor", "admin"]);
|
|
145
|
-
const members = Array.isArray(r
|
|
195
|
+
const members = Array.isArray(r.members)
|
|
146
196
|
? r.members.map((m) => ({
|
|
147
|
-
email: String(m.
|
|
197
|
+
email: String(m.inviteeEmail ?? ""),
|
|
148
198
|
role: (VALID_ROLES.has(String(m.role)) ? String(m.role) : "viewer"),
|
|
149
|
-
addedAt: typeof m.
|
|
199
|
+
addedAt: typeof m.invitationTime === "string" ? m.invitationTime : undefined,
|
|
150
200
|
}))
|
|
151
201
|
: [];
|
|
202
|
+
const protonInvitationCount = Array.isArray(r.protonInvitations) ? r.protonInvitations.length : 0;
|
|
203
|
+
const nonProtonInvitationCount = Array.isArray(r.nonProtonInvitations) ? r.nonProtonInvitations.length : 0;
|
|
204
|
+
const urlAccess = (r.urlAccess ?? undefined);
|
|
152
205
|
return {
|
|
153
206
|
path: remotePath,
|
|
154
|
-
isShared:
|
|
207
|
+
isShared: members.length > 0 || protonInvitationCount > 0 || nonProtonInvitationCount > 0 || !!urlAccess,
|
|
155
208
|
members,
|
|
156
|
-
shareUrl: typeof
|
|
209
|
+
shareUrl: typeof urlAccess?.url === "string" ? urlAccess.url : undefined,
|
|
157
210
|
};
|
|
158
211
|
}
|
|
159
212
|
async shareInvite(remotePath, email, role, message) {
|
|
@@ -228,14 +281,14 @@ export class DriveService {
|
|
|
228
281
|
throw new DriveParseError(`Expected array from invitation list, got: ${JSON.stringify(result).slice(0, 100)}`);
|
|
229
282
|
return result.map((item) => {
|
|
230
283
|
const node = (item.node ?? {});
|
|
231
|
-
|
|
232
|
-
|
|
284
|
+
// addedByEmail is also a verified Result<string,...>, same as name —
|
|
285
|
+
// confirmed via the SDK's Member type (client/js/src/interface/sharing.ts).
|
|
233
286
|
return {
|
|
234
287
|
uid: String(item.uid ?? ""),
|
|
235
288
|
role: (["viewer", "editor", "admin"].includes(String(item.role)) ? String(item.role) : "viewer"),
|
|
236
|
-
invitedByEmail:
|
|
289
|
+
invitedByEmail: unwrapResult(item.addedByEmail),
|
|
237
290
|
invitedAt: typeof item.invitationTime === "string" ? item.invitationTime : undefined,
|
|
238
|
-
nodeName,
|
|
291
|
+
nodeName: unwrapResult(node.name, "[unnamed]"),
|
|
239
292
|
nodeType: node.type === "folder" ? "folder" : "file",
|
|
240
293
|
};
|
|
241
294
|
});
|
|
@@ -250,18 +303,25 @@ export class DriveService {
|
|
|
250
303
|
await this.run(["sharing", "leave", remotePath]);
|
|
251
304
|
}
|
|
252
305
|
// Photos / Albums
|
|
306
|
+
//
|
|
307
|
+
// Albums are NodeEntity too — name needs the same {ok,value} unwrap as
|
|
308
|
+
// filesystem list(), and photoCount lives under a nested `album` object
|
|
309
|
+
// (`item.album.photoCount`), not top-level. Both confirmed live.
|
|
253
310
|
async listAlbums() {
|
|
254
311
|
const result = await this.run(["album", "list"]);
|
|
255
312
|
if (result === null)
|
|
256
313
|
return [];
|
|
257
314
|
if (!Array.isArray(result))
|
|
258
315
|
throw new DriveParseError(`Expected array from album list, got: ${JSON.stringify(result).slice(0, 100)}`);
|
|
259
|
-
return result.map((item) =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
316
|
+
return result.map((item) => {
|
|
317
|
+
const albumInfo = (item.album ?? {});
|
|
318
|
+
return {
|
|
319
|
+
name: unwrapResult(item.name, "[unnamed]"),
|
|
320
|
+
photoCount: typeof albumInfo.photoCount === "number" ? albumInfo.photoCount : 0,
|
|
321
|
+
isShared: Boolean(item.isShared ?? false),
|
|
322
|
+
creationTime: typeof item.creationTime === "string" ? item.creationTime : undefined,
|
|
323
|
+
};
|
|
324
|
+
});
|
|
265
325
|
}
|
|
266
326
|
async createAlbum(name) {
|
|
267
327
|
await this.run(["album", "create", name]);
|
package/dist/utils/subprocess.js
CHANGED
|
@@ -58,6 +58,15 @@ export async function runDrive(args) {
|
|
|
58
58
|
}
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
|
+
// Some commands (e.g. `sharing status`/`sharing remove-url` on an item
|
|
62
|
+
// with no share record) call the CLI's own printObject(undefined, true)
|
|
63
|
+
// helper, which does console.log(JSON.stringify(undefined)) — and
|
|
64
|
+
// JSON.stringify(undefined) is the JS value undefined, so console.log
|
|
65
|
+
// prints the literal 5 characters "undefined", not valid JSON. Confirmed
|
|
66
|
+
// live against the real CLI (v0.8.0). Treat it the same as empty stdout.
|
|
67
|
+
if (raw === "undefined") {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
61
70
|
try {
|
|
62
71
|
return JSON.parse(raw);
|
|
63
72
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proton-drive-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.30",
|
|
4
4
|
"description": "MCP server and CLI that gives Claude full access to Proton Drive — upload, download, share, and manage your end-to-end encrypted files without leaving the conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|