mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
1b90b1d16b
commit
1a5e152189
10 files changed
+485
-111
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))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
candidates.into_values().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;
|
||||||
|
|||||||
Reference in new issue
Block a user