| Status | Draft v1 |
| Date | 2026-07-06 |
| Owner | DreamLab AI (John O'Hare) |
| Builds on | PRD-rust-port-and-tui.md (M0–M7, shipped) and PRD-MVP-completion.md (MA–MH, shipped); see docs/IMPLEMENTATION-STATUS.md for current state. This PRD adds a new surface on top; it does not reopen either. |
| Distribution | None. Personal use only (ADR-0007). The MCP server is a child process an agent's own tooling launches over stdio: never a listening network socket, never packaged or published. |
| Out of scope | Mirror deletes, move/rename propagation, multi-account, sharing/photos/public-links (already out of scope upstream), any form of distribution. |
pdtui today is a human-driven, keystroke-first two-pane browser: Tab/F2/F3
and friends (PRD-rust-port-and-tui.md §7.4). There is no way for an agent
(Claude Code or any other MCP-speaking tool) to drive Proton Drive
programmatically: list what's on Drive, decide what needs uploading or
downloading, or reconcile a local project directory against a remote folder
without a human sitting at the TUI issuing keystrokes.
Meanwhile the SDK already computes everything a sync algorithm needs to
establish per-file identity: a SHA1 digest and modification time, carried in
each revision's encrypted XAttr blob (decrypt_xattr_json,
rust/crates/proton-drive-core/src/download.rs:577-631), but only ever
decrypts it as a post-download integrity cross-check
(verify_xattr/claimed_size). It is never exposed as something a listing
can return, and there is no path at all today for uploading a new revision of
an existing file, only brand-new files
(upload.rs::ProtonFileUploader, CreateFileRequest at
rust/crates/proton-drive-core/src/upload.rs:354).
This PRD adds an MCP server surface to pdtui so an agent can: inspect both
sides of the local/remote boundary, compute a sync plan by comparing content
hashes, and apply exactly the operations it explicitly approves: no silent
mutation, no ambient timers, no client behaviour beyond what the existing SDK
already does honestly.
- G1. A new
pdtui mcpsubcommand serves MCP over stdio via thermcpcrate (v2.1.0), sitting alongside the existinglogin/mvp/probe/logout/wheredispatch inapps/pdtui/src/main.rs:46-65. It reuses the same keyring-backedSession/SessionManagerbootstrap thatpdtui loginalready establishes (apps/pdtui/src/session.rs): no parallel auth path, no new credential store. - G2. Read tools (
drive_list,local_index) let an agent build an accurate picture of both sides without any client-side polling beyond whatevents_pollalready rides on the sanctioned Events API (spawn_volume_event_loop,rust/crates/proton-drive-core/src/events.rs). No re-listing timers, per the workspace-wide rule inCLAUDE.md. - G3. A pure
sync_planturns two listings into a diff (upload / download / skip / conflict) with zero side effects: callable repeatedly, safe to call speculatively, safe to throw away. - G4.
sync_applyexecutes only the operations the caller explicitly names: never "apply the whole plan" implicitly, never anything the agent didn't ask for by ID. - G5. Minimal, additive SDK extensions to support the above: surface SHA1
- mtime on listed nodes (WP1) and add an upload-new-revision path for existing files (WP2). Neither changes an existing public method's signature or behaviour.
- Mirror deletes. A file removed from one side is never removed from the
other, automatically or even as a suggested op;
sync_applyhas no delete operation at all in v1. - Move/rename propagation. A rename surfaces as a delete-shaped gap plus an add-shaped gap in a naive path/name diff; v1 does not attempt to detect or special-case this. It is the agent's problem if it cares.
- Multi-account. One keyring session per
pdtui mcpprocess, exactly as today. - Any distribution of the MCP server. ADR-0007 governs: this is a local stdio child process, launched by the agent's own tooling, never a listening network service, never packaged or published anywhere.
- Working around known SDK gaps. Nested-folder upload is only as good as
the underlying SDK today (B2 in
IMPLEMENTATION-STATUS.md). This PRD does not silently patch around it; see Risks (§10). - Recursive whole-tree sync in one call.
sync_plan/sync_applyoperate over one local root ↔ one remote folder pairing per call. Recursing into subfolders is the agent's loop to drive (call again per subfolder), not a v1 built-in. - Auto-resolving conflicts. A hash-differ case is always returned
undecided; the tool surface never guesses which side wins.
| User | Use case |
|---|---|
| Coding agent (Claude Code or similar) mid-session | Push a local build artefact or scratch file to Drive without shelling out to a human-driven TUI |
| Coding agent doing project sync | Compare a local repo checkout against its mirror folder on Drive, upload what changed, flag anything that diverged on both sides |
| Long-running agent session | Poll events_poll between turns to notice a change made from another device before re-running sync_plan |
| Personal use, scripted | The owner's own automation (cron-free, agent-invoked) reconciling a photos-export or notes folder into Drive on demand |
Anti-user: same as upstream, no third-party distribution, no multi-tenant service (ADR-0007).
- Upload with verification. As an agent, I call
drive_upload(local_path, remote_parent_path)to push a new file, thendrive_listthe parent to confirm the new node's SHA1 matches what I computed locally before telling the user it's done. - Directed sync. As an agent, I call
sync_plan("~/projects/foo", "/My Files/Projects/foo")and get back aplan_idand three ops: twoupload(new local files), oneskip(hash-equal). With no conflicts I callsync_apply(plan_id)with an emptydecisionsmap; it runs that plan's ops: the two uploads happen, the skip is a no-op. Nothing outside that stored plan can be touched. - Conflict surfaced, not resolved. As an agent,
sync_planreports oneconflictentry (same path, different hash on each side). I read both files (drive_download+ local read), decide which side is authoritative, and callsync_apply(plan_id, decisions={path: keep_local})(orkeep_remote); the engine turns that decision into anupload_revision(ordownload), never both, never automatically. An undecided conflict makes the whole apply fail rather than pick a side. - Staleness check before acting. As a long-running agent, I hold a
next_anchorfrom a previousevents_pollcall. Before trusting async_planresult I callevents_poll(since_event_id=anchor); if it reports a change under the folder I'm about to sync, I re-rundrive_listfirst rather than acting on a stale remote listing. - New project folder. As an agent setting up a new remote mirror, I
call
drive_mkdir(parent_path, "new-project")once, then upload into it. No separate out-of-band step required. - Revise an existing file. As an agent that edited a file already on
Drive, I call
drive_uploadagain against the same remote path. By default the tool refuses (a name collision is a hard error, never a silent overwrite), so I re-call withallow_revision_on_conflict: trueto upload a new revision of the existing node (created_revision: truein the result).
| Capability | Current state | Delta needed |
|---|---|---|
| Node listing | client.rs::{my_files_root, node, fetch_folder_children} return Node (nodes.rs) with size_bytes, modified_at, no digest. |
WP1: attach SHA1 + XAttr mtime, opt-in per call (see §6.2, §9). |
| Remote content hash | Only ever decrypted inside FileDownloader::decrypt_xattr_json (download.rs:577-631), which needs the node's private key already resolved as part of an in-flight download session. |
WP1 extracts a standalone "fetch + decrypt this revision's XAttr" path callable from a listing context, not just a download. |
| New-file upload | upload.rs::ProtonFileUploader implements CreateFileRequest → chunk → commit (ADR-0008). No CreateDraftRevisionRequest/response DTO anywhere in proton-drive-api, no client method to request a revision on an existing node. |
WP2: new DTOs + POST /drive/v2/volumes/{volumeId}/files/{nodeId}/revisions (JS reference: manager.ts::createDraftRevision, apiService.ts:137-150), reusing the existing node key + content-key-packet session key rather than generating fresh ones. |
| Folder creation | Not implemented anywhere in the Rust port. Confirmed absent: audit-2026-07-05.md notes createFolder is out of scope for the shipped MVP. |
New, small surface (folded into WP2 or WP4, see §8): POST /drive/v2/volumes/{volumeID}/folders with {ParentLinkID, NodeKey, NodeHashKey, NodePassphrase, NodePassphraseSignature, SignatureEmail, Name, Hash, XAttr?} (JS reference: nodesManagement.ts:357-390, apiService.ts:498-535). Reuses the same node-key-generation + HMAC name-hash helper CreateFile already uses (upload.rs, name-hash fn at upload.rs:81-113): no content key, no blocks, no revision/commit steps, materially smaller than file upload. |
Path → NodeUid resolution |
Does not exist. The TUI navigates one iter_folder_children level at a time via a cwd stack (apps/pdtui/src/panes.rs). |
New, small: segment-walk from my_files_root, matching child name at each level (name uniqueness per folder is already server-enforced: NodeWithSameNameExists, nodes.rs::map_api_error). Session-scoped memo only, no new persistence layer. |
| Events | spawn_volume_event_loop + ProtonDriveClient::subscribe_drive_events are already public and wired into pdtui's remote pane (events_bridge.rs, M6). |
Reuse as-is. events_poll is a thin adapter over the existing subscription, not a new poller. |
| Local hashing | pdtui's upload path already computes SHA1 client-side before queueing (PRD-rust-port-and-tui.md §7.5). |
WP3 generalises this into a directory-walking indexer with a (path, size, mtime) → sha1 cache, independent of any single upload. |
pdtui mcp is a new arm of the existing subcommand dispatch
(apps/pdtui/src/main.rs:46): Some("mcp") => run_mcp().await. It:
- Loads the existing keyring session via
SessionManager::from_keyringexactly aspdtui mvpalready does; if no session exists, it exits with an error telling the operator to runpdtui loginfirst. The MCP server is never itself a login surface. - Constructs the same
ProtonDriveClientthe rest of the app uses (sharedproton-drive-coreconstruction path, not a bespoke one). - Serves MCP over stdio only via
rmcp: no HTTP/SSE transport, no listening socket, matching the ADR-0007 constraint that this never becomes a network-reachable service. - Sets
x-pm-appversionexactly as every otherpdtuicode path does (external-drive-pdtui@{semver}-stable); the MCP server is not a separate client identity.
All paths are logical '/'-separated paths from the My Files root (case-
sensitive), not uids. The one uid that crosses the boundary is drive_download's
uid, which is the {volume_id, node_id} object a drive_list child carries.
| Tool | Args | Returns | Side effects |
|---|---|---|---|
drive_list |
path: string, include_digest: bool = false |
{path, uid, count, children: [{uid, name, kind, size, mtime, sha1?}]}: kind is the string "file"/"folder"/"album"; sha1 present only when include_digest. |
None (read-only). |
drive_download |
uid: {volume_id, node_id} (the object from drive_list), local_path: string, overwrite: bool = false |
{bytes, sha1, signature_verified} |
Writes local_path; refuses to clobber an existing file unless overwrite. |
drive_upload |
local_path: string, remote_parent_path: string, name?: string, allow_revision_on_conflict: bool = false |
{uid, revision_uid, sha1, created_revision} |
Creates a new node; a name collision fails by default (see below). |
drive_mkdir |
parent_path: string, name: string |
{uid} |
Creates a folder node. |
local_index |
root: string, cache_file?: string |
{root, count, truncated, entries: [{path, sha1, size, mtime}]}: the echoed entries list is capped at 1000; count is the full total. |
None (read-only; may read/refresh the hash cache, §6.3). |
sync_plan |
local_root: string, remote_folder: string, cache_file?: string |
{plan_id, summary, ops, conflicts, dirs_to_create, checkpoint} |
None to drive/local content, pure function of two listings (G3); stores the plan in-process and writes a best-effort snapshot checkpoint (§6.4). |
sync_apply |
plan_id: string, decisions: {path → keep_local|keep_remote|skip} |
{plan_id, applied, results: [{path, op, result, error?, uid?, revision_uid?}]} |
Executes the stored plan's full op set; decisions only resolve its conflicts. |
events_poll |
since_event_id?: string, volume_id?: string |
{volume_id, next_anchor, events} (plus refreshed when draining) |
None: on-demand drain of the Events API; no new polling loop (§6.1, G2). |
drive_list's include_digest flag exists because digest is not free.
WP1's SHA1/mtime come from decrypting each file's XAttr, which lives only on
the per-revision detail response
(GET drive/v2/volumes/{volumeId}/files/{linkID}/revisions/{revisionID},
proton-drive-api::download::RevisionWithBlocks), not on the folder-
children listing response (proton-drive-api::nodes::Revision, which has no
XAttr field at all). Turning digest on for every file in a listing is an
extra HTTP round trip per file, not a batched call. Default drive_list
calls stay cheap (name/size/mtime/type only, already on the listing
response); include_digest: true is opt-in, and sync_plan is the one
caller that always sets it for the remote side, bounded to a small
concurrency limit (reuse the existing default-3 Semaphore convention from
the transfer queue, PRD-rust-port-and-tui.md §7.5) rather than serial N+1
or unbounded-parallel fetches, to respect the shared per-account rate limits
CLAUDE.md requires every Proton Drive client to honour.
drive_upload never silently overwrites on a name collision. It first
attempts the existing CreateFile path (upload.rs); if the API returns
AlreadyExists (error code 2500, nodes.rs::map_api_error) for that name
under that parent, the default (allow_revision_on_conflict: false) is to
fail with a hard error ("Refusing to overwrite it"), leaving the existing
file untouched, so a create can never clobber an unrelated file the agent
didn't mean to. Only when the caller re-invokes with
allow_revision_on_conflict: true does it resolve the existing node and upload
a new revision via WP2's create-draft-revision path; the result then
carries created_revision: true. This is a deliberate reversal of an earlier
"overwrite, not duplicate" default in favour of failing safe.
local_index walks a local directory tree and, for each file, looks up
(size, mtime) for that relative path in a hash cache before recomputing SHA1:
if size and mtime are unchanged since the last index of that path, the
cached hash is reused (the same trick git/rsync use to avoid rehashing
unchanged files).
The cache defaults to in-memory, keyed per relative path by
(size, mtime) → sha1, scoped to the pdtui mcp process's lifetime,
consistent with ADR-0003 (in-memory cache, SQLite deferred behind explicit
triggers). A long-running agent session that keeps one pdtui mcp process
alive across many tool calls amortises the hashing cost; a fresh process
re-hashes from scratch on first local_index call. As a lightweight, opt-in
exception, both local_index and sync_plan accept an optional cache_file
parameter: a JSON file the walk reads a (size, mtime) → sha1 cache from and
rewrites, so a fresh process can reuse a prior run's digests without standing
up the SQLite store ADR-0003 still defers. It is a plain JSON side-file, not a
database; it carries no cross-process invalidation machinery beyond the
(size, mtime) validity check each entry already makes. If large local trees
or short-lived MCP processes without a cache_file make hashing a real
bottleneck, revisit ADR-0003's triggers explicitly. This PRD does not pull
SQLite forward on its own authority.
sync_plan is a pure function: diff(local_index(local_root), drive_list(remote_folder, include_digest=true)) by matching relative path.
For each path:
- Only-local →
upload. - Only-remote →
download. - Both sides, hashes equal →
skip. - Both sides, hashes differ →
conflict, returned undecided: the agent resolves it, this function never picks a side.
No hidden state, no implicit baseline/"last known good" tracking in v1: a hash-differ case is always a conflict, even if one side's mtime is obviously newer. (A future v2 could add a recorded-baseline three-way merge; explicitly deferred here, see §11.)
This is the load-bearing design constraint of the whole feature:
sync_planhas no side effects on drive or local file content. It can be called any number of times, discarded, or used only to inform a decision the agent makes some other way. Nothing about calling it changes local or remote content. (It does store the plan in-process under itsplan_idand write a best-effort remote-snapshot checkpoint side-file: neither touches drive or local file state; see ADR-0013.)sync_applyapproves a plan by reference, not by restating ops. The caller passes theplan_idof a plansync_planalready returned; the server looks up that stored plan and applies its full op set. Per-path control is limited to thedecisionsmap, which only resolves that plan's conflicts (keep_local→upload_revision,keep_remote→download,skip→ drop). The caller cannot smuggle in an op the plan never produced, because it names no ops at all; it names a plan. AnUploadRevisionre-reads the remote active revision and fails safe if it moved since the plan was computed; one op failing never aborts the rest.- Every mutation is auditable.
drive_upload,drive_download,drive_mkdir, and each op insidesync_applyreturn enough detail (uid, revision uid, sha1) to log what happened, not just what was requested. - Conflicts are never auto-resolved anywhere in the stack: not in
sync_plan(a hash-differ or missing-remote-digest path is returnedconflict, undecided), not insync_apply(an unresolved conflict, meaning one with no matching entry indecisions, or adecisionskey naming a non-conflict path, makes the whole apply fail withinvalid_paramsrather than guessing a direction). - Idempotency where it costs nothing:
skipops run as no-ops;drive_mkdiron an already-existing name surfaces the sameAlreadyExiststhe API already returns rather than silently succeeding; the agent decides whether that's fine. Re-applying a stored plan after a partial apply is safe: anUploadwhose target already carries identical content reports idempotent success rather than a duplicate or an overwrite.
x-pm-appversionunchanged and non-overridable: the MCP server is not a new client identity (§6.1).- No ad hoc polling of node/listing endpoints outside the Events API; the
workspace-wide rule in
CLAUDE.mdapplies toevents_pollexactly as it does to the TUI's own event consumer.events_pollmust not become a disguised re-list-on-timer. - All HTTP hits official Proton endpoints only. No proxying, same as every
other
pdtuicode path. - Rate limits are shared with first-party clients; WP1's digest-fetch concurrency (§6.2) and any batching in WP3's remote-side listing must respect this, not just work "in the common case".
- Personal use only, unaudited (ADR-0007, root
CLAUDE.md). This PRD does not change that status: the MCP server is one more local code path against the owner's own account, not a step toward distribution. - No breaking change to
ProtonDriveClient's existing public API: WP1/WP2 are additive (new optional parameters, new methods), not modifications ofmy_files_root/iter_folder_children/file_downloader/file_uploader's existing signatures.
| WP | Deliverable | Depends on | Notes / gotchas found during this research pass |
|---|---|---|---|
| WP1 | Surface SHA1 digest + XAttr mtime on listed nodes | — | Not free (§6.2): must be opt-in per listing call, not eagerly attached to every Node. Extract the XAttr-fetch-and-decrypt logic out of download.rs's download-session context into something callable given just a NodeUid + resolved node key. |
| WP2 | Upload-new-revision flow for existing files | WP1 (reuses the same node-key resolution path) | New DTOs + POST .../files/{nodeId}/revisions (JS: manager.ts::createDraftRevision). Reuses the existing node key and content-key-packet session key, does not generate fresh node crypto material, unlike CreateFile. Known gap this intersects: resolve_parent_context (upload.rs) is only correct when the parent is the share root (B2, IMPLEMENTATION-STATUS.md); this affects revision uploads into nested folders exactly as it affects new-file uploads into nested folders. Recommend WP2 either fixes B2's upload half as part of this package (it's already touching the same code path) or the PRD explicitly scopes drive_upload/sync_apply uploads to root-level and single-level-nested remote folders until B2 lands. Do not ship silently broken nested uploads. drive_mkdir (folder creation, §5) is small enough to fold into this package rather than standing up a fifth WP. |
| WP3 | Sync engine: local indexer + diff/plan | none; independent of WP1/WP2, can start in parallel | Pure, testable, no I/O side effects beyond reading local files and calling drive_list. Hash-cache scope decision is explicit, not implicit (§6.3). |
| WP4 | MCP server (pdtui mcp subcommand, tool wiring) |
WP1, WP2, WP3 | Wires the tool catalogue (§6.2) onto rmcp. Reuses session.rs bootstrap unchanged (§6.1). events_poll reuses subscribe_drive_events unchanged. |
pdtui mcpstarts, serves the tool catalogue in §6.2 over stdio, and exits cleanly on stdin close /SIGTERM.drive_listwithoutinclude_digestcosts no more HTTP round trips than today'siter_folder_children; withinclude_digest: trueit is bounded-concurrency, not serial-per-file and not unbounded-parallel.- Extended headless round-trip (extends the existing
pdtui mvpshape,docs/PRD-MVP-completion.md): upload a file → modify it locally → callsync_planand confirm it reports exactly oneconflictop (content_diverged) for that path (the file now exists, and differs, on both sides), everything elseskip→ callsync_apply(plan_id, decisions={path: keep_local})and confirm that path resolves to anupload_revision→ re-runlocal_indexanddrive_list(include_digest=true)on both sides and confirm the SHA1s converge. (For a plainuploadinstead, use a brand-new local file with no remote counterpart.) sync_applyonly ever executes the ops of the stored plan named byplan_id; adecisionskey that does not name one of that plan's conflicts is rejected withinvalid_params, never silently ignored.- A hash-differ case round-trips as
conflict(content_diverged) throughsync_planand, ifsync_apply(plan_id)is called without adecisionsentry for it, the whole apply is rejected (invalid_params) rather than silently resolved. drive_uploadagainst an already-existing remote name fails by default (never a silent overwrite); re-called withallow_revision_on_conflict: trueit creates a new revision (WP2 path,created_revision: truein the result), not a duplicate node.cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspaceall green, per the repo-standard quality gate.- No
unwrap/expect/panic!introduced outside#[cfg(test)]: same workspace-wide lint as everything else in this repo.
| Risk | Severity | Mitigation |
|---|---|---|
| WP1's per-file XAttr fetch is an unbounded N+1 against a large remote folder | Med | Opt-in include_digest, bounded concurrency reusing the existing transfer-queue semaphore convention (§6.2): not this PRD's job to invent a new concurrency primitive. |
| Nested-folder upload is broken today (B2) and WP2's revision-upload path shares the same parent-key-resolution code | High | Flagged explicitly in §8 as a WP2-adjacent decision point: fix B2's upload half as part of WP2, or scope drive_upload/sync_apply to root/shallow-nested remote folders until it's fixed. Do not ship a tool that silently corrupts nested uploads. |
An agent calls sync_apply with a stale plan (remote changed between sync_plan and sync_apply) |
Med | events_poll exists precisely so an agent can check staleness before applying (user story 4, §4). sync_apply itself doesn't re-validate the plan against current remote state before executing; v1 relies on the agent's own discipline plus the underlying upload/download error paths (e.g. a real hash mismatch surfaces as the existing Integrity/AlreadyExists errors) rather than a new optimistic-concurrency check. Revisit if this proves insufficient in practice. |
| Deprecated v1 share-scoped list/node endpoints (B8) could be retired upstream without notice | Med (pre-existing, not introduced by this PRD) | Unchanged from IMPLEMENTATION-STATUS.md; WP1/WP4 build on the same client.rs methods already using these endpoints; migrating to v2 endpoints is out of scope here, tracked separately. |
| MCP tool surface becomes a de facto second API that drifts from the TUI's own behaviour | Low | Both share the same proton-drive-core/ProtonDriveClient construction path (§6.1); the MCP server is a new caller, not a parallel implementation. |
| Personal-use/unaudited status gets glossed over once there's a slicker agent-facing surface | Med | Restated explicitly in §3, §7, and this PRD's header: an MCP surface makes misuse easier to automate accidentally, so the honesty obligation is if anything higher, not lower, than the interactive TUI's. |
- Three-way merge / recorded baseline for conflict resolution. v1 treats
any hash-differ as an undecided conflict, even when one side is obviously
newer. A future version could record a "last synced hash" per path to
distinguish "both changed since last sync" (real conflict) from "only one
side changed since last sync" (safe fast-forward). Deferred: not blocking
v1, and adding it changes
sync_plan's purity guarantee (it would need to read/write a baseline store), which deserves its own design pass rather than folding in here. - Where B2 (nested-folder upload) gets fixed. §8 flags this as a WP2
decision point rather than resolving it; whoever picks up WP2 should
decide and record that decision (a short ADR update, not a new PRD) before
drive_upload/sync_applyship claiming nested-folder support. local_index/hash-cache persistence. §6.3 defaults to in-memory, process-lifetime scope (consistent with ADR-0003), with an optionalcache_fileJSON side-file as a lightweight opt-in for cross-process digest reuse. Promoting that to the full SQLite store (with proper cross-process invalidation) remains an ADR-0003-trigger conversation, not something to quietly work around inside WP3.