diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs index ee5ff8ac46..60a117a0dc 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla.rs @@ -1,10 +1,16 @@ use serde::Deserialize; +use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fs; +use std::io::BufRead; use std::path::Path; use std::path::PathBuf; +use crate::model::DetectedConnectorCandidate; +use crate::model::DetectedConnectorSource; +use crate::sessions::ExternalAgentSessionMigration; + const SESSION_MANIFESTS_DIR: &str = "claude-code-sessions"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,6 +33,32 @@ struct RemoteMcpServerConfig { uuid: Option, } +pub(crate) fn detect_cla_session_connectors( + sessions: &[ExternalAgentSessionMigration], + connector_metadata_roots: &[PathBuf], +) -> Vec { + let session_attributions = sessions + .iter() + .filter_map(session_connector_attribution) + .collect::>(); + let connector_names_by_session = + detect_imported_cla_session_connectors(&session_attributions, connector_metadata_roots); + + let mut candidates = BTreeMap::::new(); + for names in connector_names_by_session.into_values() { + for name in names { + let key = name.to_lowercase(); + let candidate = candidates.entry(key).or_insert(DetectedConnectorCandidate { + name, + session_count: 0, + source: DetectedConnectorSource::RemoteMcpServersConfig, + }); + candidate.session_count = candidate.session_count.saturating_add(1); + } + } + candidates.into_values().collect() +} + pub fn detect_imported_cla_session_connectors( session_attributions: &[ImportedSessionConnectorAttribution], connector_metadata_roots: &[PathBuf], @@ -93,6 +125,36 @@ pub fn detect_imported_cla_session_connectors( .collect() } +fn session_connector_attribution( + session: &ExternalAgentSessionMigration, +) -> Option { + let session_id = session + .path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(str::trim) + .filter(|session_id| !session_id.is_empty())? + .to_string(); + let file = fs::File::open(&session.path).ok()?; + let server_ids = std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .filter_map(|line| serde_json::from_str::(line.trim()).ok()) + .filter_map(|record| { + record + .get("attributionMcpServer") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|server_id| !server_id.is_empty()) + .map(ToOwned::to_owned) + }) + .collect::>(); + (!server_ids.is_empty()).then_some(ImportedSessionConnectorAttribution { + session_id, + server_ids, + }) +} + fn read_session_manifest(path: &Path) -> Option { let contents = fs::read_to_string(path).ok()?; serde_json::from_str(&contents).ok() diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs index aa61cde80f..4ae7b3ca70 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cla_tests.rs @@ -1,6 +1,5 @@ use super::*; use pretty_assertions::assert_eq; -use std::collections::BTreeSet; use tempfile::TempDir; #[test] @@ -22,19 +21,28 @@ fn resolves_attributed_server_name_from_session_manifest() { .to_string(), ) .expect("manifest"); - let attributions = vec![ImportedSessionConnectorAttribution { - session_id: "session-1".to_string(), - server_ids: BTreeSet::from(["figma".to_string()]), + let session_path = metadata_root.path().join("session-1.jsonl"); + std::fs::write( + &session_path, + serde_json::json!({"attributionMcpServer": "figma"}).to_string(), + ) + .expect("session"); + let sessions = vec![ExternalAgentSessionMigration { + path: session_path, + cwd: metadata_root.path().to_path_buf(), + title: None, }]; - let connector_names = detect_imported_cla_session_connectors( - &attributions, - &[metadata_root.path().to_path_buf()], - ); + let connectors = + detect_cla_session_connectors(&sessions, &[metadata_root.path().to_path_buf()]); assert_eq!( - connector_names, - BTreeMap::from([("session-1".to_string(), vec!["Figma".to_string()])]) + connectors, + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 1, + source: DetectedConnectorSource::RemoteMcpServersConfig, + }] ); } @@ -57,18 +65,30 @@ fn still_resolves_attributed_server_uuid_from_session_manifest() { .to_string(), ) .expect("manifest"); - let attributions = vec![ImportedSessionConnectorAttribution { - session_id: "session-1".to_string(), - server_ids: BTreeSet::from(["c58ac595-58b5-48f8-ac77-d8d01523dede".to_string()]), + let session_path = metadata_root.path().join("session-1.jsonl"); + std::fs::write( + &session_path, + serde_json::json!({ + "attributionMcpServer": "c58ac595-58b5-48f8-ac77-d8d01523dede" + }) + .to_string(), + ) + .expect("session"); + let sessions = vec![ExternalAgentSessionMigration { + path: session_path, + cwd: metadata_root.path().to_path_buf(), + title: None, }]; - let connector_names = detect_imported_cla_session_connectors( - &attributions, - &[metadata_root.path().to_path_buf()], - ); + let connectors = + detect_cla_session_connectors(&sessions, &[metadata_root.path().to_path_buf()]); assert_eq!( - connector_names, - BTreeMap::from([("session-1".to_string(), vec!["Figma".to_string()])]) + connectors, + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 1, + source: DetectedConnectorSource::RemoteMcpServersConfig, + }] ); } diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs new file mode 100644 index 0000000000..40ed964845 --- /dev/null +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur.rs @@ -0,0 +1,192 @@ +use serde::Deserialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fs; +use std::io::BufRead; +use std::path::Component; +use std::path::Path; + +use crate::model::DetectedConnectorCandidate; +use crate::model::DetectedConnectorSource; +use crate::sessions::ExternalAgentSessionMigration; + +const PLUGIN_CACHE_DIR: &str = "plugins/cache"; +const PLUGIN_MANIFEST_PATH: &str = ".cursor-plugin/plugin.json"; +const MCP_CONFIG_PATH: &str = ".mcp.json"; +const MCP_TOOL_CALL: &str = "CallMcpTool"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginManifest { + name: String, + display_name: Option, + mcp_servers: Option, +} + +pub(crate) fn detect_cur_session_connectors( + sessions: &[ExternalAgentSessionMigration], + external_agent_home: &Path, +) -> Vec { + let connector_names_by_server_id = cached_connector_names_by_server_id(external_agent_home); + if connector_names_by_server_id.is_empty() { + return Vec::new(); + } + + let mut candidates = BTreeMap::::new(); + for session in sessions { + for (key, name) in session_connector_names(session, &connector_names_by_server_id) { + let candidate = candidates.entry(key).or_insert(DetectedConnectorCandidate { + name, + session_count: 0, + source: DetectedConnectorSource::SessionToolUse, + }); + candidate.session_count = candidate.session_count.saturating_add(1); + } + } + candidates.into_values().collect() +} + +fn session_connector_names( + session: &ExternalAgentSessionMigration, + connector_names_by_server_id: &BTreeMap, +) -> BTreeMap { + let Ok(file) = fs::File::open(&session.path) else { + return BTreeMap::new(); + }; + std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .filter_map(|line| serde_json::from_str::(line.trim()).ok()) + .flat_map(|record| session_mcp_server_ids(&record).into_iter()) + .filter_map(|server_id| connector_names_by_server_id.get(&server_id).cloned()) + .map(|name| (name.to_lowercase(), name)) + .collect() +} + +fn session_mcp_server_ids(record: &JsonValue) -> BTreeSet { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + .filter(|content| { + content.get("type").and_then(JsonValue::as_str) == Some("tool_use") + && content.get("name").and_then(JsonValue::as_str) == Some(MCP_TOOL_CALL) + }) + .filter_map(|content| { + content + .get("input") + .and_then(|input| input.get("server")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|server_id| !server_id.is_empty()) + .map(str::to_lowercase) + }) + .collect() +} + +fn cached_connector_names_by_server_id(external_agent_home: &Path) -> BTreeMap { + let cache_root = external_agent_home.join(PLUGIN_CACHE_DIR); + let mut connector_names = BTreeMap::new(); + for marketplace_root in child_directories(&cache_root) { + for plugin_root in child_directories(&marketplace_root) { + for version_root in child_directories(&plugin_root) { + let Some(manifest) = read_plugin_manifest(&version_root) else { + continue; + }; + let Some(display_name) = crate::sessions::normalized_connector_display_name( + manifest.display_name.as_deref().or(Some(&manifest.name)), + ) else { + continue; + }; + for server_name in cached_mcp_server_names(&version_root, &manifest) { + connector_names.insert( + format!("plugin-{}-{server_name}", manifest.name).to_lowercase(), + display_name.clone(), + ); + } + } + } + } + connector_names +} + +fn child_directories(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + entry + .file_type() + .ok() + .filter(std::fs::FileType::is_dir) + .map(|_| entry.path()) + }) + .collect() +} + +fn read_plugin_manifest(version_root: &Path) -> Option { + let contents = fs::read_to_string(version_root.join(PLUGIN_MANIFEST_PATH)).ok()?; + serde_json::from_str(&contents).ok() +} + +fn cached_mcp_server_names(version_root: &Path, manifest: &PluginManifest) -> Vec { + manifest + .mcp_servers + .as_ref() + .map_or_else( + || mcp_server_names_from_file(version_root, MCP_CONFIG_PATH), + |declaration| manifest_mcp_server_names(version_root, declaration), + ) + .into_iter() + .collect() +} + +fn manifest_mcp_server_names(version_root: &Path, declaration: &JsonValue) -> BTreeSet { + match declaration { + JsonValue::String(path) => mcp_server_names_from_file(version_root, path), + JsonValue::Object(_) => mcp_server_names_from_config(declaration), + JsonValue::Array(declarations) => declarations + .iter() + .flat_map(|declaration| manifest_mcp_server_names(version_root, declaration)) + .collect(), + _ => BTreeSet::new(), + } +} + +fn mcp_server_names_from_file(version_root: &Path, relative_path: &str) -> BTreeSet { + let relative_path = Path::new(relative_path); + if relative_path.as_os_str().is_empty() + || relative_path + .components() + .any(|component| !matches!(component, Component::CurDir | Component::Normal(_))) + { + return BTreeSet::new(); + } + let Ok(contents) = fs::read_to_string(version_root.join(relative_path)) else { + return BTreeSet::new(); + }; + let Ok(config) = serde_json::from_str::(&contents) else { + return BTreeSet::new(); + }; + mcp_server_names_from_config(&config) +} + +fn mcp_server_names_from_config(config: &JsonValue) -> BTreeSet { + config + .get("mcpServers") + .and_then(JsonValue::as_object) + .or_else(|| config.as_object()) + .into_iter() + .flat_map(|servers| servers.keys()) + .cloned() + .collect() +} + +#[cfg(test)] +#[path = "connectors_cur_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs new file mode 100644 index 0000000000..62ce7c6a07 --- /dev/null +++ b/codex-rs/external-agent-migration/src/detect/sessions/connectors_cur_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn detects_connector_used_by_session_mcp_tool_call() { + let source_home = TempDir::new().expect("source home"); + write_cached_plugin(source_home.path(), "figma", "Figma", "figma"); + let first_session = write_session( + source_home.path(), + "session-1", + &[ + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "GetMcpTools", + "input": {"server": "plugin-figma-figma"} + }] + } + }), + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "CallMcpTool", + "input": {"server": "plugin-figma-figma", "toolName": "whoami"} + }] + } + }), + serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "CallMcpTool", + "input": {"server": "plugin-figma-figma", "toolName": "get_metadata"} + }] + } + }), + ], + ); + let second_session = write_session( + source_home.path(), + "session-2", + &[serde_json::json!({ + "role": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "CallMcpTool", + "input": {"server": "plugin-figma-figma", "toolName": "get_screenshot"} + }] + } + })], + ); + + let connectors = detect_cur_session_connectors( + &[ + ExternalAgentSessionMigration { + path: first_session, + cwd: source_home.path().to_path_buf(), + title: None, + }, + ExternalAgentSessionMigration { + path: second_session, + cwd: source_home.path().to_path_buf(), + title: None, + }, + ], + source_home.path(), + ); + + assert_eq!( + connectors, + vec![DetectedConnectorCandidate { + name: "Figma".to_string(), + session_count: 2, + source: DetectedConnectorSource::SessionToolUse, + }] + ); +} + +fn write_cached_plugin( + source_home: &Path, + plugin_name: &str, + display_name: &str, + server_name: &str, +) { + let version_root = source_home + .join(PLUGIN_CACHE_DIR) + .join("marketplace") + .join(plugin_name) + .join("version"); + fs::create_dir_all(version_root.join(".cursor-plugin")).expect("plugin directory"); + fs::write( + version_root.join(PLUGIN_MANIFEST_PATH), + serde_json::json!({ + "name": plugin_name, + "displayName": display_name, + }) + .to_string(), + ) + .expect("plugin manifest"); + fs::write( + version_root.join(MCP_CONFIG_PATH), + serde_json::json!({ + "mcpServers": { + (server_name): {"type": "http", "url": "https://example.invalid/mcp"} + } + }) + .to_string(), + ) + .expect("mcp config"); +} + +fn write_session(source_home: &Path, name: &str, records: &[JsonValue]) -> std::path::PathBuf { + let path = source_home.join(format!("{name}.jsonl")); + fs::write( + &path, + records + .iter() + .map(JsonValue::to_string) + .collect::>() + .join("\n"), + ) + .expect("session"); + path +} diff --git a/codex-rs/external-agent-migration/src/detect/sessions/mod.rs b/codex-rs/external-agent-migration/src/detect/sessions/mod.rs index a825c94857..0f8e01b73b 100644 --- a/codex-rs/external-agent-migration/src/detect/sessions/mod.rs +++ b/codex-rs/external-agent-migration/src/detect/sessions/mod.rs @@ -1,11 +1,14 @@ mod cla; mod common; mod connectors_cla; +mod connectors_cur; mod cur; pub use cla::detect_recent_cla_sessions; pub(crate) use cla::detect_recent_cla_sessions_with_limits; pub use connectors_cla::ImportedSessionConnectorAttribution; +pub(crate) use connectors_cla::detect_cla_session_connectors; pub use connectors_cla::detect_imported_cla_session_connectors; +pub(crate) use connectors_cur::detect_cur_session_connectors; pub use cur::detect_recent_cur_sessions; pub(crate) use cur::detect_recent_cur_sessions_with_limits; diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 8c012c835b..0bf1503c6b 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -56,7 +56,10 @@ use mcp::parse_env_placeholder; pub use memory::ExternalMemoryFile; pub use memory::discover_external_memory_files; pub use rewrite::RewriteProfile; +pub use service::DetectedConnectorCandidate; +pub use service::DetectedConnectorSource; pub use service::ExternalAgentConfigDetectOptions; +pub use service::ExternalAgentConfigDetection; pub use service::ExternalAgentConfigImportItemResult; pub use service::ExternalAgentConfigImportOutcome; pub use service::ExternalAgentConfigImportRawError; diff --git a/codex-rs/external-agent-migration/src/migration_source.rs b/codex-rs/external-agent-migration/src/migration_source.rs index ae4d9fb241..dffde27107 100644 --- a/codex-rs/external-agent-migration/src/migration_source.rs +++ b/codex-rs/external-agent-migration/src/migration_source.rs @@ -2,8 +2,11 @@ use crate::ClaSource; use crate::CurSource; use crate::RewriteProfile; use crate::detect::plugins; +use crate::detect::sessions::detect_cla_session_connectors; +use crate::detect::sessions::detect_cur_session_connectors; use crate::detect::sessions::detect_recent_cla_sessions_with_limits; use crate::detect::sessions::detect_recent_cur_sessions_with_limits; +use crate::model::DetectedConnectorCandidate; use crate::model::ExternalAgentSessionImportLimits; use crate::sessions::ExternalAgentSessionMigration; use crate::sessions::SessionMetadataMode; @@ -149,6 +152,18 @@ impl ExternalAgentSource { } } + pub(super) fn detect_session_connectors( + self, + sessions: &[ExternalAgentSessionMigration], + connector_metadata_roots: &[PathBuf], + external_agent_home: &Path, + ) -> Vec { + match self { + Self::Cla => detect_cla_session_connectors(sessions, connector_metadata_roots), + Self::Cur => detect_cur_session_connectors(sessions, external_agent_home), + } + } + pub(super) fn marketplace_import_sources( self, external_agent_home: &Path, diff --git a/codex-rs/external-agent-migration/src/model.rs b/codex-rs/external-agent-migration/src/model.rs index 4f475ef198..72fcf95c0e 100644 --- a/codex-rs/external-agent-migration/src/model.rs +++ b/codex-rs/external-agent-migration/src/model.rs @@ -30,6 +30,25 @@ pub struct ExternalAgentConfigDetectOptions { pub cwds: Option>, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExternalAgentConfigDetection { + pub items: Vec, + pub connectors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedConnectorCandidate { + pub name: String, + pub session_count: u32, + pub source: DetectedConnectorSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectedConnectorSource { + RemoteMcpServersConfig, + SessionToolUse, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExternalAgentConfigMigrationItemType { Config, diff --git a/codex-rs/external-agent-migration/src/service.rs b/codex-rs/external-agent-migration/src/service.rs index 67b7e2c391..f6dcf58400 100644 --- a/codex-rs/external-agent-migration/src/service.rs +++ b/codex-rs/external-agent-migration/src/service.rs @@ -6,7 +6,10 @@ use crate::config_values::write_toml_file; use crate::memory_import; use crate::migration_source::ExternalAgentSource; use crate::migration_source::InstructionSourceGroup; +pub use crate::model::DetectedConnectorCandidate; +pub use crate::model::DetectedConnectorSource; pub use crate::model::ExternalAgentConfigDetectOptions; +pub use crate::model::ExternalAgentConfigDetection; pub use crate::model::ExternalAgentConfigImportItemResult; pub use crate::model::ExternalAgentConfigImportOutcome; pub use crate::model::ExternalAgentConfigImportRawError; @@ -24,6 +27,7 @@ use crate::reporting::emit_migration_metric; use crate::reporting::migration_metric_tags; pub use crate::reporting::record_import_error; use crate::scope::MigrationScope; +use crate::sessions::ExternalAgentSessionMigration; use crate::sessions::SessionMetadataMode; #[cfg(test)] use crate::source_cla::KNOWN_MARKETPLACES_PATH as EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH; @@ -118,6 +122,17 @@ impl ExternalAgentConfigService { &self.connector_metadata_roots } + pub fn detect_session_connectors( + &self, + sessions: &[ExternalAgentSessionMigration], + ) -> Vec { + self.source.detect_session_connectors( + sessions, + &self.connector_metadata_roots, + &self.external_agent_home, + ) + } + pub fn codex_home(&self) -> &Path { &self.codex_home }