proton-drive-mcp 1.0.39 → 1.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-09-28
4
+
5
+ A "test it like Mail Bridge" round: 6 agents live-tested every tool group against a real account for the first time at scale — full-byte round trips up to 200 MB, the real Proton Drive sync folder, real photo uploads/albums, three MCP servers sharing one CLI session concurrently, official MCP clients (SDK, Inspector, Node 20/22/24, a packed-and-installed tarball), and adversarial edge cases. 3 high-severity and 7 medium-severity bugs were found, fixed by 5 agents working in parallel on separate branches, then the merged result was re-verified live by another 5 agents (one an independent code review) before anything shipped. Every fix below was reproduced from the original bug, not assumed from a commit message.
6
+
7
+ **Behavior changes:** `drive_list`/`drive_list_trash`/`photos_list_timeline`/`photos_list_album_photos` now return items in a stable sorted order; `drive_list`'s `size` is the real file size (the old value is `storageSize`); `drive_restore`/`drive_delete` accept an optional `uid`; `drive_read_file`/`drive_write_file` interpret a `/my-files/...` path as documented.
8
+
9
+ ### Fixed — high severity
10
+ - **`drive_list` pagination lost and duplicated items.** Each page re-ran the CLI, whose item order isn't stable, so a page slice could return the same item twice or skip one entirely while `total` still looked correct. Confirmed live: walking a 1,000-file folder returned 998 of 1,000 unique items. `drive_list`, `drive_list_trash`, `photos_list_timeline` and `photos_list_album_photos` now sort deterministically before paginating (name, then trash time, then capture time — each with a uid tie-break) and their descriptions say each page is a fresh read.
11
+ - **`drive_restore`/`drive_delete` could act on the wrong item when a trash name repeated.** `drive_list_trash` documents using `uid` to tell duplicates apart, but neither tool accepted one. Confirmed live: restoring/deleting `/trash/x.txt` with two items named `x.txt` picked an arbitrary one — the delete case is irreversible. Both tools now accept `uid`, refuse an ambiguous path (listing every matching uid and trash time instead of guessing), and the same check now also covers `/photos-trash`. The underlying CLI can only address a trashed item by name, so a genuinely ambiguous duplicate still can't be targeted — resolve it in the Proton Drive web or desktop app.
12
+ - **`drive_read_file`/`drive_write_file` didn't map the documented `/my-files/...` path.** Following the example in the tool description created a local folder literally named `my-files`, which then synced to Drive as `/my-files/my-files/...`. `/my-files/<rest>` now maps to the sync root correctly; every other Drive root (`/photos`, `/trash`, ...) is rejected with a clear message, since only `/my-files` is synced locally.
13
+
14
+ ### Fixed — medium severity
15
+ - **`photos_delete_album` deleted non-empty albums** despite its description promising a refusal without `force` — the check never existed. Now enforced, and fails closed (refuses) if the album's photo count can't be looked up at all, rather than assuming empty.
16
+ - **`photos_download` advertised `/albums/<album>/<photo>` paths that the CLI can't resolve.** Now rejected up front with a message pointing at the `/photos/<name>` form.
17
+ - **`drive_list`'s `size` was the encrypted storage size summed across every revision**, not the file's real size (a 1-byte file showed 79; it doubled after a new revision). Real size is now `size`; the old value is `storageSize`.
18
+ - **`drive_move` misreported a missing source as "Destination already exists"**, because the destination was checked before the source. Now reports "Source not found" correctly, and hints at the fix when the "existing destination" is actually the target folder itself (a path/parent mix-up seen in live testing).
19
+ - **`drive_share_status` on a Drive root (`/my-files`, etc.) failed with a raw CLI decryption error**, which also broke this project's own live smoke test. Roots now get a clear "cannot be shared" error without calling the CLI.
20
+ - **A folder name ending in a backslash made its children unaddressable** (`tail\` + child `\/` is read as an escaped slash by the CLI, which has no way to escape a literal backslash). No fix exists upstream; documented as a known limitation.
21
+ - **The Claude Desktop config example didn't set `PROTON_DRIVE_BIN`**, and Claude Desktop launches servers with a minimal `PATH` that usually can't find `proton-drive`. The example now sets it, and the "CLI not found" error mentions the variable.
22
+
23
+ ### Also fixed
24
+ - Non-UTF-8 text read through the sync folder was silently corrupted (replacement characters, no warning) — now a clear error.
25
+ - The invite-message limit was 2000 characters; the CLI enforces 500 — now matched.
26
+ - `drive_mkdir` accepted a spaces-only name while `drive_rename` rejected one — now consistent.
27
+ - `drive_share_remove_all` said "Removed all access" while an active public link stayed live — now says so, and how to remove it.
28
+ - Raw upstream error codes (`InvalidRequirementsAPIError` 2000, `APICodeError` 2511/2500) are now mapped to a readable sentence, with the code kept in parentheses; bundled CLI source lines and bare `Error details: {}` are stripped from error text.
29
+ - Read-only CLI calls now retry (up to twice) on the CLI's own `database is locked` error, seen live when multiple MCP clients share one CLI session concurrently; writes are never retried.
30
+ - `photos_list_album_photos` without `loadDetails` has no `captureTime`, so its description now says the order is by `nodeUid`, not capture time.
31
+
32
+ ### Verified with no changes needed
33
+ Data integrity (sha256 round-trips up to 200 MB, 500+ file trees, every upload/download conflict strategy, `drive_copy`), the real sync folder end to end, 3 concurrent MCP server processes sharing one CLI session, a 20+ minute long-running session (no memory or process leaks), server-kill-mid-upload recovery, the official MCP SDK client and MCP Inspector, Node 20/22/24, a packed-and-installed npm tarball launched via its real `.bin` symlink, and dozens of adversarial edge cases (special Drive roots, Unicode name tricks, 40-level nesting, etc.).
34
+
35
+ ### Known, not fixed (upstream CLI limitations, now documented in README)
36
+ Big folders can't be copied; a `"` in a name becomes `_` on local download; a public link's expiration caps at ~90 days; sharing inherited from a parent folder isn't reported by `drive_share_status`; upload/download counts include folders.
37
+
38
+ ### Testing status
39
+ `drive_share_leave`, `drive_invitation_accept` and `drive_invitation_reject` have never been tested against a real account — they need a second Proton account, which the maintainer doesn't have. Only unit-tested against a fake CLI. Documented in README.
40
+
3
41
  ## 1.0.39 — 2026-09-26
4
42
 
5
43
  Test-suite hardening only — no `src/` changes, no behavior change.
package/README.md CHANGED
@@ -93,12 +93,15 @@ Add to your `claude_desktop_config.json`:
93
93
  "mcpServers": {
94
94
  "proton-drive": {
95
95
  "command": "npx",
96
- "args": ["-y", "proton-drive-mcp"]
96
+ "args": ["-y", "proton-drive-mcp"],
97
+ "env": { "PROTON_DRIVE_BIN": "/absolute/path/to/proton-drive" }
97
98
  }
98
99
  }
99
100
  }
100
101
  ```
101
102
 
103
+ Claude Desktop starts servers with a minimal `PATH`, so a `proton-drive` in `~/.local/bin` is usually not found. Set `PROTON_DRIVE_BIN` to the output of `which proton-drive` (the default install location is `~/.local/bin/proton-drive`, written out in full, e.g. `/Users/you/.local/bin/proton-drive`). If `npx` itself is not found, use its absolute path as `command` (`which npx`).
104
+
102
105
  Restart Claude Desktop. Check **`+` → Connectors → proton-drive** to confirm the server is connected.
103
106
 
104
107
  > **Tip:** Make sure `proton-drive auth login` has been run at least once before starting Claude Desktop.
@@ -330,6 +333,26 @@ proton-drive-cli share status /my-files/Projects
330
333
  - Paths are always Drive-absolute: `/my-files/folder/file.pdf`. Relative paths are not supported.
331
334
  - All calls include `--json` automatically, except `drive_version`, whose underlying CLI command ignores `--json` and always prints plain text — this MCP parses it directly.
332
335
 
336
+ ## Known limitations
337
+
338
+ These come from the upstream `proton-drive` CLI (v0.8.0), not from this server:
339
+
340
+ - Big folders cannot be copied yet (`drive_copy` fails with `InvalidRequirementsAPIError` code 2000).
341
+ - A `"` in a name becomes `_` when the item is downloaded locally.
342
+ - Public-link expiration can be at most ~90 days ahead, to the minute.
343
+ - `drive_share_status` only reports sharing set directly on the item — access inherited from a shared parent folder is not shown.
344
+ - Roots (`/my-files`, `/photos`, …) cannot be shared; `drive_share_status` on a root returns an error.
345
+ - `photos_list_timeline` pages are not a snapshot: photos added or removed between calls shift later pages.
346
+ - Upload/download counts include folders, not only files.
347
+ - `/albums/...` paths cannot be downloaded — download a photo via `/photos/<name>`.
348
+ - A name ending in a backslash (e.g. `tail\`, created by another client) cannot be used as a parent in a path: the CLI reads `tail\/child` as an escaped `/`, and has no escape for a literal backslash (`\\` does not work either). The folder itself is reachable; its children are not reachable by path.
349
+ - Trashed items are addressed by name only. When two items in `/trash` or `/photos-trash` share a name, `drive_restore` and `drive_delete` refuse (listing the uids) instead of acting on an arbitrary one — restore or delete that item in the Proton Drive web or desktop app.
350
+ - When several programs use the CLI at the same moment (e.g. Claude Desktop and Claude Code), its local cache can briefly report `database is locked`. Read-only calls are retried automatically; writes are not (a retry could repeat the change), so just run the write again.
351
+
352
+ ## Testing status
353
+
354
+ Every tool group was live-tested on 2026-09-28 against a real Proton account, **except** `drive_share_leave`, `drive_invitation_accept` and `drive_invitation_reject`. Those three have never been tested against a real account, because the maintainer has no second Proton account to share from; they are only unit-tested against a fake CLI.
355
+
333
356
  ---
334
357
 
335
358
  ## Environment variables
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ Commands:
32
32
  'remove' deletes the existing LOCAL item and needs --confirm)
33
33
  rename <path> <new-name> Rename in place, no move
34
34
  move <src> <dst> Move and/or rename
35
- delete <path> --confirm Delete a file/folder already in trash, permanently
35
+ delete <path>|--uid <uid> --confirm Delete a file/folder already in trash, permanently
36
36
  share status <path> Show sharing info
37
37
  share invite <path> <email> <role> Invite user (viewer/editor/admin)
38
38
  share revoke <path> <email> Revoke one user's access
@@ -44,7 +44,7 @@ Commands:
44
44
  trash <path> Move to trash
45
45
  trash list List trash contents
46
46
  trash empty --confirm Permanently delete all trash
47
- restore <path> Restore from trash
47
+ restore <path>|--uid <uid> Restore from trash (uid from 'trash list')
48
48
  invitation list List pending invitations
49
49
  invitation accept <uid> Accept an invitation
50
50
  invitation reject <uid> Reject an invitation
@@ -119,7 +119,7 @@ async function run() {
119
119
  if (!cliCheck.available) {
120
120
  const msg = cliCheck.reason === "not_executable"
121
121
  ? "Error: proton-drive CLI found but not executable.\nRun: chmod +x $(which proton-drive)"
122
- : "Error: proton-drive CLI not found in PATH.\nDownload from https://proton.me/download/drive/cli/index.html";
122
+ : "Error: proton-drive CLI not found in PATH.\nDownload from https://proton.me/download/drive/cli/index.html, or set PROTON_DRIVE_BIN to its absolute path (find it with `which proton-drive`).";
123
123
  console.error(msg);
124
124
  process.exit(1);
125
125
  }
@@ -241,13 +241,15 @@ async function run() {
241
241
  console.log("Folder created.");
242
242
  break;
243
243
  case "delete": {
244
- const delPath = requirePath(sub, "delete <path> --confirm");
244
+ const delUid = getFlag("--uid");
245
+ const delPath = delUid && (!sub || sub.startsWith("--")) ? undefined : requirePath(sub, "delete <path>|--uid <uid> --confirm");
246
+ const delLabel = delPath ?? `uid ${delUid}`;
245
247
  if (!args.includes("--confirm")) {
246
- console.error(`This permanently deletes an item already in trash: ${delPath}\nPass --confirm to proceed.`);
248
+ console.error(`This permanently deletes an item already in trash: ${delLabel}\nPass --confirm to proceed.`);
247
249
  process.exit(1);
248
250
  }
249
- await drive.delete(delPath);
250
- console.log(`Deleted: ${delPath}`);
251
+ await drive.delete(delPath, delUid);
252
+ console.log(`Deleted: ${delLabel}`);
251
253
  break;
252
254
  }
253
255
  case "share":
@@ -288,10 +290,11 @@ async function run() {
288
290
  console.error(`This removes everyone's access to: ${removeAllPath}\nPass --confirm to proceed.`);
289
291
  process.exit(1);
290
292
  }
291
- const removedCount = await drive.shareRemoveAll(removeAllPath);
292
- console.log(removedCount === 0
293
+ const removeAll = await drive.shareRemoveAll(removeAllPath);
294
+ console.log((removeAll.removed === 0
293
295
  ? `Nothing to remove: ${removeAllPath} has no members or pending invitations.`
294
- : `Removed all access (${removedCount} member/invitation(s)) to: ${removeAllPath}`);
296
+ : `Removed all members and invitations (${removeAll.removed}) from: ${removeAllPath}`) +
297
+ (removeAll.publicLink ? " A public link is still active — remove it with: share remove-url <path>" : ""));
295
298
  }
296
299
  else if (sub === "set-url") {
297
300
  const setUrlPath = requirePath(rest[0], "share set-url <path> [--role] [--password] [--expiration]");
@@ -342,10 +345,13 @@ async function run() {
342
345
  process.exit(1);
343
346
  }
344
347
  break;
345
- case "restore":
346
- await drive.restore(requirePath(sub, "restore <path>"));
348
+ case "restore": {
349
+ const restoreUid = getFlag("--uid");
350
+ const restorePath = restoreUid && (!sub || sub.startsWith("--")) ? undefined : requirePath(sub, "restore <path>|--uid <uid>");
351
+ await drive.restore(restorePath, restoreUid);
347
352
  console.log("Restored.");
348
353
  break;
354
+ }
349
355
  case "copy": {
350
356
  const copySrc = requirePath(sub, "copy <src> <dst>");
351
357
  const copyDst = requirePath(rest[0], "copy <src> <dst>");
@@ -452,7 +458,7 @@ async function run() {
452
458
  return;
453
459
  }
454
460
  await drive.updateAlbum(albumPath, newName, coverPhotoUid);
455
- console.log(`Album updated: ${albumPath}`);
461
+ console.log(`Album updated: ${newName ? `/albums/${newName}` : albumPath}`);
456
462
  }
457
463
  else if (sub === "delete") {
458
464
  const albumPath = requirePath(rest[0], "album delete <path> [--force] [--save] --confirm");
package/dist/index.js CHANGED
@@ -55,6 +55,18 @@ function paginate(all, a, defaultLimit) {
55
55
  const items = all.slice(offset, offset + limit);
56
56
  return { total: all.length, offset, limit, hasMore: offset + items.length < all.length, items };
57
57
  }
58
+ // Every page re-runs the CLI, whose order is not stable, so paginated lists
59
+ // must be put in a total order before slicing or pages lose/duplicate items.
60
+ function byTimeDescThenId(time, id) {
61
+ return (x, y) => {
62
+ const t = (time(y) ?? "").localeCompare(time(x) ?? "");
63
+ if (t)
64
+ return t;
65
+ const a = id(x) ?? "", b = id(y) ?? "";
66
+ return a < b ? -1 : a > b ? 1 : 0;
67
+ };
68
+ }
69
+ const photoOrder = byTimeDescThenId((p) => p.captureTime, (p) => p.nodeUid);
58
70
  // The schemas advertise additionalProperties:false, but nothing enforced it, and
59
71
  // String() coercion let e.g. an array pass as a path. Enforce the declared schema.
60
72
  function checkArgs(def, a) {
@@ -127,7 +139,7 @@ const TOOLS = [
127
139
  {
128
140
  name: "drive_list",
129
141
  description: "List the immediate children of a Proton Drive folder. Requires authentication. " +
130
- "Returns {items, total, offset, limit, hasMore} (default limit 200); items are [{name, path, type ('file'|'folder'), size?, modifiedAt?, mimeType?}]. Listing '/' returns the top-level roots. " +
142
+ "Returns {items, total, offset, limit, hasMore} (default limit 200, sorted by name); items are [{name, path, type ('file'|'folder'), size? (real file size in bytes), storageSize? (encrypted storage used by all revisions), modifiedAt?, mimeType?}]. Items come in a stable sorted order; each page is a fresh read, so changes made between page calls can still shift items. Listing '/' returns the top-level roots. " +
131
143
  "Not recursive — one directory level only. " +
132
144
  "Use before drive_upload to confirm the destination exists, or before drive_download to verify the remote path. " +
133
145
  "Do not use to list trash — use drive_list_trash instead.",
@@ -207,11 +219,10 @@ const TOOLS = [
207
219
  {
208
220
  name: "drive_download",
209
221
  description: "Download a file or folder from Proton Drive to the local filesystem. Requires authentication. " +
210
- "localPath is a destination FOLDER, not the file's exact final path — the CLI creates it automatically if missing and places the downloaded item inside it under its original remote name. " +
211
- "E.g. downloading /my-files/report.pdf with localPath '/tmp/out' produces /tmp/out/report.pdf, not /tmp/out itself as a file — confirmed live against the real CLI (v0.8.0). " +
212
- "For folders, downloads recursively. " +
213
- "Conflict strategies are set separately for files and folders (CLI v0.8.0+) — both default to 'skip'. " +
214
- "Returns {downloaded, skipped, failed} counts — not the actual local path; construct it as localPath + the remote item's basename if you need it. Fails the call if any file failed to download. " +
222
+ "localPath is a destination FOLDER, not the file's final path — created if missing; the item is placed inside it under its remote name. " +
223
+ "E.g. /my-files/report.pdf with localPath '/tmp/out' produces /tmp/out/report.pdf. " +
224
+ "Folders download recursively. File and folder conflict strategies are separate; both default to 'skip'. " +
225
+ "Returns {downloaded, skipped, failed} counts (folders count as items), not the local path — it is localPath + the remote basename. Fails the call if any file failed. " +
215
226
  "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).",
216
227
  annotations: { openWorldHint: true },
217
228
  inputSchema: {
@@ -314,7 +325,8 @@ const TOOLS = [
314
325
  name: "drive_delete",
315
326
  description: "Permanently delete a file or folder that is already in the Proton Drive trash — irreversible. Requires authentication. " +
316
327
  "The underlying CLI only allows permanent deletion of items already inside /trash or /photos-trash; it rejects live paths. " +
317
- "Use drive_trash first to move a live item into trash, then pass its trash path here — or drive_empty_trash to clear everything at once. " +
328
+ "Use drive_trash first to move a live item into trash, then pass its trash path (or its uid from drive_list_trash) here — or drive_empty_trash to clear everything at once. " +
329
+ "If several trashed items share the name, this refuses and lists their uids: the CLI can only address trashed items by name, so it cannot pick one of them (even by uid) — use the Proton Drive web or desktop app for that. " +
318
330
  "Requires confirmed=true; always show the exact path to the user and get explicit confirmation before calling.",
319
331
  annotations: { destructiveHint: true },
320
332
  inputSchema: {
@@ -322,14 +334,18 @@ const TOOLS = [
322
334
  properties: {
323
335
  path: {
324
336
  type: "string",
325
- description: "Absolute remote Drive path to permanently delete (must start with '/').",
337
+ description: "Absolute remote Drive path to permanently delete (must start with '/'). Give path or uid (or both).",
338
+ },
339
+ uid: {
340
+ type: "string",
341
+ description: "Trash uid from drive_list_trash. Pins the exact item you listed; if both path and uid are given they must refer to the same item.",
326
342
  },
327
343
  confirmed: {
328
344
  type: "boolean",
329
345
  description: "Must be true. Confirms the user has acknowledged this deletion is permanent and cannot be undone.",
330
346
  },
331
347
  },
332
- required: ["path", "confirmed"],
348
+ required: ["confirmed"],
333
349
  additionalProperties: false,
334
350
  },
335
351
  },
@@ -357,7 +373,8 @@ const TOOLS = [
357
373
  {
358
374
  name: "drive_list_trash",
359
375
  description: "List all files and folders currently in the Proton Drive trash. Requires authentication. " +
360
- "Returns {items, total, offset, limit, hasMore} (default limit 100, newest first when the CLI reports trash times); items are [{name, path, type, size?, modifiedAt?, uid, trashedAt?}]. Names are NOT unique in trash — two items can share one path; use uid to tell them apart. " +
376
+ "Returns {items, total, offset, limit, hasMore} (default limit 100, newest first, ties by uid); items are [{name, path, type, size?, storageSize?, modifiedAt?, uid, trashedAt?}]. Items come in a stable sorted order; each page is a fresh read, so changes made between page calls can still shift items. Names are NOT unique in trash — two items can share one path; uid and trashedAt tell them apart. " +
377
+ "drive_restore/drive_delete accept the uid to pin the exact item, but refuse when its name is shared (the CLI cannot target one duplicate). " +
361
378
  "Use before drive_restore to find a trashed item's exact path, or before drive_empty_trash to show the user what will be permanently deleted. " +
362
379
  "Do not use to list active (non-trashed) files — use drive_list instead.",
363
380
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -389,7 +406,7 @@ const TOOLS = [
389
406
  },
390
407
  message: {
391
408
  type: "string",
392
- description: "Optional message included in the invitation email (max 2000 characters).",
409
+ description: "Optional message included in the invitation email (max 500 characters — Proton rejects longer ones).",
393
410
  },
394
411
  },
395
412
  required: ["path", "email", "role"],
@@ -443,7 +460,8 @@ const TOOLS = [
443
460
  {
444
461
  name: "drive_restore",
445
462
  description: "Restore a trashed file or folder back to its original Proton Drive path. Requires authentication. " +
446
- "Use drive_list_trash first to find the item's current path in trash. " +
463
+ "Use drive_list_trash first to find the item's path (or uid) in trash. " +
464
+ "If several trashed items share the name, this refuses and lists their uids: the CLI can only address trashed items by name, so it cannot pick one of them (even by uid) — use the Proton Drive web or desktop app for that. " +
447
465
  "Fails if the original parent folder no longer exists or if a new item with the same name was created at that path since it was trashed. " +
448
466
  "Do not use for items not currently in trash — it will return an error.",
449
467
  annotations: { destructiveHint: false },
@@ -452,10 +470,14 @@ const TOOLS = [
452
470
  properties: {
453
471
  path: {
454
472
  type: "string",
455
- description: "Absolute remote Drive path of the item to restore, as shown in drive_list_trash output (must start with '/').",
473
+ description: "Absolute remote Drive path of the item to restore, as shown in drive_list_trash output (must start with '/'). Give path or uid (or both).",
474
+ },
475
+ uid: {
476
+ type: "string",
477
+ description: "Trash uid from drive_list_trash. Pins the exact item you listed; if both path and uid are given they must refer to the same item.",
456
478
  },
457
479
  },
458
- required: ["path"],
480
+ required: [],
459
481
  additionalProperties: false,
460
482
  },
461
483
  },
@@ -626,6 +648,7 @@ const TOOLS = [
626
648
  name: "drive_share_remove_all",
627
649
  description: "Remove access for every member and every pending invitation (Proton and non-Proton) on a shared Proton Drive path, in a single call. Requires authentication and confirmed=true. " +
628
650
  "Use drive_share_status first to show the user who currently has access. " +
651
+ "Does NOT remove a public link — the result says if one is still active; remove it with drive_share_remove_url. " +
629
652
  "For removing one specific person, use drive_share_revoke instead — it is cheaper and less error-prone.",
630
653
  annotations: { destructiveHint: true },
631
654
  inputSchema: {
@@ -701,8 +724,8 @@ const TOOLS = [
701
724
  {
702
725
  name: "photos_delete_album",
703
726
  description: "Delete a Proton Photos album. Requires authentication and confirmed=true. " +
704
- "By default refuses to delete an album that still contains photos — pass force=true to override. " +
705
- "Photos live in your timeline independently of albums. save maps to the CLI's --save option (its exact effect is undocumented upstream) — leave it off unless the user asks. " +
727
+ "Refuses to delete an album that still contains photos unless force=true. " +
728
+ "Deleting an album never deletes your own photos — they stay in your timeline. save maps to the CLI's --save option (undocumented upstream; live testing showed no observable difference for your own photos) — leave it off unless the user asks. " +
706
729
  "albumPath must start with /albums/. " +
707
730
  "Always show the user the album name and photo count (from photos_list_albums) before calling.",
708
731
  annotations: { destructiveHint: true },
@@ -723,7 +746,7 @@ const TOOLS = [
723
746
  },
724
747
  save: {
725
748
  type: "boolean",
726
- description: "If true, save album photos to your timeline before deleting. Default false.",
749
+ description: "Passes the CLI's --save option (undocumented; no observable effect for your own photos, which always stay in the timeline). Default false.",
727
750
  },
728
751
  },
729
752
  required: ["albumPath", "confirmed"],
@@ -733,7 +756,7 @@ const TOOLS = [
733
756
  {
734
757
  name: "photos_list_album_photos",
735
758
  description: "List the photos in a Proton Photos album. Requires authentication. " +
736
- "Returns {items, total, offset, limit, hasMore} (default limit 100); items are [{nodeUid}], or with loadDetails=true also name, mediaType, sizes, captureTime and tags. " +
759
+ "Returns {items, total, offset, limit, hasMore} (default limit 100, newest capture first, ties by nodeUid — without loadDetails there is no captureTime, so the order is by nodeUid); items are [{nodeUid}], or with loadDetails=true also name, mediaType, sizes, captureTime and tags. Items come in a stable sorted order; each page is a fresh read, so changes made between page calls can still shift items. " +
737
760
  "albumPath must start with /albums/. " +
738
761
  "To add or remove photos, use their Drive path under /photos/ (not the nodeUid).",
739
762
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -797,7 +820,7 @@ const TOOLS = [
797
820
  {
798
821
  name: "photos_list_timeline",
799
822
  description: "List photos in your Proton Photos timeline (your full photo library, not scoped to an album). Requires authentication. " +
800
- "Returns {items, total, offset, limit, hasMore} (default limit 50, newest first); items are [{nodeUid, captureTime, tags}], or with loadDetails=true also {name, mediaType, creationTime, totalStorageSize} (about 50% more tokens). " +
823
+ "Returns {items, total, offset, limit, hasMore} (default limit 50, newest first, ties by nodeUid; each page is a fresh read, so changes between page calls can still shift items); items are [{nodeUid, captureTime, tags}], or with loadDetails=true also {name, mediaType, creationTime, totalStorageSize} (about 50% more tokens). " +
801
824
  "Use photos_download to download items by path, or photos_add_to_album to add them to an album.",
802
825
  annotations: { readOnlyHint: true, idempotentHint: true },
803
826
  inputSchema: {
@@ -814,7 +837,8 @@ const TOOLS = [
814
837
  },
815
838
  {
816
839
  name: "photos_download",
817
- description: "Download one or more photos from Proton Photos (timeline, an album, or shared-with-me) to a local folder. Requires authentication. " +
840
+ description: "Download one or more photos from your Proton Photos timeline to a local folder by their /photos/<name> path. Requires authentication. " +
841
+ "/albums/<album>/<photo> paths are not supported (the CLI rejects them) — download an album's photos via their /photos/<name> paths. " +
818
842
  "Multiple timeline photos can share the same filename — with conflictStrategy 'remove' or 'skip' only one copy survives locally; use 'rename' to keep all. " +
819
843
  "Fails if any item fails to download. " +
820
844
  "Do not use for regular Drive files — use drive_download instead.",
@@ -846,7 +870,7 @@ const TOOLS = [
846
870
  {
847
871
  name: "photos_upload",
848
872
  description: "Upload one or more local photo or video files directly into your Proton Photos library (My Photos timeline). Requires authentication. " +
849
- "Non-photo/video files are silently skipped. Folders are recursed but flattened into My Photos — folder structure is not preserved. " +
873
+ "Non-photo/video files are skipped and counted in skippedItems (alongside duplicate skips). Folders are recursed but flattened into My Photos — folder structure is not preserved. " +
850
874
  "Never overwrites — duplicates (matched by name + content hash) resolve to 'rename' or 'skip' only. " +
851
875
  "Do not use for regular Drive files — use drive_upload instead.",
852
876
  annotations: { openWorldHint: true },
@@ -874,7 +898,7 @@ const TOOLS = [
874
898
  description: "Read the text contents of a file from the local Proton Drive sync folder. " +
875
899
  "Requires the PROTON_DRIVE_SYNC_PATH environment variable to point to the root of the synced folder (e.g. /Users/alice/Proton Drive). " +
876
900
  "The Proton Drive desktop app must be running and the file must be synced locally. " +
877
- "Limited to text files up to 1 MB — returns an error for binary files or larger files (use drive_download instead). " +
901
+ "Limited to UTF-8 text files up to 1 MB — returns an error for binary, non-UTF-8 or larger files (use drive_download instead). " +
878
902
  "Do not use for files not yet synced locally, binary files, or files over 1 MB — use drive_download instead.",
879
903
  annotations: { readOnlyHint: true },
880
904
  inputSchema: {
@@ -882,7 +906,7 @@ const TOOLS = [
882
906
  properties: {
883
907
  path: {
884
908
  type: "string",
885
- description: "Absolute remote Drive path of the file to read (must start with '/'). Mapped to the local sync folder. E.g. /my-files/notes.txt",
909
+ description: "Absolute remote Drive path of the file to read (must start with '/'). /my-files/<rest> maps to <PROTON_DRIVE_SYNC_PATH>/<rest> (the sync folder's top level is /my-files). Other Drive roots (/photos, /albums, /trash, /photos-trash, /shared-with-me, /shared-by-me, /devices) are rejected — only /my-files is synced. Legacy: a path not starting with a Drive root is taken relative to the sync folder. E.g. /my-files/notes.txt",
886
910
  },
887
911
  },
888
912
  required: ["path"],
@@ -903,7 +927,7 @@ const TOOLS = [
903
927
  properties: {
904
928
  path: {
905
929
  type: "string",
906
- description: "Absolute remote Drive path of the file to write (must start with '/'). Mapped to the local sync folder. E.g. /my-files/notes.txt",
930
+ description: "Absolute remote Drive path of the file to write (must start with '/'). /my-files/<rest> maps to <PROTON_DRIVE_SYNC_PATH>/<rest> (the sync folder's top level is /my-files). Other Drive roots (/photos, /albums, /trash, /photos-trash, /shared-with-me, /shared-by-me, /devices) are rejected — only /my-files is synced. Legacy: a path not starting with a Drive root is taken relative to the sync folder. E.g. /my-files/notes.txt",
907
931
  },
908
932
  content: {
909
933
  type: "string",
@@ -1059,14 +1083,14 @@ export async function main() {
1059
1083
  if (a.confirmed !== true) {
1060
1084
  return fail("drive_delete requires confirmed=true. Ask the user to confirm before deleting.");
1061
1085
  }
1062
- const deletePath = validateRemotePath(a.path);
1063
- await drive.delete(deletePath);
1064
- return ok({ message: `Deleted: ${deletePath}` });
1086
+ const deletePath = a.path === undefined ? undefined : validateRemotePath(a.path);
1087
+ const deleteUid = a.uid ? validateFlagValue(a.uid, "uid") : undefined;
1088
+ await drive.delete(deletePath, deleteUid);
1089
+ return ok({ message: `Deleted: ${deletePath ?? `uid ${deleteUid}`}` });
1065
1090
  }
1066
1091
  case "drive_list_trash": {
1067
1092
  const trashed = await drive.listTrash();
1068
- if (trashed.some((f) => f.trashedAt))
1069
- trashed.sort((x, y) => (y.trashedAt ?? "").localeCompare(x.trashedAt ?? ""));
1093
+ trashed.sort(byTimeDescThenId((f) => f.trashedAt, (f) => f.uid));
1070
1094
  return ok(paginate(trashed, a, PAGE_DEFAULTS.drive_list_trash));
1071
1095
  }
1072
1096
  case "drive_share_status":
@@ -1102,9 +1126,10 @@ export async function main() {
1102
1126
  return ok({ message: `Moved to trash: ${trashPath}` });
1103
1127
  }
1104
1128
  case "drive_restore": {
1105
- const restorePath = validateRemotePath(a.path);
1106
- await drive.restore(restorePath);
1107
- return ok({ message: `Restored from trash: ${restorePath}` });
1129
+ const restorePath = a.path === undefined ? undefined : validateRemotePath(a.path);
1130
+ const restoreUid = a.uid ? validateFlagValue(a.uid, "uid") : undefined;
1131
+ await drive.restore(restorePath, restoreUid);
1132
+ return ok({ message: `Restored from trash: ${restorePath ?? `uid ${restoreUid}`}` });
1108
1133
  }
1109
1134
  case "drive_empty_trash":
1110
1135
  if (a.confirmed !== true) {
@@ -1173,8 +1198,9 @@ export async function main() {
1173
1198
  return fail("drive_share_remove_all requires confirmed=true. Use drive_share_status first to show the user who has access.");
1174
1199
  }
1175
1200
  const removeAllPath = validateRemotePath(a.path);
1176
- const removedCount = await drive.shareRemoveAll(removeAllPath);
1177
- return ok({ message: removedCount === 0 ? `Nothing to remove: ${removeAllPath} has no members or pending invitations.` : `Removed all access (${removedCount} member/invitation(s)) to: ${removeAllPath}` });
1201
+ const removeAll = await drive.shareRemoveAll(removeAllPath);
1202
+ const linkNote = removeAll.publicLink ? " A public link is still active — anyone with it keeps access; remove it with drive_share_remove_url." : "";
1203
+ return ok({ message: (removeAll.removed === 0 ? `Nothing to remove: ${removeAllPath} has no members or pending invitations.` : `Removed all members and invitations (${removeAll.removed}) from: ${removeAllPath}.`) + linkNote });
1178
1204
  }
1179
1205
  case "photos_list_albums":
1180
1206
  return ok(await drive.listAlbums());
@@ -1194,7 +1220,7 @@ export async function main() {
1194
1220
  if (!newName && !coverPhotoUid)
1195
1221
  return fail("At least one of name or coverPhotoUid must be provided");
1196
1222
  await drive.updateAlbum(updateAlbumPath, newName, coverPhotoUid);
1197
- return ok({ message: `Album updated: ${updateAlbumPath}` });
1223
+ return ok({ message: `Album updated: ${newName ? `/albums/${newName}` : updateAlbumPath}` });
1198
1224
  }
1199
1225
  case "photos_delete_album": {
1200
1226
  if (a.confirmed !== true)
@@ -1209,7 +1235,7 @@ export async function main() {
1209
1235
  const albumListPath = validateRemotePath(a.albumPath);
1210
1236
  if (!albumListPath.startsWith("/albums/"))
1211
1237
  return fail("albumPath must start with /albums/");
1212
- return ok(paginate(await drive.listAlbumPhotos(albumListPath, a.loadDetails === true), a, PAGE_DEFAULTS.photos_list_album_photos));
1238
+ return ok(paginate((await drive.listAlbumPhotos(albumListPath, a.loadDetails === true)).sort(photoOrder), a, PAGE_DEFAULTS.photos_list_album_photos));
1213
1239
  }
1214
1240
  case "photos_add_to_album": {
1215
1241
  const addAlbumPath = validateRemotePath(a.albumPath);
@@ -1235,7 +1261,7 @@ export async function main() {
1235
1261
  return ok({ message: `Removed ${remPhotoPath} from ${remAlbumPath}` });
1236
1262
  }
1237
1263
  case "photos_list_timeline":
1238
- return ok(paginate(await drive.photoTimeline(a.loadDetails === true), a, PAGE_DEFAULTS.photos_list_timeline));
1264
+ return ok(paginate((await drive.photoTimeline(a.loadDetails === true)).sort(photoOrder), a, PAGE_DEFAULTS.photos_list_timeline));
1239
1265
  case "photos_download": {
1240
1266
  if (!Array.isArray(a.photoPaths) || a.photoPaths.length === 0)
1241
1267
  return fail("photoPaths must be a non-empty array of strings");
@@ -1336,7 +1362,7 @@ export async function main() {
1336
1362
  if (!cliCheck.available) {
1337
1363
  logger.error(cliCheck.reason === "not_executable"
1338
1364
  ? "proton-drive CLI found but not executable. Run: chmod +x $(which proton-drive)"
1339
- : "proton-drive CLI not found. Download from https://proton.me/download/drive/cli/index.html");
1365
+ : "proton-drive CLI not found. Download from https://proton.me/download/drive/cli/index.html, or set PROTON_DRIVE_BIN to its absolute path (find it with `which proton-drive`).");
1340
1366
  }
1341
1367
  });
1342
1368
  }
@@ -1,5 +1,6 @@
1
1
  import { runDrive as defaultRunDrive, runDriveRaw as defaultRunDriveRaw } from "../utils/subprocess.js";
2
2
  import { DriveNotAuthenticatedError, DriveParseError } from "../utils/errors.js";
3
+ import { validateName } from "../utils/validation.js";
3
4
  // The SDK verifies the author of every name (and several other fields)
4
5
  // cryptographically and returns a `Result<string, Error>`-shaped object —
5
6
  // {ok:true, value:"name"} on success, {ok:false, error:{...}} if the
@@ -54,6 +55,14 @@ function joinRemote(parent, escapedName) {
54
55
  // went on to rename whatever OTHER item happened to already be sitting at the
55
56
  // computed destination path — silently renaming an unrelated file while the
56
57
  // real source never moved.
58
+ // The API's bare codes are opaque; these meanings were confirmed live
59
+ // (2026-09-28) for the action they are keyed by.
60
+ const READABLE_ERRORS = {
61
+ "Copy|InvalidRequirementsAPIError|2000": "Proton cannot copy this item: big folders cannot be copied yet (CLI limitation)",
62
+ "Move|InvalidRequirementsAPIError|2000": "the destination is inside the source, or the source no longer exists",
63
+ "Restore|APICodeError|2511": "its original parent folder is still in the trash — restore the parent first",
64
+ "Add to album|APICodeError|2500": "that photo is already in the album",
65
+ };
57
66
  function assertItemsOk(result, action) {
58
67
  if (!Array.isArray(result))
59
68
  return;
@@ -63,6 +72,9 @@ function assertItemsOk(result, action) {
63
72
  const name = String(err.name ?? "unknown error");
64
73
  const code = typeof err.code !== "undefined" ? ` (code ${err.code})` : "";
65
74
  const message = typeof err.message === "string" && err.message ? `: ${err.message}` : "";
75
+ const readable = READABLE_ERRORS[`${action}|${name}|${err.code}`];
76
+ if (readable)
77
+ throw new Error(`${action} failed: ${readable} (${name}${code}${message})`);
66
78
  // Only a real collision deserves the collision hint — the same wrapper also
67
79
  // reports move-into-itself, restore-with-trashed-parent, etc.
68
80
  const hint = name === "NodeWithSameNameExistsValidationError"
@@ -72,6 +84,9 @@ function assertItemsOk(result, action) {
72
84
  }
73
85
  }
74
86
  }
87
+ // Confirmed live: `sharing status /my-files` fails with "Error decrypting
88
+ // session keys" — roots are not shareable nodes.
89
+ const ROOT_PATHS = new Set(["/my-files", "/photos", "/albums", "/trash", "/photos-trash", "/shared-with-me", "/shared-by-me", "/devices"]);
75
90
  // Strips "<package-name>@" and "+<hash>" from a version token like
76
91
  // "cli-drive@0.8.0+06e8c605", leaving "0.8.0". Falls back to the raw
77
92
  // token if it doesn't match, so an unexpected future format still shows
@@ -181,26 +196,34 @@ export class DriveService {
181
196
  return [];
182
197
  if (!Array.isArray(result))
183
198
  throw new DriveParseError(`Expected array from list, got: ${JSON.stringify(result).slice(0, 100)}`);
184
- return result.map((item) => {
185
- if (item.name === undefined && typeof item.path === "string") {
186
- return { name: item.path.replace(/^\//, ""), path: item.path, type: "folder" };
187
- }
188
- const name = unwrapResult(item.name, "[unnamed]");
189
- const file = {
190
- name,
191
- path: joinRemote(remotePath, escapeNameForPath(name)),
192
- type: item.type === "folder" || item.type === "album" ? "folder" : "file",
193
- size: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
194
- modifiedAt: typeof item.modificationTime === "string" ? item.modificationTime : undefined,
195
- mimeType: typeof item.mediaType === "string" ? item.mediaType : undefined,
196
- };
197
- if (opts.includeUid) {
198
- file.uid = typeof item.uid === "string" ? item.uid : undefined;
199
- const trashed = item.trashTime ?? item.trashedTime;
200
- file.trashedAt = typeof trashed === "string" ? trashed : undefined;
201
- }
202
- return file;
203
- });
199
+ // The CLI's order changes between calls and callers page by re-listing, so
200
+ // impose a total order: name, then uid (names are not unique).
201
+ const uidOf = (item) => (typeof item.uid === "string" ? item.uid : "");
202
+ const entries = result.map((item) => ({ item, file: this.mapListItem(item, remotePath, opts) }));
203
+ entries.sort((x, y) => x.file.name.localeCompare(y.file.name) || (uidOf(x.item) < uidOf(y.item) ? -1 : uidOf(x.item) > uidOf(y.item) ? 1 : 0));
204
+ return entries.map((e) => e.file);
205
+ }
206
+ mapListItem(item, remotePath, opts) {
207
+ if (item.name === undefined && typeof item.path === "string") {
208
+ return { name: item.path.replace(/^\//, ""), path: item.path, type: "folder" };
209
+ }
210
+ const name = unwrapResult(item.name, "[unnamed]");
211
+ const rev = (item.activeRevision ?? {});
212
+ const file = {
213
+ name,
214
+ path: joinRemote(remotePath, escapeNameForPath(name)),
215
+ type: item.type === "folder" || item.type === "album" ? "folder" : "file",
216
+ size: typeof rev.claimedSize === "number" ? rev.claimedSize : undefined,
217
+ storageSize: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
218
+ modifiedAt: typeof item.modificationTime === "string" ? item.modificationTime : undefined,
219
+ mimeType: typeof item.mediaType === "string" ? item.mediaType : undefined,
220
+ };
221
+ if (opts.includeUid) {
222
+ file.uid = typeof item.uid === "string" ? item.uid : undefined;
223
+ const trashed = item.trashTime ?? item.trashedTime;
224
+ file.trashedAt = typeof trashed === "string" ? trashed : undefined;
225
+ }
226
+ return file;
204
227
  }
205
228
  async upload(localPath, remotePath, fileConflictStrategy = "skip", folderConflictStrategy = "skip") {
206
229
  const result = await this.run([
@@ -253,6 +276,7 @@ export class DriveService {
253
276
  if (name.includes("\\/")) {
254
277
  throw new Error(`folder name must not contain '/': ${remotePath}`);
255
278
  }
279
+ validateName(name);
256
280
  await this.run(["filesystem", "create-folder", parent, name]);
257
281
  }
258
282
  // Returns metadata (including latest revision details) for a single file or
@@ -297,9 +321,24 @@ export class DriveService {
297
321
  assertItemsOk(await this.run(["filesystem", "move", sourcePath, dst.parent]), "Move");
298
322
  return;
299
323
  }
300
- const dstNames = new Set((await this.list(dst.parent)).map((f) => f.name));
324
+ const dstItems = await this.list(dst.parent);
325
+ const dstNames = new Set(dstItems.map((f) => f.name));
301
326
  if (dstNames.has(dst.name)) {
302
- throw new Error(`Destination already exists: ${destinationPath}`);
327
+ // Only checked on this error path (costs a call): a missing source was
328
+ // otherwise misreported as "Destination already exists".
329
+ try {
330
+ await this.info(sourcePath);
331
+ }
332
+ catch (err) {
333
+ if (err instanceof Error && /not found/i.test(err.message)) {
334
+ throw new Error(`Source not found: ${sourcePath}`);
335
+ }
336
+ throw err;
337
+ }
338
+ // Seen in live testing: passing the target folder itself as destinationPath is an easy mistake.
339
+ const isFolder = dstItems.find((f) => f.name === dst.name)?.type === "folder";
340
+ const hint = isFolder ? ` — it is a folder; to move into it, pass the full new path, e.g. ${joinRemote(destinationPath, src.name)}` : "";
341
+ throw new Error(`Destination already exists: ${destinationPath}${hint}`);
303
342
  }
304
343
  if (!dstNames.has(unescapeName(src.name))) {
305
344
  assertItemsOk(await this.run(["filesystem", "move", sourcePath, dst.parent]), "Move");
@@ -330,8 +369,9 @@ export class DriveService {
330
369
  // `filesystem delete` permanently deletes — but only items already inside
331
370
  // /trash or /photos-trash (the CLI rejects live paths). No --confirm flag
332
371
  // exists on the CLI side; our own confirmed-gate lives in the MCP layer.
333
- async delete(remotePath) {
334
- assertItemsOk(await this.run(["filesystem", "delete", remotePath]), "Delete");
372
+ async delete(remotePath, uid) {
373
+ const target = await this.resolveTrashTarget(remotePath, uid);
374
+ assertItemsOk(await this.run(["filesystem", "delete", target]), "Delete");
335
375
  }
336
376
  // Sharing
337
377
  //
@@ -347,6 +387,9 @@ export class DriveService {
347
387
  // nonProtonInvitations, not members — reading only `members` made a real,
348
388
  // successfully-sent invite completely invisible from this tool.
349
389
  async shareStatus(remotePath) {
390
+ if (ROOT_PATHS.has(remotePath)) {
391
+ throw new Error(`Roots cannot be shared — pass a file or folder inside it: ${remotePath}`);
392
+ }
350
393
  const result = await this.run(["sharing", "status", remotePath]);
351
394
  const r = (result ?? {});
352
395
  const VALID_ROLES = new Set(["viewer", "editor", "admin"]);
@@ -396,14 +439,16 @@ export class DriveService {
396
439
  }
397
440
  await this.shareRemove(remotePath, [match.email], false);
398
441
  }
399
- // Removes every member and pending invitation. Returns how many there were —
400
- // 0 means the item wasn't shared and nothing was sent to the CLI.
442
+ // Removes every member and pending invitation. `removed` is how many there
443
+ // were — 0 means nothing was sent to the CLI. `--everyone` does not touch the
444
+ // public link, so `publicLink` reports whether one is still active.
401
445
  async shareRemoveAll(remotePath) {
402
446
  const status = await this.shareStatus(remotePath);
447
+ const publicLink = Boolean(status.shareUrl);
403
448
  if (status.members.length === 0)
404
- return 0;
449
+ return { removed: 0, publicLink };
405
450
  await this.shareRemove(remotePath, [], true);
406
- return status.members.length;
451
+ return { removed: status.members.length, publicLink };
407
452
  }
408
453
  // General form of remove: specific emails, or --everyone to strip all
409
454
  // members and pending invitations (Proton and non-Proton) in one call.
@@ -418,12 +463,13 @@ export class DriveService {
418
463
  }
419
464
  // NOTE: set-url REPLACES the link's settings. Confirmed live: re-running it
420
465
  // without a password/expiration silently turned a password-protected link
421
- // into an open one (same URL). When that is about to happen, say so.
466
+ // into an open one (same URL), and setting only a password cleared an
467
+ // existing expiration. When that is about to happen, say so.
422
468
  async shareSetUrl(remotePath, role = "viewer", password, expiration) {
423
469
  let droppedProtection = false;
424
- if (!password && !expiration) {
470
+ if (!password || !expiration) {
425
471
  const before = await this.shareStatus(remotePath).catch(() => undefined);
426
- droppedProtection = Boolean(before?.sharePasswordProtected || before?.shareUrlExpiresAt);
472
+ droppedProtection = Boolean((!password && before?.sharePasswordProtected) || (!expiration && before?.shareUrlExpiresAt));
427
473
  }
428
474
  const args = ["sharing", "set-url", remotePath, "--role", role];
429
475
  if (password)
@@ -433,7 +479,7 @@ export class DriveService {
433
479
  const result = await this.run(args);
434
480
  const link = this.parsePublicLink(result);
435
481
  if (droppedProtection) {
436
- link.warning = "This link previously had a password and/or expiration; set_url replaces link settings, so they were removed. Pass password/expiration again to keep them.";
482
+ link.warning = "This link previously had a password and/or expiration; set_url replaces link settings, so whichever of them you did not pass again was removed. Pass both password and expiration to keep them.";
437
483
  }
438
484
  return link;
439
485
  }
@@ -463,8 +509,46 @@ export class DriveService {
463
509
  async trash(remotePath) {
464
510
  assertItemsOk(await this.run(["filesystem", "trash", remotePath]), "Trash");
465
511
  }
466
- async restore(remotePath) {
467
- assertItemsOk(await this.run(["filesystem", "restore", remotePath]), "Restore");
512
+ async restore(remotePath, uid) {
513
+ const target = await this.resolveTrashTarget(remotePath, uid);
514
+ assertItemsOk(await this.run(["filesystem", "restore", target]), "Restore");
515
+ }
516
+ // Confirmed live against CLI v0.8.0: restore/delete accept only /trash
517
+ // paths, and the CLI resolves /trash/<x> by decrypted NAME, taking the
518
+ // first match — "/trash/<uid>", a bare uid and "/my-files/<uid>" are all
519
+ // rejected, and renaming a trashed node fails. So a specific item among
520
+ // same-named duplicates cannot be addressed at all; the only safe move is
521
+ // to refuse. The uid pins the exact item the caller saw in drive_list_trash
522
+ // and is translated to its /trash/<name> path once that name is unique.
523
+ // /photos-trash gets the same duplicate check: it holds the user's trashed
524
+ // photos, where a same-named pair is easy to end up with.
525
+ // The lookup and the CLI call are two steps, so an item trashed under the
526
+ // same name in between is not detected.
527
+ async resolveTrashTarget(remotePath, uid) {
528
+ if (!remotePath && !uid)
529
+ throw new Error("Provide path or uid (from drive_list_trash).");
530
+ const root = remotePath?.match(/^\/(trash|photos-trash)\/(?:[^/\\]|\\.)+$/)?.[1];
531
+ if (!uid && !root)
532
+ return remotePath;
533
+ const trashed = await this.list(`/${root ?? "trash"}`, { includeUid: true });
534
+ let target = remotePath;
535
+ if (uid) {
536
+ const item = trashed.find((f) => f.uid === uid);
537
+ if (!item)
538
+ throw new Error(`No item with uid ${uid} in trash. Call drive_list_trash for current uids.`);
539
+ if (remotePath !== undefined && remotePath !== item.path) {
540
+ throw new Error(`path ${remotePath} does not match uid ${uid} (which is ${item.path} in trash).`);
541
+ }
542
+ target = item.path;
543
+ }
544
+ const matches = trashed.filter((f) => f.path === target);
545
+ if (matches.length > 1) {
546
+ const list = matches.map((f) => `uid ${f.uid ?? "?"} (trashed ${f.trashedAt ?? "unknown"})`).join("; ");
547
+ throw new Error(`${matches.length} trashed items share the path ${target}: ${list}. ` +
548
+ "The proton-drive CLI can only address trashed items by name, so it would act on an arbitrary one — refusing. " +
549
+ "Restore or delete the intended item in the Proton Drive web or desktop app instead.");
550
+ }
551
+ return target;
468
552
  }
469
553
  async emptyTrash() {
470
554
  await this.run(["filesystem", "empty-trash"]);
@@ -558,6 +642,18 @@ export class DriveService {
558
642
  }
559
643
  async deleteAlbum(albumPath, force, save) {
560
644
  await this.assertAlbumUnambiguous(albumPath);
645
+ // CLI 0.8.0 deletes a non-empty album even without --force (confirmed live).
646
+ if (!force) {
647
+ const name = unescapeName(albumPath.replace(/^\/albums\//, ""));
648
+ const album = (await this.listAlbums()).find((a) => a.name === name);
649
+ // Fail closed: if the album can't be matched, emptiness can't be checked.
650
+ if (!album) {
651
+ throw new Error(`Could not find album ${albumPath} in the album list to check that it is empty. Check the name with photos_list_albums, or pass force to delete it anyway.`);
652
+ }
653
+ if (album.photoCount > 0) {
654
+ throw new Error(`Album ${albumPath} still contains ${album.photoCount} photo(s). Pass force to delete it anyway (the photos stay in your timeline).`);
655
+ }
656
+ }
561
657
  const args = ["album", "delete", albumPath];
562
658
  if (force)
563
659
  args.push("--force");
@@ -603,6 +699,12 @@ export class DriveService {
603
699
  return result.map((item) => mapPhoto(item));
604
700
  }
605
701
  async photoDownload(remotePaths, localFolder, conflictStrategy = "skip") {
702
+ // CLI 0.8.0 treats everything after /albums/ as the album name
703
+ // ("Album not found: <album>/<photo>"), so album paths never work.
704
+ const albumPath = remotePaths.find((p) => p.startsWith("/albums/"));
705
+ if (albumPath) {
706
+ throw new Error(`Cannot download ${albumPath}: album paths are not supported by photo download. Use the photo's /photos/<name> path instead.`);
707
+ }
606
708
  const result = await this.run([
607
709
  "photo", "download", ...remotePaths, localFolder,
608
710
  "--conflict-strategy", conflictStrategy,
@@ -1,7 +1,9 @@
1
1
  export class DriveCliNotFoundError extends Error {
2
2
  constructor() {
3
3
  super("proton-drive CLI not found in PATH. " +
4
- "Download it from https://proton.me/download/drive/cli/index.html and ensure it is in your PATH.");
4
+ "Download it from https://proton.me/download/drive/cli/index.html and ensure it is in your PATH, " +
5
+ "or set PROTON_DRIVE_BIN to its absolute path (e.g. ~/.local/bin/proton-drive; find it with `which proton-drive`). " +
6
+ "Claude Desktop starts servers with a minimal PATH, so PROTON_DRIVE_BIN is usually needed there.");
5
7
  this.name = "DriveCliNotFoundError";
6
8
  }
7
9
  }
@@ -176,6 +176,12 @@ function describeFailure(e) {
176
176
  continue; // banner rules
177
177
  if (/^at\s/.test(line))
178
178
  continue; // stack frames
179
+ // Bundled CLI source excerpts ("20075 | code…" plus a "^" marker line)
180
+ // and an empty "Error details: {}" say nothing about what went wrong.
181
+ if (/^\d+\s*\|/.test(line) || /^\^+$/.test(line))
182
+ continue;
183
+ if (/^Error details:\s*(\{\})?$/.test(line) || line === "{}")
184
+ continue;
179
185
  if (!lines.includes(line))
180
186
  lines.push(line);
181
187
  }
@@ -263,9 +269,32 @@ function parseJsonLoose(raw) {
263
269
  }
264
270
  throw new Error("no JSON found");
265
271
  }
272
+ // Seen live with several clients using the CLI at once: its local SQLite cache
273
+ // briefly fails with "database is locked" (SQLITE_BUSY). Read-only commands are
274
+ // safe to re-run; writes are not retried, since a retry could repeat a mutation.
275
+ const READ_ONLY_COMMANDS = new Set([
276
+ "filesystem list", "filesystem info", "sharing status", "invitation list",
277
+ "album list", "album photos", "photo timeline", "version",
278
+ ]);
279
+ const LOCKED_RE = /database is locked|SQLITE_BUSY/i;
280
+ async function execCliWithRetry(args, cliArgs) {
281
+ const readOnly = READ_ONLY_COMMANDS.has(args.slice(0, 2).join(" "));
282
+ for (let attempt = 0;; attempt++) {
283
+ try {
284
+ return await execCli(cliArgs, timeoutFor(args));
285
+ }
286
+ catch (err) {
287
+ const e = err;
288
+ const locked = LOCKED_RE.test(`${e.stderr ?? ""}\n${e.stdout ?? ""}`);
289
+ if (!readOnly || !locked || e.cancelled || attempt >= 2)
290
+ throw err;
291
+ await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1) + Math.random() * 250));
292
+ }
293
+ }
294
+ }
266
295
  export async function runDrive(args) {
267
296
  try {
268
- const { stdout, stderr } = await execCli([...args, "--json"], timeoutFor(args));
297
+ const { stdout, stderr } = await execCliWithRetry(args, [...args, "--json"]);
269
298
  const raw = stdout.trim();
270
299
  // Only check stderr for auth errors when stdout is empty. If the CLI wrote
271
300
  // valid JSON, we honour it even when warnings appear on stderr.
@@ -304,7 +333,7 @@ export async function runDrive(args) {
304
333
  // print plain text. This runs without appending --json and returns raw stdout.
305
334
  export async function runDriveRaw(args) {
306
335
  try {
307
- const { stdout, stderr } = await execCli(args, timeoutFor(args));
336
+ const { stdout, stderr } = await execCliWithRetry(args, args);
308
337
  if (!stdout.trim() && stderr && stderr.trim() && isAuthError(stderr)) {
309
338
  throw new DriveNotAuthenticatedError();
310
339
  }
@@ -15,9 +15,20 @@ function isInside(root, target) {
15
15
  return false;
16
16
  return rel !== ".." && !rel.startsWith(".." + sep);
17
17
  }
18
- /** Map a remote Drive path to an absolute local path inside syncRoot. Lexical only; rejects traversal. */
18
+ // Drive roots other than /my-files are not part of the desktop sync folder.
19
+ const UNSYNCED_ROOTS = ["photos", "albums", "trash", "photos-trash", "shared-with-me", "shared-by-me", "devices"];
20
+ /**
21
+ * Map a remote Drive path to an absolute local path inside syncRoot. Lexical only; rejects traversal.
22
+ * The sync folder's top level is Drive's /my-files, so `/my-files/<rest>` maps to `<syncRoot>/<rest>`.
23
+ * Paths not under a known Drive root are (legacy) relative to the sync root.
24
+ */
19
25
  export function resolveSyncPath(syncRoot, remotePath) {
20
- const rel = remotePath.replace(/^\//, "");
26
+ const trimmed = remotePath.replace(/^\//, "");
27
+ const first = trimmed.split("/", 1)[0];
28
+ if (UNSYNCED_ROOTS.includes(first)) {
29
+ throw new Error(`only /my-files is synced to the local folder; /${first} is not: ${remotePath}`);
30
+ }
31
+ const rel = first === "my-files" ? trimmed.slice("my-files".length).replace(/^\//, "") : trimmed;
21
32
  const resolved = resolve(join(syncRoot, rel));
22
33
  const root = resolve(syncRoot);
23
34
  if (!isInside(root, resolved)) {
@@ -116,7 +127,12 @@ export async function readSyncFile(syncRoot, remotePath) {
116
127
  if (data.includes(0)) {
117
128
  throw new Error(`${remotePath} appears to be a binary file. Use drive_download instead.`);
118
129
  }
119
- return data.toString("utf8");
130
+ try {
131
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
132
+ }
133
+ catch {
134
+ throw new Error(`${remotePath} is not valid UTF-8 text. Use drive_download instead.`);
135
+ }
120
136
  }
121
137
  finally {
122
138
  await fh.close();
@@ -42,8 +42,10 @@ export function validateMessage(message) {
42
42
  throw new Error(`message must not start with '-': ${m}`);
43
43
  if (CONTROL_RE.test(m))
44
44
  throw new Error("message contains control characters");
45
- if (m.length > 2000)
46
- throw new Error("message must be 2000 characters or fewer");
45
+ // Proton rejects invitation messages over 500 characters (confirmed live);
46
+ // count code points, not UTF-16 units, so emoji count once.
47
+ if ([...m].length > 500)
48
+ throw new Error("message must be 500 characters or fewer");
47
49
  return m;
48
50
  }
49
51
  // For bare filename/name arguments passed as a positional CLI arg (not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proton-drive-mcp",
3
- "version": "1.0.39",
3
+ "version": "1.1.0",
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",