mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
feat: leaner MCP and clients
This commit is contained in:
1 parent
38ab1f1a2d
commit
9ed9baf5d2
26 files changed
+2656
-360
No files matched your search
@@ -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<String> {
|
||||
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<String> {
|
||||
Self::find_mmdb_asset(release, "-City.mmdb")
|
||||
}
|
||||
|
||||
fn find_mmdb_asset(release: &GithubRelease, suffix: &str) -> Option<String> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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"<html>rate limited</html>").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]
|
||||
|
||||
+39
-2
@@ -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<mcp_integrations::McpTarget, String> {
|
||||
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<String>) -> Result<Vec<String>, 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,
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
pub kimi_code_home: Option<PathBuf>,
|
||||
pub cline_dir: Option<PathBuf>,
|
||||
pub copilot_home: Option<PathBuf>,
|
||||
pub pi_agent_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn env_path(name: &str) -> Option<PathBuf> {
|
||||
@@ -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<PathBuf> {
|
||||
"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<PathBuf> {
|
||||
}
|
||||
"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<CstObject> {
|
||||
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<String, String> {
|
||||
if serde_json::from_str::<serde_json::Value>(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<String> = 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<String> = 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::<serde_json::Value>(&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));
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,9 @@ enum BridgeFrame {
|
||||
Rpc {
|
||||
cid: String,
|
||||
session_id: Option<String>,
|
||||
/// The toolsets the agent connected with (`?toolsets=` on the cloud
|
||||
/// endpoint), as the relay received them.
|
||||
toolsets: Option<String>,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
EndSession {
|
||||
@@ -852,6 +855,17 @@ fn parse_frame(text: &str) -> Option<BridgeFrame> {
|
||||
.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::<Vec<_>>()
|
||||
.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,
|
||||
|
||||
+208
-157
@@ -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<serde_json::Value, McpError> {
|
||||
Ok(serde_json::json!({
|
||||
"tools": self.get_tools()
|
||||
}))
|
||||
async fn handle_tools_list(
|
||||
&self,
|
||||
toolsets: Option<&crate::mcp_toolsets::Selection>,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let tools: Vec<serde_json::Value> = 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<serde_json::Value, McpError> {
|
||||
async fn handle_list_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
@@ -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<serde_json::Value, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
@@ -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<T: Serialize>(value: &T) -> Result<serde_json::Value, McpError> {
|
||||
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<String> = 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")
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
let optional = optional_toolsets();
|
||||
if names
|
||||
.iter()
|
||||
.any(|name| name != CORE && !optional.contains(&name.as_str()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let chosen: Vec<String> = 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<Selection> {
|
||||
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::<Vec<_>>();
|
||||
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<String> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>,
|
||||
pub proxies: HashMap<String, String>,
|
||||
pub vpns: HashMap<String, String>,
|
||||
pub statuses: HashMap<String, String>,
|
||||
/// Profiles running on the remote fleet.
|
||||
pub remote: HashSet<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub group: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub running: Option<bool>,
|
||||
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<Self, String> {
|
||||
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::<usize>()
|
||||
.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<String, String>, id: &Option<String>| {
|
||||
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<Value> = 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<Value> {
|
||||
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<BrowserProfile> =
|
||||
(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<Value> = (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<Value> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
Reference in new issue
Block a user