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 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 keep-both
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 keep-both
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 keep-both
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`, `conflictStrategy` (skip/replace/keep-both/merge) |
272
- | `drive_download` | Download to local path | `remotePath`, `localPath`, `conflictStrategy` (skip/replace/keep-both) |
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/replace/keep-both) |
302
- | `photos_upload` | Upload local files directly into your Photos library | `localPaths`, `conflictStrategy?` (skip/keep-both) |
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] Upload file/folder (skip/replace/keep-both/merge)
28
- download <remote> <local> [--conflict X] Download file/folder (skip/replace/keep-both)
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/replace/keep-both)
56
- photo upload <local>... [--conflict X] Upload photos to your library (skip/keep-both)
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 conflictRaw = getFlag("--conflict") ?? "skip";
162
- if (!["skip", "replace", "keep-both", "merge"].includes(conflictRaw)) {
163
- console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, replace, keep-both, or merge.`);
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
- const conflict = conflictRaw;
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 dConflictRaw = getFlag("--conflict") ?? "skip";
188
- if (!["skip", "replace", "keep-both"].includes(dConflictRaw)) {
189
- console.error(`Invalid --conflict value: ${dConflictRaw}. Must be skip, replace, or keep-both.`);
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, dConflictRaw));
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", "replace", "keep-both"].includes(conflictRaw)) {
440
- console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, replace, or keep-both.`);
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", "keep-both"].includes(conflictRaw)) {
463
- console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip or keep-both.`);
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
- "conflictStrategy defaults to 'skip'. Values: 'skip' (leave existing remote file unchanged), 'replace' (permanently overwrite — confirm with user first), 'keep-both' (upload with a unique name), 'merge' (folders only — merge contents instead of failing). " +
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
- conflictStrategy: {
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", "replace", "keep-both", "merge"],
136
- description: "'skip' leaves existing remote files unchanged (default). " +
137
- "'replace' permanently overwrites the remote file — confirm with user first. " +
138
- "'keep-both' uploads with a unique name to avoid conflicts. " +
139
- "'merge' merges folder contents instead of failing (folders only).",
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
- "conflictStrategy defaults to 'skip' — pass 'replace' to overwrite an existing local file, or 'keep-both' to save under a unique name. " +
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
- conflictStrategy: {
174
+ fileConflictStrategy: {
167
175
  type: "string",
168
- enum: ["skip", "replace", "keep-both"],
176
+ enum: ["skip", "rename", "remove"],
169
177
  description: "'skip' leaves an existing local file unchanged (default). " +
170
- "'replace' overwrites it — confirm with user first. " +
171
- "'keep-both' downloads under a unique name.",
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 'replace' or 'skip' only one copy survives locally; use 'keep-both' to keep all. " +
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", "replace", "keep-both"],
765
- description: "How to handle local filename collisions. Defaults to 'skip'.",
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 'keep-both' or 'skip' only. " +
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", "keep-both"],
790
- description: "How to handle duplicate photos (matched by name + content hash). Defaults to 'skip'.",
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 cs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
887
- if (!["skip", "replace", "keep-both", "merge"].includes(cs)) {
888
- return fail(`conflictStrategy must be skip, replace, keep-both, or merge`);
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 uploadResult = await drive.upload(validateLocalPath(a.localPath), validateRemotePath(a.remotePath), cs);
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 dcs = typeof a.conflictStrategy === "string" ? a.conflictStrategy : "skip";
898
- if (!["skip", "replace", "keep-both"].includes(dcs)) {
899
- return fail(`conflictStrategy must be skip, replace, or keep-both`);
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), dcs));
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", "replace", "keep-both"].includes(pdcs))
1074
- return fail("conflictStrategy must be skip, replace, or keep-both");
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", "keep-both"].includes(pucs))
1087
- return fail("conflictStrategy must be skip or keep-both");
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}`);
@@ -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 1.2.3
33
- // Proton Drive SDK 4.5.6
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 ? cliMatch[1] : "unknown",
41
- sdk: sdkMatch ? sdkMatch[1] : "unknown",
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: String(item.name ?? ""),
53
- path: String(item.path ?? remotePath),
54
- type: item.type === "folder" ? "folder" : "file",
55
- size: typeof item.size === "number" ? item.size : undefined,
56
- modifiedAt: typeof item.modifiedAt === "string" ? item.modifiedAt : undefined,
57
- mimeType: typeof item.mimeType === "string" ? item.mimeType : undefined,
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, conflictStrategy = "skip") {
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
- conflictStrategy,
104
+ "--file-conflict-strategy",
105
+ fileConflictStrategy,
106
+ "--folder-conflict-strategy",
107
+ folderConflictStrategy,
68
108
  "--skip-thumbnails",
69
109
  ]);
70
- const r = (result ?? {});
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: typeof r?.uploaded === "number" ? r.uploaded : 0,
74
- skipped: typeof r?.skipped === "number" ? r.skipped : 0,
75
- failed: typeof r?.failed === "number" ? r.failed : 0,
117
+ uploaded: summary.transferredItems,
118
+ skipped: summary.skippedItems,
119
+ failed: summary.failedItems,
76
120
  };
77
121
  }
78
- async download(remotePath, localPath, conflictStrategy = "skip") {
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", conflictStrategy,
125
+ "--file-conflict-strategy", fileConflictStrategy,
126
+ "--folder-conflict-strategy", folderConflictStrategy,
82
127
  ]);
83
- const r = (result ?? {});
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: typeof r?.downloaded === "number" ? r.downloaded : 0,
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
- if (result === null)
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?.members)
195
+ const members = Array.isArray(r.members)
146
196
  ? r.members.map((m) => ({
147
- email: String(m.email ?? ""),
197
+ email: String(m.inviteeEmail ?? ""),
148
198
  role: (VALID_ROLES.has(String(m.role)) ? String(m.role) : "viewer"),
149
- addedAt: typeof m.addedAt === "string" ? m.addedAt : undefined,
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: Boolean(r?.isShared ?? members.length > 0),
207
+ isShared: members.length > 0 || protonInvitationCount > 0 || nonProtonInvitationCount > 0 || !!urlAccess,
155
208
  members,
156
- shareUrl: typeof r?.shareUrl === "string" ? r.shareUrl : undefined,
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
- const nameVal = (node.name ?? {});
232
- const nodeName = typeof nameVal.value === "string" ? nameVal.value : String(node.name ?? "");
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: String(item.addedByEmail ?? ""),
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
- name: String(item.name ?? ""),
261
- photoCount: typeof item.photoCount === "number" ? item.photoCount : 0,
262
- isShared: Boolean(item.isShared ?? false),
263
- creationTime: typeof item.creationTime === "string" ? item.creationTime : undefined,
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]);
@@ -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.28",
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",