From 78b379ac518790ab577f81bf3597b3d7cd0b1756 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 9 Sep 2026 23:37:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(application,domain,backend):=20les=20serveu?= =?UTF-8?q?rs=20MCP=20des=20plugins=20assign=C3=A9s=20atteignent=20la=20se?= =?UTF-8?q?ssion=20de=20l'agent=20=E2=80=94=20#302=20(cause:=20MutateAgent?= =?UTF-8?q?PluginTool=20persistait=20bien=20PluginToolRef=20dans=20Project?= =?UTF-8?q?PluginSettings=20mais=20le=20launcher=20ne=20consommait=20jamai?= =?UTF-8?q?s=20assignments.tools,=20les=20configs=20de=20session=20ne=20co?= =?UTF-8?q?ntenaient=20que=20le=20serveur=20MCP=20natif=20idea,=20donc=20t?= =?UTF-8?q?ool=5Fsearch=20restait=20aveugle=20aux=20tools=20plugin=20comme?= =?UTF-8?q?=20unity=5Fdetect=5Fproject;=20fix:=20ProjectPluginResolver::as?= =?UTF-8?q?signed=5Fmcp=5Fservers=20projette=20les=20affectations=20de=20l?= =?UTF-8?q?'agent=20en=20specs=20stdio=20uniquement=20si=20plugin=20activ?= =?UTF-8?q?=C3=A9=20pour=20le=20projet/runtime-actif/install=C3=A9=20et=20?= =?UTF-8?q?serveur=20autoStart,=20avec=20substitution=20${pluginRoot}/${ap?= =?UTF-8?q?pDataDir},=20d=C3=A9dup=20par=20(pluginId,serverId),=20et=20nom?= =?UTF-8?q?=20client=20stable=20idea=5Fplugin=5F=5F=5F=5F=20via?= =?UTF-8?q?=20PluginMcpServerSpec::client=5Fname=20qui=20encode=20chaque?= =?UTF-8?q?=20octet=20non=20alphanum=C3=A9rique=20pour=20=C3=A9viter=20col?= =?UTF-8?q?lisions=20TOML/namespace;=20mat=C3=A9rialisation=20au=20lanceme?= =?UTF-8?q?nt/restart=20dans=20.mcp.json=20+=20enabledMcpjsonServers=20(Cl?= =?UTF-8?q?aude),=20mcp=5Fservers=20de=20.codex/config.toml=20(Codex),=20b?= =?UTF-8?q?loc=20mcp=20de=20opencode.json=20(OpenCode=20local/cloud),=20av?= =?UTF-8?q?ec=20r=C3=A9conciliation=20pr=C3=A9servant=20le=20wiring=20idea?= =?UTF-8?q?=20r=C3=A9el=20lors=20d'un=20restart=20interne=20sans=20McpRunt?= =?UTF-8?q?ime=20et=20retirant=20les=20entr=C3=A9es=20plugin=20obsol=C3=A8?= =?UTF-8?q?tes;=20racines=20cwd/commande=20des=20serveurs=20assign=C3=A9s?= =?UTF-8?q?=20ajout=C3=A9es=20en=20lecture=20au=20plan=20Landlock=20quand?= =?UTF-8?q?=20un=20plan=20existe;=20aucun=20changement=20de=20.ideai/mcp-t?= =?UTF-8?q?ool-permissions.json=20ni=20pollution=20du=20catalogue=20natif?= =?UTF-8?q?=20idea=5F*;=20gap=20document=C3=A9:=20OpenAiCompatibleSession/?= =?UTF-8?q?tools=5Ffor=5Fcontext=20ne=20rend=20pas=20les=20tools=20plugin?= =?UTF-8?q?=20appelables=20en=20direct=20(n=C3=A9cessite=20client=20JSON-R?= =?UTF-8?q?PC=20d=C3=A9di=C3=A9),=20=C3=A0=20traiter=20s=C3=A9par=C3=A9men?= =?UTF-8?q?t;=20QA=20verte=20reconfirm=C3=A9e=20par=20Git:=20rustup=20run?= =?UTF-8?q?=20stable=20cargo=20fmt=20--all=20--=20--check,=20git=20diff=20?= =?UTF-8?q?--check,=20cargo=20test=20-p=20application=20--lib=20180/180=20?= =?UTF-8?q?dont=20assigned=5Fmcp=5Fservers=202/2,=20cargo=20test=20-p=20ap?= =?UTF-8?q?plication=20--test=20agent=5Flifecycle=20111/111,=20cargo=20tes?= =?UTF-8?q?t=20-p=20domain=20plugin=20--lib=207/7=20dont=20plugin=5Fmcp=5F?= =?UTF-8?q?client=5Fname=5Fis=5Fstable=5Fand=5Fcollision=5Ffree=5Ffor=5Fpu?= =?UTF-8?q?nctuation,=20cargo=20check=20-p=20backend=20OK,=20cargo=20check?= =?UTF-8?q?=20-p=20app-tauri=20OK;=20fichiers=20tickets/296/*,=20tickets/2?= =?UTF-8?q?99-302/*,=20counter/index,=20attachments/=20et=20scripts/=20hor?= =?UTF-8?q?s=20p=C3=A9rim=C3=A8tre;=20aucun=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/application/src/agent/lifecycle.rs | 451 ++++++++++++++++++++-- crates/application/src/project_plugin.rs | 437 ++++++++++++++++++++- crates/backend/src/lib.rs | 2 + crates/domain/src/plugin.rs | 56 +++ 4 files changed, 906 insertions(+), 40 deletions(-) diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 763d6cb8..2b80f260 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, + servers: &[PluginMcpServerSpec], +) -> Option { + 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, 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>, + /// 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>, /// 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) -> 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) -> 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::(&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::>(), + "cwd": server.cwd, + "transport": server.transport, + }) +} + +fn reconcile_plugin_mcp_json( + existing: &[u8], + plugin_servers: &[PluginMcpServerSpec], +) -> Option { + 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::>() + .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::>() + .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::>(); 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::>(); 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, + 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::>(); + servers.insert( + server.client_name(), + serde_json::json!({ + "type": "local", + "command": command, + "environment": server.env.iter().cloned().collect::>(), + "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(¤t), + ); + 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(¤t.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(¤t)) + .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(¤t)); + assert!(codex.contains("command = \"/opt/Idea.AppImage\"")); + assert!(!codex.contains("idea_plugin__stale__server")); + assert!(codex.contains(¤t.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"); diff --git a/crates/application/src/project_plugin.rs b/crates/application/src/project_plugin.rs index 836844e4..d28c1c84 100644 --- a/crates/application/src/project_plugin.rs +++ b/crates/application/src/project_plugin.rs @@ -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, 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, + } + + #[async_trait] + impl ProjectPluginSettingsStore for MemorySettings { + async fn load( + &self, + _project_root: &ProjectPath, + ) -> Result { + 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 { + Ok(self.registry.clone()) + } + + async fn save_registry( + &self, + _registry: &PluginRegistry, + ) -> Result<(), domain::PluginRegistryError> { + Ok(()) + } + } + + struct MemoryPackages { + packages: Vec, + app_data_dir: Option, + } + + #[async_trait] + impl PluginPackageStore for MemoryPackages { + async fn list_installed(&self) -> Result, PluginStoreError> { + Ok(self.packages.clone()) + } + + async fn read_manifest( + &self, + _package: &PluginPackageRef, + ) -> Result { + Ok(PluginManifestBytes { bytes: Vec::new() }) + } + + async fn install_from_archive( + &self, + _archive: &LocalPath, + ) -> Result { + Err(PluginStoreError::Invalid("not implemented".to_owned())) + } + + async fn install_from_directory( + &self, + _dir: &LocalPath, + ) -> Result { + Err(PluginStoreError::Invalid("not implemented".to_owned())) + } + + async fn commit_install( + &self, + _staged: StagedPluginPackage, + _plugin_id: &PluginId, + ) -> Result { + Err(PluginStoreError::Invalid("not implemented".to_owned())) + } + + async fn remove_package( + &self, + _plugin_id: &PluginId, + ) -> Result { + Err(PluginStoreError::Invalid("not implemented".to_owned())) + } + + fn bundle_url( + &self, + _plugin_id: &PluginId, + _entry: &RelativePath, + _hash: &ContentHash, + ) -> Result { + Err(PluginStoreError::Invalid("not implemented".to_owned())) + } + + fn app_data_dir_label(&self) -> Option { + self.app_data_dir.clone() + } + } + + struct FixedManifest(PluginManifest); + + impl PluginManifestValidator for FixedManifest { + fn validate( + &self, + _bytes: &[u8], + _package_root: &PluginPackageRef, + ) -> Result { + 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) -> 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()); + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 4b9fae5c..915fa09f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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)) diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index de881d0d..be2d4366 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -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!(