Improve connector detection for migrated sessions (#36977)

## Why

Session IDs are derived from file stems and can repeat across projects. Keying
connector attribution only by session ID can therefore assign one session's
connectors to another session during a batched migration.

## What changed

- Preserve connector candidates by canonical source path until each imported
  session is annotated, then aggregate them for detection summaries.
- Resolve project-backed MCP tool calls from both `CallMcpTool` server IDs and
  `mcpDetails.serverName`, while requiring a match in project metadata.

## Testing

Add coverage for duplicate session stems, per-source connector results, and
both supported project-backed MCP tool-call formats.

GitOrigin-RevId: 10563d47c29673c07daa83be5f4e1a256740b901
This commit is contained in:
charlesgong-openai authored and copyberry committed 2026-08-04 21:45:28 +00:00
1 parent 1b90b1d16b
commit 1a5e152189
10 files changed
+486 -112

No files matched your search

@@ -719,16 +719,14 @@ fn detected_session_connectors(
) { ) {
let mut connector_names_by_source_path = BTreeMap::new(); let mut connector_names_by_source_path = BTreeMap::new();
let mut connectors_by_name = BTreeMap::<String, DetectedConnectorCandidate>::new(); let mut connectors_by_name = BTreeMap::<String, DetectedConnectorCandidate>::new();
for session in sessions { let sessions = sessions
if !session.path.is_file() { .iter()
continue; .filter(|session| session.path.is_file())
} .cloned()
let session_connectors = .collect::<Vec<_>>();
migration_service.detect_session_connectors(std::slice::from_ref(session)); for (source_path, session_connectors) in
if session_connectors.is_empty() { migration_service.detect_session_connectors_by_source_path(&sessions)
continue; {
}
if let Ok(source_path) = std::fs::canonicalize(&session.path) {
connector_names_by_source_path.insert( connector_names_by_source_path.insert(
source_path, source_path,
session_connectors session_connectors
@@ -736,7 +734,6 @@ fn detected_session_connectors(
.map(|candidate| candidate.name.clone()) .map(|candidate| candidate.name.clone())
.collect(), .collect(),
); );
}
for candidate in session_connectors { for candidate in session_connectors {
let key = candidate.name.to_lowercase(); let key = candidate.name.to_lowercase();
let session_count = candidate.session_count; let session_count = candidate.session_count;
@@ -20,7 +20,7 @@ use codex_external_agent_migration::sessions::SessionImportTarget;
use codex_external_agent_migration::sessions::SessionMetadataMode; use codex_external_agent_migration::sessions::SessionMetadataMode;
use codex_external_agent_migration::sessions::append_existing_session; use codex_external_agent_migration::sessions::append_existing_session;
use codex_external_agent_migration::sessions::append_imported_session_connector_names; use codex_external_agent_migration::sessions::append_imported_session_connector_names;
use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors; use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors_by_source_path;
use codex_external_agent_migration::sessions::prepare_validated_session_import_with_metadata_mode; use codex_external_agent_migration::sessions::prepare_validated_session_import_with_metadata_mode;
use codex_external_agent_migration::sessions::record_completed_session_imports; use codex_external_agent_migration::sessions::record_completed_session_imports;
use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::RefreshStrategy;
@@ -191,20 +191,26 @@ impl ExternalAgentSessionImporter {
if completed_imports.is_empty() { if completed_imports.is_empty() {
return item_result; return item_result;
} }
let connector_attributions = completed_imports let connector_attributions_by_source_path = completed_imports
.iter() .iter()
.filter_map(|completed_import| completed_import.connector_attribution.clone()) .filter_map(|completed_import| {
.collect::<Vec<_>>(); completed_import
.connector_attribution
.clone()
.map(|attribution| (completed_import.import.source_path.clone(), attribution))
})
.collect::<BTreeMap<_, _>>();
let connector_metadata_roots = self.connector_metadata_roots.clone(); let connector_metadata_roots = self.connector_metadata_roots.clone();
let mut connector_names_by_session = match tokio::task::spawn_blocking(move || { let mut attributed_connector_names_by_source_path =
detect_imported_cla_session_connectors( match tokio::task::spawn_blocking(move || {
&connector_attributions, detect_imported_cla_session_connectors_by_source_path(
&connector_attributions_by_source_path,
&connector_metadata_roots, &connector_metadata_roots,
) )
}) })
.await .await
{ {
Ok(connector_names_by_session) => connector_names_by_session, Ok(connector_names_by_source_path) => connector_names_by_source_path,
Err(err) => { Err(err) => {
record_import_error( record_import_error(
&mut item_result, &mut item_result,
@@ -217,11 +223,8 @@ impl ExternalAgentSessionImporter {
} }
}; };
for completed_import in &mut completed_imports { for completed_import in &mut completed_imports {
let Some(attribution) = &completed_import.connector_attribution else { completed_import.import.connector_names = attributed_connector_names_by_source_path
continue; .remove(&completed_import.import.source_path)
};
completed_import.import.connector_names = connector_names_by_session
.remove(&attribution.session_id)
.unwrap_or_default(); .unwrap_or_default();
} }
for completed_import in &mut completed_imports { for completed_import in &mut completed_imports {
@@ -37,47 +37,90 @@ pub(crate) fn detect_cla_session_connectors(
sessions: &[ExternalAgentSessionMigration], sessions: &[ExternalAgentSessionMigration],
connector_metadata_roots: &[PathBuf], connector_metadata_roots: &[PathBuf],
) -> Vec<DetectedConnectorCandidate> { ) -> Vec<DetectedConnectorCandidate> {
let session_attributions = sessions super::aggregate_session_connector_candidates(detect_cla_session_connectors_by_source_path(
.iter() sessions,
.filter_map(session_connector_attribution) connector_metadata_roots,
.collect::<Vec<_>>(); ))
let connector_names_by_session = }
detect_imported_cla_session_connectors(&session_attributions, connector_metadata_roots);
let mut candidates = BTreeMap::<String, DetectedConnectorCandidate>::new(); pub(crate) fn detect_cla_session_connectors_by_source_path(
for names in connector_names_by_session.into_values() { sessions: &[ExternalAgentSessionMigration],
for name in names { connector_metadata_roots: &[PathBuf],
let key = name.to_lowercase(); ) -> BTreeMap<PathBuf, Vec<DetectedConnectorCandidate>> {
let candidate = candidates.entry(key).or_insert(DetectedConnectorCandidate { let session_attributions_by_source_path = sessions
.iter()
.filter_map(|session| {
let source_path = fs::canonicalize(&session.path).ok()?;
let attribution = session_connector_attribution(session)?;
Some((source_path, attribution))
})
.collect::<BTreeMap<_, _>>();
let connector_names_by_source_path = detect_imported_cla_session_connectors_by_source_path(
&session_attributions_by_source_path,
connector_metadata_roots,
);
connector_names_by_source_path
.into_iter()
.filter_map(|(source_path, names)| {
let candidates = names
.into_iter()
.map(|name| DetectedConnectorCandidate {
name, name,
session_count: 0, session_count: 1,
source: DetectedConnectorSource::RemoteMcpServersConfig, source: DetectedConnectorSource::RemoteMcpServersConfig,
}); })
candidate.session_count = candidate.session_count.saturating_add(1); .collect::<Vec<_>>();
} (!candidates.is_empty()).then_some((source_path, candidates))
} })
candidates.into_values().collect() .collect()
}
pub fn detect_imported_cla_session_connectors_by_source_path(
session_attributions_by_source_path: &BTreeMap<PathBuf, ImportedSessionConnectorAttribution>,
connector_metadata_roots: &[PathBuf],
) -> BTreeMap<PathBuf, Vec<String>> {
detect_connector_names_by_key(
session_attributions_by_source_path
.iter()
.map(|(source_path, attribution)| (source_path.clone(), attribution)),
connector_metadata_roots,
)
} }
pub fn detect_imported_cla_session_connectors( pub fn detect_imported_cla_session_connectors(
session_attributions: &[ImportedSessionConnectorAttribution], session_attributions: &[ImportedSessionConnectorAttribution],
connector_metadata_roots: &[PathBuf], connector_metadata_roots: &[PathBuf],
) -> BTreeMap<String, Vec<String>> { ) -> BTreeMap<String, Vec<String>> {
if session_attributions.is_empty() { detect_connector_names_by_key(
session_attributions
.iter()
.map(|attribution| (attribution.session_id.clone(), attribution)),
connector_metadata_roots,
)
}
fn detect_connector_names_by_key<'a, Key>(
keyed_attributions: impl IntoIterator<Item = (Key, &'a ImportedSessionConnectorAttribution)>,
connector_metadata_roots: &[PathBuf],
) -> BTreeMap<Key, Vec<String>>
where
Key: Clone + Ord,
{
// Session IDs come from file stems and can repeat across projects, so
// preserve the caller's key while matching shared manifest metadata.
let mut attributions_by_session_id = BTreeMap::<String, Vec<(Key, BTreeSet<String>)>>::new();
for (key, attribution) in keyed_attributions {
attributions_by_session_id
.entry(attribution.session_id.clone())
.or_default()
.push((key, attribution.server_ids.clone()));
}
if attributions_by_session_id.is_empty() {
return BTreeMap::new(); return BTreeMap::new();
} }
let attributed_server_ids_by_session = session_attributions let mut connector_names_by_key = BTreeMap::<Key, BTreeMap<String, String>>::new();
.iter()
.map(|attribution| {
(
attribution.session_id.clone(),
attribution.server_ids.clone(),
)
})
.collect::<BTreeMap<_, _>>();
let mut connector_names_by_session = BTreeMap::<String, BTreeMap<String, String>>::new();
for metadata_root in connector_metadata_roots { for metadata_root in connector_metadata_roots {
let manifests_root = metadata_root.join(SESSION_MANIFESTS_DIR); let manifests_root = metadata_root.join(SESSION_MANIFESTS_DIR);
for manifest_path in json_files_recursively(&manifests_root) { for manifest_path in json_files_recursively(&manifests_root) {
@@ -87,21 +130,19 @@ pub fn detect_imported_cla_session_connectors(
let Some(session_id) = manifest.cli_session_id else { let Some(session_id) = manifest.cli_session_id else {
continue; continue;
}; };
let Some(attributed_server_ids) = attributed_server_ids_by_session.get(&session_id) let Some(keyed_attributions) = attributions_by_session_id.get(&session_id) else {
else {
continue; continue;
}; };
if attributed_server_ids.is_empty() { for server in &manifest.remote_mcp_servers_config {
continue;
}
let connector_names = connector_names_by_session.entry(session_id).or_default();
for server in manifest.remote_mcp_servers_config {
let Some(name) = let Some(name) =
crate::sessions::normalized_connector_display_name(server.name.as_deref()) crate::sessions::normalized_connector_display_name(server.name.as_deref())
else { else {
continue; continue;
}; };
for (key, attributed_server_ids) in keyed_attributions {
if attributed_server_ids.is_empty() {
continue;
}
// Depending on the source client, attributionMcpServer contains either the // Depending on the source client, attributionMcpServer contains either the
// manifest UUID or its configured server name. // manifest UUID or its configured server name.
let matches_uuid = server let matches_uuid = server
@@ -114,14 +155,19 @@ pub fn detect_imported_cla_session_connectors(
if !matches_uuid && !matches_name { if !matches_uuid && !matches_name {
continue; continue;
} }
connector_names.entry(name.to_lowercase()).or_insert(name); connector_names_by_key
.entry(key.clone())
.or_default()
.entry(name.to_lowercase())
.or_insert_with(|| name.clone());
}
} }
} }
} }
connector_names_by_session connector_names_by_key
.into_iter() .into_iter()
.map(|(session_id, names)| (session_id, names.into_values().collect())) .map(|(key, names)| (key, names.into_values().collect()))
.collect() .collect()
} }
@@ -1,5 +1,6 @@
use super::*; use super::*;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
@@ -92,3 +93,94 @@ fn still_resolves_attributed_server_uuid_from_session_manifest() {
}] }]
); );
} }
#[test]
fn keeps_duplicate_session_stems_separate_when_batched() {
let metadata_root = TempDir::new().expect("tempdir");
let manifests_root = metadata_root.path().join(SESSION_MANIFESTS_DIR);
std::fs::create_dir_all(&manifests_root).expect("manifest directory");
std::fs::write(
manifests_root.join("figma-session.json"),
serde_json::json!({
"cliSessionId": "session",
"remoteMcpServersConfig": [
{
"uuid": "figma-server",
"name": "Figma",
},
],
})
.to_string(),
)
.expect("figma manifest");
std::fs::write(
manifests_root.join("slack-session.json"),
serde_json::json!({
"cliSessionId": "session",
"remoteMcpServersConfig": [
{
"uuid": "slack-server",
"name": "Slack",
},
],
})
.to_string(),
)
.expect("slack manifest");
let first_project = metadata_root.path().join("project-a");
let second_project = metadata_root.path().join("project-b");
std::fs::create_dir_all(&first_project).expect("first project");
std::fs::create_dir_all(&second_project).expect("second project");
let first_session = first_project.join("session.jsonl");
let second_session = second_project.join("session.jsonl");
std::fs::write(
&first_session,
serde_json::json!({"attributionMcpServer": "figma-server"}).to_string(),
)
.expect("first session");
std::fs::write(
&second_session,
serde_json::json!({"attributionMcpServer": "slack-server"}).to_string(),
)
.expect("second session");
let first_source_path = std::fs::canonicalize(&first_session).expect("first source path");
let second_source_path = std::fs::canonicalize(&second_session).expect("second source path");
let sessions = [
ExternalAgentSessionMigration {
path: first_session,
cwd: first_project,
title: None,
},
ExternalAgentSessionMigration {
path: second_session,
cwd: second_project,
title: None,
},
];
assert_eq!(
detect_cla_session_connectors_by_source_path(
&sessions,
&[metadata_root.path().to_path_buf()]
),
BTreeMap::from([
(
first_source_path,
vec![DetectedConnectorCandidate {
name: "Figma".to_string(),
session_count: 1,
source: DetectedConnectorSource::RemoteMcpServersConfig,
}],
),
(
second_source_path,
vec![DetectedConnectorCandidate {
name: "Slack".to_string(),
session_count: 1,
source: DetectedConnectorSource::RemoteMcpServersConfig,
}],
),
])
);
}
@@ -6,6 +6,7 @@ use std::fs;
use std::io::BufRead; use std::io::BufRead;
use std::path::Component; use std::path::Component;
use std::path::Path; use std::path::Path;
use std::path::PathBuf;
use crate::model::DetectedConnectorCandidate; use crate::model::DetectedConnectorCandidate;
use crate::model::DetectedConnectorSource; use crate::model::DetectedConnectorSource;
@@ -34,40 +35,63 @@ struct ProjectMcpServerMetadata {
server_name: String, server_name: String,
} }
#[derive(Default)]
struct ProjectConnectorMetadata {
names_by_server_id: BTreeMap<String, String>,
names_by_normalized_name: BTreeMap<String, String>,
}
pub(crate) fn detect_cur_session_connectors( pub(crate) fn detect_cur_session_connectors(
sessions: &[ExternalAgentSessionMigration], sessions: &[ExternalAgentSessionMigration],
external_agent_home: &Path, external_agent_home: &Path,
) -> Vec<DetectedConnectorCandidate> { ) -> Vec<DetectedConnectorCandidate> {
let cached_connector_names_by_server_id = super::aggregate_session_connector_candidates(detect_cur_session_connectors_by_source_path(
cached_connector_names_by_server_id(external_agent_home); sessions,
let mut candidates = BTreeMap::<String, DetectedConnectorCandidate>::new(); external_agent_home,
for session in sessions { ))
let mut connector_names_by_server_id = cached_connector_names_by_server_id.clone();
connector_names_by_server_id.extend(project_connector_names_by_server_id(session));
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 project_connector_names_by_server_id( pub(crate) fn detect_cur_session_connectors_by_source_path(
session: &ExternalAgentSessionMigration, sessions: &[ExternalAgentSessionMigration],
) -> BTreeMap<String, String> { external_agent_home: &Path,
) -> BTreeMap<PathBuf, Vec<DetectedConnectorCandidate>> {
let cached_connector_names_by_server_id =
cached_connector_names_by_server_id(external_agent_home);
let mut candidates_by_source_path = BTreeMap::new();
for session in sessions {
let Ok(source_path) = fs::canonicalize(&session.path) else {
continue;
};
let project_connector_metadata = project_connector_metadata(session);
let candidates = session_connector_names(
session,
&cached_connector_names_by_server_id,
&project_connector_metadata,
)
.into_values()
.map(|name| DetectedConnectorCandidate {
name,
session_count: 1,
source: DetectedConnectorSource::SessionToolUse,
})
.collect::<Vec<_>>();
if !candidates.is_empty() {
candidates_by_source_path.insert(source_path, candidates);
}
}
candidates_by_source_path
}
fn project_connector_metadata(session: &ExternalAgentSessionMigration) -> ProjectConnectorMetadata {
let Some(project_root) = session let Some(project_root) = session
.path .path
.ancestors() .ancestors()
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(AGENT_TRANSCRIPTS_DIR)) .find(|path| path.file_name().and_then(|name| name.to_str()) == Some(AGENT_TRANSCRIPTS_DIR))
.and_then(Path::parent) .and_then(Path::parent)
else { else {
return BTreeMap::new(); return ProjectConnectorMetadata::default();
}; };
let mut connector_names = BTreeMap::new(); let mut connector_metadata = ProjectConnectorMetadata::default();
for server_root in child_directories(&project_root.join(PROJECT_MCP_DIR)) { for server_root in child_directories(&project_root.join(PROJECT_MCP_DIR)) {
let Some(metadata) = read_project_mcp_server_metadata(&server_root) else { let Some(metadata) = read_project_mcp_server_metadata(&server_root) else {
continue; continue;
@@ -81,14 +105,26 @@ fn project_connector_names_by_server_id(
if server_identifier.is_empty() { if server_identifier.is_empty() {
continue; continue;
} }
connector_names.insert(server_identifier.to_lowercase(), display_name); connector_metadata
.names_by_server_id
.insert(server_identifier.to_lowercase(), display_name.clone());
// Cursor has emitted both the configured identifier and display name in
// mcpDetails.serverName. Match either only after finding it in project
// metadata; never infer a connector from the tool namespace alone.
connector_metadata
.names_by_normalized_name
.insert(server_identifier.to_lowercase(), display_name.clone());
connector_metadata
.names_by_normalized_name
.insert(display_name.to_lowercase(), display_name);
} }
connector_names connector_metadata
} }
fn session_connector_names( fn session_connector_names(
session: &ExternalAgentSessionMigration, session: &ExternalAgentSessionMigration,
connector_names_by_server_id: &BTreeMap<String, String>, cached_connector_names_by_server_id: &BTreeMap<String, String>,
project_connector_metadata: &ProjectConnectorMetadata,
) -> BTreeMap<String, String> { ) -> BTreeMap<String, String> {
let Ok(file) = fs::File::open(&session.path) else { let Ok(file) = fs::File::open(&session.path) else {
return BTreeMap::new(); return BTreeMap::new();
@@ -97,32 +133,64 @@ fn session_connector_names(
.lines() .lines()
.map_while(Result::ok) .map_while(Result::ok)
.filter_map(|line| serde_json::from_str::<JsonValue>(line.trim()).ok()) .filter_map(|line| serde_json::from_str::<JsonValue>(line.trim()).ok())
.flat_map(|record| session_mcp_server_ids(&record).into_iter()) .flat_map(|record| {
.filter_map(|server_id| connector_names_by_server_id.get(&server_id).cloned()) let mut connector_names = Vec::new();
.map(|name| (name.to_lowercase(), name)) let Some(contents) = record
.collect()
}
fn session_mcp_server_ids(record: &JsonValue) -> BTreeSet<String> {
record
.get("message") .get("message")
.and_then(|message| message.get("content")) .and_then(|message| message.get("content"))
.and_then(JsonValue::as_array) .and_then(JsonValue::as_array)
.into_iter() else {
.flatten() return connector_names;
.filter(|content| { };
content.get("type").and_then(JsonValue::as_str) == Some("tool_use") for content in contents {
&& content.get("name").and_then(JsonValue::as_str) == Some(MCP_TOOL_CALL) if content.get("type").and_then(JsonValue::as_str) != Some("tool_use") {
}) continue;
.filter_map(|content| { }
content let Some(tool_name) = content.get("name").and_then(JsonValue::as_str) else {
.get("input") continue;
.and_then(|input| input.get("server")) };
let Some(input) = content.get("input") else {
continue;
};
let connector_name = if tool_name == MCP_TOOL_CALL {
input
.get("server")
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
.map(str::trim) .map(str::trim)
.filter(|server_id| !server_id.is_empty()) .filter(|server_id| !server_id.is_empty())
.map(str::to_lowercase) .map(str::to_lowercase)
.and_then(|server_id| {
project_connector_metadata
.names_by_server_id
.get(&server_id)
.or_else(|| cached_connector_names_by_server_id.get(&server_id))
}) })
.cloned()
} else if tool_name.starts_with("mcp__") {
input
.get("mcpDetails")
.and_then(|details| details.get("serverName"))
.and_then(JsonValue::as_str)
.and_then(|server_name| {
crate::sessions::normalized_connector_display_name(Some(server_name))
})
.map(|server_name| server_name.to_lowercase())
.and_then(|server_name| {
project_connector_metadata
.names_by_normalized_name
.get(&server_name)
})
.cloned()
} else {
None
};
if let Some(connector_name) = connector_name {
connector_names.push(connector_name);
}
}
connector_names
})
.map(|name| (name.to_lowercase(), name))
.collect() .collect()
} }
@@ -1,5 +1,6 @@
use super::*; use super::*;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
@@ -57,8 +58,9 @@ fn detects_connector_used_by_session_mcp_tool_call() {
})], })],
); );
let connectors = detect_cur_session_connectors( let first_source_path = fs::canonicalize(&first_session).expect("first canonical source");
&[ let second_source_path = fs::canonicalize(&second_session).expect("second canonical source");
let sessions = [
ExternalAgentSessionMigration { ExternalAgentSessionMigration {
path: first_session, path: first_session,
cwd: source_home.path().to_path_buf(), cwd: source_home.path().to_path_buf(),
@@ -69,10 +71,32 @@ fn detects_connector_used_by_session_mcp_tool_call() {
cwd: source_home.path().to_path_buf(), cwd: source_home.path().to_path_buf(),
title: None, title: None,
}, },
], ];
source_home.path(),
assert_eq!(
detect_cur_session_connectors_by_source_path(&sessions, source_home.path()),
BTreeMap::from([
(
first_source_path,
vec![DetectedConnectorCandidate {
name: "Figma".to_string(),
session_count: 1,
source: DetectedConnectorSource::SessionToolUse,
}],
),
(
second_source_path,
vec![DetectedConnectorCandidate {
name: "Figma".to_string(),
session_count: 1,
source: DetectedConnectorSource::SessionToolUse,
}],
),
])
); );
let connectors = detect_cur_session_connectors(&sessions, source_home.path());
assert_eq!( assert_eq!(
connectors, connectors,
vec![DetectedConnectorCandidate { vec![DetectedConnectorCandidate {
@@ -84,7 +108,7 @@ fn detects_connector_used_by_session_mcp_tool_call() {
} }
#[test] #[test]
fn detects_connector_from_project_mcp_server_metadata() { fn detects_call_mcp_tool_connector_from_project_mcp_server_metadata() {
let source_home = TempDir::new().expect("source home"); let source_home = TempDir::new().expect("source home");
let project_root = source_home.path().join("projects/project"); let project_root = source_home.path().join("projects/project");
let session_path = project_root let session_path = project_root
@@ -94,18 +118,106 @@ fn detects_connector_from_project_mcp_server_metadata() {
fs::create_dir_all(session_path.parent().expect("session parent")).expect("session directory"); fs::create_dir_all(session_path.parent().expect("session parent")).expect("session directory");
fs::write( fs::write(
&session_path, &session_path,
[
serde_json::json!({
"role": "assistant",
"message": {
"content": [{
"type": "tool_use",
"name": "GetMcpTools",
"input": {"server": "user-figma"}
}]
}
}),
serde_json::json!({ serde_json::json!({
"role": "assistant", "role": "assistant",
"message": { "message": {
"content": [{ "content": [{
"type": "tool_use", "type": "tool_use",
"name": "CallMcpTool", "name": "CallMcpTool",
"input": {"server": "plugin-figma-figma", "toolName": "whoami"} "input": {"server": "user-figma", "toolName": "whoami"}
}] }]
} }
}),
]
.into_iter()
.map(|record| record.to_string())
.collect::<Vec<_>>()
.join("\n"),
)
.expect("session");
let server_root = project_root.join(PROJECT_MCP_DIR).join("user-figma");
fs::create_dir_all(&server_root).expect("server directory");
fs::write(
server_root.join(PROJECT_MCP_SERVER_METADATA_PATH),
serde_json::json!({
"serverIdentifier": "user-figma",
"serverName": "Figma",
}) })
.to_string(), .to_string(),
) )
.expect("server metadata");
assert_eq!(
detect_cur_session_connectors(
&[ExternalAgentSessionMigration {
path: session_path,
cwd: source_home.path().to_path_buf(),
title: None,
}],
source_home.path(),
),
vec![DetectedConnectorCandidate {
name: "Figma".to_string(),
session_count: 1,
source: DetectedConnectorSource::SessionToolUse,
}]
);
}
#[test]
fn detects_project_backed_mcp_details_connector_from_metadata_name() {
let source_home = TempDir::new().expect("source home");
let project_root = source_home.path().join("projects/project");
let session_path = project_root
.join(AGENT_TRANSCRIPTS_DIR)
.join("session-1")
.join("session-1.jsonl");
fs::create_dir_all(session_path.parent().expect("session parent")).expect("session directory");
fs::write(
&session_path,
[
serde_json::json!({
"role": "assistant",
"message": {
"content": [{
"type": "tool_use",
"name": "mcp__figma__get_design_context",
"input": {
"namespace": "figma",
"toolName": "get_design_context",
"mcpDetails": {"serverName": "Figma"},
"arguments": {"nodeId": "fixture-node"}
}
}]
}
}),
serde_json::json!({
"role": "assistant",
"message": {
"content": [{
"type": "tool_use",
"name": "mcp__untrusted__whoami",
"input": {"mcpDetails": {"serverName": "Not In Metadata"}}
}]
}
}),
]
.into_iter()
.map(|record| record.to_string())
.collect::<Vec<_>>()
.join("\n"),
)
.expect("session"); .expect("session");
let server_root = project_root let server_root = project_root
.join(PROJECT_MCP_DIR) .join(PROJECT_MCP_DIR)
@@ -4,11 +4,37 @@ mod connectors_cla;
mod connectors_cur; mod connectors_cur;
mod cur; mod cur;
use crate::model::DetectedConnectorCandidate;
use std::collections::BTreeMap;
use std::path::PathBuf;
pub use cla::detect_recent_cla_sessions; pub use cla::detect_recent_cla_sessions;
pub(crate) use cla::detect_recent_cla_sessions_with_limits; pub(crate) use cla::detect_recent_cla_sessions_with_limits;
pub use connectors_cla::ImportedSessionConnectorAttribution; pub use connectors_cla::ImportedSessionConnectorAttribution;
pub(crate) use connectors_cla::detect_cla_session_connectors; pub(crate) use connectors_cla::detect_cla_session_connectors;
pub(crate) use connectors_cla::detect_cla_session_connectors_by_source_path;
pub use connectors_cla::detect_imported_cla_session_connectors; pub use connectors_cla::detect_imported_cla_session_connectors;
pub use connectors_cla::detect_imported_cla_session_connectors_by_source_path;
pub(crate) use connectors_cur::detect_cur_session_connectors; pub(crate) use connectors_cur::detect_cur_session_connectors;
pub(crate) use connectors_cur::detect_cur_session_connectors_by_source_path;
pub use cur::detect_recent_cur_sessions; pub use cur::detect_recent_cur_sessions;
pub(crate) use cur::detect_recent_cur_sessions_with_limits; pub(crate) use cur::detect_recent_cur_sessions_with_limits;
pub(crate) fn aggregate_session_connector_candidates(
candidates_by_source_path: BTreeMap<PathBuf, Vec<DetectedConnectorCandidate>>,
) -> Vec<DetectedConnectorCandidate> {
let mut candidates = BTreeMap::<String, DetectedConnectorCandidate>::new();
for session_candidates in candidates_by_source_path.into_values() {
for candidate in session_candidates {
let key = candidate.name.to_lowercase();
let session_count = candidate.session_count;
let aggregate = candidates.entry(key).or_insert_with(|| {
let mut aggregate = candidate;
aggregate.session_count = 0;
aggregate
});
aggregate.session_count = aggregate.session_count.saturating_add(session_count);
}
}
candidates.into_values().collect()
}
@@ -3,7 +3,9 @@ use crate::CurSource;
use crate::RewriteProfile; use crate::RewriteProfile;
use crate::detect::plugins; use crate::detect::plugins;
use crate::detect::sessions::detect_cla_session_connectors; use crate::detect::sessions::detect_cla_session_connectors;
use crate::detect::sessions::detect_cla_session_connectors_by_source_path;
use crate::detect::sessions::detect_cur_session_connectors; use crate::detect::sessions::detect_cur_session_connectors;
use crate::detect::sessions::detect_cur_session_connectors_by_source_path;
use crate::detect::sessions::detect_recent_cla_sessions_with_limits; use crate::detect::sessions::detect_recent_cla_sessions_with_limits;
use crate::detect::sessions::detect_recent_cur_sessions_with_limits; use crate::detect::sessions::detect_recent_cur_sessions_with_limits;
use crate::model::DetectedConnectorCandidate; use crate::model::DetectedConnectorCandidate;
@@ -172,6 +174,22 @@ impl ExternalAgentSource {
} }
} }
pub(super) fn detect_session_connectors_by_source_path(
self,
sessions: &[ExternalAgentSessionMigration],
connector_metadata_roots: &[PathBuf],
external_agent_home: &Path,
) -> BTreeMap<PathBuf, Vec<DetectedConnectorCandidate>> {
match self {
Self::Cla => {
detect_cla_session_connectors_by_source_path(sessions, connector_metadata_roots)
}
Self::Cur => {
detect_cur_session_connectors_by_source_path(sessions, external_agent_home)
}
}
}
pub(super) fn marketplace_import_sources( pub(super) fn marketplace_import_sources(
self, self,
external_agent_home: &Path, external_agent_home: &Path,
@@ -133,6 +133,17 @@ impl ExternalAgentConfigService {
) )
} }
pub fn detect_session_connectors_by_source_path(
&self,
sessions: &[ExternalAgentSessionMigration],
) -> BTreeMap<PathBuf, Vec<DetectedConnectorCandidate>> {
self.source.detect_session_connectors_by_source_path(
sessions,
&self.connector_metadata_roots,
&self.external_agent_home,
)
}
pub fn codex_home(&self) -> &Path { pub fn codex_home(&self) -> &Path {
&self.codex_home &self.codex_home
} }
@@ -17,6 +17,7 @@ use std::path::PathBuf;
pub use crate::detect::sessions::ImportedSessionConnectorAttribution; pub use crate::detect::sessions::ImportedSessionConnectorAttribution;
pub use crate::detect::sessions::detect_imported_cla_session_connectors; pub use crate::detect::sessions::detect_imported_cla_session_connectors;
pub use crate::detect::sessions::detect_imported_cla_session_connectors_by_source_path;
pub use crate::detect::sessions::detect_recent_cla_sessions; pub use crate::detect::sessions::detect_recent_cla_sessions;
pub use crate::detect::sessions::detect_recent_cur_sessions; pub use crate::detect::sessions::detect_recent_cur_sessions;
pub use append::ExistingSessionAppend; pub use append::ExistingSessionAppend;