Skip to content

Commit c65c02f

Browse files
axpnetclaude
andcommitted
feat(transfer): expose transfer capabilities to agents
Surface scheduler transfer capabilities in agent-info and MCP profile resources without exposing credentials. Use protocol-default capability blocks for discovery and live provider capabilities after agent-connect opens a backend. Co-authored-by: aeroftp[bot] <aeroftp[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44d306e commit c65c02f

7 files changed

Lines changed: 382 additions & 8 deletions

File tree

src-tauri/src/agent_session.rs

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
use crate::credential_store::CredentialStore;
1717
use crate::providers::{
1818
ProviderConfig, ProviderError, ProviderFactory, ProviderType, StorageProvider,
19+
TransferOptimizationHints,
1920
};
21+
use crate::transfer_dag::TransferCapabilities;
2022
use serde_json::{json, Value};
2123
use std::collections::HashMap;
2224
use std::time::Instant;
@@ -297,6 +299,135 @@ pub fn capabilities_for_protocol(protocol: &str) -> Vec<&'static str> {
297299
}
298300
}
299301

302+
pub fn provider_type_for_transfer_capabilities(protocol: &str) -> Option<ProviderType> {
303+
match protocol.to_ascii_lowercase().as_str() {
304+
"ftp" => Some(ProviderType::Ftp),
305+
"ftps" => Some(ProviderType::Ftps),
306+
"sftp" => Some(ProviderType::Sftp),
307+
"webdav" | "webdavs" | "web_dav" => Some(ProviderType::WebDav),
308+
"s3" => Some(ProviderType::S3),
309+
"aerocloud" | "aero_cloud" => Some(ProviderType::AeroCloud),
310+
"googledrive" | "google_drive" | "google drive" => Some(ProviderType::GoogleDrive),
311+
"googlephotos" | "google_photos" | "google photos" => Some(ProviderType::GooglePhotos),
312+
"dropbox" => Some(ProviderType::Dropbox),
313+
"onedrive" | "one_drive" | "one drive" => Some(ProviderType::OneDrive),
314+
"mega" => Some(ProviderType::Mega),
315+
"box" => Some(ProviderType::Box),
316+
"pcloud" | "p_cloud" => Some(ProviderType::PCloud),
317+
"azure" => Some(ProviderType::Azure),
318+
"filen" => Some(ProviderType::Filen),
319+
"fourshared" | "four_shared" | "4shared" => Some(ProviderType::FourShared),
320+
"zohoworkdrive" | "zoho_workdrive" | "zoho workdrive" => Some(ProviderType::ZohoWorkdrive),
321+
"internxt" => Some(ProviderType::Internxt),
322+
"kdrive" | "k_drive" => Some(ProviderType::KDrive),
323+
"jottacloud" => Some(ProviderType::Jottacloud),
324+
"drime" | "drimecloud" | "drime_cloud" => Some(ProviderType::DrimeCloud),
325+
"filelu" | "file_lu" => Some(ProviderType::FileLu),
326+
"koofr" => Some(ProviderType::Koofr),
327+
"opendrive" | "open_drive" => Some(ProviderType::OpenDrive),
328+
"yandexdisk" | "yandex_disk" | "yandex disk" => Some(ProviderType::YandexDisk),
329+
"github" => Some(ProviderType::GitHub),
330+
"gitlab" => Some(ProviderType::GitLab),
331+
"swift" => Some(ProviderType::Swift),
332+
"immich" => Some(ProviderType::Immich),
333+
"imagekit" | "image_kit" => Some(ProviderType::ImageKit),
334+
"uploadcare" | "upload_care" => Some(ProviderType::Uploadcare),
335+
"backblaze" | "b2" | "backblazeb2" | "backblaze_b2" => Some(ProviderType::Backblaze),
336+
"cloudinary" => Some(ProviderType::Cloudinary),
337+
_ => None,
338+
}
339+
}
340+
341+
pub fn default_transfer_optimization_hints_for_provider(
342+
provider_type: ProviderType,
343+
) -> TransferOptimizationHints {
344+
match provider_type {
345+
ProviderType::Sftp => TransferOptimizationHints {
346+
supports_range_download: true,
347+
supports_compression: true,
348+
supports_delta_sync: true,
349+
..Default::default()
350+
},
351+
ProviderType::Ftp | ProviderType::Ftps => TransferOptimizationHints {
352+
supports_resume_download: true,
353+
supports_resume_upload: true,
354+
supports_range_download: true,
355+
..Default::default()
356+
},
357+
ProviderType::S3 => TransferOptimizationHints {
358+
supports_multipart: true,
359+
multipart_threshold: 5 * 1024 * 1024,
360+
multipart_part_size: 5 * 1024 * 1024,
361+
multipart_max_parallel: 4,
362+
supports_range_download: true,
363+
supports_resume_download: true,
364+
supports_server_checksum: true,
365+
preferred_checksum_algo: Some("ETag".to_string()),
366+
..Default::default()
367+
},
368+
ProviderType::Backblaze => TransferOptimizationHints {
369+
supports_multipart: true,
370+
multipart_threshold: 200 * 1024 * 1024,
371+
multipart_part_size: 100 * 1024 * 1024,
372+
multipart_max_parallel: 4,
373+
..Default::default()
374+
},
375+
ProviderType::WebDav | ProviderType::Koofr => TransferOptimizationHints {
376+
supports_range_download: true,
377+
supports_resume_download: true,
378+
..Default::default()
379+
},
380+
ProviderType::Azure => TransferOptimizationHints {
381+
supports_range_download: true,
382+
supports_resume_download: true,
383+
..Default::default()
384+
},
385+
ProviderType::Swift => TransferOptimizationHints {
386+
supports_resume_download: true,
387+
..Default::default()
388+
},
389+
_ => TransferOptimizationHints::default(),
390+
}
391+
}
392+
393+
pub fn transfer_capabilities_for_protocol(protocol: &str) -> Option<TransferCapabilities> {
394+
let provider_type = provider_type_for_transfer_capabilities(protocol)?;
395+
Some(transfer_capabilities_for_provider_type(
396+
provider_type,
397+
protocol,
398+
))
399+
}
400+
401+
pub fn transfer_capabilities_for_provider_type(
402+
provider_type: ProviderType,
403+
protocol_for_feature_hint: &str,
404+
) -> TransferCapabilities {
405+
TransferCapabilities::from_provider_hints(
406+
provider_type,
407+
&default_transfer_optimization_hints_for_provider(provider_type),
408+
capabilities_for_protocol(protocol_for_feature_hint).contains(&"server_copy"),
409+
)
410+
}
411+
412+
pub fn transfer_capabilities_block(
413+
protocol: &str,
414+
live_capabilities: Option<TransferCapabilities>,
415+
source: &str,
416+
) -> Value {
417+
match live_capabilities.or_else(|| transfer_capabilities_for_protocol(protocol)) {
418+
Some(capabilities) => json!({
419+
"status": "ok",
420+
"source": source,
421+
"capabilities": capabilities,
422+
}),
423+
None => json!({
424+
"status": "unsupported",
425+
"source": source,
426+
"capabilities": TransferCapabilities::default(),
427+
}),
428+
}
429+
}
430+
300431
/// Profile block: always present, summarises which profile the agent
301432
/// is talking about. Never carries `status` (it's metadata, not a step
302433
/// that can fail).
@@ -331,6 +462,11 @@ pub fn capabilities_block(protocol: &str) -> Value {
331462
json!({
332463
"status": "ok",
333464
"features": features,
465+
"transfer_capabilities": transfer_capabilities_block(
466+
protocol,
467+
None,
468+
"protocol_defaults"
469+
),
334470
})
335471
}
336472

@@ -527,14 +663,19 @@ pub async fn build_agent_connect_payload(query: &str) -> Value {
527663
};
528664

529665
let path = path_block(&profile);
530-
let capabilities = capabilities_block(&profile.protocol);
666+
let mut capabilities = capabilities_block(&profile.protocol);
531667

532668
let connect_started = Instant::now();
533669
let connect_result = connect_provider(&profile).await;
534670
let elapsed_ms = connect_started.elapsed().as_millis();
535671

536672
let (connect, quota) = match connect_result {
537673
ConnectOutcome::Connected(mut provider) => {
674+
capabilities["transfer_capabilities"] = transfer_capabilities_block(
675+
&profile.protocol,
676+
Some(provider.transfer_capabilities()),
677+
"live_provider",
678+
);
538679
let connect = connect_block_ok(&profile.id, elapsed_ms);
539680
let quota = match provider.storage_info().await {
540681
Ok(info) => quota_block_ok(info.used, info.total, info.free),
@@ -614,6 +755,36 @@ mod tests {
614755
assert!(capabilities_for_protocol("xyzzy").is_empty());
615756
}
616757

758+
#[test]
759+
fn transfer_capabilities_block_exposes_parallel_limits() {
760+
let v = transfer_capabilities_block("backblaze", None, "profile_defaults");
761+
assert_eq!(v["status"], "ok");
762+
assert_eq!(v["source"], "profile_defaults");
763+
assert_eq!(v["capabilities"]["multipart_upload"], "supported");
764+
assert_eq!(v["capabilities"]["max_chunk_slots"], 4);
765+
assert_eq!(v["capabilities"]["preferred_chunk_size"], 100 * 1024 * 1024);
766+
}
767+
768+
#[test]
769+
fn transfer_capabilities_unknown_protocol_is_explicit() {
770+
let v = transfer_capabilities_block("xyzzy", None, "profile_defaults");
771+
assert_eq!(v["status"], "unsupported");
772+
assert_eq!(v["source"], "profile_defaults");
773+
assert_eq!(v["capabilities"]["max_file_slots"], 1);
774+
}
775+
776+
#[test]
777+
fn capabilities_block_contains_transfer_capabilities() {
778+
let v = capabilities_block("sftp");
779+
assert_eq!(v["status"], "ok");
780+
assert_eq!(v["transfer_capabilities"]["status"], "ok");
781+
assert_eq!(v["transfer_capabilities"]["source"], "protocol_defaults");
782+
assert_eq!(
783+
v["transfer_capabilities"]["capabilities"]["strict_concurrent_range_download"],
784+
"unsupported"
785+
);
786+
}
787+
617788
#[test]
618789
fn block_helpers_carry_status() {
619790
// Status is the agent's primary read: make sure each helper

src-tauri/src/ai_core/mcp_impl.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ impl RemoteBackend for McpRemoteBackend {
311311
available: info.free,
312312
})
313313
}
314+
315+
async fn transfer_capabilities(
316+
&self,
317+
) -> Result<Option<crate::transfer_dag::TransferCapabilities>, String> {
318+
self.with_provider(move |p| Box::pin(async move { Ok(p.transfer_capabilities()) }))
319+
.await
320+
.map(Some)
321+
}
314322
}
315323

316324
#[cfg(test)]

src-tauri/src/ai_core/remote_backend.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// Copyright (c) 2024-2026 axpnet: AI-assisted (see AI-TRANSPARENCY.md)
88

99
use crate::providers::RemoteEntry;
10+
use crate::transfer_dag::TransferCapabilities;
1011
use async_trait::async_trait;
1112

1213
/// Storage quota information
@@ -55,4 +56,10 @@ pub trait RemoteBackend: Send + Sync {
5556

5657
/// Get storage quota information.
5758
async fn storage_info(&self) -> Result<StorageQuota, String>;
59+
60+
/// Transfer scheduler capabilities for the live provider, when the
61+
/// backend can expose them without credentials leaving the surface.
62+
async fn transfer_capabilities(&self) -> Result<Option<TransferCapabilities>, String> {
63+
Ok(None)
64+
}
5865
}

src-tauri/src/ai_core/remote_tools.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,11 @@ async fn list_servers(ctx: &dyn ToolCtx, args: &Value) -> Result<Value, ToolErro
412412
name_ok && proto_ok
413413
})
414414
.map(|p| {
415+
let transfer_capabilities = crate::agent_session::transfer_capabilities_block(
416+
&p.protocol,
417+
None,
418+
"profile_defaults",
419+
);
415420
let auth_state = auth_lookup
416421
.as_ref()
417422
.map(|(store, accounts)| {
@@ -433,6 +438,7 @@ async fn list_servers(ctx: &dyn ToolCtx, args: &Value) -> Result<Value, ToolErro
433438
"initialPath": p.initial_path,
434439
"providerId": p.provider_id,
435440
"auth_state": auth_state,
441+
"transfer_capabilities": transfer_capabilities,
436442
})
437443
})
438444
.collect();
@@ -1205,7 +1211,7 @@ async fn agent_connect(ctx: &dyn ToolCtx, args: &Value) -> Result<Value, ToolErr
12051211
};
12061212

12071213
let path = agent_session::path_block(&profile);
1208-
let capabilities = agent_session::capabilities_block(&profile.protocol);
1214+
let mut capabilities = agent_session::capabilities_block(&profile.protocol);
12091215

12101216
// `remote_backend()` opens (or reuses) the pooled connection: its
12111217
// outcome IS the "connect" block. No separate connect() call needed.
@@ -1216,6 +1222,13 @@ async fn agent_connect(ctx: &dyn ToolCtx, args: &Value) -> Result<Value, ToolErr
12161222
let (connect, quota) = match backend_result {
12171223
Ok(backend) => {
12181224
let connect = agent_session::connect_block_ok(&profile.id, elapsed_ms);
1225+
if let Ok(Some(live_caps)) = backend.transfer_capabilities().await {
1226+
capabilities["transfer_capabilities"] = agent_session::transfer_capabilities_block(
1227+
&profile.protocol,
1228+
Some(live_caps),
1229+
"live_provider",
1230+
);
1231+
}
12191232
let quota = match backend.storage_info().await {
12201233
Ok(q) => agent_session::quota_block_ok(q.used, q.total, q.available),
12211234
Err(msg) => {

src-tauri/src/bin/aeroftp_cli.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9110,6 +9110,7 @@ fn safe_vault_profiles(cli: &Cli) -> Result<Vec<serde_json::Value>, String> {
91109110
"port": p.get("port").and_then(|v| v.as_u64()).unwrap_or(0),
91119111
"username": p.get("username").and_then(|v| v.as_str()).unwrap_or(""),
91129112
"initialPath": p.get("initialPath").and_then(|v| v.as_str()).unwrap_or("/"),
9113+
"providerId": p.get("providerId").and_then(|v| v.as_str()).unwrap_or(""),
91139114
"auth_state": auth_state,
91149115
})
91159116
})
@@ -9139,6 +9140,7 @@ fn safe_vault_profiles_for_agent() -> Result<Vec<serde_json::Value>, String> {
91399140
"port": p.get("port").and_then(|v| v.as_u64()).unwrap_or(0),
91409141
"username": p.get("username").and_then(|v| v.as_str()).unwrap_or(""),
91419142
"initialPath": p.get("initialPath").and_then(|v| v.as_str()).unwrap_or("/"),
9143+
"providerId": p.get("providerId").and_then(|v| v.as_str()).unwrap_or(""),
91429144
})
91439145
})
91449146
.collect())
@@ -9285,14 +9287,69 @@ fn cmd_agent_info(cli: &Cli) -> i32 {
92859287
let profiles = profiles
92869288
.into_iter()
92879289
.map(|p| {
9290+
let protocol = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("");
92889291
serde_json::json!({
9292+
"id": p.get("id").and_then(|v| v.as_str()).unwrap_or(""),
92899293
"name": p.get("name").and_then(|v| v.as_str()).unwrap_or(""),
9290-
"protocol": p.get("protocol").and_then(|v| v.as_str()).unwrap_or(""),
9294+
"protocol": protocol,
92919295
"host": p.get("host").and_then(|v| v.as_str()).unwrap_or(""),
9296+
"port": p.get("port").and_then(|v| v.as_u64()).unwrap_or(0),
9297+
"username": p.get("username").and_then(|v| v.as_str()).unwrap_or(""),
92929298
"initialPath": p.get("initialPath").and_then(|v| v.as_str()).unwrap_or("/"),
9299+
"providerId": p.get("providerId").and_then(|v| v.as_str()).unwrap_or(""),
9300+
"transfer_capabilities": ftp_client_gui_lib::agent_session::transfer_capabilities_block(
9301+
protocol,
9302+
None,
9303+
"profile_defaults",
9304+
),
92939305
})
92949306
})
92959307
.collect::<Vec<_>>();
9308+
let protocol_transfer_capabilities = [
9309+
"ftp",
9310+
"ftps",
9311+
"sftp",
9312+
"webdav",
9313+
"webdavs",
9314+
"s3",
9315+
"backblaze",
9316+
"b2",
9317+
"azure",
9318+
"swift",
9319+
"googledrive",
9320+
"googlephotos",
9321+
"dropbox",
9322+
"onedrive",
9323+
"box",
9324+
"pcloud",
9325+
"mega",
9326+
"filen",
9327+
"internxt",
9328+
"kdrive",
9329+
"jottacloud",
9330+
"zohoworkdrive",
9331+
"yandexdisk",
9332+
"koofr",
9333+
"opendrive",
9334+
"drime",
9335+
"filelu",
9336+
"fourshared",
9337+
"immich",
9338+
"github",
9339+
"gitlab",
9340+
]
9341+
.into_iter()
9342+
.map(|protocol| {
9343+
(
9344+
protocol.to_string(),
9345+
ftp_client_gui_lib::agent_session::transfer_capabilities_block(
9346+
protocol,
9347+
None,
9348+
"protocol_defaults",
9349+
),
9350+
)
9351+
})
9352+
.collect::<serde_json::Map<_, _>>();
92969353

92979354
let info = serde_json::json!({
92989355
"version": env!("CARGO_PKG_VERSION"),
@@ -9398,7 +9455,7 @@ fn cmd_agent_info(cli: &Cli) -> i32 {
93989455
"130": "interrupted (SIGINT)"
93999456
},
94009457
"protocols": [
9401-
"ftp", "ftps", "sftp", "webdav", "webdavs", "s3", "aerocloud",
9458+
"ftp", "ftps", "sftp", "webdav", "webdavs", "s3", "backblaze", "b2", "aerocloud",
94029459
"mega", "filen", "internxt", "kdrive", "koofr",
94039460
"jottacloud", "filelu", "opendrive", "yandexdisk", "azure",
94049461
"github", "gitlab", "googledrive", "dropbox", "onedrive", "box",
@@ -9440,6 +9497,7 @@ fn cmd_agent_info(cli: &Cli) -> i32 {
94409497
"github": ftp_client_gui_lib::agent_session::capabilities_for_protocol("github"),
94419498
"gitlab": ftp_client_gui_lib::agent_session::capabilities_for_protocol("gitlab")
94429499
},
9500+
"protocol_transfer_capabilities": protocol_transfer_capabilities,
94439501
"agent_connect_supported_protocols": [
94449502
"ftp", "ftps", "sftp", "webdav", "s3", "github", "gitlab"
94459503
],

0 commit comments

Comments
 (0)