fix(application,domain,backend): les serveurs MCP des plugins assignés atteignent la session de l'agent — #302 (cause: MutateAgentPluginTool persistait bien PluginToolRef dans ProjectPluginSettings mais le launcher ne consommait jamais assignments.tools, les configs de session ne contenaient que le serveur MCP natif idea, donc tool_search restait aveugle aux tools plugin comme unity_detect_project; fix: ProjectPluginResolver::assigned_mcp_servers projette les affectations de l'agent en specs stdio uniquement si plugin activé pour le projet/runtime-actif/installé et serveur autoStart, avec substitution ${pluginRoot}/${appDataDir}, dédup par (pluginId,serverId), et nom client stable idea_plugin__<enc>__<enc> via PluginMcpServerSpec::client_name qui encode chaque octet non alphanumérique pour éviter collisions TOML/namespace; matérialisation au lancement/restart dans .mcp.json + enabledMcpjsonServers (Claude), mcp_servers de .codex/config.toml (Codex), bloc mcp de opencode.json (OpenCode local/cloud), avec réconciliation préservant le wiring idea réel lors d'un restart interne sans McpRuntime et retirant les entrées plugin obsolètes; racines cwd/commande des serveurs assignés ajoutées en lecture au plan Landlock quand un plan existe; aucun changement de .ideai/mcp-tool-permissions.json ni pollution du catalogue natif idea_*; gap documenté: OpenAiCompatibleSession/tools_for_context ne rend pas les tools plugin appelables en direct (nécessite client JSON-RPC dédié), à traiter séparément; QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p application --lib 180/180 dont assigned_mcp_servers 2/2, cargo test -p application --test agent_lifecycle 111/111, cargo test -p domain plugin --lib 7/7 dont plugin_mcp_client_name_is_stable_and_collision_free_for_punctuation, cargo check -p backend OK, cargo check -p app-tauri OK; fichiers tickets/296/*, tickets/299-302/*, counter/index, attachments/ et scripts/ hors périmètre; aucun push)
This commit is contained in:
@ -1,11 +1,12 @@
|
||||
//! Project-scoped plugin activation and per-agent capability assignments.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AgentContextStore, AgentId, AgentPluginAssignments, MarkdownDoc, PluginId, PluginManifest,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginPackageStore, PluginRegistryStore,
|
||||
PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
PluginManifestValidator, PluginMcpServerId, PluginMcpServerSpec, PluginPackageStore,
|
||||
PluginRegistryStore, PluginSkillId, PluginSkillRef, PluginStoreError, PluginToolRef, Project,
|
||||
ProjectPluginActivation, ProjectPluginSettings, ProjectPluginSettingsStore,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -119,6 +120,112 @@ impl ProjectPluginResolver {
|
||||
}
|
||||
Ok(MarkdownDoc::new(body))
|
||||
}
|
||||
|
||||
/// Resolves the runtime-active MCP servers that carry at least one tool
|
||||
/// explicitly assigned to `agent_id`.
|
||||
///
|
||||
/// The persisted [`PluginToolRef`] remains the authorization/source of intent;
|
||||
/// this projection only turns its owning server into launch-ready wiring for a
|
||||
/// native MCP client. Disabled, uninstalled, invalid, non-auto-start and
|
||||
/// unassigned servers are deliberately omitted.
|
||||
pub async fn assigned_mcp_servers(
|
||||
&self,
|
||||
project_root: &domain::ProjectPath,
|
||||
agent_id: domain::AgentId,
|
||||
) -> Result<Vec<PluginMcpServerSpec>, AppError> {
|
||||
let settings = self.settings(project_root).await?;
|
||||
let Some(assignments) = settings.agents.iter().find(|a| a.agent_id == agent_id) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let packages = self
|
||||
.packages
|
||||
.list_installed()
|
||||
.await
|
||||
.map_err(package_error)?;
|
||||
let app_data_dir = self.packages.app_data_dir_label();
|
||||
let mut seen = HashSet::new();
|
||||
let mut specs = Vec::new();
|
||||
|
||||
for reference in &assignments.tools {
|
||||
let key = (reference.plugin_id.clone(), reference.server_id.clone());
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let Some(manifest) = self
|
||||
.enabled_manifest(project_root, &reference.plugin_id)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(server) = manifest
|
||||
.contributes
|
||||
.mcp_servers
|
||||
.into_iter()
|
||||
.find(|server| server.id == reference.server_id && server.auto_start)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(package_root) = packages
|
||||
.iter()
|
||||
.find(|package| package.plugin_id.as_ref() == Some(&reference.plugin_id))
|
||||
.map(|package| package.root.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let command =
|
||||
substitute_plugin_vars(&server.command, package_root, app_data_dir.as_deref());
|
||||
let command = if server.allow_absolute_command || looks_absolute(&command) {
|
||||
command
|
||||
} else {
|
||||
format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command)
|
||||
};
|
||||
specs.push(PluginMcpServerSpec {
|
||||
identity: format!(
|
||||
"plugin:{}:{}",
|
||||
reference.plugin_id.as_str(),
|
||||
server.id.as_str()
|
||||
),
|
||||
plugin_id: reference.plugin_id.clone(),
|
||||
server_id: server.id,
|
||||
display_name: server.display_name,
|
||||
command,
|
||||
args: server
|
||||
.args
|
||||
.into_iter()
|
||||
.map(|arg| substitute_plugin_vars(&arg, package_root, app_data_dir.as_deref()))
|
||||
.collect(),
|
||||
env: server
|
||||
.env
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key,
|
||||
substitute_plugin_vars(&value, package_root, app_data_dir.as_deref()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
cwd: substitute_plugin_vars(
|
||||
server.cwd.as_deref().unwrap_or("${pluginRoot}"),
|
||||
package_root,
|
||||
app_data_dir.as_deref(),
|
||||
),
|
||||
transport: server.transport,
|
||||
});
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
}
|
||||
|
||||
fn substitute_plugin_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
|
||||
let value = raw.replace("${pluginRoot}", plugin_root);
|
||||
match app_data_dir {
|
||||
Some(app_data_dir) => value.replace("${appDataDir}", app_data_dir),
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_absolute(path: &str) -> bool {
|
||||
path.starts_with('/') || path.starts_with('\\') || path.as_bytes().get(1) == Some(&b':')
|
||||
}
|
||||
|
||||
/// Read model consumed by project settings surfaces.
|
||||
@ -437,3 +544,329 @@ pub fn plugin_tool_ref(
|
||||
tool_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
ContentHash, LocalPath, PluginActivationScope, PluginCapability, PluginContributionSet,
|
||||
PluginInstallSource, PluginLifecycleState, PluginManifestBytes, PluginManifestError,
|
||||
PluginMcpServerContribution, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
|
||||
PluginTrustLevel, PluginVersion, ProjectPath, RelativePath, RemovalOutcome,
|
||||
StagedPluginPackage,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MemorySettings {
|
||||
settings: Mutex<ProjectPluginSettings>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectPluginSettingsStore for MemorySettings {
|
||||
async fn load(
|
||||
&self,
|
||||
_project_root: &ProjectPath,
|
||||
) -> Result<ProjectPluginSettings, domain::StoreError> {
|
||||
Ok(self.settings.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
_project_root: &ProjectPath,
|
||||
settings: &ProjectPluginSettings,
|
||||
) -> Result<(), domain::StoreError> {
|
||||
*self.settings.lock().unwrap() = settings.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryRegistry {
|
||||
registry: PluginRegistry,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginRegistryStore for MemoryRegistry {
|
||||
async fn load_registry(&self) -> Result<PluginRegistry, domain::PluginRegistryError> {
|
||||
Ok(self.registry.clone())
|
||||
}
|
||||
|
||||
async fn save_registry(
|
||||
&self,
|
||||
_registry: &PluginRegistry,
|
||||
) -> Result<(), domain::PluginRegistryError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryPackages {
|
||||
packages: Vec<PluginPackageRef>,
|
||||
app_data_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPackageStore for MemoryPackages {
|
||||
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
||||
Ok(self.packages.clone())
|
||||
}
|
||||
|
||||
async fn read_manifest(
|
||||
&self,
|
||||
_package: &PluginPackageRef,
|
||||
) -> Result<PluginManifestBytes, PluginStoreError> {
|
||||
Ok(PluginManifestBytes { bytes: Vec::new() })
|
||||
}
|
||||
|
||||
async fn install_from_archive(
|
||||
&self,
|
||||
_archive: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
async fn install_from_directory(
|
||||
&self,
|
||||
_dir: &LocalPath,
|
||||
) -> Result<StagedPluginPackage, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
async fn commit_install(
|
||||
&self,
|
||||
_staged: StagedPluginPackage,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<PluginPackageRef, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
async fn remove_package(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
) -> Result<RemovalOutcome, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
fn bundle_url(
|
||||
&self,
|
||||
_plugin_id: &PluginId,
|
||||
_entry: &RelativePath,
|
||||
_hash: &ContentHash,
|
||||
) -> Result<domain::PluginBundleUrl, PluginStoreError> {
|
||||
Err(PluginStoreError::Invalid("not implemented".to_owned()))
|
||||
}
|
||||
|
||||
fn app_data_dir_label(&self) -> Option<String> {
|
||||
self.app_data_dir.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedManifest(PluginManifest);
|
||||
|
||||
impl PluginManifestValidator for FixedManifest {
|
||||
fn validate(
|
||||
&self,
|
||||
_bytes: &[u8],
|
||||
_package_root: &PluginPackageRef,
|
||||
) -> Result<PluginManifest, PluginManifestError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_id() -> PluginId {
|
||||
PluginId::new("com.idea.unity-tools").unwrap()
|
||||
}
|
||||
|
||||
fn agent_id() -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(1))
|
||||
}
|
||||
|
||||
fn project_root() -> ProjectPath {
|
||||
ProjectPath::new("/project").unwrap()
|
||||
}
|
||||
|
||||
fn manifest(auto_start: bool) -> PluginManifest {
|
||||
PluginManifest {
|
||||
idea_plugin_manifest_version: 1,
|
||||
id: plugin_id(),
|
||||
display_name: "Unity Tools".to_owned(),
|
||||
publisher: None,
|
||||
version: PluginVersion::new("1.0.0").unwrap(),
|
||||
description: None,
|
||||
engine_idea: None,
|
||||
main: RelativePath::new("dist/index.js").unwrap(),
|
||||
icon: None,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: vec![PluginCapability::Mcp],
|
||||
activation_scope: PluginActivationScope::Project,
|
||||
contributes: PluginContributionSet {
|
||||
mcp_servers: vec![
|
||||
PluginMcpServerContribution {
|
||||
id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
display_name: "Unity Editor".to_owned(),
|
||||
command: "bin/server".to_owned(),
|
||||
args: vec!["--socket".to_owned(), "${appDataDir}/unity.sock".to_owned()],
|
||||
env: vec![("PLUGIN_ROOT".to_owned(), "${pluginRoot}".to_owned())],
|
||||
cwd: Some("${pluginRoot}/work".to_owned()),
|
||||
transport: "stdio".to_owned(),
|
||||
auto_start,
|
||||
allow_absolute_command: false,
|
||||
tools: Vec::new(),
|
||||
},
|
||||
PluginMcpServerContribution {
|
||||
id: PluginMcpServerId::new("unassigned").unwrap(),
|
||||
display_name: "Unassigned".to_owned(),
|
||||
command: "bin/other".to_owned(),
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
cwd: None,
|
||||
transport: "stdio".to_owned(),
|
||||
auto_start: true,
|
||||
allow_absolute_command: false,
|
||||
tools: Vec::new(),
|
||||
},
|
||||
],
|
||||
..PluginContributionSet::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn registry(state: PluginLifecycleState) -> PluginRegistry {
|
||||
PluginRegistry {
|
||||
version: 1,
|
||||
plugins: vec![PluginRegistryEntry {
|
||||
id: plugin_id(),
|
||||
lifecycle_state: state,
|
||||
source: PluginInstallSource::Directory {
|
||||
path_label: "/source".to_owned(),
|
||||
},
|
||||
content_hash: ContentHash::new("abcdef").unwrap(),
|
||||
restart_required: false,
|
||||
error: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn settings(enabled: bool, tools: Vec<PluginToolRef>) -> ProjectPluginSettings {
|
||||
ProjectPluginSettings {
|
||||
version: 1,
|
||||
plugins: vec![ProjectPluginActivation {
|
||||
plugin_id: plugin_id(),
|
||||
enabled,
|
||||
}],
|
||||
agents: vec![AgentPluginAssignments {
|
||||
agent_id: agent_id(),
|
||||
skills: Vec::new(),
|
||||
tools,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn resolver(
|
||||
settings: ProjectPluginSettings,
|
||||
registry: PluginRegistry,
|
||||
manifest: PluginManifest,
|
||||
) -> ProjectPluginResolver {
|
||||
ProjectPluginResolver::new(
|
||||
Arc::new(MemorySettings {
|
||||
settings: Mutex::new(settings),
|
||||
}),
|
||||
Arc::new(MemoryPackages {
|
||||
packages: vec![PluginPackageRef {
|
||||
plugin_id: Some(plugin_id()),
|
||||
root: "/plugins/unity".to_owned(),
|
||||
}],
|
||||
app_data_dir: Some("/app-data".to_owned()),
|
||||
}),
|
||||
Arc::new(MemoryRegistry { registry }),
|
||||
Arc::new(FixedManifest(manifest)),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assigned_mcp_servers_project_assigned_active_autostart_server_only() {
|
||||
let tool = PluginToolRef {
|
||||
plugin_id: plugin_id(),
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
tool_name: "unity_detect_project".to_owned(),
|
||||
};
|
||||
let resolver = resolver(
|
||||
settings(true, vec![tool]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
|
||||
let servers = resolver
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(servers.len(), 1);
|
||||
let server = &servers[0];
|
||||
assert_eq!(
|
||||
server.client_name(),
|
||||
"idea_plugin__com_2eidea_2eunity_2dtools__unity_2deditor"
|
||||
);
|
||||
assert_eq!(server.command, "/plugins/unity/bin/server");
|
||||
assert_eq!(
|
||||
server.args,
|
||||
vec!["--socket".to_owned(), "/app-data/unity.sock".to_owned()]
|
||||
);
|
||||
assert_eq!(
|
||||
server.env,
|
||||
vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())]
|
||||
);
|
||||
assert_eq!(server.cwd, "/plugins/unity/work");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assigned_mcp_servers_omits_unassigned_disabled_and_non_autostart_servers() {
|
||||
let assigned_tool = PluginToolRef {
|
||||
plugin_id: plugin_id(),
|
||||
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
|
||||
tool_name: "unity_detect_project".to_owned(),
|
||||
};
|
||||
let unassigned_only = resolver(
|
||||
settings(true, Vec::new()),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
assert!(unassigned_only
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let project_disabled = resolver(
|
||||
settings(false, vec![assigned_tool.clone()]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(true),
|
||||
);
|
||||
assert!(project_disabled
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let globally_disabled = resolver(
|
||||
settings(true, vec![assigned_tool.clone()]),
|
||||
registry(PluginLifecycleState::Disabled),
|
||||
manifest(true),
|
||||
);
|
||||
assert!(globally_disabled
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
let non_autostart = resolver(
|
||||
settings(true, vec![assigned_tool]),
|
||||
registry(PluginLifecycleState::Enabled),
|
||||
manifest(false),
|
||||
);
|
||||
assert!(non_autostart
|
||||
.assigned_mcp_servers(&project_root(), agent_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user