From 9ed9baf5d2e79e348905fbc61555cce099f66ed0 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:31:24 +0400 Subject: [PATCH] feat: leaner MCP and clients --- e2e/coverage-map.mjs | 1 + e2e/tests/integrations.test.mjs | 30 ++ e2e/tests/ui.test.mjs | 89 +++++ src-tauri/src/geoip_downloader.rs | 226 +++++++++--- src-tauri/src/lib.rs | 41 ++- src-tauri/src/mcp_integrations.rs | 307 ++++++++++++++-- src-tauri/src/mcp_remote.rs | 32 +- src-tauri/src/mcp_server.rs | 365 +++++++++++-------- src-tauri/src/mcp_toolsets.rs | 317 ++++++++++++++++ src-tauri/src/mcp_views.rs | 478 +++++++++++++++++++++++++ src-tauri/src/remote_session.rs | 6 + src-tauri/src/settings_manager.rs | 6 + src/components/integrations-dialog.tsx | 451 +++++++++++++++++------ src/i18n/locales/de.json | 57 ++- src/i18n/locales/en.json | 57 ++- src/i18n/locales/es.json | 61 +++- src/i18n/locales/fr.json | 61 +++- src/i18n/locales/ja.json | 57 ++- src/i18n/locales/ko.json | 57 ++- src/i18n/locales/pt.json | 61 +++- src/i18n/locales/ru.json | 65 +++- src/i18n/locales/tr.json | 57 ++- src/i18n/locales/vi.json | 57 ++- src/i18n/locales/zh.json | 57 ++- src/lib/backend-errors.ts | 3 + src/lib/mcp.ts | 17 + 26 files changed, 2656 insertions(+), 360 deletions(-) create mode 100644 src-tauri/src/mcp_toolsets.rs create mode 100644 src-tauri/src/mcp_views.rs diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 1940a236..fd28e92a 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -253,6 +253,7 @@ export const commandCoverage = { "list_mcp_agents", "add_mcp_to_agent", "remove_mcp_from_agent", + "set_mcp_toolsets", "start_mcp_remote_bridge", "stop_mcp_remote_bridge", "get_mcp_remote_status", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index 88aba982..4920a79e 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -797,6 +797,36 @@ test("MCP clients are installed only against the remote endpoint", async () => { "DONUT_MCP_TOKEN", ); assert.equal(agents.find((agent) => agent.id === "cursor").token_env, null); + assert.ok(agents.some((agent) => agent.id === "pi")); + assert.ok(agents.some((agent) => agent.id === "mastracode")); + + // Toolsets: only known groups, saved in display order; every group is the + // same as none (all tools), and core alone is the core tools only. + await assertCommandErrorCode( + app, + "set_mcp_toolsets", + "MCP_TOOLSET_UNKNOWN", + { + toolsets: ["network", "everything"], + }, + ); + assert.deepEqual( + await app.invoke("set_mcp_toolsets", { + toolsets: ["cookies", "network"], + }), + [], + "no client is connected, so none can fail", + ); + assert.deepEqual((await app.invoke("get_app_settings")).mcp_toolsets, [ + "network", + "cookies", + ]); + await app.invoke("set_mcp_toolsets", { toolsets: ["core"] }); + assert.deepEqual((await app.invoke("get_app_settings")).mcp_toolsets, [ + "core", + ]); + await app.invoke("set_mcp_toolsets", { toolsets: [] }); + assert.deepEqual((await app.invoke("get_app_settings")).mcp_toolsets, []); await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", { agentId: "missing-e2e-agent", diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 219fb5a8..f88997aa 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -1573,6 +1573,95 @@ test("the integrations page ships only the Local API tab and never names remote }); }); +test("MCP clients on this computer come first, and toolsets go to the connected clients", async () => { + await withApp("ui-mcp-clients", async (app) => { + await stubCommand(app, "get_remote_control_entitlement", true); + await stubCommand(app, "get_mcp_remote_status", { + enabled: true, + connected: true, + instanceId: "ui-mcp-clients", + lastError: null, + }); + await stubCommand(app, "get_mcp_remote_credential", { + present: true, + tokenPrefix: "dmk_ui", + }); + const agent = (id, display_name, connected, detected) => ({ + id, + display_name, + category: "cli", + connected, + detected, + token_env: null, + }); + await stubCommand(app, "list_mcp_agents", [ + agent("cursor", "Cursor", false, false), + agent("codex", "Codex", false, true), + agent("claude-code", "Claude Code", true, true), + agent("pi", "Pi", false, true), + agent("zed", "Zed", false, false), + ]); + await stubCommand(app, "add_mcp_to_agent", null); + await stubCommand(app, "set_mcp_toolsets", []); + const calls = (command) => + app.execute( + "return (window.__donutStubbedCalls ?? []).filter((c) => c.command === arguments[0]).map((c) => c.payload);", + [command], + ); + const shown = () => + app.execute( + `return [...document.querySelectorAll('[data-slot="mcp-client"]')].map((node) => node.dataset.agentId);`, + ); + + await app.clickSelector('[aria-label="Integrations"]'); + await app.clickText(en.integrations.tabRemote, { roles: ["tab"] }); + await app.waitForText(en.integrations.mcp.onThisComputer); + assert.deepEqual( + await shown(), + ["claude-code", "codex", "pi"], + "connected first, then the other apps found here; the rest wait", + ); + await app.clickSelector('[data-slot="mcp-show-more"]'); + await app.waitFor(async () => (await shown()).length === 5, { + description: "every client after Show more", + }); + + await app.clickSelector('[data-slot="mcp-add-all"]'); + await app.waitFor( + async () => (await calls("add_mcp_to_agent")).length === 2, + { + description: "one add per suggested client", + }, + ); + assert.deepEqual( + (await calls("add_mcp_to_agent")).map((payload) => payload.agentId), + ["codex", "pi"], + ); + + await app.clickSelector('[data-slot="mcp-toolsets"]'); + await app.waitForText(en.integrations.mcp.toolsets.title); + await app.capture("mcp-toolsets"); + await app.clickSelector('[data-toolset="cookies"]'); + await app.waitFor( + async () => (await calls("set_mcp_toolsets")).length === 1, + { + description: "the toolset choice saved", + }, + ); + assert.deepEqual((await calls("set_mcp_toolsets"))[0].toolsets, [ + "organize", + "network", + "fingerprint", + "extensions", + "sync", + "remote", + "recipes", + ]); + await dismissSurface(app); + await dismissSurface(app); + }); +}); + test("the local API page shows a one-line example and opens runnable examples in a dialog", async () => { await withApp("ui-integrations-examples", async (app) => { const settings = await app.invoke("get_app_settings"); diff --git a/src-tauri/src/geoip_downloader.rs b/src-tauri/src/geoip_downloader.rs index 48596f43..9a7fb5ce 100644 --- a/src-tauri/src/geoip_downloader.rs +++ b/src-tauri/src/geoip_downloader.rs @@ -1,4 +1,4 @@ -use crate::browser::GithubRelease; +use crate::browser::{GithubAsset, GithubRelease}; use crate::events; use crate::profile::manager::ProfileManager; use reqwest::Client; @@ -28,11 +28,54 @@ pub struct GeoIPDownloader { client: Client, } +/// How the files are checked before they replace the ones in use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DatabaseKind { + City, + Asn, +} + +impl DatabaseKind { + /// What the file's own metadata names it, e.g. `GeoLite2-City`. + fn type_suffix(self) -> &'static str { + match self { + Self::City => "City", + Self::Asn => "ASN", + } + } +} + +/// A finished download that is not the database it claims to be: cut short, +/// an error page, or another file. It never replaces the file in use. +fn verify_database( + path: &std::path::Path, + kind: DatabaseKind, + expected_size: u64, +) -> Result<(), String> { + let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if expected_size > 0 && size != expected_size { + return Err(format!( + "size {size} differs from the published {expected_size}" + )); + } + let reader = maxminddb::Reader::open_readfile(path).map_err(|e| e.to_string())?; + let database_type = &reader.metadata().database_type; + if !database_type.ends_with(kind.type_suffix()) { + return Err(format!("the file is a {database_type} database")); + } + Ok(()) +} + impl GeoIPDownloader { fn new() -> Self { - Self { - client: Client::new(), - } + // A stalled connection must not hold the one download slot forever. The + // overall limit leaves room for a 60 MB file on a slow link. + let client = Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_secs(30 * 60)) + .build() + .unwrap_or_else(|_| Client::new()); + Self { client } } pub fn instance() -> &'static GeoIPDownloader { @@ -62,6 +105,23 @@ impl GeoIPDownloader { Ok(Self::get_cache_dir().join("GeoLite2-ASN.mmdb")) } + fn get_release_path() -> PathBuf { + crate::app_dirs::cache_dir().join("geoip_release") + } + + /// The release the files in place came from, so an unchanged release is not + /// downloaded again. + fn installed_release() -> Option { + std::fs::read_to_string(Self::get_release_path()) + .ok() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + } + + fn asn_missing() -> bool { + Self::get_asn_mmdb_file_path().map_or(true, |path| !path.exists()) + } + pub fn is_geoip_database_available() -> bool { if let Ok(mmdb_path) = Self::get_mmdb_file_path() { mmdb_path.exists() @@ -101,22 +161,39 @@ impl GeoIPDownloader { let needs_geoip = profiles.iter().any(|profile| profile.browser == "wayfern"); if needs_geoip { - return Ok(!Self::is_geoip_database_available() || Self::is_geoip_stale()); + return Ok( + !Self::is_geoip_database_available() || Self::is_geoip_stale() || Self::asn_missing(), + ); } Ok(false) } - fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option { - Self::find_mmdb_asset(release, "-City.mmdb") - } - - fn find_mmdb_asset(release: &GithubRelease, suffix: &str) -> Option { + fn find_mmdb_asset<'a>(release: &'a GithubRelease, suffix: &str) -> Option<&'a GithubAsset> { release .assets .iter() .find(|asset| asset.name.ends_with(suffix)) - .map(|asset| asset.browser_download_url.clone()) + } + + /// The newest published release that has a City database. + fn pick_release(releases: &[GithubRelease]) -> Option<&GithubRelease> { + releases + .iter() + .filter(|release| !release.prerelease) + .find(|release| Self::find_mmdb_asset(release, "-City.mmdb").is_some()) + } + + fn write_timestamp() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let path = Self::get_timestamp_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, now.to_string()); } pub async fn download_geoip_database( @@ -169,19 +246,55 @@ impl GeoIPDownloader { // The ASN asset comes off the same release as the city one, so the release // is kept rather than looked up twice. - let (download_url, asn_url) = if let Some(url) = fixture_url { - (url, asn_fixture_url) + let (download_url, city_size, asn_url, asn_size, release_tag) = if let Some(url) = fixture_url { + (url, 0, asn_fixture_url, 0, None) } else { let releases = self.fetch_geoip_releases().await?; - let latest_release = releases.first().ok_or("No GeoIP database releases found")?; + let release = + Self::pick_release(&releases).ok_or("No compatible GeoIP database release found")?; + let city = Self::find_mmdb_asset(release, "-City.mmdb") + .ok_or("No compatible GeoIP database asset found")?; + let asn = Self::find_mmdb_asset(release, "-ASN.mmdb"); ( - self - .find_city_mmdb_asset(latest_release) - .ok_or("No compatible GeoIP database asset found")?, - Self::find_mmdb_asset(latest_release, "-ASN.mmdb"), + city.browser_download_url.clone(), + city.size, + asn.map(|asset| asset.browser_download_url.clone()), + asn.map_or(0, |asset| asset.size), + Some(release.tag_name.clone()), ) }; + // The same release as the files in place: nothing to fetch but a missing + // ASN file. A 60 MB file is not downloaded again to learn that. + let city_current = release_tag.is_some() + && release_tag == Self::installed_release() + && Self::is_geoip_database_available(); + if city_current { + Self::write_timestamp(); + if let (true, Some(url)) = (Self::asn_missing(), asn_url.as_deref()) { + if let Err(e) = self.download_asn_database(url, asn_size).await { + log::warn!( + "GeoIP ASN database download failed err=\"{}\"", + crate::log_redaction::text(&e.to_string()) + ); + } + } + log::debug!("GeoIP database already current, download skipped"); + let _ = events::emit( + "geoip-download-progress", + GeoIPDownloadProgress { + stage: "completed".to_string(), + percentage: 100.0, + message: "GeoIP database is current".to_string(), + downloaded_bytes: None, + total_bytes: None, + speed_bytes_per_sec: None, + eta_seconds: None, + }, + ); + return Ok(()); + } + // Create cache directory let cache_dir = Self::get_cache_dir(); fs::create_dir_all(&cache_dir).await?; @@ -260,25 +373,27 @@ impl GeoIPDownloader { file.flush().await?; drop(file); + if let Err(reason) = verify_database(&temp_path, DatabaseKind::City, city_size) { + let _ = fs::remove_file(&temp_path).await; + return Err(format!("Downloaded GeoIP database failed verification: {reason}").into()); + } + // Atomically replace the old database with the new one fs::rename(&temp_path, &mmdb_path).await?; - - // Write download timestamp - let timestamp_path = Self::get_timestamp_path(); - if let Some(parent) = timestamp_path.parent() { - let _ = fs::create_dir_all(parent).await; + Self::write_timestamp(); + if let Some(tag) = &release_tag { + let _ = fs::write(Self::get_release_path(), tag).await; } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let _ = fs::write(×tamp_path, now.to_string()).await; + log::info!( + "GeoIP database updated release={} bytes={downloaded}", + release_tag.as_deref().unwrap_or("fixture") + ); // The autonomous-system database, best effort. It only feeds the exit // organisation a proxy check reports, so a failure here must never fail // the download that fingerprint geolocation actually depends on. if let Some(url) = asn_url { - if let Err(e) = self.download_asn_database(&url).await { + if let Err(e) = self.download_asn_database(&url, asn_size).await { log::warn!( "GeoIP ASN database download failed err=\"{}\"", crate::log_redaction::text(&e.to_string()) @@ -308,6 +423,7 @@ impl GeoIPDownloader { async fn download_asn_database( &self, url: &str, + expected_size: u64, ) -> Result<(), Box> { let path = Self::get_asn_mmdb_file_path()?; let temp_path = path.with_extension("mmdb.downloading"); @@ -327,6 +443,10 @@ impl GeoIPDownloader { file.flush().await?; drop(file); + if let Err(reason) = verify_database(&temp_path, DatabaseKind::Asn, expected_size) { + let _ = fs::remove_file(&temp_path).await; + return Err(format!("failed verification: {reason}").into()); + } fs::rename(&temp_path, &path).await?; Ok(()) } @@ -436,24 +556,48 @@ mod tests { assert_eq!(fetched_releases[0].tag_name, "v1.0.0"); } - #[tokio::test] - async fn test_find_city_mmdb_asset() { - let downloader = GeoIPDownloader::new(); + #[test] + fn test_find_city_mmdb_asset() { let release = create_mock_release(); - - let asset_url = downloader.find_city_mmdb_asset(&release); - assert!(asset_url.is_some()); - assert_eq!(asset_url.unwrap(), "https://example.com/GeoLite2-City.mmdb"); + let asset = GeoIPDownloader::find_mmdb_asset(&release, "-City.mmdb"); + assert_eq!( + asset.map(|asset| asset.browser_download_url.as_str()), + Some("https://example.com/GeoLite2-City.mmdb") + ); } - #[tokio::test] - async fn test_find_city_mmdb_asset_not_found() { - let downloader = GeoIPDownloader::new(); + #[test] + fn test_find_city_mmdb_asset_not_found() { let mut release = create_mock_release(); release.assets[0].name = "wrong-file.txt".to_string(); + assert!(GeoIPDownloader::find_mmdb_asset(&release, "-City.mmdb").is_none()); + } - let asset_url = downloader.find_city_mmdb_asset(&release); - assert!(asset_url.is_none()); + #[test] + fn a_prerelease_or_a_release_without_a_city_file_is_skipped() { + let mut prerelease = create_mock_release(); + prerelease.tag_name = "pre".to_string(); + prerelease.prerelease = true; + let mut empty = create_mock_release(); + empty.tag_name = "empty".to_string(); + empty.assets.clear(); + let releases = vec![prerelease, empty, create_mock_release()]; + assert_eq!( + GeoIPDownloader::pick_release(&releases).map(|r| r.tag_name.as_str()), + Some("v1.0.0") + ); + } + + #[test] + fn a_file_that_is_not_the_database_never_replaces_the_one_in_use() { + let dir = tempfile::tempdir().unwrap(); + let page = dir.path().join("City.mmdb.downloading"); + std::fs::write(&page, b"rate limited").unwrap(); + assert!(verify_database(&page, DatabaseKind::City, 0).is_err()); + assert!(verify_database(&page, DatabaseKind::City, 25).is_err()); + assert!(verify_database(&page, DatabaseKind::City, 999) + .unwrap_err() + .contains("differs")); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af6dc7a6..c568cf78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -151,6 +151,8 @@ pub mod events; mod mcp_integrations; mod mcp_remote; mod mcp_server; +mod mcp_toolsets; +mod mcp_views; mod tag_manager; mod team_lock; mod totp; @@ -1304,14 +1306,48 @@ fn update_claude_extensions_registry( /// Minting one is the credential commands' job, so a missing key is reported /// rather than created here. fn mcp_target() -> Result { - let stored = settings_manager::SettingsManager::instance() + let manager = settings_manager::SettingsManager::instance(); + let stored = manager .get_mcp_remote_key() .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; let key = stored .map(|stored| stored.key) .filter(|key| !key.is_empty()) .ok_or_else(|| backend_error("MCP_REMOTE_KEY_MISSING"))?; - Ok(mcp_integrations::McpTarget::remote(key)) + let toolsets = manager + .load_settings() + .map(|settings| settings.mcp_toolsets) + .unwrap_or_default(); + Ok(mcp_integrations::McpTarget::remote_with(key, &toolsets)) +} + +/// Choose the toolsets connected clients list, and rewrite every connected +/// client to match. Returns the ids of the clients that could not be +/// rewritten. +#[tauri::command] +async fn set_mcp_toolsets(toolsets: Vec) -> Result, String> { + let toolsets = mcp_toolsets::normalize_choice(&toolsets) + .ok_or_else(|| backend_error("MCP_TOOLSET_UNKNOWN"))?; + let manager = settings_manager::SettingsManager::instance(); + let mut settings = manager + .load_settings() + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + if settings.mcp_toolsets == toolsets { + return Ok(Vec::new()); + } + settings.mcp_toolsets = toolsets; + manager + .save_settings(&settings) + .map_err(|e| backend_error_with_detail("INTERNAL_ERROR", e))?; + log::info!( + "MCP toolsets set toolsets={}", + if settings.mcp_toolsets.is_empty() { + "all".to_string() + } else { + settings.mcp_toolsets.join(",") + } + ); + Ok(reinstall_mcp_agents()) } fn install_mcp_agent(agent_id: &str, target: &mcp_integrations::McpTarget) -> Result<(), String> { @@ -3501,6 +3537,7 @@ pub fn run_with_builder( list_mcp_agents, add_mcp_to_agent, remove_mcp_from_agent, + set_mcp_toolsets, start_mcp_remote_bridge, stop_mcp_remote_bridge, get_mcp_remote_status, diff --git a/src-tauri/src/mcp_integrations.rs b/src-tauri/src/mcp_integrations.rs index f52ee70b..01275a4d 100644 --- a/src-tauri/src/mcp_integrations.rs +++ b/src-tauri/src/mcp_integrations.rs @@ -1,5 +1,5 @@ //! MCP client installer: writes the Donut Browser server entry into the global -//! config of twenty AI clients and reads it back for the Integrations page. +//! config of twenty-two AI clients and reads it back for the Integrations page. //! //! The endpoint lives under `CLOUD_API_URL`, needs a bearer credential and is //! only offered to accounts with remote control. `server_entry` decides per @@ -17,6 +17,11 @@ //! installer treated a parse failure as an empty file and wiped configs that //! merely had a comment in them. //! +//! Copilot CLI is the exception: it refuses comments and trailing commas, so a +//! file that has them is written back as plain JSON. OpenCode keeps servers +//! either directly under `mcp` or, in its newer layout, under `mcp.servers`; +//! the entry goes where the file already keeps its servers. +//! //! Claude Desktop has no HTTP entry in its config file; lib.rs installs a //! local extension bundle with a node bridge instead. This module only //! resolves its directory. @@ -47,11 +52,20 @@ pub struct McpTarget { } impl McpTarget { + #[cfg(test)] pub fn remote(key: String) -> Self { - Self { - url: remote_mcp_url(), - bearer: key, - } + Self::remote_with(key, &[]) + } + + /// With toolsets, the client lists only the core tools and those groups + /// (`mcp_toolsets`); with none, every tool. + pub fn remote_with(key: String, toolsets: &[String]) -> Self { + let url = if toolsets.is_empty() { + remote_mcp_url() + } else { + format!("{}?toolsets={}", remote_mcp_url(), toolsets.join(",")) + }; + Self { url, bearer: key } } pub fn authorization(&self) -> String { @@ -132,6 +146,8 @@ pub struct AgentEnv { pub grok_home: Option, pub kimi_code_home: Option, pub cline_dir: Option, + pub copilot_home: Option, + pub pi_agent_dir: Option, } fn env_path(name: &str) -> Option { @@ -154,6 +170,8 @@ impl AgentEnv { grok_home: env_path("GROK_HOME"), kimi_code_home: env_path("KIMI_CODE_HOME"), cline_dir: env_path("CLINE_DIR"), + copilot_home: env_path("COPILOT_HOME"), + pi_agent_dir: env_path("PI_CODING_AGENT_DIR"), }) } @@ -244,6 +262,21 @@ impl AgentEnv { fn kilo_config_dir(&self) -> PathBuf { self.config_home().join("kilo") } + + /// `$COPILOT_HOME`, else `~/.copilot`. Copilot CLI ignores XDG_CONFIG_HOME. + fn copilot_home(&self) -> PathBuf { + self + .copilot_home + .clone() + .unwrap_or_else(|| self.home.join(".copilot")) + } + + fn pi_agent_dir(&self) -> PathBuf { + self + .pi_agent_dir + .clone() + .unwrap_or_else(|| self.home.join(".pi").join("agent")) + } } /// The first candidate that already exists, else `default`, which the install @@ -279,12 +312,7 @@ fn config_path(env: &AgentEnv, agent_id: &str) -> Option { "fx" => home.join(".fx").join("mcp.json"), "gemini-cli" => home.join(".gemini").join("settings.json"), "goose" => env.goose_config_path(), - // Copilot joins XDG_CONFIG_HOME directly rather than a subdirectory of it. - "github-copilot-cli" => env - .xdg_config_home - .clone() - .unwrap_or_else(|| home.join(".copilot")) - .join("mcp-config.json"), + "github-copilot-cli" => env.copilot_home().join("mcp-config.json"), "grok-build" => env.grok_home().join("config.toml"), "kilo-code" => { let dir = env.kilo_config_dir(); @@ -295,6 +323,8 @@ fn config_path(env: &AgentEnv, agent_id: &str) -> Option { } "kimi-code" => env.kimi_code_home().join("mcp.json"), "kiro-cli" => home.join(".kiro").join("settings").join("mcp.json"), + "mastracode" => home.join(".mastracode").join("mcp.json"), + "pi" => env.pi_agent_dir().join("mcp.json"), "mcporter" => { let dir = home.join(".mcporter"); first_existing( @@ -333,7 +363,10 @@ fn detected(env: &AgentEnv, agent_id: &str) -> bool { }; match agent_id { "antigravity" => home.join(".gemini").join("config").exists(), - "cline" | "cline-cli" | "github-copilot-cli" => parent_exists(), + "cline" | "cline-cli" => parent_exists(), + "github-copilot-cli" => env.copilot_home().exists(), + "mastracode" => home.join(".mastracode").exists(), + "pi" => env.pi_agent_dir().exists(), "claude-code" => home.join(".claude").exists(), "claude-desktop" => env.claude_desktop_dir().exists(), // Codex detection looks at the default directory even when CODEX_HOME @@ -504,6 +537,21 @@ const AGENT_SPECS: &[AgentSpec] = &[ config_key: "mcpServers", format: ConfigFormat::Json, }, + // Pi reads MCP servers only with its MCP adapter extension installed. + AgentSpec { + id: "pi", + display_name: "Pi", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "mastracode", + display_name: "Mastra Code", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, ]; fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> { @@ -527,7 +575,7 @@ fn server_entry(agent_id: &str, target: &McpTarget) -> Entry { let headers = json!({ "Authorization": target.authorization() }); let mut entry: Entry = match agent_id { "antigravity" | "windsurf" => vec![("serverUrl", url)], - "cursor" | "kiro-cli" | "grok-build" => vec![("url", url)], + "cursor" | "kiro-cli" | "grok-build" | "pi" | "mastracode" => vec![("url", url)], "cline" | "cline-cli" => vec![ ("url", url), ("type", json!("streamableHttp")), @@ -576,9 +624,11 @@ fn server_entry(agent_id: &str, target: &McpTarget) -> Entry { entry } -/// Whether a URL is Donut's endpoint, a trailing slash tolerated. +/// Whether a URL is Donut's endpoint, a trailing slash and a query (the +/// toolsets) tolerated. pub fn is_remote_url(url: &str) -> bool { - url.trim().trim_end_matches('/') == remote_mcp_url().trim_end_matches('/') + let base = url.trim().split(['?', '#']).next().unwrap_or_default(); + base.trim_end_matches('/') == remote_mcp_url().trim_end_matches('/') } fn points_at_remote(value: &serde_json::Value) -> bool { @@ -731,8 +781,13 @@ impl Document { }) } + /// The server map at `key`; a dotted key (`mcp.servers`) is a nested map. fn json_servers(root: &CstRootNode, key: &str) -> Option { - root.object_value()?.object_value(key) + let mut object = root.object_value()?; + for part in key.split('.') { + object = object.object_value(part)?; + } + Some(object) } /// Every entry under the server map, as plain JSON for inspection. @@ -784,12 +839,14 @@ impl Document { fn set_entry(&mut self, key: &str, entry: &Entry) -> Result<(), String> { match self { Self::Json(root) => { - let root_object = root + let mut servers = root .object_value_or_create() .ok_or("the file's top level is not a JSON object")?; - let servers = root_object - .object_value_or_create(key) - .ok_or_else(|| format!("\"{key}\" is not a JSON object"))?; + for part in key.split('.') { + servers = servers + .object_value_or_create(part) + .ok_or_else(|| format!("\"{key}\" is not a JSON object"))?; + } match servers.get(SERVER_NAME) { Some(prop) => prop.set_value(entry_to_cst(entry)), None => { @@ -948,13 +1005,89 @@ fn restrict_to_owner(_path: &Path) -> Result<(), String> { Ok(()) } +/// OpenCode's newer layout keeps servers under `mcp.servers`. +const OPENCODE_SERVERS_KEY: &str = "mcp.servers"; + +/// Every key a client may keep its servers under. +fn server_keys(spec: &AgentSpec) -> Vec<&'static str> { + if spec.id == "opencode" { + vec![spec.config_key, OPENCODE_SERVERS_KEY] + } else { + vec![spec.config_key] + } +} + +fn is_opencode_server(value: &serde_json::Value) -> bool { + value + .get("type") + .and_then(|t| t.as_str()) + .is_some_and(|t| t == "local" || t == "remote") + || value.get("url").is_some() + || value.get("command").is_some() +} + +/// Where OpenCode's entry goes: where it already is, else the newer map when +/// the file uses it and keeps nothing in the older place. +fn opencode_key(document: &Document) -> &'static str { + let Document::Json(root) = document else { + return "mcp"; + }; + let native_map = Document::json_servers(root, "mcp") + .and_then(|mcp| mcp.get("servers")) + .and_then(|servers| servers.to_serde_value()) + .is_some_and(|servers| servers.is_object() && !is_opencode_server(&servers)); + let native = document.entries(OPENCODE_SERVERS_KEY); + let legacy: Vec<(String, serde_json::Value)> = document + .entries("mcp") + .into_iter() + .filter(|(name, value)| !(native_map && name == "servers") && is_opencode_server(value)) + .collect(); + if native_map && native.iter().any(|(name, _)| name == SERVER_NAME) { + OPENCODE_SERVERS_KEY + } else if legacy.iter().any(|(name, _)| name == SERVER_NAME) { + "mcp" + } else if native_map && legacy.is_empty() { + OPENCODE_SERVERS_KEY + } else { + "mcp" + } +} + +/// Copilot CLI refuses comments and trailing commas; a file that has them is +/// written back as plain JSON. +fn strict_json(text: &str) -> Result { + if serde_json::from_str::(text).is_ok() { + return Ok(text.to_string()); + } + let value = CstRootNode::parse(text, &ParseOptions::default()) + .map_err(|e| e.to_string())? + .to_serde_value() + .unwrap_or_else(|| serde_json::json!({})); + serde_json::to_string_pretty(&value) + .map(|text| text + "\n") + .map_err(|e| e.to_string()) +} + fn install_in(env: &AgentEnv, agent_id: &str, target: &McpTarget) -> Result<(), String> { let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; let path = config_path(env, agent_id) .ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?; let (mut document, was_empty) = read_document(&path, spec.format)?; - document.set_entry(spec.config_key, &server_entry(agent_id, target))?; + let mut entry = server_entry(agent_id, target); + let key = if agent_id == "opencode" { + opencode_key(&document) + } else { + spec.config_key + }; + if key == OPENCODE_SERVERS_KEY { + // The newer layout enables a server unless it says `disabled`. + entry.retain(|(name, _)| *name != "enabled"); + } + document.set_entry(key, &entry)?; let mut text = document.to_text()?; + if agent_id == "github-copilot-cli" { + text = strict_json(&text)?; + } if was_empty && !text.ends_with('\n') { text.push('\n'); } @@ -974,17 +1107,27 @@ fn uninstall_in(env: &AgentEnv, agent_id: &str) -> Result<(), String> { return Ok(()); } let (mut document, _) = read_document(&path, spec.format)?; - let names: Vec = document - .entries(spec.config_key) - .into_iter() - .filter(|(name, value)| is_donut_entry(name, value)) - .map(|(name, _)| name) - .collect(); - if names.is_empty() { + let mut changed = false; + for key in server_keys(spec) { + let names: Vec = document + .entries(key) + .into_iter() + .filter(|(name, value)| is_donut_entry(name, value)) + .map(|(name, _)| name) + .collect(); + if !names.is_empty() { + document.remove_entries(key, &names); + changed = true; + } + } + if !changed { return Ok(()); } - document.remove_entries(spec.config_key, &names); - write_text(&path, &document.to_text()?, false) + let mut text = document.to_text()?; + if agent_id == "github-copilot-cli" { + text = strict_json(&text)?; + } + write_text(&path, &text, false) } /// Whether any entry in a client's config points at the endpoint, so a @@ -1001,10 +1144,12 @@ fn connected_in(env: &AgentEnv, agent_id: &str) -> bool { return false; } read_document(&path, spec.format).is_ok_and(|(document, _)| { - document - .entries(spec.config_key) - .iter() - .any(|(_, value)| points_at_remote(value)) + server_keys(spec).into_iter().any(|key| { + document + .entries(key) + .iter() + .any(|(_, value)| points_at_remote(value)) + }) }) } @@ -1083,6 +1228,8 @@ mod tests { grok_home: None, kimi_code_home: None, cline_dir: None, + copilot_home: None, + pi_agent_dir: None, } } @@ -1135,8 +1282,10 @@ mod tests { "kilo-code", "kimi-code", "kiro-cli", + "mastracode", "mcporter", "opencode", + "pi", "vscode", "windsurf", "zed", @@ -1260,7 +1409,22 @@ mod tests { expect(&env, "zed", root.join("xdg/zed/settings.json")); expect(&env, "goose", root.join("xdg/goose/config.yaml")); expect(&env, "kilo-code", root.join("xdg/kilo/kilo.json")); - expect(&env, "github-copilot-cli", root.join("xdg/mcp-config.json")); + // Copilot ignores XDG_CONFIG_HOME and reads COPILOT_HOME instead. + expect( + &env, + "github-copilot-cli", + root.join("home/.copilot/mcp-config.json"), + ); + env.copilot_home = Some(root.join("copilot-home")); + expect( + &env, + "github-copilot-cli", + root.join("copilot-home/mcp-config.json"), + ); + expect(&env, "pi", root.join("home/.pi/agent/mcp.json")); + env.pi_agent_dir = Some(root.join("pi-dir")); + expect(&env, "pi", root.join("pi-dir/mcp.json")); + expect(&env, "mastracode", root.join("home/.mastracode/mcp.json")); expect( &env, "claude-desktop", @@ -1413,6 +1577,8 @@ mod tests { "github-copilot-cli", json!({ "type": "http", "url": url, "tools": ["*"], "headers": auth }), ), + ("pi", json!({ "url": url, "headers": auth })), + ("mastracode", json!({ "url": url, "headers": auth })), ( "codex", json!({ "type": "http", "url": url, "http_headers": auth }), @@ -1775,4 +1941,75 @@ mod tests { ); assert!(!path.parent().unwrap().join(".mcp.json.donut-tmp").exists()); } + + #[test] + fn copilot_gets_plain_json_and_reads_copilot_home() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "github-copilot-cli").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "{\n // mine\n \"mcpServers\": { \"other\": { \"url\": \"https://x.example\" }, },\n}\n", + ) + .unwrap(); + assert!(detected(&env, "github-copilot-cli")); + install_in(&env, "github-copilot-cli", &remote()).unwrap(); + let text = read(&path); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("plain JSON"); + assert_eq!(parsed["mcpServers"]["other"]["url"], "https://x.example"); + assert!(connected_in(&env, "github-copilot-cli")); + uninstall_in(&env, "github-copilot-cli").unwrap(); + assert!(serde_json::from_str::(&read(&path)).is_ok()); + assert!(!connected_in(&env, "github-copilot-cli")); + } + + #[test] + fn opencode_writes_where_the_file_keeps_its_servers() { + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::Linux); + let path = config_path(&env, "opencode").unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + + // Newer layout: servers under mcp.servers, written without `enabled`. + fs::write( + &path, + "{\n \"mcp\": { \"servers\": { \"other\": { \"type\": \"remote\", \"url\": \"https://x.example\" } } }\n}\n", + ) + .unwrap(); + install_in(&env, "opencode", &remote()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&read(&path)).unwrap(); + let entry = &parsed["mcp"]["servers"][SERVER_NAME]; + assert_eq!(entry["type"], "remote"); + assert!(entry.get("enabled").is_none()); + assert!(parsed["mcp"].get(SERVER_NAME).is_none(), "no mixed layout"); + assert!(connected_in(&env, "opencode")); + uninstall_in(&env, "opencode").unwrap(); + assert!(!connected_in(&env, "opencode")); + + // Older layout, or a fresh file: directly under mcp, with `enabled`. + fs::write( + &path, + "{\n \"mcp\": { \"other\": { \"type\": \"local\", \"command\": [\"x\"] } }\n}\n", + ) + .unwrap(); + install_in(&env, "opencode", &remote()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&read(&path)).unwrap(); + assert_eq!(parsed["mcp"][SERVER_NAME]["enabled"], true); + } + + #[test] + fn toolsets_go_into_the_url_and_still_read_as_connected() { + let target = McpTarget::remote_with(KEY.to_string(), &["network".into(), "cookies".into()]); + assert!(target.url.ends_with("/api/mcp?toolsets=network,cookies")); + assert!(is_remote_url(&target.url)); + assert!(is_remote_url(&format!("{}/", remote_mcp_url()))); + assert!(!is_remote_url("https://example.com/api/mcp?toolsets=core")); + + let dir = tempfile::tempdir().unwrap(); + let env = test_env(dir.path(), Platform::MacOs); + install_in(&env, "cursor", &target).unwrap(); + assert!(connected_in(&env, "cursor")); + assert_eq!(entry_of(&env, "cursor")["url"], json!(target.url)); + } } diff --git a/src-tauri/src/mcp_remote.rs b/src-tauri/src/mcp_remote.rs index 075c496e..8f4e4042 100644 --- a/src-tauri/src/mcp_remote.rs +++ b/src-tauri/src/mcp_remote.rs @@ -819,6 +819,9 @@ enum BridgeFrame { Rpc { cid: String, session_id: Option, + /// The toolsets the agent connected with (`?toolsets=` on the cloud + /// endpoint), as the relay received them. + toolsets: Option, payload: Vec, }, EndSession { @@ -852,6 +855,17 @@ fn parse_frame(text: &str) -> Option { .get("sessionId") .and_then(serde_json::Value::as_str) .map(str::to_string), + toolsets: match value.get("toolsets") { + Some(serde_json::Value::String(list)) => Some(list.clone()), + Some(serde_json::Value::Array(names)) => Some( + names + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + .join(","), + ), + _ => None, + }, payload: serde_json::to_vec(payload).ok()?, }) } @@ -906,6 +920,7 @@ fn dispatch( BridgeFrame::Rpc { cid, session_id, + toolsets, payload, } => { let Ok(permit) = Arc::clone(permits).try_acquire_owned() else { @@ -921,7 +936,7 @@ fn dispatch( let tx = tx.clone(); tokio::spawn(async move { let outcome = McpServer::instance() - .handle_message(session_id.as_deref(), &payload) + .handle_message_with(session_id.as_deref(), &payload, toolsets.as_deref()) .await; drop(permit); let _ = tx.send(encode(&result_frame(&cid, outcome))).await; @@ -1684,9 +1699,11 @@ mod tests { BridgeFrame::Rpc { cid, session_id, + toolsets, payload, } => { assert_eq!(cid, "c1"); + assert_eq!(toolsets, None); assert_eq!(session_id.as_deref(), Some("s1")); let parsed: serde_json::Value = serde_json::from_slice(&payload).expect("payload"); assert_eq!(parsed["method"], "ping"); @@ -1701,6 +1718,18 @@ mod tests { BridgeFrame::Rpc { session_id, .. } => assert!(session_id.is_none()), _ => panic!("expected an rpc frame"), } + + // The relay passes the toolsets the agent connected with, as a list or + // as the comma-separated text from the endpoint URL. + for frame in [ + r#"{"t":"rpc","cid":"c3","toolsets":"core,network","payload":{"jsonrpc":"2.0","id":3,"method":"tools/list"}}"#, + r#"{"t":"rpc","cid":"c3","toolsets":["core","network"],"payload":{"jsonrpc":"2.0","id":3,"method":"tools/list"}}"#, + ] { + match parse_frame(frame).expect("frame") { + BridgeFrame::Rpc { toolsets, .. } => assert_eq!(toolsets.as_deref(), Some("core,network")), + _ => panic!("expected an rpc frame"), + } + } } #[test] @@ -1742,6 +1771,7 @@ mod tests { BridgeFrame::Rpc { cid: "cid-over-cap".to_string(), session_id: None, + toolsets: None, payload: br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#.to_vec(), }, &tx, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index b4c71ffd..41cba55c 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -299,6 +299,8 @@ pub struct McpServer { pub(crate) struct McpCaller<'a> { /// The session id echoed back by the caller, when it opened one at all. session: Option<&'a str>, + /// The toolsets the caller connected with; `None` is every tool. + toolsets: Option<&'a crate::mcp_toolsets::Selection>, } /// The longest a single `type_text` call may plan to spend typing, over the @@ -2236,7 +2238,20 @@ impl McpServer { /// /// This is the whole protocol: parse, route `initialize`, absorb /// notifications, validate the session, meter the automation tools, dispatch. + #[cfg(test)] pub(crate) async fn handle_message(&self, session_id: Option<&str>, body: &[u8]) -> McpOutcome { + self.handle_message_with(session_id, body, None).await + } + + /// `handle_message` for a caller that connected with toolsets + /// (`?toolsets=` on the endpoint URL, relayed by the bridge). + pub(crate) async fn handle_message_with( + &self, + session_id: Option<&str>, + body: &[u8], + toolsets: Option<&str>, + ) -> McpOutcome { + let toolsets = crate::mcp_toolsets::parse(toolsets); if body.len() > Self::MAX_MESSAGE_BYTES { return McpOutcome::BadRequest; } @@ -2247,7 +2262,7 @@ impl McpServer { }; if request.method == "initialize" { - return match self.handle_initialize(request).await { + return match self.handle_initialize(request, toolsets.as_ref()).await { Ok((new_session_id, (id, result))) => McpOutcome::Body { body: serde_json::to_value(McpResponse { jsonrpc: "2.0".to_string(), @@ -2323,6 +2338,7 @@ impl McpServer { .handle_request( McpCaller { session: session_id, + toolsets: toolsets.as_ref(), }, request, ) @@ -2413,10 +2429,34 @@ impl McpServer { vec![ McpTool { name: "list_profiles".to_string(), - description: "List all Wayfern browser profiles".to_string(), + description: "List Wayfern browser profiles as short summaries: id, name, group, tags, status, route (proxy or VPN, with names), running (local, remote or stopped), last launch and note. Filter with query, group, tag and running; page with limit and cursor. Use get_profile for one profile's settings and get_profile_fingerprint for its device.".to_string(), input_schema: serde_json::json!({ "type": "object", - "properties": {}, + "properties": { + "query": { + "type": "string", + "description": "Text to find in the name, the note or a tag (any case)" + }, + "group": { + "type": "string", + "description": "A group name or id; \"none\" for profiles in no group" + }, + "tag": { "type": "string", "description": "Only profiles with this tag" }, + "running": { + "type": "boolean", + "description": "true: only running profiles (here or on the remote fleet); false: only stopped ones" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Profiles per page, 50 when left out" + }, + "cursor": { + "type": "string", + "description": "next_cursor from the previous page" + } + }, "required": [] }), }, @@ -4286,6 +4326,7 @@ impl McpServer { async fn handle_initialize( &self, request: McpRequest, + toolsets: Option<&crate::mcp_toolsets::Selection>, ) -> Result<(String, (serde_json::Value, serde_json::Value)), (serde_json::Value, McpError)> { let id = request.id.clone().unwrap_or(serde_json::Value::Null); @@ -4370,6 +4411,16 @@ impl McpServer { if !CLOUD_AUTH.can_use_browser_automation().await { instructions.push_str(PLAN_WITHOUT_AUTOMATION); } + if let Some(selection) = toolsets { + let missing = selection.missing(); + if !missing.is_empty() { + instructions.push_str(&format!( + " This connection lists the toolsets {}. Not listed: {}. To use them, reconnect with ?toolsets= naming them, or with no toolsets for every tool.", + selection.names().join(", "), + missing.join(", ") + )); + } + } let result = serde_json::json!({ "protocolVersion": negotiated, "capabilities": { @@ -4406,7 +4457,7 @@ impl McpServer { let result = match request.method.as_str() { "ping" => Ok(serde_json::json!({})), - "tools/list" => self.handle_tools_list().await, + "tools/list" => self.handle_tools_list(caller.toolsets).await, "tools/call" => self.handle_tool_call(caller, request.params).await, _ => Err(McpError { code: -32601, @@ -4431,10 +4482,24 @@ impl McpServer { } } - async fn handle_tools_list(&self) -> Result { - Ok(serde_json::json!({ - "tools": self.get_tools() - })) + async fn handle_tools_list( + &self, + toolsets: Option<&crate::mcp_toolsets::Selection>, + ) -> Result { + let tools: Vec = self + .get_tools() + .into_iter() + .filter(|tool| toolsets.is_none_or(|selection| selection.includes_tool(&tool.name))) + .map(|tool| { + let output_schema = crate::mcp_views::output_schema_for(&tool.name); + let mut value = serde_json::to_value(tool).unwrap_or_default(); + if let Some(schema) = output_schema { + value["outputSchema"] = schema; + } + value + }) + .collect(); + Ok(serde_json::json!({ "tools": tools })) } async fn handle_tool_call( @@ -4552,7 +4617,7 @@ impl McpServer { } "report_progress" => Self::handle_report_progress(caller, arguments), "send_feedback" => self.handle_send_feedback(caller, arguments).await, - "list_profiles" => self.handle_list_profiles().await, + "list_profiles" => self.handle_list_profiles(arguments).await, "get_profile" => self.handle_get_profile(arguments).await, "run_profile" => { Self::require_capability( @@ -4610,12 +4675,7 @@ impl McpServer { message: format!("Proxy IP change failed: {e}"), data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&result).unwrap_or_default() - }] - })) + Self::json_content(&result) } "get_profile_status" => self.handle_get_profile_status(arguments).await, // Group management @@ -4654,18 +4714,8 @@ impl McpServer { }] })) } - "list_profile_statuses" => Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&crate::profile_statuses::list()).unwrap_or_default() - }] - })), - "list_profile_templates" => Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&crate::profile_templates::list()).unwrap_or_default() - }] - })), + "list_profile_statuses" => Self::json_content(&crate::profile_statuses::list()), + "list_profile_templates" => Self::json_content(&crate::profile_templates::list()), "create_profiles_from_template" => self.handle_create_profiles_from_template(arguments).await, "get_group" => self.handle_get_group(arguments).await, "create_group" => self.handle_create_group(arguments).await, @@ -4922,7 +4972,16 @@ impl McpServer { } } - async fn handle_list_profiles(&self) -> Result { + async fn handle_list_profiles( + &self, + arguments: &serde_json::Value, + ) -> Result { + let query = + crate::mcp_views::ProfileQuery::from_arguments(arguments).map_err(|message| McpError { + code: -32602, + message, + data: None, + })?; let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { @@ -4930,17 +4989,11 @@ impl McpServer { message: format!("Failed to list profiles: {e}"), data: None, })?; - - // Filter to only Wayfern profiles - let filtered: Vec<&BrowserProfile> = - profiles.iter().filter(|p| p.browser == "wayfern").collect(); - - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&filtered).unwrap_or_default() - }] - })) + Self::json_content(&crate::mcp_views::list_profiles( + &profiles, + &Self::profile_names(), + &query, + )) } async fn handle_get_profile( @@ -4982,12 +5035,10 @@ impl McpServer { }); } - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&profile).unwrap_or_default() - }] - })) + Self::json_content(&crate::mcp_views::profile_detail( + profile, + &Self::profile_names(), + )) } async fn handle_run_profile( @@ -5823,12 +5874,7 @@ impl McpServer { data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&tags).unwrap_or_default() - }] - })) + Self::json_content(&tags) } async fn handle_list_proxies(&self) -> Result { @@ -5842,12 +5888,7 @@ impl McpServer { redact_proxy_secrets(proxy); } - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&proxies).unwrap_or_default() - }] - })) + Self::json_content(&proxies) } async fn handle_get_profile_status( @@ -5953,12 +5994,7 @@ impl McpServer { data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&groups).unwrap_or_default() - }] - })) + Self::json_content(&groups) } async fn handle_create_profiles_from_template( @@ -6006,15 +6042,9 @@ impl McpServer { .iter() .map(|profile| serde_json::json!({ "id": profile.id, "name": profile.name })) .collect(); - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&serde_json::json!({ - "created": profiles, - "stopped_early": result.error, - })) - .unwrap_or_default() - }] + Self::json_content(&serde_json::json!({ + "created": profiles, + "stopped_early": result.error, })) } @@ -6054,12 +6084,7 @@ impl McpServer { data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&group).unwrap_or_default() - }] - })) + Self::json_content(&group) } async fn handle_create_group( @@ -6346,7 +6371,7 @@ impl McpServer { "text": format!( "{assigned} of {} profile(s) assigned a proxy:\n{}", results.len(), - serde_json::to_string_pretty(&results).unwrap_or_default() + serde_json::to_string(&results).unwrap_or_default() ) }] })) @@ -6383,12 +6408,7 @@ impl McpServer { })?; redact_proxy_secrets(&mut proxy); - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&proxy).unwrap_or_default() - }] - })) + Self::json_content(&proxy) } async fn handle_create_proxy( @@ -6866,12 +6886,7 @@ impl McpServer { data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&profiles).unwrap_or_else(|_| "[]".to_string()) - }] - })) + Self::json_content(&profiles) } async fn handle_import_browser_profiles( @@ -6939,7 +6954,7 @@ impl McpServer { result.imported_count, result.skipped_count, result.failed_count, - serde_json::to_string_pretty(&result.results).unwrap_or_default() + serde_json::to_string(&result.results).unwrap_or_default() ) }] })) @@ -7085,12 +7100,7 @@ impl McpServer { data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&configs).unwrap_or_default() - }] - })) + Self::json_content(&configs) } async fn handle_delete_vpn( @@ -7230,12 +7240,7 @@ impl McpServer { last_handshake: None, }; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&status).unwrap_or_default() - }] - })) + Self::json_content(&status) } // Fingerprint management handlers @@ -7302,12 +7307,7 @@ impl McpServer { } }; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&fingerprint_info).unwrap_or_default() - }] - })) + Self::json_content(&fingerprint_info) } async fn handle_update_profile_fingerprint( @@ -7566,12 +7566,7 @@ impl McpServer { async fn handle_get_dns_blocklist_status(&self) -> Result { let statuses = crate::dns_blocklist::BlocklistManager::get_cache_status(); - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&statuses).unwrap_or_default() - }] - })) + Self::json_content(&statuses) } async fn handle_list_extensions(&self) -> Result { @@ -7986,12 +7981,7 @@ impl McpServer { }); } let locks = crate::team_lock::TEAM_LOCK.get_locks().await; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&locks).unwrap_or_default() - }] - })) + Self::json_content(&locks) } async fn handle_get_team_lock_status( @@ -8016,12 +8006,7 @@ impl McpServer { let lock_status = crate::team_lock::TEAM_LOCK .get_lock_status(profile_id) .await; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&lock_status).unwrap_or_default() - }] - })) + Self::json_content(&lock_status) } // --- CDP utility methods for browser interaction --- @@ -8276,12 +8261,7 @@ impl McpServer { serde_json::json!({ "value": null }) }; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&value).unwrap_or_default() - }] - })) + Self::json_content(&value) } async fn handle_click_element( @@ -8660,12 +8640,7 @@ impl McpServer { let info: serde_json::Value = serde_json::from_str(info_str).unwrap_or(serde_json::json!({})); - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&info).unwrap_or_default() - }] - })) + Self::json_content(&info) } async fn handle_get_interactive_elements( @@ -9525,12 +9500,7 @@ impl McpServer { data: None, })?; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&info).unwrap_or_default() - }] - })) + Self::json_content(&info) } async fn handle_stop_sync_session( @@ -9577,12 +9547,7 @@ impl McpServer { .get_sessions() .await; - Ok(serde_json::json!({ - "content": [{ - "type": "text", - "text": serde_json::to_string_pretty(&sessions).unwrap_or_default() - }] - })) + Self::json_content(&sessions) } async fn handle_remove_sync_follower( @@ -9639,14 +9604,50 @@ impl McpServer { // Nothing here decides when a run happens or what it does. What this file // DOES decide is which profiles may be offered to the bot at all. - /// Render a value as the single text block an MCP tool answers with. + /// Render a value as a tool's answer (see `mcp_views::tool_json`). fn json_content(value: &T) -> Result { - let text = serde_json::to_string_pretty(value).map_err(|e| McpError { + let value = serde_json::to_value(value).map_err(|e| McpError { code: -32000, message: format!("Failed to encode response: {e}"), data: None, })?; - Ok(serde_json::json!({ "content": [{ "type": "text", "text": text }] })) + Ok(crate::mcp_views::tool_json(value)) + } + + /// The names an answer shows next to the ids a profile points at. + fn profile_names() -> crate::mcp_views::Names { + let groups = GROUP_MANAGER + .lock() + .ok() + .and_then(|manager| manager.get_all_groups().ok()) + .unwrap_or_default() + .into_iter() + .map(|group| (group.id, group.name)) + .collect(); + let proxies = PROXY_MANAGER + .get_stored_proxies() + .into_iter() + .map(|proxy| (proxy.id, proxy.name)) + .collect(); + let vpns = crate::vpn::VPN_STORAGE + .lock() + .ok() + .and_then(|storage| storage.list_configs().ok()) + .unwrap_or_default() + .into_iter() + .map(|vpn| (vpn.id, vpn.name)) + .collect(); + let statuses = crate::profile_statuses::list() + .into_iter() + .map(|status| (status.id, status.name)) + .collect(); + crate::mcp_views::Names { + groups, + proxies, + vpns, + statuses, + remote: crate::remote_session::indexed_live_profiles(), + } } /// Read a caller-supplied URL and validate its scheme in one step. @@ -10966,9 +10967,13 @@ mod tests { // refusal has to come FIRST, before argument parsing and before anything // touches the page. let server = McpServer::new(); - let sessionless = McpCaller { session: None }; + let sessionless = McpCaller { + session: None, + toolsets: None, + }; let with_session = McpCaller { session: Some("11111111-2222-3333-4444-555555555555"), + toolsets: None, }; // Deliberately complete arguments: the refusal must be about the missing // session, not about anything the caller forgot to send. @@ -11784,10 +11789,56 @@ mod tests { ); } + #[tokio::test] + async fn every_tool_is_in_a_toolset_and_a_selection_lists_only_its_toolsets() { + let server = McpServer::new(); + let advertised: std::collections::HashSet = server + .get_tools() + .into_iter() + .map(|tool| tool.name) + .collect(); + for name in &advertised { + assert!( + crate::mcp_toolsets::toolset_of(name).is_some(), + "{name} is in no toolset, so a client that picks toolsets never sees it" + ); + } + for (set, tools) in crate::mcp_toolsets::TOOLSETS { + for tool in *tools { + assert!( + advertised.contains(*tool), + "{set} lists {tool}, which is no tool" + ); + } + } + + let selection = crate::mcp_toolsets::parse(Some("network")).unwrap(); + let listed = server + .handle_tools_list(Some(&selection)) + .await + .expect("tools list"); + let names: Vec<&str> = listed["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"list_profiles") && names.contains(&"rotate_proxy_ip")); + assert!(!names.contains(&"list_cookie_bot_schedules")); + assert!(names.len() < advertised.len()); + let list = listed["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "list_profiles") + .unwrap(); + assert_eq!(list["outputSchema"]["type"], "object"); + } + #[tokio::test] async fn the_launch_settings_an_agent_can_set_are_the_ones_it_can_read() { let server = McpServer::new(); - let tools = server.handle_tools_list().await.expect("tools list"); + let tools = server.handle_tools_list(None).await.expect("tools list"); let update = tools["tools"] .as_array() .expect("tools array") diff --git a/src-tauri/src/mcp_toolsets.rs b/src-tauri/src/mcp_toolsets.rs new file mode 100644 index 00000000..d7c6c1c3 --- /dev/null +++ b/src-tauri/src/mcp_toolsets.rs @@ -0,0 +1,317 @@ +//! Toolsets: named groups of MCP tools. +//! +//! A client that names toolsets when it connects (`?toolsets=network,cookies` +//! on the endpoint URL) is shown the core tools plus those groups, so it loads +//! far fewer tool schemas into its context. A client that names none is shown +//! every tool, as before. +//! +//! The selection only shapes `tools/list`. A call to a tool outside it still +//! works: hiding a tool saves the client tokens, it is not a permission. + +use std::collections::BTreeSet; + +pub const CORE: &str = "core"; + +/// Every desktop tool, each in exactly one toolset. +pub const TOOLSETS: &[(&str, &[&str])] = &[ + ( + CORE, + &[ + // Profiles + "list_profiles", + "get_profile", + "get_profile_status", + "create_profile", + "update_profile", + "delete_profile", + "bulk_update_profiles", + "list_tags", + "list_groups", + "get_group", + "list_profile_statuses", + "list_profile_templates", + "create_profiles_from_template", + // Browser control + "run_profile", + "kill_profile", + "batch_run_profiles", + "batch_stop_profiles", + "navigate", + "screenshot", + "evaluate_javascript", + "click_element", + "type_text", + "get_page_content", + "get_page_info", + "get_interactive_elements", + "click_by_index", + "type_by_index", + "perceive_page", + "resolve_locator", + "click_locator", + "type_locator", + "extract_structured", + "pick_element", + // The person watching + "ask_human", + "request_human_help", + "wait_for_human", + "get_human_updates", + "report_progress", + "send_feedback", + ], + ), + ( + "organize", + &[ + "create_group", + "update_group", + "delete_group", + "assign_profiles_to_group", + "detect_browser_profiles", + "import_browser_profiles", + ], + ), + ( + "network", + &[ + "list_proxies", + "get_proxy", + "create_proxy", + "update_proxy", + "delete_proxy", + "export_proxies", + "import_proxies", + "rotate_proxy_ip", + "distribute_proxies", + "import_vpn", + "list_vpn_configs", + "delete_vpn", + "connect_vpn", + "disconnect_vpn", + "get_vpn_status", + "update_profile_proxy_bypass_rules", + "update_profile_dns_blocklist", + "get_dns_blocklist_status", + ], + ), + ( + "fingerprint", + &["get_profile_fingerprint", "update_profile_fingerprint"], + ), + ( + "extensions", + &[ + "list_extensions", + "list_extension_groups", + "add_extension", + "update_extension", + "create_extension_group", + "update_extension_group", + "add_extension_to_group", + "remove_extension_from_group", + "delete_extension", + "delete_extension_group", + "assign_extension_group_to_profile", + ], + ), + ( + "cookies", + &[ + "import_profile_cookies", + "list_cookie_bot_schedules", + "get_cookie_bot_schedule", + "set_cookie_bot_schedule", + "delete_cookie_bot_schedule", + "check_cookie_bot_conflicts", + "list_cookie_bot_runs", + "run_cookie_bot_now", + "cancel_cookie_bot_run", + "list_cookie_bot_presets", + "get_cookie_bot_usage", + ], + ), + ( + "sync", + &[ + "get_team_locks", + "get_team_lock_status", + "start_sync_session", + "stop_sync_session", + "get_sync_sessions", + "remove_sync_follower", + ], + ), + ( + "remote", + &[ + "run_profile_remote", + "stop_remote_session", + "list_remote_sessions", + "get_remote_session", + "get_remote_hours_quota", + ], + ), +]; + +/// Toolsets whose tools Donut cloud serves, not this desktop. Known here so +/// a selection that names them keeps them. +pub const CLOUD_TOOLSETS: &[&str] = &["recipes"]; + +/// The toolsets a person can add to core, in display order. +pub fn optional_toolsets() -> Vec<&'static str> { + TOOLSETS + .iter() + .map(|(set, _)| *set) + .filter(|set| *set != CORE) + .chain(CLOUD_TOOLSETS.iter().copied()) + .collect() +} + +/// A saved choice in display order, or `None` when a name is unknown. An +/// empty choice, or every optional toolset, is all tools; `core` alone is the +/// core tools only. +pub fn normalize_choice(names: &[String]) -> Option> { + let optional = optional_toolsets(); + if names + .iter() + .any(|name| name != CORE && !optional.contains(&name.as_str())) + { + return None; + } + let chosen: Vec = optional + .iter() + .filter(|set| names.iter().any(|name| name == *set)) + .map(|set| set.to_string()) + .collect(); + Some(if chosen.len() == optional.len() || names.is_empty() { + Vec::new() + } else if chosen.is_empty() { + vec![CORE.to_string()] + } else { + chosen + }) +} + +/// The toolsets a client asked for, core always among them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection(BTreeSet<&'static str>); + +/// Read `core,network` (any case, any spacing). Nothing, an empty value or +/// `all` selects every tool; an unknown name is ignored. +pub fn parse(raw: Option<&str>) -> Option { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + let mut chosen = BTreeSet::from([CORE]); + for name in raw.split(',').map(|name| name.trim().to_ascii_lowercase()) { + if name == "all" { + return None; + } + let known = TOOLSETS + .iter() + .map(|(set, _)| *set) + .chain(CLOUD_TOOLSETS.iter().copied()) + .find(|set| *set == name); + if let Some(set) = known { + chosen.insert(set); + } + } + Some(Selection(chosen)) +} + +pub fn toolset_of(tool: &str) -> Option<&'static str> { + TOOLSETS + .iter() + .find(|(_, tools)| tools.contains(&tool)) + .map(|(set, _)| *set) +} + +impl Selection { + pub fn includes_tool(&self, tool: &str) -> bool { + toolset_of(tool).is_some_and(|set| self.0.contains(set)) + } + + pub fn names(&self) -> Vec<&'static str> { + self.0.iter().copied().collect() + } + + /// The toolsets left out, for the note that tells an agent how to get them. + pub fn missing(&self) -> Vec<&'static str> { + TOOLSETS + .iter() + .map(|(set, _)| *set) + .chain(CLOUD_TOOLSETS.iter().copied()) + .filter(|set| !self.0.contains(set)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_selection_means_every_tool() { + assert_eq!(parse(None), None); + assert_eq!(parse(Some(" ")), None); + assert_eq!(parse(Some("network,ALL")), None); + } + + #[test] + fn core_is_always_selected_and_unknown_names_are_ignored() { + let selection = parse(Some(" Network , nonsense,recipes")).unwrap(); + assert_eq!(selection.names(), ["core", "network", "recipes"]); + assert!(selection.includes_tool("list_profiles")); + assert!(selection.includes_tool("rotate_proxy_ip")); + assert!(!selection.includes_tool("list_extensions")); + assert!(!selection.includes_tool("no_such_tool")); + assert!(selection.missing().contains(&"extensions")); + assert!(!selection.missing().contains(&"recipes")); + } + + #[test] + fn a_saved_choice_is_ordered_and_all_of_them_means_every_tool() { + let owned = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + assert_eq!( + normalize_choice(&owned(&["cookies", "network", "network"])), + Some(owned(&["network", "cookies"])) + ); + assert_eq!(normalize_choice(&owned(&["core"])), Some(owned(&["core"]))); + assert_eq!( + normalize_choice(&owned(&["core", "sync"])), + Some(owned(&["sync"])) + ); + assert_eq!(normalize_choice(&[]), Some(Vec::new())); + assert_eq!(normalize_choice(&owned(&["nope"])), None); + let every: Vec = optional_toolsets().iter().map(|s| s.to_string()).collect(); + assert_eq!(normalize_choice(&every), Some(Vec::new())); + } + + #[test] + fn the_integrations_page_offers_the_same_toolsets() { + let source = include_str!("../../src/lib/mcp.ts"); + let list = source + .split_once("export const OPTIONAL_TOOLSETS = [") + .and_then(|(_, rest)| rest.split_once(']')) + .map(|(list, _)| list) + .expect("OPTIONAL_TOOLSETS in src/lib/mcp.ts"); + let offered: Vec<&str> = list + .split(',') + .map(|item| item.trim().trim_matches('"')) + .filter(|item| !item.is_empty()) + .collect(); + assert_eq!(offered, optional_toolsets()); + } + + #[test] + fn a_tool_belongs_to_one_toolset() { + let mut seen = std::collections::HashSet::new(); + for (_, tools) in TOOLSETS { + for tool in *tools { + assert!(seen.insert(*tool), "{tool} is in two toolsets"); + } + } + } +} diff --git a/src-tauri/src/mcp_views.rs b/src-tauri/src/mcp_views.rs new file mode 100644 index 00000000..1eef0444 --- /dev/null +++ b/src-tauri/src/mcp_views.rs @@ -0,0 +1,478 @@ +//! What MCP tools answer with, kept small: an agent pays for every byte in +//! its context. +//! +//! - `tool_json`: compact JSON text plus `structuredContent`, a list cut to fit +//! when it is too large, with a note that says so. +//! - `list_profiles`: a short summary per profile, filters and paging, names +//! next to ids so an agent needs no second call to read them. +//! - `profile_detail`: one profile without device internals, secrets or +//! another person's email. + +use crate::profile::BrowserProfile; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +/// The most text one tool answer carries. Past it a list is cut to fit, so +/// one call can not fill an agent's context. +pub const MAX_TOOL_TEXT_BYTES: usize = 100_000; + +/// A tool's answer: compact JSON text that every client reads, and the same +/// value as `structuredContent` for clients that read that instead (the spec +/// allows an object there, not a list). +pub fn tool_json(value: Value) -> Value { + let text = serde_json::to_string(&value).unwrap_or_default(); + if text.len() <= MAX_TOOL_TEXT_BYTES { + let mut result = json!({ "content": [{ "type": "text", "text": text }] }); + if value.is_object() { + result["structuredContent"] = value; + } + return result; + } + let Value::Array(items) = value else { + // One record this large has a large field the caller asked for by id; + // cutting it would make it wrong, so it goes whole, once. + return json!({ "content": [{ "type": "text", "text": text }] }); + }; + let total = items.len(); + let mut kept = Vec::new(); + let mut size = 2; + for item in items { + let item_size = serde_json::to_string(&item).map_or(0, |t| t.len()) + 1; + if size + item_size > MAX_TOOL_TEXT_BYTES { + break; + } + size += item_size; + kept.push(item); + } + let shown = kept.len(); + json!({ "content": [ + { "type": "text", "text": serde_json::to_string(&kept).unwrap_or_default() }, + { + "type": "text", + "text": format!( + "Truncated: {shown} of {total} items shown. Narrow the request (a filter, a limit or one id) to see the rest." + ) + } + ]}) +} + +/// Names for the ids a profile points at, read once per call. +#[derive(Default)] +pub struct Names { + pub groups: HashMap, + pub proxies: HashMap, + pub vpns: HashMap, + pub statuses: HashMap, + /// Profiles running on the remote fleet. + pub remote: HashSet, +} + +pub const DEFAULT_LIST_LIMIT: usize = 50; +pub const MAX_LIST_LIMIT: usize = 200; +const NOTE_CHARS: usize = 200; + +#[derive(Debug, Default, PartialEq)] +pub struct ProfileQuery { + pub text: Option, + pub group: Option, + pub tag: Option, + pub running: Option, + pub limit: usize, + pub offset: usize, +} + +impl ProfileQuery { + /// From the tool's arguments. A bad cursor or limit is an error, not a + /// silent first page. + pub fn from_arguments(arguments: &Value) -> Result { + let text_of = |key: &str| { + arguments + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let limit = match arguments.get("limit") { + None | Some(Value::Null) => DEFAULT_LIST_LIMIT, + Some(value) => value + .as_u64() + .filter(|limit| (1..=MAX_LIST_LIMIT as u64).contains(limit)) + .ok_or_else(|| format!("limit must be a whole number from 1 to {MAX_LIST_LIMIT}"))? + as usize, + }; + let offset = match text_of("cursor") { + None => 0, + Some(cursor) => cursor + .parse::() + .map_err(|_| "cursor must be a next_cursor from an earlier page".to_string())?, + }; + Ok(Self { + text: text_of("query").map(|q| q.to_lowercase()), + group: text_of("group"), + tag: text_of("tag"), + running: arguments.get("running").and_then(Value::as_bool), + limit, + offset, + }) + } +} + +fn running_state(profile: &BrowserProfile, names: &Names) -> &'static str { + if profile.process_id.is_some() { + "local" + } else if names.remote.contains(&profile.id.to_string()) { + "remote" + } else { + "stopped" + } +} + +fn short_note(note: &str) -> String { + if note.chars().count() <= NOTE_CHARS { + return note.to_string(); + } + let cut: String = note.chars().take(NOTE_CHARS).collect(); + format!("{cut}…") +} + +fn summary(profile: &BrowserProfile, names: &Names) -> Value { + let id = profile.id.to_string(); + let named = |map: &HashMap, id: &Option| { + id.as_ref() + .map(|id| json!({ "id": id, "name": map.get(id) })) + }; + let route = match (&profile.proxy_id, &profile.vpn_id) { + (Some(proxy), _) => json!({ "kind": "proxy", "id": proxy, "name": names.proxies.get(proxy) }), + (None, Some(vpn)) => json!({ "kind": "vpn", "id": vpn, "name": names.vpns.get(vpn) }), + (None, None) => json!({ "kind": "direct" }), + }; + json!({ + "id": id, + "name": profile.name, + "group": named(&names.groups, &profile.group_id), + "tags": profile.tags, + "status": profile.status_id.as_ref().and_then(|s| names.statuses.get(s)), + "route": route, + "running": running_state(profile, names), + "last_launch": profile.last_launch, + "note": profile.note.as_deref().map(short_note), + }) +} + +fn matches(profile: &BrowserProfile, names: &Names, query: &ProfileQuery) -> bool { + if let Some(text) = &query.text { + let hit = profile.name.to_lowercase().contains(text) + || profile + .note + .as_ref() + .is_some_and(|note| note.to_lowercase().contains(text)) + || profile + .tags + .iter() + .any(|tag| tag.to_lowercase().contains(text)); + if !hit { + return false; + } + } + if let Some(group) = &query.group { + let ok = match &profile.group_id { + None => group.eq_ignore_ascii_case("none"), + Some(id) => { + id == group + || names + .groups + .get(id) + .is_some_and(|name| name.eq_ignore_ascii_case(group)) + } + }; + if !ok { + return false; + } + } + if let Some(tag) = &query.tag { + if !profile.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) { + return false; + } + } + if let Some(running) = query.running { + if (running_state(profile, names) != "stopped") != running { + return false; + } + } + true +} + +/// The `list_profiles` answer. Only Wayfern profiles can be driven over MCP; +/// the others are counted, so an agent knows they exist. +pub fn list_profiles(profiles: &[BrowserProfile], names: &Names, query: &ProfileQuery) -> Value { + let hidden = profiles.iter().filter(|p| p.browser != "wayfern").count(); + let mut found: Vec<&BrowserProfile> = profiles + .iter() + .filter(|p| p.browser == "wayfern" && matches(p, names, query)) + .collect(); + found.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.id.cmp(&b.id)) + }); + let total = found.len(); + let page: Vec = found + .iter() + .skip(query.offset) + .take(query.limit) + .map(|profile| summary(profile, names)) + .collect(); + let next = query.offset + page.len(); + let mut answer = json!({ + "profiles": page, + "total": total, + "next_cursor": (next < total).then(|| next.to_string()), + }); + if hidden > 0 { + answer["hidden"] = json!({ + "count": hidden, + "reason": "Not Wayfern profiles: they can not be driven over MCP.", + }); + } + answer +} + +/// One profile for `get_profile`. Left out: the device itself (ask +/// `get_profile_fingerprint`), the encryption salt, the creator's email and +/// id, and the launch hook URL, which can carry a token. +pub fn profile_detail(profile: &BrowserProfile, names: &Names) -> Value { + let mut value = serde_json::to_value(profile).unwrap_or_default(); + if let Some(object) = value.as_object_mut() { + for field in [ + "encryption_salt", + "created_by_email", + "created_by_id", + "launch_hook", + "process_id", + ] { + object.remove(field); + } + object.insert( + "has_launch_hook".into(), + json!(profile.launch_hook.is_some()), + ); + object.insert("running".into(), json!(running_state(profile, names))); + if let Some(Value::Object(config)) = object.get_mut("wayfern_config") { + for field in [ + "fingerprint", + "identity_baseline", + "identity_overrides", + "persona", + "location", + "geo_proxy_signature", + "proxy", + ] { + config.remove(field); + } + } + let summary = summary(profile, names); + for field in ["group", "status", "route"] { + object.insert(field.into(), summary[field].clone()); + } + } + value +} + +/// The `outputSchema` of a tool whose answer has a fixed shape. +pub fn output_schema_for(tool: &str) -> Option { + match tool { + "list_profiles" => Some(json!({ + "type": "object", + "properties": { + "profiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "group": { + "type": ["object", "null"], + "properties": { "id": { "type": "string" }, "name": { "type": ["string", "null"] } } + }, + "tags": { "type": "array", "items": { "type": "string" } }, + "status": { "type": ["string", "null"] }, + "route": { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["proxy", "vpn", "direct"] }, + "id": { "type": "string" }, + "name": { "type": ["string", "null"] } + }, + "required": ["kind"] + }, + "running": { "type": "string", "enum": ["local", "remote", "stopped"] }, + "last_launch": { "type": ["integer", "null"] }, + "note": { "type": ["string", "null"] } + }, + "required": ["id", "name", "tags", "route", "running"] + } + }, + "total": { "type": "integer" }, + "next_cursor": { "type": ["string", "null"] }, + "hidden": { + "type": "object", + "properties": { "count": { "type": "integer" }, "reason": { "type": "string" } } + } + }, + "required": ["profiles", "total", "next_cursor"] + })), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profile(name: &str) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "wayfern".to_string(), + ..Default::default() + } + } + + fn query(arguments: Value) -> ProfileQuery { + ProfileQuery::from_arguments(&arguments).unwrap() + } + + #[test] + fn a_summary_is_small_and_names_what_ids_point_at() { + let mut shop = profile("Shop"); + shop.group_id = Some("g1".into()); + shop.proxy_id = Some("p1".into()); + shop.status_id = Some("s1".into()); + shop.tags = vec!["eu".into()]; + shop.note = Some("x".repeat(500)); + shop.launch_hook = Some("https://hook.example/?token=secret".into()); + shop.wayfern_config = Some(crate::wayfern_manager::WayfernConfig { + identity_overrides: Some("{\"a\":\"".to_string() + &"z".repeat(4000) + "\"}"), + ..Default::default() + }); + let names = Names { + groups: HashMap::from([("g1".into(), "Europe".into())]), + proxies: HashMap::from([("p1".into(), "Berlin".into())]), + statuses: HashMap::from([("s1".into(), "Warming".into())]), + ..Default::default() + }; + let answer = list_profiles(&[shop.clone()], &names, &query(json!({}))); + let row = &answer["profiles"][0]; + assert_eq!(row["group"]["name"], "Europe"); + assert_eq!(row["route"]["name"], "Berlin"); + assert_eq!(row["status"], "Warming"); + assert_eq!(row["running"], "stopped"); + assert_eq!(row["note"].as_str().unwrap().chars().count(), 201); + let text = serde_json::to_string(row).unwrap(); + assert!(text.len() < 700, "summary is {} bytes", text.len()); + assert!(!text.contains("secret") && !text.contains("zzzz")); + + let detail = serde_json::to_string(&profile_detail(&shop, &names)).unwrap(); + assert!(!detail.contains("secret"), "the hook URL can carry a token"); + assert!( + !detail.contains("zzzz"), + "device internals are a separate tool" + ); + assert!(detail.contains("\"has_launch_hook\":true")); + } + + #[test] + fn filters_and_pages_combine() { + let mut profiles: Vec = + (1..=5).map(|i| profile(&format!("Mail {i}"))).collect(); + profiles[0].process_id = Some(42); + profiles[1].tags = vec!["VIP".into()]; + profiles[2].group_id = Some("g1".into()); + let mut camoufox = profile("Old"); + camoufox.browser = "camoufox".into(); + profiles.push(camoufox); + let names = Names { + groups: HashMap::from([("g1".into(), "Europe".into())]), + remote: HashSet::from([profiles[3].id.to_string()]), + ..Default::default() + }; + + let first = list_profiles(&profiles, &names, &query(json!({ "limit": 2 }))); + assert_eq!(first["total"], 5); + assert_eq!(first["profiles"].as_array().unwrap().len(), 2); + assert_eq!(first["next_cursor"], "2"); + assert_eq!(first["hidden"]["count"], 1); + let last = list_profiles( + &profiles, + &names, + &query(json!({ "limit": 2, "cursor": "4" })), + ); + assert_eq!(last["profiles"].as_array().unwrap().len(), 1); + assert_eq!(last["next_cursor"], Value::Null); + + let running = list_profiles(&profiles, &names, &query(json!({ "running": true }))); + let states: Vec<&str> = running["profiles"] + .as_array() + .unwrap() + .iter() + .map(|p| p["running"].as_str().unwrap()) + .collect(); + assert_eq!(states, ["local", "remote"]); + for (arguments, expected) in [ + (json!({ "tag": "vip" }), 1), + (json!({ "group": "europe" }), 1), + (json!({ "group": "g1" }), 1), + (json!({ "group": "none" }), 4), + (json!({ "query": "MAIL 5" }), 1), + ] { + assert_eq!( + list_profiles(&profiles, &names, &query(arguments.clone()))["total"], + expected, + "{arguments}" + ); + } + assert!(ProfileQuery::from_arguments(&json!({ "limit": 0 })).is_err()); + assert!(ProfileQuery::from_arguments(&json!({ "cursor": "abc" })).is_err()); + } + + #[test] + fn objects_get_structured_content_and_long_lists_are_cut_with_a_note() { + let small = tool_json(json!({ "a": 1 })); + assert_eq!(small["structuredContent"]["a"], 1); + assert_eq!(small["content"][0]["text"], "{\"a\":1}"); + assert!(tool_json(json!([1, 2]))["structuredContent"].is_null()); + + let big: Vec = (0..5000) + .map(|i| json!({ "i": i, "pad": "x".repeat(40) })) + .collect(); + let answer = tool_json(Value::Array(big)); + let text = answer["content"][0]["text"].as_str().unwrap(); + assert!(text.len() <= MAX_TOOL_TEXT_BYTES); + let kept: Vec = serde_json::from_str(text).unwrap(); + assert!(kept.len() < 5000); + assert!(answer["content"][1]["text"] + .as_str() + .unwrap() + .starts_with(&format!("Truncated: {} of 5000", kept.len()))); + } + + #[test] + fn the_list_schema_describes_what_the_list_returns() { + let schema = output_schema_for("list_profiles").unwrap(); + let names = Names::default(); + let answer = list_profiles(&[profile("A")], &names, &query(json!({}))); + for key in schema["required"].as_array().unwrap() { + assert!(answer.get(key.as_str().unwrap()).is_some(), "{key}"); + } + let row = &answer["profiles"][0]; + for key in schema["properties"]["profiles"]["items"]["required"] + .as_array() + .unwrap() + { + assert!(row.get(key.as_str().unwrap()).is_some(), "{key}"); + } + } +} diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index a59846b5..01788250 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -743,6 +743,12 @@ fn reindex(app: Option<&AppHandle>, sessions: &[RemoteSessionState]) { with_index(|map| *map = next); } +/// The profiles holding a drivable session, from the in-process index alone: +/// no cloud round trip, so it can miss a session while the stream is down. +pub fn indexed_live_profiles() -> std::collections::HashSet { + with_index(|map| map.keys().cloned().collect()) +} + /// The drivable session holding `profile_id`, if there is one. /// /// Consults the in-process index first. Only when the stream is not delivering diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index 34f6ade1..5ae3666b 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -242,6 +242,10 @@ pub struct AppSettings { /// Soft interface sounds for presses, toggles and results. #[serde(default = "default_ui_sounds")] pub ui_sounds: bool, + /// The optional MCP toolsets written into clients' endpoint URLs. Empty is + /// every tool. + #[serde(default)] + pub mcp_toolsets: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -321,6 +325,7 @@ impl Default for AppSettings { trash_retention_days: DEFAULT_TRASH_RETENTION_DAYS, record_traffic_domains: true, ui_sounds: true, + mcp_toolsets: Vec::new(), } } } @@ -1467,6 +1472,7 @@ mod tests { trash_retention_days: 7, record_traffic_domains: false, ui_sounds: false, + mcp_toolsets: vec!["network".to_string()], }; let save_result = manager.save_settings(&test_settings); diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index ef71e529..7da6abea 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -11,6 +11,7 @@ import { LuEyeOff, LuPlug, LuRefreshCw, + LuSlidersHorizontal, LuTrash2, } from "react-icons/lu"; import { @@ -29,6 +30,7 @@ import { AnimatedTabsTrigger, } from "@/components/ui/animated-tabs"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { CodeSnippet } from "@/components/ui/code-snippet"; import { ConfirmationMark } from "@/components/ui/confirmation-mark"; import { CopyToClipboard } from "@/components/ui/copy-to-clipboard"; @@ -41,6 +43,11 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { OperationFlow } from "@/components/ui/operation-flow"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { FeatureIcon, SECTION_LABEL_CLASS, @@ -65,6 +72,7 @@ import { translateBackendError } from "@/lib/backend-errors"; import { canUseRemoteControl } from "@/lib/entitlements"; import { type AgentCategory, + CORE_TOOLSET, credentialPrefixOf, FX_AGENT_ID, fxExportLine, @@ -72,6 +80,7 @@ import { type McpRemoteCredential, type McpRemoteCredentialRotation, type McpRemoteStatus, + OPTIONAL_TOOLSETS, REMOTE_MCP_URL, } from "@/lib/mcp"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; @@ -83,6 +92,7 @@ interface AppSettings { api_token?: string; mcp_remote_enabled: boolean; mcp_remote_key?: string | null; + mcp_toolsets?: string[]; } type IntegrationsTab = "api" | "remote"; @@ -128,6 +138,9 @@ export function IntegrationsDialog({ const [showApiToken, setShowApiToken] = useState(false); const [isApiStarting, setIsApiStarting] = useState(false); const [agents, setAgents] = useState([]); + const [showAllClients, setShowAllClients] = useState(false); + const [isAddingAll, setIsAddingAll] = useState(false); + const [isSavingToolsets, setIsSavingToolsets] = useState(false); const [busyAgentIds, setBusyAgentIds] = useState>(new Set()); const [apiPortDraft, setApiPortDraft] = useState("10108"); const [remote, setRemote] = useState(null); @@ -442,6 +455,63 @@ export function IntegrationsDialog({ } }; + /** Add Donut to several clients in one go, with one toast for all of them. */ + const handleAddAll = async (targets: McpAgentInfo[]) => { + setIsAddingAll(true); + for (const agent of targets) markAgentBusy(agent.id, true); + const failed: string[] = []; + try { + await ensureCredential(); + for (const agent of targets) { + try { + await invoke("add_mcp_to_agent", { agentId: agent.id }); + } catch (e) { + console.error(`Failed to add MCP to ${agent.id}:`, e); + failed.push(agent.display_name); + } + } + const added = targets.length - failed.length; + if (added > 0) { + showSuccessToast(t("integrations.mcp.addedToMany", { count: added })); + } + if (failed.length > 0) { + showErrorToast( + t("integrations.mcp.addFailed", { names: failed.join(", ") }), + ); + } + } catch (e) { + showErrorToast(translateBackendError(t, e)); + } finally { + for (const agent of targets) markAgentBusy(agent.id, false); + setIsAddingAll(false); + void loadCredential(); + void loadSettings(); + void loadAgents(); + } + }; + + /** Save the toolsets; the backend rewrites every connected client. */ + const handleSetToolsets = async (toolsets: string[]) => { + setIsSavingToolsets(true); + try { + const failed = await invoke("set_mcp_toolsets", { toolsets }); + setSettings((current) => ({ ...current, mcp_toolsets: toolsets })); + if (failed.length > 0) { + const names = failed.map( + (id) => agents.find((agent) => agent.id === id)?.display_name ?? id, + ); + showErrorToast( + t("integrations.mcp.toolsets.failed", { names: names.join(", ") }), + ); + } + } catch (e) { + showErrorToast(translateBackendError(t, e)); + } finally { + setIsSavingToolsets(false); + void loadSettings(); + } + }; + const handleRemoveAgent = async (agent: McpAgentInfo) => { markAgentBusy(agent.id, true); try { @@ -566,121 +636,185 @@ export function IntegrationsDialog({ const shownTab: IntegrationsTab = activeTab === "remote" && !remoteTabShown ? "api" : activeTab; - const clientsGrid = ( -
-

- {t("integrations.mcp.clientsLabel")} -

-
- {agents.map((agent) => { - const busy = busyAgentIds.has(agent.id); - return ( -
-
- -
-

- {agent.display_name} -

-

- {categoryLabel(t, agent.category)} -

-
- {agent.connected ? ( -
- - - {t("appFeedback.configured")} - - -
- ) : ( - - )} -
- {agent.connected && ( -
- - {t("appFeedback.routeDetails")} - - {REMOTE_MCP_URL} - ), - }, - { - id: "device", - label: t("appFeedback.thisDevice"), - detail: t( - remote?.connected - ? "appFeedback.reachable" - : "appFeedback.notVerified", - ), - }, - ]} - /> -

- {t("appFeedback.remoteProbeScope")} -

-
- )} - {agent.connected && agent.id === FX_AGENT_ID && ( -
-

- {t("integrations.remote.fxHint")} -

- {fxLine && ( - - )} -
- )} + const renderAgent = (agent: McpAgentInfo) => { + const busy = busyAgentIds.has(agent.id); + return ( +
+
+ +
+

{agent.display_name}

+

+ {categoryLabel(t, agent.category)} +

+
+ {agent.connected ? ( +
+ + + {t("appFeedback.configured")} + +
- ); - })} + ) : ( + + )} +
+ {agent.connected && ( +
+ + {t("appFeedback.routeDetails")} + + {REMOTE_MCP_URL}, + }, + { + id: "device", + label: t("appFeedback.thisDevice"), + detail: t( + remote?.connected + ? "appFeedback.reachable" + : "appFeedback.notVerified", + ), + }, + ]} + /> +

+ {t("appFeedback.remoteProbeScope")} +

+
+ )} + {agent.connected && agent.id === FX_AGENT_ID && ( +
+

+ {t("integrations.remote.fxHint")} +

+ {fxLine && ( + + )} +
+ )}
+ ); + }; + // The clients on this computer come first; the rest wait behind a toggle, + // so the page leads with the apps the person actually has. + const onThisComputer = agents + .filter((agent) => agent.connected || agent.detected) + .sort((a, b) => Number(b.connected) - Number(a.connected)); + const otherClients = agents.filter( + (agent) => !agent.connected && !agent.detected, + ); + const suggestedClients = onThisComputer.filter((agent) => !agent.connected); + const clientGridClass = "grid grid-cols-1 gap-2 @2xl:grid-cols-2"; + + const clientsGrid = ( +
+
+

+ {t("integrations.mcp.clientsLabel")} +

+ void handleSetToolsets(next)} + disabled={isSavingToolsets} + /> +
+ {onThisComputer.length > 0 ? ( + <> +
+

+ {t("integrations.mcp.onThisComputer")} +

+ {suggestedClients.length > 1 && ( + + )} +
+
+ {onThisComputer.map(renderAgent)} +
+ + ) : ( +

+ {t("integrations.mcp.noneFound")} +

+ )} + {otherClients.length > 0 && onThisComputer.length > 0 && ( + + )} + {otherClients.length > 0 && + (onThisComputer.length === 0 || showAllClients) && ( +
{otherClients.map(renderAgent)}
+ )}
); @@ -1102,3 +1236,94 @@ export function IntegrationsDialog({ ); } + +/** Which tool groups agents list, besides the core tools. */ +function ToolsetsPicker({ + value, + onChange, + disabled, +}: { + value: string[]; + onChange: (next: string[]) => void; + disabled: boolean; +}) { + const { t } = useTranslation(); + const everything = value.length === 0; + const chosen: string[] = everything + ? [...OPTIONAL_TOOLSETS] + : value.filter((name) => name !== CORE_TOOLSET); + const label = everything + ? t("integrations.mcp.toolsets.all") + : chosen.length === 0 + ? t("integrations.mcp.toolsets.coreOnly") + : t("integrations.mcp.toolsets.some", { count: chosen.length }); + const toggle = (name: string, on: boolean) => { + const next = OPTIONAL_TOOLSETS.filter((set) => + set === name ? on : chosen.includes(set), + ); + onChange( + next.length === OPTIONAL_TOOLSETS.length + ? [] + : next.length === 0 + ? [CORE_TOOLSET] + : next, + ); + }; + + return ( + + + + + +

+ {t("integrations.mcp.toolsets.title")} +

+

+ {t("integrations.mcp.toolsets.description")} +

+
+ {OPTIONAL_TOOLSETS.map((name) => ( +
+ toggle(name, checked === true)} + className="mt-0.5" + /> + +
+ ))} +
+

+ {t("integrations.mcp.toolsets.core")} +

+
+
+ ); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 66f260a4..95811c1c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -864,6 +864,60 @@ "cli": "CLI", "editor": "Editor", "editorExt": "Editor-Erweiterung" + }, + "onThisComputer": "Auf diesem Computer", + "addToAll_one": "Zu allen {{count}} hinzufügen", + "addToAll_other": "Zu allen {{count}} hinzufügen", + "addedToMany_one": "Donut zu {{count}} App hinzugefügt", + "addedToMany_other": "Donut zu {{count}} Apps hinzugefügt", + "addFailed": "Donut konnte nicht zu {{names}} hinzugefügt werden.", + "noneFound": "Auf diesem Computer wurden keine KI-Apps gefunden. Sie können Donut trotzdem zu jeder dieser Apps hinzufügen.", + "showMore_one": "{{count}} weiteren Client anzeigen", + "showMore_other": "{{count}} weitere Clients anzeigen", + "showFewer": "Weniger Clients anzeigen", + "toolsets": { + "all": "Alle Werkzeuge", + "coreOnly": "Nur Kernwerkzeuge", + "some_one": "Kern + {{count}} Gruppe", + "some_other": "Kern + {{count}} Gruppen", + "title": "Werkzeuge, die Agenten sehen", + "description": "Ein Agent lädt eine Beschreibung jedes Werkzeugs, das er sieht. Wählen Sie nur die Gruppen, die er braucht – dann startet er schneller und hat mehr Platz zum Arbeiten. Verbundene Apps werden sofort aktualisiert.", + "core": "Profile, Browsersteuerung und die Werkzeuge, die Sie auf dem Laufenden halten, sind immer aktiv.", + "failed": "{{names}} konnte nicht aktualisiert werden. Entfernen Sie Donut dort und fügen Sie es erneut hinzu.", + "names": { + "organize": { + "label": "Organisieren", + "description": "Gruppen erstellen und ändern, Profile importieren" + }, + "network": { + "label": "Netzwerk", + "description": "Proxys, VPNs, IP-Wechsel und DNS-Regeln" + }, + "fingerprint": { + "label": "Fingerabdruck", + "description": "Das Gerät eines Profils lesen und ändern" + }, + "extensions": { + "label": "Erweiterungen", + "description": "Erweiterungen und Erweiterungsgruppen" + }, + "cookies": { + "label": "Cookies", + "description": "Cookie-Import und der Cookie-Bot" + }, + "sync": { + "label": "Synchronisierung", + "description": "Team-Sperren und der Synchronizer" + }, + "remote": { + "label": "Remote-Flotte", + "description": "Profile auf der Remote-Flotte ausführen" + }, + "recipes": { + "label": "Rezepte", + "description": "Gespeicherte Abläufe, die ein Agent wiederholen kann" + } + } } }, "tabApi": "Lokale API", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "Ein Vorlagenname darf höchstens 60 Zeichen haben.", "templateLimitReached": "Sie können höchstens 100 Vorlagen behalten. Löschen Sie zuerst eine.", "bulkCreateCountInvalid": "Sie können 1 bis {{max}} Profile auf einmal erstellen.", - "bulkCreateBusy": "Es werden bereits Profile erstellt. Warten Sie, bis das abgeschlossen ist." + "bulkCreateBusy": "Es werden bereits Profile erstellt. Warten Sie, bis das abgeschlossen ist.", + "mcpToolsetUnknown": "Diese Werkzeuggruppe existiert nicht." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 334805b5..744dfb46 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -864,6 +864,60 @@ "cli": "CLI", "editor": "Editor", "editorExt": "Editor ext" + }, + "onThisComputer": "On this computer", + "addToAll_one": "Add to all {{count}}", + "addToAll_other": "Add to all {{count}}", + "addedToMany_one": "Added Donut to {{count}} app", + "addedToMany_other": "Added Donut to {{count}} apps", + "addFailed": "Could not add Donut to {{names}}.", + "noneFound": "No AI apps found on this computer. You can still add Donut to any of these.", + "showMore_one": "Show {{count}} more client", + "showMore_other": "Show {{count}} more clients", + "showFewer": "Show fewer clients", + "toolsets": { + "all": "All tools", + "coreOnly": "Core tools only", + "some_one": "Core + {{count}} group", + "some_other": "Core + {{count}} groups", + "title": "Tools agents see", + "description": "An agent loads a description of every tool it can see. Choose only the groups it needs, and it starts faster and has more room to work. Connected apps update at once.", + "core": "Profiles, browser control and the tools that keep you in the loop are always on.", + "failed": "Could not update {{names}}. Remove Donut there and add it again.", + "names": { + "organize": { + "label": "Organize", + "description": "Create and change groups, import profiles" + }, + "network": { + "label": "Network", + "description": "Proxies, VPNs, IP rotation and DNS rules" + }, + "fingerprint": { + "label": "Fingerprint", + "description": "Read and change a profile's device" + }, + "extensions": { + "label": "Extensions", + "description": "Extensions and extension groups" + }, + "cookies": { + "label": "Cookies", + "description": "Cookie import and the cookie bot" + }, + "sync": { + "label": "Sync", + "description": "Team locks and the synchronizer" + }, + "remote": { + "label": "Remote fleet", + "description": "Run profiles on the remote fleet" + }, + "recipes": { + "label": "Recipes", + "description": "Saved flows an agent can repeat" + } + } } }, "tabApi": "Local API", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "A template name can have at most 60 characters.", "templateLimitReached": "You can keep at most 100 templates. Delete one first.", "bulkCreateCountInvalid": "You can create from 1 to {{max}} profiles at a time.", - "bulkCreateBusy": "Profiles are already being created. Wait until that is done." + "bulkCreateBusy": "Profiles are already being created. Wait until that is done.", + "mcpToolsetUnknown": "This tool group does not exist." }, "rail": { "profiles": "Profiles", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5685e5ea..d963a5b9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -869,6 +869,64 @@ "cli": "CLI", "editor": "Editor", "editorExt": "Extensión de editor" + }, + "onThisComputer": "En este ordenador", + "addToAll_one": "Añadir a los {{count}}", + "addToAll_many": "Añadir a los {{count}}", + "addToAll_other": "Añadir a los {{count}}", + "addedToMany_one": "Donut añadido a {{count}} app", + "addedToMany_many": "Donut añadido a {{count}} apps", + "addedToMany_other": "Donut añadido a {{count}} apps", + "addFailed": "No se pudo añadir Donut a {{names}}.", + "noneFound": "No se encontraron apps de IA en este ordenador. Aun así puedes añadir Donut a cualquiera de estas.", + "showMore_one": "Mostrar {{count}} cliente más", + "showMore_many": "Mostrar {{count}} clientes más", + "showMore_other": "Mostrar {{count}} clientes más", + "showFewer": "Mostrar menos clientes", + "toolsets": { + "all": "Todas las herramientas", + "coreOnly": "Solo herramientas básicas", + "some_one": "Básicas + {{count}} grupo", + "some_many": "Básicas + {{count}} grupos", + "some_other": "Básicas + {{count}} grupos", + "title": "Herramientas que ven los agentes", + "description": "Un agente carga una descripción de cada herramienta que ve. Elige solo los grupos que necesita: arrancará más rápido y tendrá más espacio para trabajar. Las apps conectadas se actualizan al momento.", + "core": "Los perfiles, el control del navegador y las herramientas que te mantienen al tanto están siempre activos.", + "failed": "No se pudo actualizar {{names}}. Quita Donut ahí y vuelve a añadirlo.", + "names": { + "organize": { + "label": "Organizar", + "description": "Crear y cambiar grupos, importar perfiles" + }, + "network": { + "label": "Red", + "description": "Proxies, VPN, rotación de IP y reglas DNS" + }, + "fingerprint": { + "label": "Huella", + "description": "Leer y cambiar el dispositivo de un perfil" + }, + "extensions": { + "label": "Extensiones", + "description": "Extensiones y grupos de extensiones" + }, + "cookies": { + "label": "Cookies", + "description": "Importación de cookies y el bot de cookies" + }, + "sync": { + "label": "Sincronización", + "description": "Bloqueos de equipo y el sincronizador" + }, + "remote": { + "label": "Flota remota", + "description": "Ejecutar perfiles en la flota remota" + }, + "recipes": { + "label": "Recetas", + "description": "Flujos guardados que un agente puede repetir" + } + } } }, "tabApi": "API local", @@ -2581,7 +2639,8 @@ "templateNameTooLong": "El nombre de una plantilla puede tener como máximo 60 caracteres.", "templateLimitReached": "Puedes guardar como máximo 100 plantillas. Elimina una primero.", "bulkCreateCountInvalid": "Puedes crear de 1 a {{max}} perfiles a la vez.", - "bulkCreateBusy": "Ya se están creando perfiles. Espera a que termine." + "bulkCreateBusy": "Ya se están creando perfiles. Espera a que termine.", + "mcpToolsetUnknown": "Este grupo de herramientas no existe." }, "rail": { "profiles": "Perfiles", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c8c7450f..9fb50c53 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -869,6 +869,64 @@ "cli": "CLI", "editor": "Éditeur", "editorExt": "Ext. d'éditeur" + }, + "onThisComputer": "Sur cet ordinateur", + "addToAll_one": "Ajouter aux {{count}}", + "addToAll_many": "Ajouter aux {{count}}", + "addToAll_other": "Ajouter aux {{count}}", + "addedToMany_one": "Donut ajouté à {{count}} app", + "addedToMany_many": "Donut ajouté à {{count}} apps", + "addedToMany_other": "Donut ajouté à {{count}} apps", + "addFailed": "Impossible d'ajouter Donut à {{names}}.", + "noneFound": "Aucune app d'IA trouvée sur cet ordinateur. Vous pouvez quand même ajouter Donut à l'une d'elles.", + "showMore_one": "Afficher {{count}} autre client", + "showMore_many": "Afficher {{count}} autres clients", + "showMore_other": "Afficher {{count}} autres clients", + "showFewer": "Afficher moins de clients", + "toolsets": { + "all": "Tous les outils", + "coreOnly": "Outils de base uniquement", + "some_one": "Base + {{count}} groupe", + "some_many": "Base + {{count}} groupes", + "some_other": "Base + {{count}} groupes", + "title": "Outils visibles par les agents", + "description": "Un agent charge une description de chaque outil qu'il voit. Ne choisissez que les groupes dont il a besoin : il démarre plus vite et a plus de place pour travailler. Les apps connectées sont mises à jour aussitôt.", + "core": "Les profils, le contrôle du navigateur et les outils qui vous tiennent informé sont toujours actifs.", + "failed": "Impossible de mettre à jour {{names}}. Retirez Donut de cette app et ajoutez-le à nouveau.", + "names": { + "organize": { + "label": "Organiser", + "description": "Créer et modifier des groupes, importer des profils" + }, + "network": { + "label": "Réseau", + "description": "Proxys, VPN, rotation d'IP et règles DNS" + }, + "fingerprint": { + "label": "Empreinte", + "description": "Lire et modifier l'appareil d'un profil" + }, + "extensions": { + "label": "Extensions", + "description": "Extensions et groupes d'extensions" + }, + "cookies": { + "label": "Cookies", + "description": "Import de cookies et le bot de cookies" + }, + "sync": { + "label": "Synchronisation", + "description": "Verrous d'équipe et le synchroniseur" + }, + "remote": { + "label": "Flotte distante", + "description": "Lancer des profils sur la flotte distante" + }, + "recipes": { + "label": "Recettes", + "description": "Flux enregistrés qu'un agent peut répéter" + } + } } }, "tabApi": "API locale", @@ -2581,7 +2639,8 @@ "templateNameTooLong": "Le nom d'un modèle peut contenir au plus 60 caractères.", "templateLimitReached": "Vous pouvez garder au plus 100 modèles. Supprimez-en un d'abord.", "bulkCreateCountInvalid": "Vous pouvez créer de 1 à {{max}} profils à la fois.", - "bulkCreateBusy": "Des profils sont déjà en cours de création. Attendez la fin." + "bulkCreateBusy": "Des profils sont déjà en cours de création. Attendez la fin.", + "mcpToolsetUnknown": "Ce groupe d'outils n'existe pas." }, "rail": { "profiles": "Profils", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 77aa4b98..d3e23905 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -864,6 +864,60 @@ "cli": "CLI", "editor": "エディタ", "editorExt": "エディタ拡張" + }, + "onThisComputer": "このコンピューター", + "addToAll_one": "{{count}} 件すべてに追加", + "addToAll_other": "{{count}} 件すべてに追加", + "addedToMany_one": "{{count}} 件のアプリに Donut を追加しました", + "addedToMany_other": "{{count}} 件のアプリに Donut を追加しました", + "addFailed": "{{names}} に Donut を追加できませんでした。", + "noneFound": "このコンピューターに AI アプリが見つかりません。以下のどのアプリにも Donut を追加できます。", + "showMore_one": "ほかのクライアントを {{count}} 件表示", + "showMore_other": "ほかのクライアントを {{count}} 件表示", + "showFewer": "表示するクライアントを減らす", + "toolsets": { + "all": "すべてのツール", + "coreOnly": "基本ツールのみ", + "some_one": "基本 + {{count}} グループ", + "some_other": "基本 + {{count}} グループ", + "title": "エージェントに見せるツール", + "description": "エージェントは見えるすべてのツールの説明を読み込みます。必要なグループだけを選ぶと、起動が速くなり作業の余裕も増えます。接続済みのアプリはすぐに更新されます。", + "core": "プロファイル、ブラウザー操作、あなたと連携するためのツールは常に有効です。", + "failed": "{{names}} を更新できませんでした。そのアプリから Donut を削除して、もう一度追加してください。", + "names": { + "organize": { + "label": "整理", + "description": "グループの作成と変更、プロファイルのインポート" + }, + "network": { + "label": "ネットワーク", + "description": "プロキシ、VPN、IP の切り替え、DNS ルール" + }, + "fingerprint": { + "label": "フィンガープリント", + "description": "プロファイルのデバイスの確認と変更" + }, + "extensions": { + "label": "拡張機能", + "description": "拡張機能と拡張機能グループ" + }, + "cookies": { + "label": "Cookie", + "description": "Cookie のインポートと Cookie ボット" + }, + "sync": { + "label": "同期", + "description": "チームロックとシンクロナイザー" + }, + "remote": { + "label": "リモートフリート", + "description": "リモートフリートでプロファイルを実行" + }, + "recipes": { + "label": "レシピ", + "description": "エージェントが繰り返せる保存済みのフロー" + } + } } }, "tabApi": "ローカル API", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "テンプレート名は60文字以内にしてください。", "templateLimitReached": "保存できるテンプレートは最大100件です。先に1件削除してください。", "bulkCreateCountInvalid": "一度に作成できるプロファイルは1〜{{max}}件です。", - "bulkCreateBusy": "既にプロファイルを作成中です。完了するまでお待ちください。" + "bulkCreateBusy": "既にプロファイルを作成中です。完了するまでお待ちください。", + "mcpToolsetUnknown": "このツールグループは存在しません。" }, "rail": { "profiles": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 64dfd5d8..da7302e6 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -864,6 +864,60 @@ "cli": "CLI", "editor": "에디터", "editorExt": "에디터 확장" + }, + "onThisComputer": "이 컴퓨터", + "addToAll_one": "{{count}}개 모두에 추가", + "addToAll_other": "{{count}}개 모두에 추가", + "addedToMany_one": "앱 {{count}}개에 Donut을 추가했습니다", + "addedToMany_other": "앱 {{count}}개에 Donut을 추가했습니다", + "addFailed": "{{names}}에 Donut을 추가하지 못했습니다.", + "noneFound": "이 컴퓨터에서 AI 앱을 찾지 못했습니다. 아래 앱 중 어디에나 Donut을 추가할 수 있습니다.", + "showMore_one": "클라이언트 {{count}}개 더 보기", + "showMore_other": "클라이언트 {{count}}개 더 보기", + "showFewer": "클라이언트 적게 보기", + "toolsets": { + "all": "모든 도구", + "coreOnly": "기본 도구만", + "some_one": "기본 + {{count}}개 그룹", + "some_other": "기본 + {{count}}개 그룹", + "title": "에이전트가 보는 도구", + "description": "에이전트는 볼 수 있는 모든 도구의 설명을 불러옵니다. 필요한 그룹만 고르면 더 빨리 시작하고 작업할 여유도 늘어납니다. 연결된 앱은 바로 업데이트됩니다.", + "core": "프로필, 브라우저 제어, 사용자와 함께 작업하는 도구는 항상 켜져 있습니다.", + "failed": "{{names}}을(를) 업데이트하지 못했습니다. 그곳에서 Donut을 제거한 뒤 다시 추가하세요.", + "names": { + "organize": { + "label": "정리", + "description": "그룹 만들기 및 변경, 프로필 가져오기" + }, + "network": { + "label": "네트워크", + "description": "프록시, VPN, IP 교체, DNS 규칙" + }, + "fingerprint": { + "label": "핑거프린트", + "description": "프로필의 기기 확인 및 변경" + }, + "extensions": { + "label": "확장 프로그램", + "description": "확장 프로그램과 확장 프로그램 그룹" + }, + "cookies": { + "label": "쿠키", + "description": "쿠키 가져오기와 쿠키 봇" + }, + "sync": { + "label": "동기화", + "description": "팀 잠금과 동기화 도구" + }, + "remote": { + "label": "원격 플릿", + "description": "원격 플릿에서 프로필 실행" + }, + "recipes": { + "label": "레시피", + "description": "에이전트가 반복할 수 있는 저장된 흐름" + } + } } }, "tabApi": "로컬 API", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "템플릿 이름은 최대 60자까지 가능합니다.", "templateLimitReached": "템플릿은 최대 100개까지 보관할 수 있습니다. 먼저 하나를 삭제하세요.", "bulkCreateCountInvalid": "한 번에 1개부터 {{max}}개까지 프로필을 만들 수 있습니다.", - "bulkCreateBusy": "이미 프로필을 만드는 중입니다. 끝날 때까지 기다리세요." + "bulkCreateBusy": "이미 프로필을 만드는 중입니다. 끝날 때까지 기다리세요.", + "mcpToolsetUnknown": "이 도구 그룹은 없습니다." }, "rail": { "profiles": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index cc27a7c4..e61e7ee7 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -869,6 +869,64 @@ "cli": "CLI", "editor": "Editor", "editorExt": "Extensão de editor" + }, + "onThisComputer": "Neste computador", + "addToAll_one": "Adicionar aos {{count}}", + "addToAll_many": "Adicionar aos {{count}}", + "addToAll_other": "Adicionar aos {{count}}", + "addedToMany_one": "Donut adicionado a {{count}} app", + "addedToMany_many": "Donut adicionado a {{count}} apps", + "addedToMany_other": "Donut adicionado a {{count}} apps", + "addFailed": "Não foi possível adicionar o Donut a {{names}}.", + "noneFound": "Nenhum app de IA encontrado neste computador. Você ainda pode adicionar o Donut a qualquer um destes.", + "showMore_one": "Mostrar mais {{count}} cliente", + "showMore_many": "Mostrar mais {{count}} clientes", + "showMore_other": "Mostrar mais {{count}} clientes", + "showFewer": "Mostrar menos clientes", + "toolsets": { + "all": "Todas as ferramentas", + "coreOnly": "Só ferramentas básicas", + "some_one": "Básicas + {{count}} grupo", + "some_many": "Básicas + {{count}} grupos", + "some_other": "Básicas + {{count}} grupos", + "title": "Ferramentas que os agentes veem", + "description": "Um agente carrega uma descrição de cada ferramenta que vê. Escolha só os grupos de que ele precisa: ele começa mais rápido e tem mais espaço para trabalhar. Os apps conectados são atualizados na hora.", + "core": "Perfis, controle do navegador e as ferramentas que mantêm você informado ficam sempre ativos.", + "failed": "Não foi possível atualizar {{names}}. Remova o Donut lá e adicione-o de novo.", + "names": { + "organize": { + "label": "Organizar", + "description": "Criar e alterar grupos, importar perfis" + }, + "network": { + "label": "Rede", + "description": "Proxies, VPNs, rotação de IP e regras de DNS" + }, + "fingerprint": { + "label": "Impressão digital", + "description": "Ler e alterar o dispositivo de um perfil" + }, + "extensions": { + "label": "Extensões", + "description": "Extensões e grupos de extensões" + }, + "cookies": { + "label": "Cookies", + "description": "Importação de cookies e o bot de cookies" + }, + "sync": { + "label": "Sincronização", + "description": "Bloqueios de equipe e o sincronizador" + }, + "remote": { + "label": "Frota remota", + "description": "Executar perfis na frota remota" + }, + "recipes": { + "label": "Receitas", + "description": "Fluxos salvos que um agente pode repetir" + } + } } }, "tabApi": "API local", @@ -2581,7 +2639,8 @@ "templateNameTooLong": "O nome de um modelo pode ter no máximo 60 caracteres.", "templateLimitReached": "Você pode manter no máximo 100 modelos. Exclua um primeiro.", "bulkCreateCountInvalid": "Você pode criar de 1 a {{max}} perfis de cada vez.", - "bulkCreateBusy": "Já há perfis sendo criados. Aguarde até terminar." + "bulkCreateBusy": "Já há perfis sendo criados. Aguarde até terminar.", + "mcpToolsetUnknown": "Este grupo de ferramentas não existe." }, "rail": { "profiles": "Perfis", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c605bf9a..7402cdfe 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -874,6 +874,68 @@ "cli": "CLI", "editor": "Редактор", "editorExt": "Расширение редактора" + }, + "onThisComputer": "На этом компьютере", + "addToAll_one": "Добавить во все {{count}}", + "addToAll_few": "Добавить во все {{count}}", + "addToAll_many": "Добавить во все {{count}}", + "addToAll_other": "Добавить во все {{count}}", + "addedToMany_one": "Donut добавлен в {{count}} приложение", + "addedToMany_few": "Donut добавлен в {{count}} приложения", + "addedToMany_many": "Donut добавлен в {{count}} приложений", + "addedToMany_other": "Donut добавлен в {{count}} приложений", + "addFailed": "Не удалось добавить Donut в {{names}}.", + "noneFound": "На этом компьютере не найдено ИИ-приложений. Donut всё равно можно добавить в любое из них.", + "showMore_one": "Показать ещё {{count}} клиент", + "showMore_few": "Показать ещё {{count}} клиента", + "showMore_many": "Показать ещё {{count}} клиентов", + "showMore_other": "Показать ещё {{count}} клиентов", + "showFewer": "Показать меньше клиентов", + "toolsets": { + "all": "Все инструменты", + "coreOnly": "Только основные инструменты", + "some_one": "Основные + {{count}} группа", + "some_few": "Основные + {{count}} группы", + "some_many": "Основные + {{count}} групп", + "some_other": "Основные + {{count}} групп", + "title": "Инструменты, которые видят агенты", + "description": "Агент загружает описание каждого инструмента, который видит. Выберите только нужные группы — он будет запускаться быстрее и получит больше места для работы. Подключённые приложения обновятся сразу.", + "core": "Профили, управление браузером и инструменты, которые держат вас в курсе, включены всегда.", + "failed": "Не удалось обновить {{names}}. Удалите там Donut и добавьте его снова.", + "names": { + "organize": { + "label": "Организация", + "description": "Создание и изменение групп, импорт профилей" + }, + "network": { + "label": "Сеть", + "description": "Прокси, VPN, смена IP и правила DNS" + }, + "fingerprint": { + "label": "Отпечаток", + "description": "Просмотр и изменение устройства профиля" + }, + "extensions": { + "label": "Расширения", + "description": "Расширения и группы расширений" + }, + "cookies": { + "label": "Cookie", + "description": "Импорт cookie и cookie-бот" + }, + "sync": { + "label": "Синхронизация", + "description": "Командные блокировки и синхронизатор" + }, + "remote": { + "label": "Удалённый парк", + "description": "Запуск профилей в удалённом парке" + }, + "recipes": { + "label": "Рецепты", + "description": "Сохранённые сценарии, которые агент может повторить" + } + } } }, "tabApi": "Локальный API", @@ -2597,7 +2659,8 @@ "templateNameTooLong": "Название шаблона может содержать не более 60 символов.", "templateLimitReached": "Можно хранить не более 100 шаблонов. Сначала удалите один.", "bulkCreateCountInvalid": "За один раз можно создать от 1 до {{max}} профилей.", - "bulkCreateBusy": "Профили уже создаются. Дождитесь окончания." + "bulkCreateBusy": "Профили уже создаются. Дождитесь окончания.", + "mcpToolsetUnknown": "Такой группы инструментов нет." }, "rail": { "profiles": "Профили", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 27beda0d..23156134 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -864,6 +864,60 @@ "cli": "Komut satırı", "editor": "Editör", "editorExt": "Editör uzantısı" + }, + "onThisComputer": "Bu bilgisayarda", + "addToAll_one": "{{count}} uygulamanın hepsine ekle", + "addToAll_other": "{{count}} uygulamanın hepsine ekle", + "addedToMany_one": "Donut {{count}} uygulamaya eklendi", + "addedToMany_other": "Donut {{count}} uygulamaya eklendi", + "addFailed": "Donut şuraya eklenemedi: {{names}}.", + "noneFound": "Bu bilgisayarda yapay zeka uygulaması bulunamadı. Donut'u yine de bunlardan herhangi birine ekleyebilirsiniz.", + "showMore_one": "{{count}} istemci daha göster", + "showMore_other": "{{count}} istemci daha göster", + "showFewer": "Daha az istemci göster", + "toolsets": { + "all": "Tüm araçlar", + "coreOnly": "Yalnızca temel araçlar", + "some_one": "Temel + {{count}} grup", + "some_other": "Temel + {{count}} grup", + "title": "Ajanların gördüğü araçlar", + "description": "Bir ajan, gördüğü her aracın açıklamasını yükler. Yalnızca ihtiyaç duyduğu grupları seçin; daha hızlı başlar ve çalışmak için daha fazla yeri olur. Bağlı uygulamalar hemen güncellenir.", + "core": "Profiller, tarayıcı denetimi ve sizi bilgilendiren araçlar her zaman açıktır.", + "failed": "{{names}} güncellenemedi. Donut'u oradan kaldırıp yeniden ekleyin.", + "names": { + "organize": { + "label": "Düzenle", + "description": "Grup oluşturma ve değiştirme, profil içe aktarma" + }, + "network": { + "label": "Ağ", + "description": "Proxy'ler, VPN'ler, IP değiştirme ve DNS kuralları" + }, + "fingerprint": { + "label": "Parmak izi", + "description": "Bir profilin cihazını okuma ve değiştirme" + }, + "extensions": { + "label": "Uzantılar", + "description": "Uzantılar ve uzantı grupları" + }, + "cookies": { + "label": "Çerezler", + "description": "Çerez içe aktarma ve çerez botu" + }, + "sync": { + "label": "Eşitleme", + "description": "Ekip kilitleri ve eşitleyici" + }, + "remote": { + "label": "Uzak filo", + "description": "Profilleri uzak filoda çalıştırma" + }, + "recipes": { + "label": "Tarifler", + "description": "Bir ajanın tekrarlayabileceği kayıtlı akışlar" + } + } } }, "tabApi": "Yerel API", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "Şablon adı en fazla 60 karakter olabilir.", "templateLimitReached": "En fazla 100 şablon tutabilirsiniz. Önce birini silin.", "bulkCreateCountInvalid": "Bir seferde 1 ile {{max}} arasında profil oluşturabilirsiniz.", - "bulkCreateBusy": "Profiller zaten oluşturuluyor. Bitmesini bekleyin." + "bulkCreateBusy": "Profiller zaten oluşturuluyor. Bitmesini bekleyin.", + "mcpToolsetUnknown": "Bu araç grubu yok." }, "rail": { "profiles": "Profiller", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index d983113f..b0c8b035 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -864,6 +864,60 @@ "cli": "CLI", "editor": "Trình soạn thảo", "editorExt": "Tiện ích editor" + }, + "onThisComputer": "Trên máy tính này", + "addToAll_one": "Thêm vào cả {{count}}", + "addToAll_other": "Thêm vào cả {{count}}", + "addedToMany_one": "Đã thêm Donut vào {{count}} ứng dụng", + "addedToMany_other": "Đã thêm Donut vào {{count}} ứng dụng", + "addFailed": "Không thể thêm Donut vào {{names}}.", + "noneFound": "Không tìm thấy ứng dụng AI nào trên máy tính này. Bạn vẫn có thể thêm Donut vào bất kỳ ứng dụng nào dưới đây.", + "showMore_one": "Hiện thêm {{count}} ứng dụng khách", + "showMore_other": "Hiện thêm {{count}} ứng dụng khách", + "showFewer": "Hiện ít ứng dụng khách hơn", + "toolsets": { + "all": "Tất cả công cụ", + "coreOnly": "Chỉ công cụ cơ bản", + "some_one": "Cơ bản + {{count}} nhóm", + "some_other": "Cơ bản + {{count}} nhóm", + "title": "Công cụ mà tác tử thấy", + "description": "Tác tử tải mô tả của mọi công cụ mà nó thấy. Chỉ chọn những nhóm nó cần để nó khởi động nhanh hơn và có thêm chỗ để làm việc. Các ứng dụng đã kết nối được cập nhật ngay.", + "core": "Profile, điều khiển trình duyệt và các công cụ giữ bạn trong vòng làm việc luôn được bật.", + "failed": "Không thể cập nhật {{names}}. Hãy gỡ Donut ở đó rồi thêm lại.", + "names": { + "organize": { + "label": "Sắp xếp", + "description": "Tạo và đổi nhóm, nhập profile" + }, + "network": { + "label": "Mạng", + "description": "Proxy, VPN, đổi IP và quy tắc DNS" + }, + "fingerprint": { + "label": "Dấu vân tay", + "description": "Xem và đổi thiết bị của profile" + }, + "extensions": { + "label": "Tiện ích", + "description": "Tiện ích và nhóm tiện ích" + }, + "cookies": { + "label": "Cookie", + "description": "Nhập cookie và bot cookie" + }, + "sync": { + "label": "Đồng bộ", + "description": "Khóa nhóm và bộ đồng bộ" + }, + "remote": { + "label": "Đội máy từ xa", + "description": "Chạy profile trên đội máy từ xa" + }, + "recipes": { + "label": "Công thức", + "description": "Các luồng đã lưu mà tác tử có thể lặp lại" + } + } } }, "tabApi": "API cục bộ", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "Tên mẫu có tối đa 60 ký tự.", "templateLimitReached": "Bạn chỉ có thể giữ tối đa 100 mẫu. Hãy xóa một mẫu trước.", "bulkCreateCountInvalid": "Mỗi lần bạn có thể tạo từ 1 đến {{max}} profile.", - "bulkCreateBusy": "Đang có profile được tạo. Hãy chờ đến khi xong." + "bulkCreateBusy": "Đang có profile được tạo. Hãy chờ đến khi xong.", + "mcpToolsetUnknown": "Nhóm công cụ này không tồn tại." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 705b4260..05be1463 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -864,6 +864,60 @@ "cli": "CLI", "editor": "编辑器", "editorExt": "编辑器扩展" + }, + "onThisComputer": "此电脑上", + "addToAll_one": "全部添加({{count}} 个)", + "addToAll_other": "全部添加({{count}} 个)", + "addedToMany_one": "已将 Donut 添加到 {{count}} 个应用", + "addedToMany_other": "已将 Donut 添加到 {{count}} 个应用", + "addFailed": "无法将 Donut 添加到 {{names}}。", + "noneFound": "此电脑上未找到 AI 应用。你仍可以将 Donut 添加到以下任一应用。", + "showMore_one": "再显示 {{count}} 个客户端", + "showMore_other": "再显示 {{count}} 个客户端", + "showFewer": "少显示一些客户端", + "toolsets": { + "all": "全部工具", + "coreOnly": "仅基础工具", + "some_one": "基础 + {{count}} 个分组", + "some_other": "基础 + {{count}} 个分组", + "title": "代理可见的工具", + "description": "代理会加载它能看到的每个工具的说明。只选择它需要的分组,它就能更快启动,也有更多空间工作。已连接的应用会立即更新。", + "core": "配置文件、浏览器控制以及让你随时掌握进展的工具始终开启。", + "failed": "无法更新 {{names}}。请在其中移除 Donut 后重新添加。", + "names": { + "organize": { + "label": "整理", + "description": "创建和修改分组,导入配置文件" + }, + "network": { + "label": "网络", + "description": "代理、VPN、IP 轮换和 DNS 规则" + }, + "fingerprint": { + "label": "指纹", + "description": "查看和修改配置文件的设备" + }, + "extensions": { + "label": "扩展", + "description": "扩展和扩展分组" + }, + "cookies": { + "label": "Cookie", + "description": "Cookie 导入和 Cookie 机器人" + }, + "sync": { + "label": "同步", + "description": "团队锁和同步器" + }, + "remote": { + "label": "远程机群", + "description": "在远程机群上运行配置文件" + }, + "recipes": { + "label": "配方", + "description": "代理可以重复执行的已保存流程" + } + } } }, "tabApi": "本地 API", @@ -2565,7 +2619,8 @@ "templateNameTooLong": "模板名称最多 60 个字符。", "templateLimitReached": "最多可保留 100 个模板。请先删除一个。", "bulkCreateCountInvalid": "每次可以创建 1 到 {{max}} 个配置文件。", - "bulkCreateBusy": "已在创建配置文件。请等待完成。" + "bulkCreateBusy": "已在创建配置文件。请等待完成。", + "mcpToolsetUnknown": "该工具分组不存在。" }, "rail": { "profiles": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 6b209256..11c34600 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -121,6 +121,7 @@ export type BackendErrorCode = | "WAYFERN_TERMS_REQUIRED" | "API_PORT_UNAVAILABLE" | "MCP_AGENT_UNKNOWN" + | "MCP_TOOLSET_UNKNOWN" | "MCP_AGENT_INSTALL_FAILED" | "MCP_AGENT_REMOVE_FAILED" | "MCP_REMOTE_REQUIRES_SIGN_IN" @@ -635,6 +636,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.mcpRemoteKeyLimit"); case "MCP_REMOTE_KEY_UNAVAILABLE": return t("backendErrors.mcpRemoteKeyUnavailable"); + case "MCP_TOOLSET_UNKNOWN": + return t("backendErrors.mcpToolsetUnknown"); case "MCP_AGENT_UNKNOWN": return t("backendErrors.mcpAgentUnknown"); case "MCP_AGENT_INSTALL_FAILED": diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index 98d58225..37fd7b3d 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -16,6 +16,23 @@ export function fxExportLine(key: string | null | undefined): string | null { export type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext"; +/** + * The tool groups an agent can list besides the core tools, in display + * order. The backend checks every name; `["core"]` is the core tools only and + * an empty list is every tool. + */ +export const OPTIONAL_TOOLSETS = [ + "organize", + "network", + "fingerprint", + "extensions", + "cookies", + "sync", + "remote", + "recipes", +] as const; +export const CORE_TOOLSET = "core"; + export interface McpAgentInfo { id: string; display_name: string;