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");