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

@ -30,8 +30,8 @@ use domain::{
ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions,
Handoff, HandoffStore, ManagedTempKind, ManifestEntry, MarkdownDoc, MemoryIndexEntry,
MemoryType, NetworkPolicy, NodeId, OrchestrationCapabilitySnapshot, PermissionProjector,
ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey,
ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind,
PluginMcpServerSpec, ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext,
ProjectorKey, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind,
TerminalSession, HANDOFF_SUMMARY_MAX_CHARS,
};
@ -50,6 +50,7 @@ use crate::model_server::{
EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard,
};
use crate::project::project_context_path;
use crate::project_plugin::ProjectPluginResolver;
use crate::skill::{AgentCapability, AssignedSkillResolver, ResolveAgentCapabilities};
use crate::terminal::{
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
@ -1539,6 +1540,31 @@ fn enrich_sandbox_plan_with_managed_temp_roots(
enrich_sandbox_plan_with_rw_roots(plan, managed_temp_roots)
}
fn enrich_sandbox_plan_with_plugin_mcp_roots(
plan: Option<SandboxPlan>,
servers: &[PluginMcpServerSpec],
) -> Option<SandboxPlan> {
let mut plan = plan?;
let roots = servers.iter().flat_map(|server| {
let command_parent = server
.command
.rsplit_once(['/', '\\'])
.map(|(parent, _)| parent.to_owned());
std::iter::once(server.cwd.clone()).chain(command_parent)
});
for root in roots.filter(|root| !root.is_empty()) {
match plan.allowed.iter_mut().find(|grant| grant.abs_root == root) {
Some(grant) => grant.access.insert(PathAccess::RO),
None => plan.allowed.push(PathGrant {
abs_root: root,
access: PathAccess::RO,
}),
}
}
plan.allowed.sort_by(|a, b| a.abs_root.cmp(&b.abs_root));
Some(plan)
}
fn enrich_sandbox_plan_with_rw_roots(
plan: Option<SandboxPlan>,
writable_roots: &[String],
@ -1738,6 +1764,10 @@ pub struct LaunchAgent {
/// Releases the primary launch-scoped managed temp directory when launch fails
/// before registration.
managed_temp_releaser: Option<Arc<ReleaseManagedTemp>>,
/// Resolves agent-assigned plugin MCP servers into launch-ready CLI wiring.
/// Optional for legacy tests/callers; runtime composition wires the shared
/// project-plugin resolver used by the capability assignment use cases.
plugin_mcp: Option<Arc<ProjectPluginResolver>>,
/// Explicit intent for structured profiles when structured ports are absent.
structured_routing_mode: StructuredRoutingMode,
}
@ -1789,6 +1819,7 @@ impl LaunchAgent {
managed_temp_resolver: None,
managed_temp_allocator: None,
managed_temp_releaser: None,
plugin_mcp: None,
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
}
}
@ -1811,6 +1842,14 @@ impl LaunchAgent {
self
}
/// Injects the resolver that projects assigned plugin MCP servers into the
/// launched CLI's isolated MCP configuration.
#[must_use]
pub fn with_plugin_mcp_resolver(mut self, resolver: Arc<ProjectPluginResolver>) -> Self {
self.plugin_mcp = Some(resolver);
self
}
/// Injects the local model-server ensure use case used by OpenCode profiles.
#[must_use]
pub fn with_local_model_server(mut self, ensure: Arc<EnsureLocalModelServer>) -> Self {
@ -2484,6 +2523,15 @@ impl LaunchAgent {
}
};
let plugin_mcp_servers = match &self.plugin_mcp {
Some(resolver) if profile.mcp.is_some() => {
resolver
.assigned_mcp_servers(&input.project.root, agent.id)
.await?
}
_ => Vec::new(),
};
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
// `factory.start` (étapes 5b/6). Si le profil porte une `McpCapability`,
@ -2497,6 +2545,7 @@ impl LaunchAgent {
&input.project.root,
input.mcp_runtime.as_ref(),
effective_permissions.as_ref(),
&plugin_mcp_servers,
&mut spec,
)
.await
@ -2528,6 +2577,12 @@ impl LaunchAgent {
return Err(err);
}
// Claude's permission projector owns `settings.local.json` and writes its
// MCP allow-list after `.mcp.json`; extend that allow-list with the exact
// assigned server names once projection is complete.
self.enable_claude_plugin_mcp_servers(&profile, &run_dir, &plugin_mcp_servers)
.await;
// 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ──
// Strictement APRÈS la résolution des permissions (3) et la projection
// advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan`
@ -2570,6 +2625,7 @@ impl LaunchAgent {
enrich_sandbox_plan_with_rw_roots(spec.sandbox, &codex_permission_writable_roots);
spec.sandbox =
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
spec.sandbox = enrich_sandbox_plan_with_plugin_mcp_roots(spec.sandbox, &plugin_mcp_servers);
append_codex_pty_managed_temp_roots(
&profile,
effective_permissions.as_ref(),
@ -3078,6 +3134,63 @@ impl LaunchAgent {
Ok(())
}
async fn enable_claude_plugin_mcp_servers(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
plugin_servers: &[PluginMcpServerSpec],
) {
if select_projector_key(profile) != Some(ProjectorKey::Claude) {
return;
}
let path = RemotePath::new(join(run_dir, ".claude/settings.local.json"));
let Ok(bytes) = self.fs.read(&path).await else {
return;
};
let Ok(mut document) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return;
};
let Some(root) = document.as_object_mut() else {
return;
};
if plugin_servers.is_empty()
&& !root
.get("enabledMcpjsonServers")
.and_then(serde_json::Value::as_array)
.is_some_and(|values| {
values.iter().any(|value| {
value
.as_str()
.is_some_and(|name| name.starts_with("idea_plugin__"))
})
})
{
return;
}
let enabled = root
.entry("enabledMcpjsonServers")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let Some(enabled) = enabled.as_array_mut() else {
return;
};
enabled.retain(|value| {
!value
.as_str()
.is_some_and(|name| name.starts_with("idea_plugin__"))
});
for server in plugin_servers {
let name = server.client_name();
if !enabled.iter().any(|value| value.as_str() == Some(&name)) {
enabled.push(serde_json::Value::String(name));
}
}
let Ok(mut body) = serde_json::to_vec_pretty(&document) else {
return;
};
body.push(b'\n');
let _ = self.fs.write(&path, &body).await;
}
async fn resolve_effective_permissions(
&self,
project: &Project,
@ -3186,6 +3299,7 @@ impl LaunchAgent {
project_root: &ProjectPath,
runtime: Option<&McpRuntime>,
eff: Option<&EffectivePermissions>,
plugin_servers: &[PluginMcpServerSpec],
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
let Some(mcp) = &profile.mcp else {
@ -3194,7 +3308,7 @@ impl LaunchAgent {
match &mcp.config {
domain::profile::McpConfigStrategy::ConfigFile { target } => {
let path = RemotePath::new(join(run_dir, target));
let declaration = mcp_server_declaration(mcp.transport, runtime);
let declaration = mcp_server_declaration(mcp.transport, runtime, plugin_servers);
match runtime {
// Real launch (app-tauri injected the runtime): the declaration
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
@ -3214,7 +3328,15 @@ impl LaunchAgent {
// overwrite a real declaration previously written by an app-tauri
// launch with the minimal one.
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(true) => {
if let Ok(existing) = self.fs.read(&path).await {
if let Some(rendered) =
reconcile_plugin_mcp_json(&existing, plugin_servers)
{
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
}
}
Ok(false) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
@ -3229,7 +3351,7 @@ impl LaunchAgent {
// le **dossier parent** de ce fichier. Codex lit alors ses serveurs MCP
// dans ce `config.toml` ISOLÉ au run dir, jamais le `~/.codex` global.
let path = RemotePath::new(join(run_dir, target));
let declaration = mcp_server_wiring(mcp.transport, runtime).to_config_toml();
let declaration = codex_mcp_declaration(mcp.transport, runtime, plugin_servers);
// Même régime clobber/non-clobber que `.mcp.json` : avec un runtime réel
// (lancement app-tauri) on régénère/clobber à chaque (re)lancement (l'exe
// `$APPIMAGE` et l'endpoint dérivent entre runs) ; sans runtime
@ -3250,7 +3372,21 @@ impl LaunchAgent {
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(true) => {
if let Ok(existing) = self.fs.read(&path).await {
if let Ok(existing) = String::from_utf8(existing) {
if !plugin_servers.is_empty()
|| existing.contains("[mcp_servers.\"idea_plugin__")
{
let rendered = merge_codex_plugin_mcp_tables(
&existing,
plugin_servers,
);
let _ = self.fs.write(&path, rendered.as_bytes()).await;
}
}
}
}
Ok(false) => {
let rendered = codex_config_toml(
None,
@ -3278,7 +3414,14 @@ impl LaunchAgent {
return Ok(());
}
let body = if let Some(opencode) = profile.opencode.as_ref() {
opencode_config_json(opencode, project_root.as_str(), runtime, eff).to_string()
opencode_config_json(
opencode,
project_root.as_str(),
runtime,
eff,
plugin_servers,
)
.to_string()
} else if let Some(provider) = profile.opencode_provider.as_ref() {
let api_key = self.resolve_opencode_provider_api_key(provider).await?;
opencode_provider_config_json(
@ -3287,6 +3430,7 @@ impl LaunchAgent {
project_root.as_str(),
runtime,
eff,
plugin_servers,
)
.to_string()
} else {
@ -3525,8 +3669,98 @@ fn reattach_decision(
fn mcp_server_declaration(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
plugin_servers: &[PluginMcpServerSpec],
) -> String {
mcp_server_wiring(transport, runtime).to_mcp_json()
let mut document: serde_json::Value =
serde_json::from_str(&mcp_server_wiring(transport, runtime).to_mcp_json())
.expect("the domain MCP encoder always produces valid JSON");
let servers = document["mcpServers"]
.as_object_mut()
.expect("the domain MCP encoder always produces an mcpServers object");
for server in plugin_servers {
servers.insert(server.client_name(), plugin_mcp_json_entry(server));
}
let mut rendered = serde_json::to_string_pretty(&document)
.expect("MCP declaration contains only serializable values");
rendered.push('\n');
rendered
}
fn plugin_mcp_json_entry(server: &PluginMcpServerSpec) -> serde_json::Value {
serde_json::json!({
"command": server.command,
"args": server.args,
"env": server.env.iter().cloned().collect::<HashMap<_, _>>(),
"cwd": server.cwd,
"transport": server.transport,
})
}
fn reconcile_plugin_mcp_json(
existing: &[u8],
plugin_servers: &[PluginMcpServerSpec],
) -> Option<String> {
let mut document: serde_json::Value = serde_json::from_slice(existing).ok()?;
let servers = document.get_mut("mcpServers")?.as_object_mut()?;
if plugin_servers.is_empty() && !servers.keys().any(|name| name.starts_with("idea_plugin__")) {
return None;
}
servers.retain(|name, _| !name.starts_with("idea_plugin__"));
for server in plugin_servers {
servers.insert(server.client_name(), plugin_mcp_json_entry(server));
}
let mut rendered = serde_json::to_string_pretty(&document).ok()?;
rendered.push('\n');
Some(rendered)
}
fn codex_mcp_declaration(
transport: domain::profile::McpTransport,
runtime: Option<&McpRuntime>,
plugin_servers: &[PluginMcpServerSpec],
) -> String {
let mut rendered = mcp_server_wiring(transport, runtime).to_config_toml();
rendered.push_str(&codex_plugin_mcp_declaration(plugin_servers));
rendered
}
fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> String {
let mut rendered = String::new();
for server in plugin_servers {
rendered.push('\n');
rendered.push_str(&format!(
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\n",
toml_string(&server.client_name()),
toml_string(&server.command),
server
.args
.iter()
.map(|arg| toml_string(arg))
.collect::<Vec<_>>()
.join(", "),
toml_inline_string_map(&server.env),
toml_string(&server.cwd),
));
}
rendered
}
fn merge_codex_plugin_mcp_tables(existing: &str, plugin_servers: &[PluginMcpServerSpec]) -> String {
let mut rendered = remove_codex_plugin_mcp_tables(existing);
rendered.push_str(&codex_plugin_mcp_declaration(plugin_servers));
if !rendered.ends_with('\n') {
rendered.push('\n');
}
rendered
}
fn toml_inline_string_map(entries: &[(String, String)]) -> String {
let body = entries
.iter()
.map(|(key, value)| format!("{} = {}", toml_string(key), toml_string(value)))
.collect::<Vec<_>>()
.join(", ");
format!("{{ {body} }}")
}
/// Builds the IdeA MCP server **wiring** (`command` + `args` + transport) shared by
@ -3596,6 +3830,7 @@ fn opencode_config_json(
project_root: &str,
runtime: Option<&McpRuntime>,
eff: Option<&EffectivePermissions>,
plugin_servers: &[PluginMcpServerSpec],
) -> serde_json::Value {
let model = config.model.as_str();
let opencode_model = format!("llamacpp/{model}");
@ -3651,15 +3886,7 @@ fn opencode_config_json(
.collect::<Vec<_>>();
root.insert(
"mcp".to_owned(),
serde_json::json!({
"idea": {
"type": "local",
"command": command_array,
"cwd": project_root,
"enabled": true,
"timeout": resolve_opencode_mcp_timeout_ms()
}
}),
opencode_mcp_servers(command_array, project_root, plugin_servers),
);
if let Some(permission) = domain::opencode_permission_block(eff) {
root.insert("permission".to_owned(), permission);
@ -3684,6 +3911,7 @@ fn opencode_provider_config_json(
project_root: &str,
runtime: Option<&McpRuntime>,
eff: Option<&EffectivePermissions>,
plugin_servers: &[PluginMcpServerSpec],
) -> serde_json::Value {
let mut root = serde_json::Map::new();
root.insert(
@ -3734,15 +3962,7 @@ fn opencode_provider_config_json(
.collect::<Vec<_>>();
root.insert(
"mcp".to_owned(),
serde_json::json!({
"idea": {
"type": "local",
"command": command_array,
"cwd": project_root,
"enabled": true,
"timeout": resolve_opencode_mcp_timeout_ms()
}
}),
opencode_mcp_servers(command_array, project_root, plugin_servers),
);
if let Some(permission) = domain::opencode_permission_block(eff) {
root.insert("permission".to_owned(), permission);
@ -3750,6 +3970,40 @@ fn opencode_provider_config_json(
serde_json::Value::Object(root)
}
fn opencode_mcp_servers(
idea_command: Vec<serde_json::Value>,
project_root: &str,
plugin_servers: &[PluginMcpServerSpec],
) -> serde_json::Value {
let mut servers = serde_json::Map::new();
servers.insert(
"idea".to_owned(),
serde_json::json!({
"type": "local",
"command": idea_command,
"cwd": project_root,
"enabled": true,
"timeout": resolve_opencode_mcp_timeout_ms()
}),
);
for server in plugin_servers {
let command = std::iter::once(server.command.clone())
.chain(server.args.iter().cloned())
.collect::<Vec<_>>();
servers.insert(
server.client_name(),
serde_json::json!({
"type": "local",
"command": command,
"environment": server.env.iter().cloned().collect::<HashMap<_, _>>(),
"cwd": server.cwd,
"enabled": true,
}),
);
}
serde_json::Value::Object(servers)
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {
@ -3859,7 +4113,7 @@ fn codex_config_toml(
run_dir: &str,
project_root: &str,
) -> String {
let mut text = existing.unwrap_or_default().to_owned();
let mut text = remove_codex_plugin_mcp_tables(existing.unwrap_or_default());
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = replace_toml_table(
&text,
@ -3874,6 +4128,25 @@ fn codex_config_toml(
text
}
fn remove_codex_plugin_mcp_tables(input: &str) -> String {
let mut out = Vec::new();
let mut skipping = false;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.starts_with("[mcp_servers.\"idea_plugin__") && trimmed.ends_with("\"]") {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
out.push(line);
}
}
out.join("\n")
}
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
/// `managed_keys` are touched; every other line of `existing` is preserved.
@ -5045,7 +5318,7 @@ mod tests {
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
};
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt), &[]);
// It is valid JSON with the IdeA server under `mcpServers/idea`.
let parsed: serde_json::Value =
@ -5069,7 +5342,7 @@ mod tests {
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
// command — still a valid, self-consistent `mcpServers/idea` entry.
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None, &[]);
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
@ -5085,6 +5358,104 @@ mod tests {
assert!(!args.contains(&"--endpoint"));
}
fn assigned_plugin_server() -> PluginMcpServerSpec {
PluginMcpServerSpec {
plugin_id: domain::PluginId::new("com.idea.unity-tools").unwrap(),
server_id: domain::PluginMcpServerId::new("unity-editor").unwrap(),
identity: "plugin:com.idea.unity-tools:unity-editor".to_owned(),
display_name: "Unity Editor".to_owned(),
command: "/plugins/unity/server".to_owned(),
args: vec!["--stdio".to_owned()],
env: vec![("UNITY_SOCKET".to_owned(), "/tmp/unity.sock".to_owned())],
cwd: "/plugins/unity".to_owned(),
transport: "stdio".to_owned(),
}
}
#[test]
fn assigned_plugin_server_is_materialized_for_claude_codex_and_opencode() {
let server = assigned_plugin_server();
let name = server.client_name();
let claude = mcp_server_declaration(
domain::profile::McpTransport::Stdio,
None,
std::slice::from_ref(&server),
);
let claude: serde_json::Value = serde_json::from_str(&claude).unwrap();
assert_eq!(claude["mcpServers"][&name]["command"], server.command);
assert_eq!(claude["mcpServers"][&name]["args"][0], "--stdio");
let codex = codex_mcp_declaration(
domain::profile::McpTransport::Stdio,
None,
std::slice::from_ref(&server),
);
assert!(codex.contains(&format!("[mcp_servers.{}]", toml_string(&name))));
assert!(codex.contains("UNITY_SOCKET"));
let opencode = opencode_mcp_servers(
vec!["idea".into(), "mcp-server".into()],
"/project",
std::slice::from_ref(&server),
);
assert_eq!(opencode[&name]["command"][0], server.command);
assert_eq!(
opencode[&name]["environment"]["UNITY_SOCKET"],
"/tmp/unity.sock"
);
}
#[test]
fn codex_plugin_server_projection_removes_stale_managed_tables() {
let current = assigned_plugin_server();
let stale = "idea_plugin__stale__server";
let existing = format!(
"user_key = \"keep\"\n[mcp_servers.\"{stale}\"]\ncommand = \"old\"\n\n[preview]\nenabled = true\n"
);
let declaration = codex_mcp_declaration(
domain::profile::McpTransport::Stdio,
None,
std::slice::from_ref(&current),
);
let rendered = codex_config_toml(Some(&existing), &declaration, "/run", "/project");
assert!(!rendered.contains(stale));
assert!(rendered.contains("user_key = \"keep\""));
assert!(rendered.contains("[preview]\nenabled = true"));
assert_eq!(rendered.matches(&current.client_name()).count(), 1);
}
#[test]
fn internal_restart_refreshes_plugin_servers_without_replacing_real_idea_wiring() {
let current = assigned_plugin_server();
let existing_json = br#"{
"mcpServers": {
"idea": { "command": "/opt/Idea.AppImage", "args": ["mcp-server", "--endpoint", "/live.sock"] },
"idea_plugin__stale__server": { "command": "old" }
}
}"#;
let claude = reconcile_plugin_mcp_json(existing_json, std::slice::from_ref(&current))
.expect("valid existing Claude config");
let claude: serde_json::Value = serde_json::from_str(&claude).unwrap();
assert_eq!(
claude["mcpServers"]["idea"]["command"],
"/opt/Idea.AppImage"
);
assert!(claude["mcpServers"]
.get("idea_plugin__stale__server")
.is_none());
assert_eq!(
claude["mcpServers"][current.client_name()]["command"],
current.command
);
let existing_toml = "[mcp_servers.idea]\ncommand = \"/opt/Idea.AppImage\"\nargs = [\"mcp-server\", \"--endpoint\", \"/live.sock\"]\n\n[mcp_servers.\"idea_plugin__stale__server\"]\ncommand = \"old\"\n";
let codex = merge_codex_plugin_mcp_tables(existing_toml, std::slice::from_ref(&current));
assert!(codex.contains("command = \"/opt/Idea.AppImage\""));
assert!(!codex.contains("idea_plugin__stale__server"));
assert!(codex.contains(&current.client_name()));
}
/// Builds a [`Handoff`] for the pure-section tests (lot P7).
fn handoff(summary: &str, objective: Option<&str>) -> Handoff {
Handoff::new(
@ -5438,7 +5809,7 @@ command = "idea-mcp"
domain::ports::SecretRef::new("secret-ref"),
)
.unwrap();
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
let provider = &body["provider"]["anthropic"];
assert_eq!(provider["options"]["apiKey"], "sk-live");
assert!(provider["options"].get("baseURL").is_none());
@ -5462,7 +5833,7 @@ command = "idea-mcp"
.unwrap()
.with_custom(custom);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
let provider = &body["provider"]["my-custom"];
assert_eq!(provider["options"]["apiKey"], "sk-live");
assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
@ -5486,7 +5857,7 @@ command = "idea-mcp"
.unwrap()
.with_custom(custom);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
assert_eq!(
body["provider"]["my-custom"]["models"]["my-model"]["name"],
"My Model"
@ -5505,7 +5876,7 @@ command = "idea-mcp"
OpenCodeProviderConfig::new("zai", "glm-5.1", domain::ports::SecretRef::new("ref"))
.unwrap()
.with_custom(custom);
let body = opencode_provider_config_json(&config, "secret", "/project", None, None);
let body = opencode_provider_config_json(&config, "secret", "/project", None, None, &[]);
assert_eq!(body["model"], "zai/glm-5.1");
assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible");
assert_eq!(
@ -5535,7 +5906,7 @@ command = "idea-mcp"
let config =
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
.unwrap();
let body = opencode_config_json(&config, "/project", None, None);
let body = opencode_config_json(&config, "/project", None, None, &[]);
assert!(body.get("permission").is_none());
}
@ -5551,7 +5922,7 @@ command = "idea-mcp"
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
};
let body = opencode_config_json(&config, "/project", Some(&runtime), None);
let body = opencode_config_json(&config, "/project", Some(&runtime), None, &[]);
let command = body["mcp"]["idea"]["command"]
.as_array()
.expect("OpenCode local command is an argv array")
@ -5577,11 +5948,13 @@ command = "idea-mcp"
.unwrap();
use domain::permission::Posture;
let allowed = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)));
let allowed =
opencode_config_json(&config, "/project", None, Some(&eff(Posture::Allow)), &[]);
assert_eq!(allowed["permission"]["bash"], "allow");
assert_eq!(allowed["permission"]["edit"], "allow");
let denied = opencode_config_json(&config, "/project", None, Some(&eff(Posture::Deny)));
let denied =
opencode_config_json(&config, "/project", None, Some(&eff(Posture::Deny)), &[]);
assert_eq!(denied["permission"]["bash"], "deny");
assert_eq!(denied["permission"]["edit"], "deny");
}
@ -5594,7 +5967,7 @@ command = "idea-mcp"
domain::ports::SecretRef::new("secret-ref"),
)
.unwrap();
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None, &[]);
assert!(body.get("permission").is_none());
}
@ -5613,6 +5986,7 @@ command = "idea-mcp"
"/project",
None,
Some(&eff(domain::permission::Posture::Allow)),
&[],
);
assert_eq!(allowed["permission"]["bash"], "allow");
assert_eq!(allowed["permission"]["edit"], "allow");
@ -5623,6 +5997,7 @@ command = "idea-mcp"
"/project",
None,
Some(&eff(domain::permission::Posture::Deny)),
&[],
);
assert_eq!(denied["permission"]["bash"], "deny");
assert_eq!(denied["permission"]["edit"], "deny");

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

View File

@ -3076,6 +3076,7 @@ impl BackendCore {
Some(Arc::clone(&check_embedder_suggestion)),
)
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
.with_plugin_mcp_resolver(Arc::clone(&project_plugin_resolver))
.with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback)
.with_permission_store(Arc::clone(&permission_store_port))
.with_system_permission_store(Arc::clone(&system_permission_store_port))
@ -3134,6 +3135,7 @@ impl BackendCore {
Some(Arc::clone(&check_embedder_suggestion)),
)
.with_assigned_skill_resolver(Arc::clone(&assigned_skill_resolver))
.with_plugin_mcp_resolver(Arc::clone(&project_plugin_resolver))
.with_structured_routing_mode(StructuredRoutingMode::RequireStructured)
.with_permission_store(Arc::clone(&permission_store_port))
.with_system_permission_store(Arc::clone(&system_permission_store_port))

View File

@ -796,6 +796,36 @@ pub struct PluginMcpServerSpec {
pub transport: String,
}
impl PluginMcpServerSpec {
/// Stable, collision-free MCP client name for this plugin server.
///
/// Plugin/server ids may contain punctuation that is awkward in TOML table
/// paths and becomes part of the tool namespace in MCP clients. Encode every
/// non-alphanumeric byte instead of merely replacing it, so distinct ids can
/// never collapse onto the same session server name.
#[must_use]
pub fn client_name(&self) -> String {
fn component(raw: &str) -> String {
let mut out = String::new();
for byte in raw.bytes() {
if byte.is_ascii_alphanumeric() {
out.push(char::from(byte).to_ascii_lowercase());
} else {
out.push('_');
out.push_str(&format!("{byte:02x}"));
}
}
out
}
format!(
"idea_plugin__{}__{}",
component(self.plugin_id.as_str()),
component(self.server_id.as_str())
)
}
}
/// MCP server status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -862,6 +892,32 @@ mod tests {
assert!(PluginLifecycleState::Enabled.is_runtime_active());
}
#[test]
fn plugin_mcp_client_name_is_stable_and_collision_free_for_punctuation() {
fn spec(plugin: &str) -> PluginMcpServerSpec {
PluginMcpServerSpec {
plugin_id: PluginId::new(plugin).unwrap(),
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
identity: String::new(),
display_name: String::new(),
command: String::new(),
args: Vec::new(),
env: Vec::new(),
cwd: String::new(),
transport: "stdio".to_owned(),
}
}
assert_eq!(
spec("com.idea.unity").client_name(),
"idea_plugin__com_2eidea_2eunity__unity_2deditor"
);
assert_ne!(
spec("com.idea-unity").client_name(),
spec("com.idea.unity").client_name()
);
}
#[test]
fn plugin_capabilities_serialize_public_manifest_names() {
assert_eq!(