proton-drive-mcp 1.0.32 → 1.0.34
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 +31 -0
- package/dist/cli.js +54 -12
- package/dist/index.js +25 -16
- package/dist/services/drive.js +37 -6
- package/dist/utils/subprocess.js +6 -1
- package/dist/utils/validation.js +26 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.34 — 2026-09-03
|
|
4
|
+
|
|
5
|
+
Continued the live-testing bug sweep from v1.0.33. 7 more real, confirmed bugs across two rounds — every one reproduced live against the real Proton Drive CLI and account before being called a bug, and every fix re-verified live afterward.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Flag injection via `--password`/`--expiration` on `drive_share_set_url`.** Confirmed live: `sharing set-url ... --password "-x"` makes the real CLI's arg parser treat `-x` as an unknown flag and dump usage instead of setting the password — the same flag-injection class fixed for other params in v1.0.33, just missed here. Added `validateFlagValue()` and applied it in both the MCP dispatch and the CLI companion.
|
|
9
|
+
- **`drive_upload` silently disabled thumbnail generation on every upload**, since v1.0.0. `upload()` hardcoded `--skip-thumbnails` unconditionally — never exposed as an option, never documented. Every file uploaded through this tool got no Drive thumbnail, degrading grid/preview views in the Proton apps, with no way to opt out. Removed the hardcoded flag.
|
|
10
|
+
- **`photos_list_timeline` with `loadDetails=true` did nothing useful.** The tool description promises "full node metadata" when `loadDetails=true`. Confirmed live that `photo timeline --load-details` returns rich objects (`name`, `mediaType`, `creationTime`, `totalStorageSize`, `photo.captureTime`, `photo.tags`, ...) — but the code discarded everything except `nodeUid`, identical to the cheap default-mode output. `loadDetails=true` paid for a slower, memory-buffered CLI call and threw the result away. Now extracts and returns the full set of fields.
|
|
11
|
+
- **`drive_list` didn't escape a literal `/` in an item's name when building its path.** The CLI's own path syntax requires escaping `/` in a name with a backslash. Our `validateName()` prevents *our* tools from creating such names, but an item created by any other Proton Drive client (web, desktop, mobile) can still have a literal `/` in its name. Confirmed live: creating such an item via the raw CLI, then naively joining its name into a path made every path-based tool (`drive_info`, `drive_download`, `drive_move`, `drive_delete`, ...) fail with "Node not found", while the correctly escaped form resolved. `drive_list` now escapes `/` in names before building the path it returns.
|
|
12
|
+
- **`drive_invitation_accept`/`drive_invitation_reject` had no flag-injection guard on `uid`**, in both the MCP tool and the CLI. Confirmed live: `invitation accept "-fakeuid"` was misparsed as a CLI flag by the underlying binary ("Unknown option 'f'") instead of a clean rejection. Now validated with `validateFlagValue()`.
|
|
13
|
+
- **`drive_download` silently swallowed partial-download failures.** `download()` narrowed the CLI's real `TransferSummary` response down to `{path, localPath, downloaded}`, dropping `skippedItems`/`failedItems` entirely — and the MCP dispatch never checked for failure, unlike `drive_upload`'s equivalent check. A download where some files failed would report success. `DownloadResult` now includes `skipped`/`failed`, and `drive_download` fails the call when `failed > 0`, mirroring `drive_upload`.
|
|
14
|
+
- **`drive_read_file`/`drive_write_file` echoed the raw, untrimmed input path** instead of the validated one, inconsistent with every other tool in the codebase (which always echo the validated variable). Minor but real: a path with leading/trailing whitespace would show up unchanged in the response even though the actual operation used the trimmed path.
|
|
15
|
+
|
|
16
|
+
## 1.0.33 — 2026-09-02
|
|
17
|
+
|
|
18
|
+
A full skeptical re-read of every source file (`drive.ts`, `index.ts`, `cli.ts`, `subprocess.ts`, `validation.ts`, `syncfs.ts`, `errors.ts`, `logger.ts`) plus live testing of every candidate finding. 7 real, confirmed bugs — every one reproduced live before being called a bug, and every fix re-verified live afterward, through both the MCP JSON-RPC protocol and the companion CLI.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **`validateName()` didn't reject `/`.** Confirmed live: renaming a file to a name containing `/` (e.g. `"evil/nested.txt"`) succeeded on the CLI side, but the resulting item's real Drive name literally contains the slash — it is not two path segments. `drive_list`'s path is computed by joining parent + name, so it then looks like a nested folder that doesn't exist; every other tool (`drive_info`, `drive_download`, `drive_move`, `drive_delete`, ...) fails with "Node not found" on that path, and the item becomes unreachable except by re-listing its real parent. `validateName()` now rejects `/` anywhere in the name.
|
|
22
|
+
- **`photos_create_album` had no flag-injection protection at all**, in both the MCP tool and the CLI (`album create <name>`) — unlike every other user-typed name/path/email in this codebase, it was never run through a validator. Confirmed live: `album create "--help"` was misparsed as a CLI flag by the underlying binary, producing a confusing parse error instead of a clean rejection. Now validated with `validateName()`.
|
|
23
|
+
- **`photos_update_album`'s `--name` and `--cover-photo-uid` had the same gap**, in both the MCP tool and the CLI (`album update`). Same fix.
|
|
24
|
+
- **Large `drive_copy`/`drive_move` operations could be killed mid-flight.** The extended 30-minute timeout was only applied to `upload`/`download`; `copy`/`move` on a large folder tree — which can take just as long, re-encrypting or re-sharing many files server-side — got only the 60-second default and would be `SIGKILL`'d if it ran long, potentially leaving Drive in a partially-copied/moved state. `copy` and `move` now get the same extended timeout.
|
|
25
|
+
|
|
26
|
+
### Investigated, confirmed NOT bugs (avoided introducing a regression)
|
|
27
|
+
- `photos_download` was missing a `/photos/`-only path check that its sibling tools (`photos_add_to_album` etc.) have. Checked the CLI source first: `photo download` explicitly supports `/photos/`, `/albums/`, and `/photos-shared-with-me/` roots (`SUPPORTED_REMOTE_PATH_TYPES`). Adding the restriction would have been a regression, not a fix — left as-is.
|
|
28
|
+
- Suspected `photos_add_to_album`/`remove_from_album`'s `/photos/`-only restriction was too narrow, since the underlying CLI command imposes no path-type restriction at all. Tested live: a compound `/albums/<name>/<photo>` path does **not** actually resolve through the CLI's path resolver — the existing restriction is correct as shipped.
|
|
29
|
+
- Suspected the MCP-mode logger (stdout vs. stderr gating via `PROTON_DRIVE_MCP=1`) could leak a stray `console.log` into the JSON-RPC stream under some import order. Traced the actual call graph: `logger` is only ever used from within `index.ts`'s own functions, and the env var is set at that same module's top level before those functions can be invoked — the ordering is guaranteed correct by construction, not a bug.
|
|
30
|
+
|
|
31
|
+
### Testing
|
|
32
|
+
122 tests passing (was 121).
|
|
33
|
+
|
|
3
34
|
## 1.0.32 — 2026-08-21
|
|
4
35
|
|
|
5
36
|
Closed the last disclosed gap from v1.0.31 — tested `drive_share_invite` for real, against the user's own Gmail address, through the actual MCP JSON-RPC server. Found a real bug in the process.
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
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
|
-
import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName } from "./utils/validation.js";
|
|
12
|
+
import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName, validateFlagValue } from "./utils/validation.js";
|
|
13
13
|
const drive = new DriveService();
|
|
14
14
|
const args = process.argv.slice(2);
|
|
15
15
|
const jsonMode = args.includes("--json");
|
|
@@ -288,8 +288,10 @@ async function run() {
|
|
|
288
288
|
console.error(`Invalid --role value: ${roleRaw}. Must be viewer or editor.`);
|
|
289
289
|
process.exit(1);
|
|
290
290
|
}
|
|
291
|
-
const
|
|
292
|
-
const
|
|
291
|
+
const rawPassword = getFlag("--password");
|
|
292
|
+
const rawExpiration = getFlag("--expiration");
|
|
293
|
+
const password = rawPassword ? validateFlagValue(rawPassword, "password") : undefined;
|
|
294
|
+
const expiration = rawExpiration ? validateFlagValue(rawExpiration, "expiration") : undefined;
|
|
293
295
|
print(await drive.shareSetUrl(setUrlPath, roleRaw, password, expiration));
|
|
294
296
|
}
|
|
295
297
|
else if (sub === "remove-url") {
|
|
@@ -344,20 +346,38 @@ async function run() {
|
|
|
344
346
|
print(await drive.listInvitations());
|
|
345
347
|
}
|
|
346
348
|
else if (sub === "accept") {
|
|
347
|
-
const
|
|
348
|
-
if (!
|
|
349
|
+
const rawAcceptUid = rest[0];
|
|
350
|
+
if (!rawAcceptUid) {
|
|
349
351
|
console.error("Usage: invitation accept <uid>");
|
|
350
352
|
process.exit(1);
|
|
351
353
|
}
|
|
354
|
+
let acceptUid;
|
|
355
|
+
try {
|
|
356
|
+
acceptUid = validateFlagValue(rawAcceptUid, "uid");
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
360
|
+
process.exit(1);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
352
363
|
await drive.invitationAccept(acceptUid);
|
|
353
364
|
console.log("Invitation accepted.");
|
|
354
365
|
}
|
|
355
366
|
else if (sub === "reject") {
|
|
356
|
-
const
|
|
357
|
-
if (!
|
|
367
|
+
const rawRejectUid = rest[0];
|
|
368
|
+
if (!rawRejectUid) {
|
|
358
369
|
console.error("Usage: invitation reject <uid>");
|
|
359
370
|
process.exit(1);
|
|
360
371
|
}
|
|
372
|
+
let rejectUid;
|
|
373
|
+
try {
|
|
374
|
+
rejectUid = validateFlagValue(rawRejectUid, "uid");
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
378
|
+
process.exit(1);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
361
381
|
await drive.invitationReject(rejectUid);
|
|
362
382
|
console.log("Invitation rejected.");
|
|
363
383
|
}
|
|
@@ -371,21 +391,43 @@ async function run() {
|
|
|
371
391
|
print(await drive.listAlbums());
|
|
372
392
|
}
|
|
373
393
|
else if (sub === "create") {
|
|
374
|
-
const
|
|
375
|
-
if (!
|
|
394
|
+
const rawAlbumName = rest[0];
|
|
395
|
+
if (!rawAlbumName) {
|
|
376
396
|
console.error("Usage: album create <name>");
|
|
377
397
|
process.exit(1);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
let albumName;
|
|
401
|
+
try {
|
|
402
|
+
albumName = validateName(rawAlbumName);
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
406
|
+
process.exit(1);
|
|
407
|
+
return;
|
|
378
408
|
}
|
|
379
409
|
await drive.createAlbum(albumName);
|
|
380
410
|
console.log(`Album created: ${albumName}`);
|
|
381
411
|
}
|
|
382
412
|
else if (sub === "update") {
|
|
383
413
|
const albumPath = requirePath(rest[0], "album update <path> [--name] [--cover-photo-uid]");
|
|
384
|
-
const
|
|
385
|
-
const
|
|
386
|
-
if (!
|
|
414
|
+
const rawNewName = getFlag("--name");
|
|
415
|
+
const rawCoverPhotoUid = getFlag("--cover-photo-uid");
|
|
416
|
+
if (!rawNewName && !rawCoverPhotoUid) {
|
|
387
417
|
console.error("Usage: album update <path> [--name <name>] [--cover-photo-uid <uid>] (at least one required)");
|
|
388
418
|
process.exit(1);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
let newName;
|
|
422
|
+
let coverPhotoUid;
|
|
423
|
+
try {
|
|
424
|
+
newName = rawNewName ? validateName(rawNewName) : undefined;
|
|
425
|
+
coverPhotoUid = rawCoverPhotoUid ? validateName(rawCoverPhotoUid) : undefined;
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
429
|
+
process.exit(1);
|
|
430
|
+
return;
|
|
389
431
|
}
|
|
390
432
|
await drive.updateAlbum(albumPath, newName, coverPhotoUid);
|
|
391
433
|
console.log(`Album updated: ${albumPath}`);
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ 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";
|
|
10
|
-
import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName } from "./utils/validation.js";
|
|
10
|
+
import { validateRemotePath, validateLocalPath, validateEmail, validateMessage, validateName, validateFlagValue } from "./utils/validation.js";
|
|
11
11
|
import { logger } from "./utils/logger.js";
|
|
12
12
|
import { getSyncRoot, readSyncFile, writeSyncFile } from "./utils/syncfs.js";
|
|
13
13
|
const require = createRequire(import.meta.url);
|
|
@@ -158,7 +158,7 @@ const TOOLS = [
|
|
|
158
158
|
"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). " +
|
|
159
159
|
"For folders, downloads recursively. " +
|
|
160
160
|
"Conflict strategies are set separately for files and folders (CLI v0.8.0+) — both default to 'skip'. " +
|
|
161
|
-
"Returns {downloaded}
|
|
161
|
+
"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. " +
|
|
162
162
|
"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
163
|
annotations: { openWorldHint: true },
|
|
164
164
|
inputSchema: {
|
|
@@ -743,7 +743,7 @@ const TOOLS = [
|
|
|
743
743
|
{
|
|
744
744
|
name: "photos_list_timeline",
|
|
745
745
|
description: "List photos in your Proton Photos timeline (your full photo library, not scoped to an album). Requires authentication. " +
|
|
746
|
-
"Returns [{nodeUid}] by default — pass loadDetails=true for
|
|
746
|
+
"Returns [{nodeUid}] by default — pass loadDetails=true for [{nodeUid, name, mediaType, creationTime, totalStorageSize, captureTime, tags}] (slower; buffers the whole list in memory first). " +
|
|
747
747
|
"Use photos_download to download items by path, or photos_add_to_album to add them to an album.",
|
|
748
748
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
749
749
|
inputSchema: {
|
|
@@ -926,7 +926,11 @@ export async function main() {
|
|
|
926
926
|
if (!["skip", "merge", "rename", "remove"].includes(fodcs)) {
|
|
927
927
|
return fail(`folderConflictStrategy must be skip, merge, rename, or remove`);
|
|
928
928
|
}
|
|
929
|
-
|
|
929
|
+
const downloadResult = await drive.download(validateRemotePath(a.remotePath), validateLocalPath(a.localPath), fdcs, fodcs);
|
|
930
|
+
if (downloadResult.failed > 0) {
|
|
931
|
+
return fail(`Download completed with ${downloadResult.failed} failed file(s). downloaded=${downloadResult.downloaded} skipped=${downloadResult.skipped}`);
|
|
932
|
+
}
|
|
933
|
+
return ok(downloadResult);
|
|
930
934
|
}
|
|
931
935
|
case "drive_rename": {
|
|
932
936
|
const renamePath = validateRemotePath(a.path);
|
|
@@ -999,13 +1003,15 @@ export async function main() {
|
|
|
999
1003
|
case "drive_invitation_accept": {
|
|
1000
1004
|
if (typeof a.uid !== "string" || !a.uid)
|
|
1001
1005
|
return fail("uid must be a non-empty string");
|
|
1002
|
-
|
|
1006
|
+
const acceptUid = validateFlagValue(a.uid, "uid");
|
|
1007
|
+
await drive.invitationAccept(acceptUid);
|
|
1003
1008
|
return ok({ message: "Invitation accepted." });
|
|
1004
1009
|
}
|
|
1005
1010
|
case "drive_invitation_reject": {
|
|
1006
1011
|
if (typeof a.uid !== "string" || !a.uid)
|
|
1007
1012
|
return fail("uid must be a non-empty string");
|
|
1008
|
-
|
|
1013
|
+
const rejectUid = validateFlagValue(a.uid, "uid");
|
|
1014
|
+
await drive.invitationReject(rejectUid);
|
|
1009
1015
|
return ok({ message: "Invitation rejected." });
|
|
1010
1016
|
}
|
|
1011
1017
|
case "drive_share_leave": {
|
|
@@ -1018,8 +1024,8 @@ export async function main() {
|
|
|
1018
1024
|
const role = typeof a.role === "string" ? a.role : "viewer";
|
|
1019
1025
|
if (!["viewer", "editor"].includes(role))
|
|
1020
1026
|
return fail("role must be viewer or editor");
|
|
1021
|
-
const password = typeof a.password === "string" ? a.password : undefined;
|
|
1022
|
-
const expiration = typeof a.expiration === "string" ? a.expiration : undefined;
|
|
1027
|
+
const password = typeof a.password === "string" && a.password ? validateFlagValue(a.password, "password") : undefined;
|
|
1028
|
+
const expiration = typeof a.expiration === "string" && a.expiration ? validateFlagValue(a.expiration, "expiration") : undefined;
|
|
1023
1029
|
const link = await drive.shareSetUrl(setUrlPath, role, password, expiration);
|
|
1024
1030
|
return ok(link);
|
|
1025
1031
|
}
|
|
@@ -1041,15 +1047,16 @@ export async function main() {
|
|
|
1041
1047
|
case "photos_create_album": {
|
|
1042
1048
|
if (typeof a.name !== "string" || !a.name.trim())
|
|
1043
1049
|
return fail("name must be a non-empty string");
|
|
1044
|
-
|
|
1045
|
-
|
|
1050
|
+
const albumName = validateName(a.name);
|
|
1051
|
+
await drive.createAlbum(albumName);
|
|
1052
|
+
return ok({ message: `Album created: ${albumName}` });
|
|
1046
1053
|
}
|
|
1047
1054
|
case "photos_update_album": {
|
|
1048
1055
|
const updateAlbumPath = validateRemotePath(a.albumPath);
|
|
1049
1056
|
if (!updateAlbumPath.startsWith("/albums/"))
|
|
1050
1057
|
return fail("albumPath must start with /albums/");
|
|
1051
|
-
const newName = typeof a.name === "string" && a.name.trim() ? a.name
|
|
1052
|
-
const coverPhotoUid = typeof a.coverPhotoUid === "string" && a.coverPhotoUid.trim() ? a.coverPhotoUid
|
|
1058
|
+
const newName = typeof a.name === "string" && a.name.trim() ? validateName(a.name) : undefined;
|
|
1059
|
+
const coverPhotoUid = typeof a.coverPhotoUid === "string" && a.coverPhotoUid.trim() ? validateName(a.coverPhotoUid) : undefined;
|
|
1053
1060
|
if (!newName && !coverPhotoUid)
|
|
1054
1061
|
return fail("At least one of name or coverPhotoUid must be provided");
|
|
1055
1062
|
await drive.updateAlbum(updateAlbumPath, newName, coverPhotoUid);
|
|
@@ -1123,8 +1130,9 @@ export async function main() {
|
|
|
1123
1130
|
const syncRoot = getSyncRoot();
|
|
1124
1131
|
if (!syncRoot)
|
|
1125
1132
|
return fail("PROTON_DRIVE_SYNC_PATH is not set. Set it to the root of your Proton Drive sync folder.");
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1133
|
+
const readPath = validateRemotePath(a.path);
|
|
1134
|
+
const content = await readSyncFile(syncRoot, readPath);
|
|
1135
|
+
return ok({ path: readPath, content });
|
|
1128
1136
|
}
|
|
1129
1137
|
case "drive_write_file": {
|
|
1130
1138
|
const syncRoot = getSyncRoot();
|
|
@@ -1132,8 +1140,9 @@ export async function main() {
|
|
|
1132
1140
|
return fail("PROTON_DRIVE_SYNC_PATH is not set. Set it to the root of your Proton Drive sync folder.");
|
|
1133
1141
|
if (typeof a.content !== "string")
|
|
1134
1142
|
return fail("content must be a string");
|
|
1135
|
-
|
|
1136
|
-
|
|
1143
|
+
const writePath = validateRemotePath(a.path);
|
|
1144
|
+
await writeSyncFile(syncRoot, writePath, a.content);
|
|
1145
|
+
return ok({ message: `Written: ${writePath}` });
|
|
1137
1146
|
}
|
|
1138
1147
|
default:
|
|
1139
1148
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
package/dist/services/drive.js
CHANGED
|
@@ -17,6 +17,18 @@ function unwrapResult(value, fallback = "") {
|
|
|
17
17
|
}
|
|
18
18
|
return typeof value === "string" ? value : fallback;
|
|
19
19
|
}
|
|
20
|
+
// The CLI's own path syntax requires escaping a literal '/' inside a node
|
|
21
|
+
// name with a backslash (confirmed in `filesystem info --help`: "Escape /
|
|
22
|
+
// in node names with a backslash"). Our own validateName() rejects '/' in
|
|
23
|
+
// names WE create, but an item created by any other Proton Drive client
|
|
24
|
+
// (web, desktop, mobile) can still have a literal '/' in its name. Confirmed
|
|
25
|
+
// live: naively joining such a name into a path ("/parent/raw/slash") makes
|
|
26
|
+
// every path-based tool (info, download, move, rename, delete, ...) fail
|
|
27
|
+
// with "Node not found", while the correctly escaped form ("/parent/raw\/slash")
|
|
28
|
+
// resolves. list() must escape names before building the path it returns.
|
|
29
|
+
function escapeNameForPath(name) {
|
|
30
|
+
return name.replace(/\//g, "\\/");
|
|
31
|
+
}
|
|
20
32
|
// Strips "<package-name>@" and "+<hash>" from a version token like
|
|
21
33
|
// "cli-drive@0.8.0+06e8c605", leaving "0.8.0". Falls back to the raw
|
|
22
34
|
// token if it doesn't match, so an unexpected future format still shows
|
|
@@ -87,7 +99,7 @@ export class DriveService {
|
|
|
87
99
|
const name = unwrapResult(item.name, "[unnamed]");
|
|
88
100
|
return {
|
|
89
101
|
name,
|
|
90
|
-
path: posixPath.join(remotePath, name),
|
|
102
|
+
path: posixPath.join(remotePath, escapeNameForPath(name)),
|
|
91
103
|
type: item.type === "folder" || item.type === "album" ? "folder" : "file",
|
|
92
104
|
size: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
|
|
93
105
|
modifiedAt: typeof item.modificationTime === "string" ? item.modificationTime : undefined,
|
|
@@ -105,7 +117,6 @@ export class DriveService {
|
|
|
105
117
|
fileConflictStrategy,
|
|
106
118
|
"--folder-conflict-strategy",
|
|
107
119
|
folderConflictStrategy,
|
|
108
|
-
"--skip-thumbnails",
|
|
109
120
|
]);
|
|
110
121
|
// Real shape is TransferSummary: {transferredItems, transferredBytes,
|
|
111
122
|
// skippedItems, failedItems, failures}. Confirmed live — the old
|
|
@@ -125,12 +136,17 @@ export class DriveService {
|
|
|
125
136
|
"--file-conflict-strategy", fileConflictStrategy,
|
|
126
137
|
"--folder-conflict-strategy", folderConflictStrategy,
|
|
127
138
|
]);
|
|
128
|
-
// Same real shape as upload — TransferSummary, not {downloaded}.
|
|
139
|
+
// Same real shape as upload — TransferSummary, not {downloaded}. Previously
|
|
140
|
+
// this dropped skippedItems/failedItems entirely, and the MCP dispatch
|
|
141
|
+
// never checked for partial failure the way drive_upload's does — a
|
|
142
|
+
// download where some files failed silently reported success.
|
|
129
143
|
const summary = this.parseTransferSummary(result);
|
|
130
144
|
return {
|
|
131
145
|
path: remotePath,
|
|
132
146
|
localPath,
|
|
133
147
|
downloaded: summary.transferredItems,
|
|
148
|
+
skipped: summary.skippedItems,
|
|
149
|
+
failed: summary.failedItems,
|
|
134
150
|
};
|
|
135
151
|
}
|
|
136
152
|
// The CLI has no `mkdir` — it's `create-folder <parentPath> <name>`.
|
|
@@ -376,9 +392,24 @@ export class DriveService {
|
|
|
376
392
|
return [];
|
|
377
393
|
if (!Array.isArray(result))
|
|
378
394
|
throw new DriveParseError(`Expected array from photo timeline, got: ${JSON.stringify(result).slice(0, 100)}`);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
})
|
|
395
|
+
// Without --load-details the CLI returns {nodeUid, captureTime, tags}.
|
|
396
|
+
// With --load-details it returns full node objects ({uid, name, mediaType,
|
|
397
|
+
// creationTime, totalStorageSize, photo: {captureTime, tags}, ...}) —
|
|
398
|
+
// confirmed live. Previously this always extracted only nodeUid, so
|
|
399
|
+
// loadDetails=true paid the CLI's slower buffered call for nothing: the
|
|
400
|
+
// "full node metadata" the tool description promises was thrown away.
|
|
401
|
+
return result.map((item) => {
|
|
402
|
+
const photo = (item.photo ?? {});
|
|
403
|
+
return {
|
|
404
|
+
nodeUid: String(item.nodeUid ?? item.uid ?? ""),
|
|
405
|
+
name: item.name !== undefined ? unwrapResult(item.name) || undefined : undefined,
|
|
406
|
+
mediaType: typeof item.mediaType === "string" ? item.mediaType : undefined,
|
|
407
|
+
creationTime: typeof item.creationTime === "string" ? item.creationTime : undefined,
|
|
408
|
+
totalStorageSize: typeof item.totalStorageSize === "number" ? item.totalStorageSize : undefined,
|
|
409
|
+
captureTime: typeof (item.captureTime ?? photo.captureTime) === "string" ? (item.captureTime ?? photo.captureTime) : undefined,
|
|
410
|
+
tags: Array.isArray(item.tags ?? photo.tags) ? (item.tags ?? photo.tags) : undefined,
|
|
411
|
+
};
|
|
412
|
+
});
|
|
382
413
|
}
|
|
383
414
|
async photoDownload(remotePaths, localFolder, conflictStrategy = "skip") {
|
|
384
415
|
const result = await this.run([
|
package/dist/utils/subprocess.js
CHANGED
|
@@ -6,7 +6,12 @@ const CLI_BINARY = process.env["PROTON_DRIVE_BIN"] ?? "proton-drive";
|
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
7
7
|
// Upload and download transfer actual file bytes — use a much longer timeout.
|
|
8
8
|
const TRANSFER_TIMEOUT_MS = 30 * 60_000; // 30 minutes
|
|
9
|
-
|
|
9
|
+
// copy/move can process large folder trees server-side (re-encrypting or
|
|
10
|
+
// re-sharing many files), just like upload/download — they need the same
|
|
11
|
+
// extended timeout. Previously only upload/download got it; a large
|
|
12
|
+
// drive_copy or drive_move would be SIGKILL'd after the 60s default,
|
|
13
|
+
// potentially leaving Drive in a partially-copied/moved state.
|
|
14
|
+
const TRANSFER_COMMANDS = new Set(["upload", "download", "copy", "move"]);
|
|
10
15
|
const TRANSFER_GROUPS = new Set(["filesystem", "photo"]);
|
|
11
16
|
function timeoutFor(args) {
|
|
12
17
|
// args[0] is the group (e.g. "filesystem"), args[1] is the subcommand
|
package/dist/utils/validation.js
CHANGED
|
@@ -31,18 +31,42 @@ export function validateMessage(message) {
|
|
|
31
31
|
return m;
|
|
32
32
|
}
|
|
33
33
|
// For bare filename/name arguments passed as a positional CLI arg (not a
|
|
34
|
-
// path) — e.g. rename's newName. Guards against
|
|
35
|
-
// validatePath/validateEmail/validateMessage do.
|
|
34
|
+
// path) — e.g. rename's newName, album create/update's name. Guards against
|
|
35
|
+
// flag injection the same way validatePath/validateEmail/validateMessage do.
|
|
36
|
+
//
|
|
37
|
+
// Also rejects '/': confirmed live that renaming a file to a name containing
|
|
38
|
+
// '/' (e.g. "evil/nested.txt") succeeds on the CLI side, but the resulting
|
|
39
|
+
// item's actual Drive name contains the slash literally — it is not two path
|
|
40
|
+
// segments. Every other tool computes this item's path by joining parent +
|
|
41
|
+
// name (see list()), producing a path that LOOKS like a nested folder but
|
|
42
|
+
// isn't, and that path then fails to resolve ("Node not found") in every
|
|
43
|
+
// other tool (info, download, move, delete, ...). The item becomes
|
|
44
|
+
// unreachable except by listing its real parent and matching by prefix.
|
|
36
45
|
export function validateName(name) {
|
|
37
46
|
const n = String(name ?? "").trim();
|
|
38
47
|
if (!n)
|
|
39
48
|
throw new Error("name must not be empty");
|
|
40
49
|
if (n.startsWith("-"))
|
|
41
50
|
throw new Error(`name must not start with '-': ${n}`);
|
|
51
|
+
if (n.includes("/"))
|
|
52
|
+
throw new Error(`name must not contain '/': ${n}`);
|
|
42
53
|
if (CONTROL_RE.test(n))
|
|
43
54
|
throw new Error("name contains control characters");
|
|
44
55
|
return n;
|
|
45
56
|
}
|
|
57
|
+
// Guards a value passed to a bare CLI flag (e.g. --password, --expiration)
|
|
58
|
+
// against flag injection — confirmed live: `sharing set-url --password "-x"`
|
|
59
|
+
// makes the real CLI's arg parser treat "-x" as an unknown flag and print
|
|
60
|
+
// usage instead of setting the password. Unlike validateName, allows '/'
|
|
61
|
+
// since these aren't Drive item names.
|
|
62
|
+
export function validateFlagValue(value, label) {
|
|
63
|
+
const v = String(value ?? "").trim();
|
|
64
|
+
if (v.startsWith("-"))
|
|
65
|
+
throw new Error(`${label} must not start with '-': ${v}`);
|
|
66
|
+
if (CONTROL_RE.test(v))
|
|
67
|
+
throw new Error(`${label} contains control characters`);
|
|
68
|
+
return v;
|
|
69
|
+
}
|
|
46
70
|
export function validateLocalPath(path) {
|
|
47
71
|
const p = validatePath(path);
|
|
48
72
|
if (!isAbsolute(p))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proton-drive-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.34",
|
|
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",
|