Detect connectors used in external agent sessions (#36336)

## What changed

- Add session connector detection to `ExternalAgentConfigService` for Claude and Cursor migrations.
- Resolve Claude connector attributions through session manifests and map Cursor `CallMcpTool` server IDs through cached plugin metadata.
- Return deduplicated connector candidates with their session counts and detection sources, and expose the new result types from the migration crate.

## Testing

- Cover Claude connector resolution by server name and UUID.
- Cover Cursor connector detection and per-session counting from MCP tool calls.

GitOrigin-RevId: 772bfe949bdc734b385a6e06f6a4b1d35b4cf210
This commit is contained in:
charlesgong-openai
2026-07-31 14:35:55 +00:00
committed by copyberry
parent ef293f7ac9
commit 448118f544
9 changed files with 479 additions and 19 deletions
@@ -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<String>,
}
pub(crate) fn detect_cla_session_connectors(
sessions: &[ExternalAgentSessionMigration],
connector_metadata_roots: &[PathBuf],
) -> Vec<DetectedConnectorCandidate> {
let session_attributions = sessions
.iter()
.filter_map(session_connector_attribution)
.collect::<Vec<_>>();
let connector_names_by_session =
detect_imported_cla_session_connectors(&session_attributions, connector_metadata_roots);
let mut candidates = BTreeMap::<String, DetectedConnectorCandidate>::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<ImportedSessionConnectorAttribution> {
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::<JsonValue>(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::<BTreeSet<_>>();
(!server_ids.is_empty()).then_some(ImportedSessionConnectorAttribution {
session_id,
server_ids,
})
}
fn read_session_manifest(path: &Path) -> Option<SessionManifest> {
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
@@ -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,
}]
);
}
@@ -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<String>,
mcp_servers: Option<JsonValue>,
}
pub(crate) fn detect_cur_session_connectors(
sessions: &[ExternalAgentSessionMigration],
external_agent_home: &Path,
) -> Vec<DetectedConnectorCandidate> {
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::<String, DetectedConnectorCandidate>::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<String, String>,
) -> BTreeMap<String, String> {
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::<JsonValue>(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<String> {
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<String, String> {
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<std::path::PathBuf> {
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<PluginManifest> {
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<String> {
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<String> {
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<String> {
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::<JsonValue>(&contents) else {
return BTreeSet::new();
};
mcp_server_names_from_config(&config)
}
fn mcp_server_names_from_config(config: &JsonValue) -> BTreeSet<String> {
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;
@@ -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::<Vec<_>>()
.join("\n"),
)
.expect("session");
path
}
@@ -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;
@@ -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;
@@ -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<DetectedConnectorCandidate> {
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,
@@ -30,6 +30,25 @@ pub struct ExternalAgentConfigDetectOptions {
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExternalAgentConfigDetection {
pub items: Vec<ExternalAgentConfigMigrationItem>,
pub connectors: Vec<DetectedConnectorCandidate>,
}
#[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,
@@ -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<DetectedConnectorCandidate> {
self.source.detect_session_connectors(
sessions,
&self.connector_metadata_roots,
&self.external_agent_home,
)
}
pub fn codex_home(&self) -> &Path {
&self.codex_home
}