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:
2026-09-09 23:37:32 +02:00
parent 0cfbc064b6
commit 78b379ac51
4 changed files with 906 additions and 40 deletions

View File

@ -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());
}
}