proton-drive-mcp 1.0.12
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 +140 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/dist/cli.js +245 -0
- package/dist/index.js +384 -0
- package/dist/services/drive.js +137 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/errors.js +27 -0
- package/dist/utils/logger.js +15 -0
- package/dist/utils/subprocess.js +74 -0
- package/dist/utils/validation.js +35 -0
- package/glama.json +9 -0
- package/package.json +73 -0
- package/smithery.yaml +37 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { runDrive as defaultRunDrive } from "../utils/subprocess.js";
|
|
2
|
+
import { DriveParseError } from "../utils/errors.js";
|
|
3
|
+
export class DriveService {
|
|
4
|
+
run;
|
|
5
|
+
constructor(runner) {
|
|
6
|
+
this.run = runner ?? defaultRunDrive;
|
|
7
|
+
}
|
|
8
|
+
// Auth
|
|
9
|
+
async authStatus() {
|
|
10
|
+
const result = await this.run(["auth", "status"]);
|
|
11
|
+
if (result === null)
|
|
12
|
+
return { authenticated: false };
|
|
13
|
+
const r = result;
|
|
14
|
+
return {
|
|
15
|
+
authenticated: Boolean(r?.authenticated ?? r?.loggedIn ?? false),
|
|
16
|
+
email: typeof r?.email === "string" ? r.email : undefined,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async authLogout() {
|
|
20
|
+
await this.run(["auth", "logout"]);
|
|
21
|
+
}
|
|
22
|
+
async version() {
|
|
23
|
+
const result = await this.run(["version"]);
|
|
24
|
+
const r = result;
|
|
25
|
+
return {
|
|
26
|
+
cli: typeof r?.cli === "string" ? r.cli : String(r?.version ?? "unknown"),
|
|
27
|
+
sdk: typeof r?.sdk === "string" ? r.sdk : "unknown",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// Filesystem
|
|
31
|
+
async list(remotePath) {
|
|
32
|
+
const result = await this.run(["filesystem", "list", remotePath]);
|
|
33
|
+
if (result === null)
|
|
34
|
+
return [];
|
|
35
|
+
if (!Array.isArray(result))
|
|
36
|
+
throw new DriveParseError(`Expected array from list, got: ${JSON.stringify(result).slice(0, 100)}`);
|
|
37
|
+
return result.map((item) => ({
|
|
38
|
+
name: String(item.name ?? ""),
|
|
39
|
+
path: String(item.path ?? remotePath),
|
|
40
|
+
type: item.type === "folder" ? "folder" : "file",
|
|
41
|
+
size: typeof item.size === "number" ? item.size : undefined,
|
|
42
|
+
modifiedAt: typeof item.modifiedAt === "string" ? item.modifiedAt : undefined,
|
|
43
|
+
mimeType: typeof item.mimeType === "string" ? item.mimeType : undefined,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
async upload(localPath, remotePath, conflictStrategy = "skip") {
|
|
47
|
+
const result = await this.run([
|
|
48
|
+
"filesystem",
|
|
49
|
+
"upload",
|
|
50
|
+
localPath,
|
|
51
|
+
remotePath,
|
|
52
|
+
"--conflict-strategy",
|
|
53
|
+
conflictStrategy,
|
|
54
|
+
"--skip-thumbnails",
|
|
55
|
+
]);
|
|
56
|
+
const r = result;
|
|
57
|
+
return {
|
|
58
|
+
path: remotePath,
|
|
59
|
+
uploaded: typeof r?.uploaded === "number" ? r.uploaded : 0,
|
|
60
|
+
skipped: typeof r?.skipped === "number" ? r.skipped : 0,
|
|
61
|
+
failed: typeof r?.failed === "number" ? r.failed : 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async download(remotePath, localPath) {
|
|
65
|
+
const result = await this.run(["filesystem", "download", remotePath, localPath]);
|
|
66
|
+
const r = result;
|
|
67
|
+
return {
|
|
68
|
+
path: remotePath,
|
|
69
|
+
localPath,
|
|
70
|
+
downloaded: typeof r?.downloaded === "number" ? r.downloaded : 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async mkdir(remotePath) {
|
|
74
|
+
await this.run(["filesystem", "mkdir", remotePath]);
|
|
75
|
+
}
|
|
76
|
+
async move(remoteSrc, remoteDst) {
|
|
77
|
+
await this.run(["filesystem", "move", remoteSrc, remoteDst]);
|
|
78
|
+
}
|
|
79
|
+
async delete(remotePath) {
|
|
80
|
+
await this.run(["filesystem", "delete", remotePath]);
|
|
81
|
+
}
|
|
82
|
+
// Sharing
|
|
83
|
+
async shareStatus(remotePath) {
|
|
84
|
+
const result = await this.run(["sharing", "status", remotePath]);
|
|
85
|
+
const r = result;
|
|
86
|
+
const members = Array.isArray(r?.members)
|
|
87
|
+
? r.members.map((m) => ({
|
|
88
|
+
email: String(m.email ?? ""),
|
|
89
|
+
role: (m.role ?? "viewer"),
|
|
90
|
+
addedAt: typeof m.addedAt === "string" ? m.addedAt : undefined,
|
|
91
|
+
}))
|
|
92
|
+
: [];
|
|
93
|
+
return {
|
|
94
|
+
path: remotePath,
|
|
95
|
+
isShared: Boolean(r?.isShared ?? members.length > 0),
|
|
96
|
+
members,
|
|
97
|
+
shareUrl: typeof r?.shareUrl === "string" ? r.shareUrl : undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async shareInvite(remotePath, email, role, message) {
|
|
101
|
+
await this.run([
|
|
102
|
+
"sharing", "invite",
|
|
103
|
+
...(message ? ["--message", message] : []),
|
|
104
|
+
"--user", email,
|
|
105
|
+
"--role", role,
|
|
106
|
+
remotePath,
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
async shareRevoke(remotePath, email) {
|
|
110
|
+
await this.run(["sharing", "revoke", "--user", email, remotePath]);
|
|
111
|
+
}
|
|
112
|
+
// Trash
|
|
113
|
+
async listTrash() {
|
|
114
|
+
const result = await this.run(["trash", "list"]);
|
|
115
|
+
if (result === null)
|
|
116
|
+
return [];
|
|
117
|
+
if (!Array.isArray(result))
|
|
118
|
+
throw new DriveParseError(`Expected array from trash list, got: ${JSON.stringify(result).slice(0, 100)}`);
|
|
119
|
+
return result.map((item) => ({
|
|
120
|
+
name: String(item.name ?? ""),
|
|
121
|
+
path: String(item.path ?? ""),
|
|
122
|
+
type: item.type === "folder" ? "folder" : "file",
|
|
123
|
+
size: typeof item.size === "number" ? item.size : undefined,
|
|
124
|
+
modifiedAt: typeof item.modifiedAt === "string" ? item.modifiedAt : undefined,
|
|
125
|
+
mimeType: typeof item.mimeType === "string" ? item.mimeType : undefined,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
async trash(remotePath) {
|
|
129
|
+
await this.run(["trash", remotePath]);
|
|
130
|
+
}
|
|
131
|
+
async restore(remotePath) {
|
|
132
|
+
await this.run(["restore", remotePath]);
|
|
133
|
+
}
|
|
134
|
+
async emptyTrash() {
|
|
135
|
+
await this.run(["trash", "empty"]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class DriveCliNotFoundError extends Error {
|
|
2
|
+
constructor() {
|
|
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.");
|
|
5
|
+
this.name = "DriveCliNotFoundError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class DriveCliError extends Error {
|
|
9
|
+
stderr;
|
|
10
|
+
constructor(message, stderr = "") {
|
|
11
|
+
super(message);
|
|
12
|
+
this.stderr = stderr;
|
|
13
|
+
this.name = "DriveCliError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class DriveNotAuthenticatedError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super("Not authenticated. Run `proton-drive auth login` in your terminal first.");
|
|
19
|
+
this.name = "DriveNotAuthenticatedError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class DriveParseError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "DriveParseError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const isMcp = () => process.env["PROTON_DRIVE_MCP"] === "1";
|
|
2
|
+
export const logger = {
|
|
3
|
+
info: (...args) => {
|
|
4
|
+
if (!isMcp())
|
|
5
|
+
console.log("[INFO]", ...args);
|
|
6
|
+
else
|
|
7
|
+
process.stderr.write(`[INFO] ${args.join(" ")}\n`);
|
|
8
|
+
},
|
|
9
|
+
warn: (...args) => {
|
|
10
|
+
process.stderr.write(`[WARN] ${args.join(" ")}\n`);
|
|
11
|
+
},
|
|
12
|
+
error: (...args) => {
|
|
13
|
+
process.stderr.write(`[ERROR] ${args.join(" ")}\n`);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { DriveCliError, DriveCliNotFoundError, DriveNotAuthenticatedError, DriveParseError } from "./errors.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const CLI_BINARY = process.env["PROTON_DRIVE_BIN"] ?? "proton-drive";
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
7
|
+
// Upload and download transfer actual file bytes — use a much longer timeout.
|
|
8
|
+
const TRANSFER_TIMEOUT_MS = 30 * 60_000; // 30 minutes
|
|
9
|
+
const TRANSFER_COMMANDS = new Set(["upload", "download"]);
|
|
10
|
+
function timeoutFor(args) {
|
|
11
|
+
// args[0] is the group (e.g. "filesystem"), args[1] is the subcommand
|
|
12
|
+
if (args[0] === "filesystem" && args[1] && TRANSFER_COMMANDS.has(args[1])) {
|
|
13
|
+
return TRANSFER_TIMEOUT_MS;
|
|
14
|
+
}
|
|
15
|
+
return DEFAULT_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
export async function runDrive(args) {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout, stderr } = await execFileAsync(CLI_BINARY, [...args, "--json"], { timeout: timeoutFor(args), maxBuffer: 50 * 1024 * 1024, killSignal: "SIGKILL" });
|
|
20
|
+
const raw = stdout.trim();
|
|
21
|
+
// Only check stderr for auth errors when stdout is empty. If the CLI wrote
|
|
22
|
+
// valid JSON, we honour it even when warnings appear on stderr.
|
|
23
|
+
if (!raw) {
|
|
24
|
+
if (stderr && stderr.trim()) {
|
|
25
|
+
const lower = stderr.toLowerCase();
|
|
26
|
+
if (lower.includes("not authenticated") || lower.includes("not logged in")) {
|
|
27
|
+
throw new DriveNotAuthenticatedError();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
process.stderr.write(`[proton-drive-mcp] parse error: ${raw.slice(0, 500)}\n`);
|
|
37
|
+
throw new DriveParseError("Failed to parse CLI output as JSON");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof DriveCliError ||
|
|
42
|
+
err instanceof DriveNotAuthenticatedError ||
|
|
43
|
+
err instanceof DriveParseError) {
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
const e = err;
|
|
47
|
+
if (e.code === "ENOENT") {
|
|
48
|
+
throw new DriveCliNotFoundError();
|
|
49
|
+
}
|
|
50
|
+
if (e.killed === true || e.code === "ETIMEDOUT" || e.signal != null) {
|
|
51
|
+
const secs = timeoutFor(args) / 1000;
|
|
52
|
+
throw new DriveCliError(`CLI process timed out after ${secs}s`, "");
|
|
53
|
+
}
|
|
54
|
+
const stderr = e.stderr ?? "";
|
|
55
|
+
const lower = stderr.toLowerCase();
|
|
56
|
+
if (lower.includes("not authenticated") || lower.includes("not logged in")) {
|
|
57
|
+
throw new DriveNotAuthenticatedError();
|
|
58
|
+
}
|
|
59
|
+
throw new DriveCliError(e.message ?? String(e), stderr);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Returns false only when the binary is not installed (ENOENT). Any other
|
|
63
|
+
// error (non-zero exit, auth required, etc.) still returns true — the binary
|
|
64
|
+
// is present and real errors will surface on the first actual tool call.
|
|
65
|
+
export async function checkCliAvailable() {
|
|
66
|
+
try {
|
|
67
|
+
await execFileAsync(CLI_BINARY, ["version", "--json"], { timeout: 5_000 });
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
const err = e;
|
|
72
|
+
return err.code !== "ENOENT";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2
|
+
// Control chars: 0x00-0x1F, DEL (0x7F), Unicode separators U+2028 and U+2029.
|
|
3
|
+
const CONTROL_RE = new RegExp("[\\x00-\\x1f\\x7f\\u2028\\u2029]");
|
|
4
|
+
export function validatePath(path) {
|
|
5
|
+
const p = String(path ?? "").trim();
|
|
6
|
+
if (!p)
|
|
7
|
+
throw new Error("path must not be empty");
|
|
8
|
+
if (p.startsWith("-"))
|
|
9
|
+
throw new Error(`path must not start with '-': ${p}`);
|
|
10
|
+
if (CONTROL_RE.test(p))
|
|
11
|
+
throw new Error("path contains control characters");
|
|
12
|
+
if (p.includes(".."))
|
|
13
|
+
throw new Error(`path must not contain '..' segments: ${p}`);
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
export function validateMessage(message) {
|
|
17
|
+
const m = String(message ?? "").trim();
|
|
18
|
+
if (m.startsWith("-"))
|
|
19
|
+
throw new Error(`message must not start with '-': ${m}`);
|
|
20
|
+
if (CONTROL_RE.test(m))
|
|
21
|
+
throw new Error("message contains control characters");
|
|
22
|
+
if (m.length > 2000)
|
|
23
|
+
throw new Error("message must be 2000 characters or fewer");
|
|
24
|
+
return m;
|
|
25
|
+
}
|
|
26
|
+
export function validateEmail(email) {
|
|
27
|
+
const e = String(email ?? "").trim();
|
|
28
|
+
if (e.startsWith("-"))
|
|
29
|
+
throw new Error(`email must not start with '-': ${e}`);
|
|
30
|
+
if (CONTROL_RE.test(e))
|
|
31
|
+
throw new Error("email contains control characters");
|
|
32
|
+
if (!EMAIL_RE.test(e))
|
|
33
|
+
throw new Error(`invalid email address: ${e}`);
|
|
34
|
+
return e;
|
|
35
|
+
}
|
package/glama.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://glama.ai/mcp/schemas/glama.json",
|
|
3
|
+
"name": "proton-drive-mcp",
|
|
4
|
+
"description": "Claude Desktop MCP and CLI for Proton Drive. List, upload, download, share, and manage end-to-end encrypted files.",
|
|
5
|
+
"icon": "https://proton.me/favicon.ico",
|
|
6
|
+
"categories": ["storage", "productivity", "privacy"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": "https://github.com/googlarz/proton-drive-mcp"
|
|
9
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "proton-drive-mcp",
|
|
3
|
+
"version": "1.0.12",
|
|
4
|
+
"description": "Proton Drive MCP — Claude Desktop MCP and CLI for Proton Drive. List, upload, download, share, and manage files with end-to-end encryption.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "npm@10.9.3",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"proton-drive-mcp": "dist/index.js",
|
|
14
|
+
"proton-drive-cli": "dist/cli.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsc --watch",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"cli": "npm run build && node dist/cli.js",
|
|
21
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
22
|
+
"lint": "tsc --noEmit",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"ci": "npm run lint && npm test",
|
|
25
|
+
"pack:check": "npm pack --dry-run",
|
|
26
|
+
"release:check": "npm run lint && npm test && npm run pack:check"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"proton",
|
|
30
|
+
"proton-drive",
|
|
31
|
+
"mcp",
|
|
32
|
+
"cloud-storage",
|
|
33
|
+
"encryption",
|
|
34
|
+
"claude",
|
|
35
|
+
"ai",
|
|
36
|
+
"automation",
|
|
37
|
+
"typescript"
|
|
38
|
+
],
|
|
39
|
+
"author": {
|
|
40
|
+
"name": "googlarz",
|
|
41
|
+
"url": "https://github.com/googlarz"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"homepage": "https://github.com/googlarz/proton-drive-mcp#readme",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/googlarz/proton-drive-mcp.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/googlarz/proton-drive-mcp/issues"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=18.0.0"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@modelcontextprotocol/sdk": "^1.11.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^22.10.2",
|
|
60
|
+
"typescript": "^5.7.2"
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"dist/**/*",
|
|
64
|
+
"README.md",
|
|
65
|
+
"LICENSE",
|
|
66
|
+
"CHANGELOG.md",
|
|
67
|
+
"glama.json",
|
|
68
|
+
"smithery.yaml"
|
|
69
|
+
],
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/smithery.yaml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: proton-drive-mcp
|
|
2
|
+
displayName: Proton Drive MCP
|
|
3
|
+
description: >
|
|
4
|
+
Claude Desktop MCP and companion CLI for Proton Drive.
|
|
5
|
+
List, upload, download, share, and manage end-to-end encrypted files
|
|
6
|
+
without credential exposure — auth handled by the official Proton Drive CLI.
|
|
7
|
+
license: MIT
|
|
8
|
+
homepage: https://github.com/googlarz/proton-drive-mcp
|
|
9
|
+
repository: https://github.com/googlarz/proton-drive-mcp
|
|
10
|
+
categories:
|
|
11
|
+
- storage
|
|
12
|
+
- productivity
|
|
13
|
+
- privacy
|
|
14
|
+
tools:
|
|
15
|
+
- drive_auth_status
|
|
16
|
+
- drive_auth_logout
|
|
17
|
+
- drive_version
|
|
18
|
+
- drive_list
|
|
19
|
+
- drive_mkdir
|
|
20
|
+
- drive_upload
|
|
21
|
+
- drive_download
|
|
22
|
+
- drive_move
|
|
23
|
+
- drive_delete
|
|
24
|
+
- drive_list_trash
|
|
25
|
+
- drive_share_status
|
|
26
|
+
- drive_share_invite
|
|
27
|
+
- drive_share_revoke
|
|
28
|
+
- drive_trash
|
|
29
|
+
- drive_restore
|
|
30
|
+
- drive_empty_trash
|
|
31
|
+
startCommand:
|
|
32
|
+
type: stdio
|
|
33
|
+
configSchema:
|
|
34
|
+
type: object
|
|
35
|
+
properties: {}
|
|
36
|
+
commandFunction: |-
|
|
37
|
+
(_config) => ({ command: "npx", args: ["-y", "proton-drive-mcp"] })
|