feat: leaner MCP and clients

This commit is contained in:
zhom committed 2026-10-12 04:31:24 +04:00
1 parent 38ab1f1a2d
commit 9ed9baf5d2
26 files changed
+2656 -360

No files matched your search

+185 -41
View File
@@ -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(&timestamp_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
View File
@@ -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,
+272 -35
View File
@@ -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));
}
}
+31 -1
View File
@@ -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
View File
@@ -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")
+317
View File
@@ -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");
}
}
}
}
+478
View File
@@ -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}");
}
}
}
+6
View File
@@ -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
+6
View File
@@ -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);