From 519a0f8bcbb3a95e2394989983bafeca65872dcb Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 12 Jun 2026 11:30:23 +0000 Subject: [PATCH 1/4] feat(agent): configure MCP services from workspace.json Reads the `mcp` block from `.kaiden/workspace.json` and writes MCP server entries into agent settings during the image build. For Claude, command-based servers become stdio entries and URL-based servers become sse entries, both merged into `.claude.json` under `mcpServers`. The `Agent` trait gets a default no-op `set_mcp_servers` so other agents (e.g. OpenCode) are unaffected. Closes #88 Co-authored-by: Claude Sonnet 4.6 Signed-off-by: Philippe Martin --- src/agent/claude.rs | 182 ++++++++++++++++++++++++++++++++++++++++++++ src/agent/mod.rs | 9 +++ src/main.rs | 97 ++++++++++++++++++++++- 3 files changed, 284 insertions(+), 4 deletions(-) diff --git a/src/agent/claude.rs b/src/agent/claude.rs index e2c1217..77858e8 100644 --- a/src/agent/claude.rs +++ b/src/agent/claude.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; +use kdn_workspace_configuration::McpConfiguration; + use super::Agent; use crate::inference; @@ -90,6 +92,55 @@ impl Agent for ClaudeAgent { files } + fn set_mcp_servers( + &self, + mut files: HashMap, + mcp: Option<&McpConfiguration>, + ) -> HashMap { + let Some(mcp) = mcp else { return files }; + let content = files + .get(CLAUDE_CONFIG_FILE) + .cloned() + .unwrap_or_else(|| "{}".to_string()); + let mut config: serde_json::Value = + serde_json::from_str(&content).unwrap_or(serde_json::json!({})); + + let mut mcp_servers = config + .get("mcpServers") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + for cmd in &mcp.commands { + mcp_servers.insert( + cmd.name.clone(), + serde_json::json!({ + "type": "stdio", + "command": cmd.command, + "args": cmd.args, + "env": cmd.env, + }), + ); + } + for srv in &mcp.servers { + let mut entry = serde_json::json!({ + "type": "sse", + "url": srv.url, + }); + if !srv.headers.is_empty() { + entry["headers"] = serde_json::json!(srv.headers); + } + mcp_servers.insert(srv.name.clone(), entry); + } + + config["mcpServers"] = serde_json::Value::Object(mcp_servers); + files.insert( + CLAUDE_CONFIG_FILE.to_string(), + serde_json::to_string_pretty(&config).expect("valid json value"), + ); + files + } + fn env_vars( &self, inference: Option<&inference::InferenceKind>, @@ -302,4 +353,135 @@ mod tests { let result = ClaudeAgent.skip_onboarding(files); assert_eq!(result["other.json"], "content"); } + + // set_mcp_servers + + fn make_mcp_command(name: &str, command: &str) -> kdn_workspace_configuration::McpCommand { + kdn_workspace_configuration::McpCommand { + name: name.to_string(), + command: command.to_string(), + args: vec![], + env: Default::default(), + } + } + + fn make_mcp_server(name: &str, url: &str) -> kdn_workspace_configuration::McpServer { + kdn_workspace_configuration::McpServer { + name: name.to_string(), + url: url.to_string(), + headers: Default::default(), + } + } + + fn make_mcp( + commands: Vec, + servers: Vec, + ) -> kdn_workspace_configuration::McpConfiguration { + kdn_workspace_configuration::McpConfiguration { commands, servers } + } + + #[test] + fn set_mcp_servers_with_none_returns_files_unchanged() { + let mut files = HashMap::new(); + files.insert("other.json".to_string(), "content".to_string()); + let result = ClaudeAgent.set_mcp_servers(files.clone(), None); + assert_eq!(result, files); + } + + #[test] + fn set_mcp_servers_with_command_writes_stdio_entry() { + let mcp = make_mcp(vec![make_mcp_command("my-server", "npx")], vec![]); + let result = ClaudeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcpServers"]["my-server"]["type"], "stdio"); + assert_eq!(json["mcpServers"]["my-server"]["command"], "npx"); + } + + #[test] + fn set_mcp_servers_with_command_writes_args_and_env() { + let mut cmd = make_mcp_command("srv", "node"); + cmd.args = vec!["server.js".to_string()]; + cmd.env.insert("TOKEN".to_string(), "abc".to_string()); + let mcp = make_mcp(vec![cmd], vec![]); + let result = ClaudeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcpServers"]["srv"]["args"][0], "server.js"); + assert_eq!(json["mcpServers"]["srv"]["env"]["TOKEN"], "abc"); + } + + #[test] + fn set_mcp_servers_with_server_writes_sse_entry() { + let mcp = make_mcp( + vec![], + vec![make_mcp_server("remote", "https://mcp.example.com")], + ); + let result = ClaudeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcpServers"]["remote"]["type"], "sse"); + assert_eq!( + json["mcpServers"]["remote"]["url"], + "https://mcp.example.com" + ); + } + + #[test] + fn set_mcp_servers_with_server_omits_headers_when_empty() { + let mcp = make_mcp( + vec![], + vec![make_mcp_server("remote", "https://mcp.example.com")], + ); + let result = ClaudeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert!(json["mcpServers"]["remote"]["headers"].is_null()); + } + + #[test] + fn set_mcp_servers_with_server_writes_headers_when_present() { + let mut srv = make_mcp_server("remote", "https://mcp.example.com"); + srv.headers + .insert("Authorization".to_string(), "Bearer tok".to_string()); + let mcp = make_mcp(vec![], vec![srv]); + let result = ClaudeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!( + json["mcpServers"]["remote"]["headers"]["Authorization"], + "Bearer tok" + ); + } + + #[test] + fn set_mcp_servers_preserves_existing_claude_json_fields() { + let mut files = HashMap::new(); + files.insert( + CLAUDE_CONFIG_FILE.to_string(), + r#"{"hasCompletedOnboarding": true}"#.to_string(), + ); + let mcp = make_mcp(vec![make_mcp_command("s", "cmd")], vec![]); + let result = ClaudeAgent.set_mcp_servers(files, Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["hasCompletedOnboarding"], true); + assert!(json["mcpServers"]["s"].is_object()); + } + + #[test] + fn set_mcp_servers_merges_with_existing_mcp_servers() { + let mut files = HashMap::new(); + files.insert( + CLAUDE_CONFIG_FILE.to_string(), + r#"{"mcpServers": {"existing": {"type": "sse", "url": "https://old.example.com"}}}"# + .to_string(), + ); + let mcp = make_mcp(vec![make_mcp_command("new-srv", "npx")], vec![]); + let result = ClaudeAgent.set_mcp_servers(files, Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[CLAUDE_CONFIG_FILE].as_str()).unwrap(); + assert!(json["mcpServers"]["existing"].is_object()); + assert!(json["mcpServers"]["new-srv"].is_object()); + } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 18a1d4e..805ae40 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -53,6 +53,15 @@ pub trait Agent { ) -> HashMap { files } + /// Merges MCP server configuration into `files` and returns the result. + /// If `mcp` is `None`, returns `files` unchanged. + fn set_mcp_servers( + &self, + files: HashMap, + _mcp: Option<&kdn_workspace_configuration::McpConfiguration>, + ) -> HashMap { + files + } /// Returns environment variables to bake into the image for this agent. /// `endpoint` overrides the inference provider's default URL when `Some`. /// `model` sets the default model when `Some`. diff --git a/src/main.rs b/src/main.rs index 89bda7f..0385f04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,6 +127,7 @@ fn run( inference_kind.as_ref(), endpoint, model, + workspace.as_ref().and_then(|ws| ws.mcp.as_ref()), context_dir.path(), )? } else { @@ -246,6 +247,7 @@ fn stage_agent_settings( inference: Option<&inference::InferenceKind>, endpoint: Option<&str>, model: Option<&str>, + mcp: Option<&kdn_workspace_configuration::McpConfiguration>, context_dir: &Path, ) -> Result> { let existing = match settings_dir { @@ -256,6 +258,7 @@ fn stage_agent_settings( let base_url = resolve_base_url(inference, endpoint); let files = agent.skip_onboarding(existing); let files = agent.set_inference(files, inference, base_url.as_deref(), model); + let files = agent.set_mcp_servers(files, mcp); if settings_dir.is_none() && files.is_empty() { return Ok(false); @@ -560,6 +563,7 @@ mod tests { None, None, None, + None, context.path(), ) .unwrap(); @@ -577,6 +581,7 @@ mod tests { None, None, None, + None, context.path(), ) .unwrap(); @@ -599,6 +604,7 @@ mod tests { None, None, None, + None, context.path(), ) .unwrap(); @@ -624,6 +630,7 @@ mod tests { None, None, None, + None, context.path(), ) .unwrap(); @@ -633,9 +640,16 @@ mod tests { #[test] fn stage_agent_settings_creates_claude_json_for_claude_agent_without_settings_dir() { let context = tempfile::tempdir().unwrap(); - let staged = - stage_agent_settings(&agent::ClaudeAgent, None, None, None, None, context.path()) - .unwrap(); + let staged = stage_agent_settings( + &agent::ClaudeAgent, + None, + None, + None, + None, + None, + context.path(), + ) + .unwrap(); assert!(staged); assert!( context @@ -649,7 +663,16 @@ mod tests { #[test] fn stage_agent_settings_claude_json_has_onboarding_flags() { let context = tempfile::tempdir().unwrap(); - stage_agent_settings(&agent::ClaudeAgent, None, None, None, None, context.path()).unwrap(); + stage_agent_settings( + &agent::ClaudeAgent, + None, + None, + None, + None, + None, + context.path(), + ) + .unwrap(); let content = std::fs::read_to_string(context.path().join("agent-settings").join(".claude.json")) .unwrap(); @@ -667,6 +690,7 @@ mod tests { Some(&inference::InferenceKind::Ollama), None, None, + None, context.path(), ) .unwrap(); @@ -920,6 +944,7 @@ mod tests { Some(&inference::InferenceKind::Anthropic), None, Some("claude-opus-4-5"), + None, context.path(), ) .unwrap(); @@ -944,6 +969,7 @@ mod tests { Some(&inference::InferenceKind::VertexAi), None, Some("vertex/claude-opus-4-5"), + None, context.path(), ) .unwrap(); @@ -1084,6 +1110,7 @@ mod tests { Some(&inference::InferenceKind::OpenAi), None, Some("gpt-4o"), + None, context.path(), ) .unwrap(); @@ -1099,6 +1126,68 @@ mod tests { ); } + #[test] + fn stage_agent_settings_with_mcp_command_writes_mcp_servers_to_claude_json() { + let context = tempfile::tempdir().unwrap(); + let mcp = kdn_workspace_configuration::McpConfiguration { + commands: vec![kdn_workspace_configuration::McpCommand { + name: "my-mcp".to_string(), + command: "npx".to_string(), + args: vec!["-y".to_string(), "my-mcp-pkg".to_string()], + env: Default::default(), + }], + servers: vec![], + }; + stage_agent_settings( + &agent::ClaudeAgent, + None, + None, + None, + None, + Some(&mcp), + context.path(), + ) + .unwrap(); + let content = + std::fs::read_to_string(context.path().join("agent-settings").join(".claude.json")) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["mcpServers"]["my-mcp"]["type"], "stdio"); + assert_eq!(json["mcpServers"]["my-mcp"]["command"], "npx"); + } + + #[test] + fn stage_agent_settings_with_mcp_server_writes_sse_entry_to_claude_json() { + let context = tempfile::tempdir().unwrap(); + let mcp = kdn_workspace_configuration::McpConfiguration { + commands: vec![], + servers: vec![kdn_workspace_configuration::McpServer { + name: "remote-mcp".to_string(), + url: "https://mcp.example.com".to_string(), + headers: Default::default(), + }], + }; + stage_agent_settings( + &agent::ClaudeAgent, + None, + None, + None, + None, + Some(&mcp), + context.path(), + ) + .unwrap(); + let content = + std::fs::read_to_string(context.path().join("agent-settings").join(".claude.json")) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["mcpServers"]["remote-mcp"]["type"], "sse"); + assert_eq!( + json["mcpServers"]["remote-mcp"]["url"], + "https://mcp.example.com" + ); + } + // parse_workspace_host #[test] From 0c5dd2719e235e6dca67d73186fca70bc94038bc Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 12 Jun 2026 11:41:26 +0000 Subject: [PATCH 2/4] feat(agent): configure MCP services for opencode agent Implements set_mcp_servers for OpencodeAgent. Command-based servers become local entries (type "local", command list = [cmd, ...args], environment map) and URL-based servers become remote entries (type "remote", url, headers). Both are merged into the "mcp" key of .config/opencode/config.json, preserving all existing fields. Co-authored-by: Claude Sonnet 4.6 Signed-off-by: Philippe Martin --- src/agent/opencode/mod.rs | 208 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 37 +++++++ 2 files changed, 245 insertions(+) diff --git a/src/agent/opencode/mod.rs b/src/agent/opencode/mod.rs index cab46f1..e8533f9 100644 --- a/src/agent/opencode/mod.rs +++ b/src/agent/opencode/mod.rs @@ -21,9 +21,13 @@ mod vertexai; use std::collections::HashMap; +use kdn_workspace_configuration::McpConfiguration; + use super::Agent; use crate::inference; +const OPENCODE_CONFIG_FILE: &str = ".config/opencode/config.json"; + pub struct OpencodeAgent; impl Agent for OpencodeAgent { @@ -83,6 +87,59 @@ impl Agent for OpencodeAgent { "/sandbox/.opencode/skills" } + fn set_mcp_servers( + &self, + mut files: HashMap, + mcp: Option<&McpConfiguration>, + ) -> HashMap { + let Some(mcp) = mcp else { return files }; + let content = files.get(OPENCODE_CONFIG_FILE).cloned().unwrap_or_else(|| { + serde_json::json!({ "$schema": "https://opencode.ai/config.json" }).to_string() + }); + let mut config: serde_json::Value = + serde_json::from_str(&content).unwrap_or(serde_json::json!({})); + + let mut mcp_map = config + .get("mcp") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + for cmd in &mcp.commands { + let command: Vec = std::iter::once(cmd.command.as_str()) + .chain(cmd.args.iter().map(String::as_str)) + .map(|s| serde_json::json!(s)) + .collect(); + let mut entry = serde_json::json!({ + "type": "local", + "command": command, + "enabled": true, + }); + if !cmd.env.is_empty() { + entry["environment"] = serde_json::json!(cmd.env); + } + mcp_map.insert(cmd.name.clone(), entry); + } + for srv in &mcp.servers { + let mut entry = serde_json::json!({ + "type": "remote", + "url": srv.url, + "enabled": true, + }); + if !srv.headers.is_empty() { + entry["headers"] = serde_json::json!(srv.headers); + } + mcp_map.insert(srv.name.clone(), entry); + } + + config["mcp"] = serde_json::Value::Object(mcp_map); + files.insert( + OPENCODE_CONFIG_FILE.to_string(), + serde_json::to_string_pretty(&config).expect("valid json value"), + ); + files + } + fn policy_yaml(&self) -> &str { r#"version: 1 network_policies: @@ -347,4 +404,155 @@ mod tests { ); assert_eq!(result, files); } + + // set_mcp_servers + + fn make_mcp_command(name: &str, command: &str) -> kdn_workspace_configuration::McpCommand { + kdn_workspace_configuration::McpCommand { + name: name.to_string(), + command: command.to_string(), + args: vec![], + env: Default::default(), + } + } + + fn make_mcp_server(name: &str, url: &str) -> kdn_workspace_configuration::McpServer { + kdn_workspace_configuration::McpServer { + name: name.to_string(), + url: url.to_string(), + headers: Default::default(), + } + } + + fn make_mcp( + commands: Vec, + servers: Vec, + ) -> kdn_workspace_configuration::McpConfiguration { + kdn_workspace_configuration::McpConfiguration { commands, servers } + } + + #[test] + fn set_mcp_servers_with_none_returns_files_unchanged() { + let mut files = HashMap::new(); + files.insert("other.json".to_string(), "content".to_string()); + let result = OpencodeAgent.set_mcp_servers(files.clone(), None); + assert_eq!(result, files); + } + + #[test] + fn set_mcp_servers_with_command_writes_local_entry() { + let mcp = make_mcp(vec![make_mcp_command("my-mcp", "npx")], vec![]); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcp"]["my-mcp"]["type"], "local"); + assert_eq!(json["mcp"]["my-mcp"]["command"][0], "npx"); + assert_eq!(json["mcp"]["my-mcp"]["enabled"], true); + } + + #[test] + fn set_mcp_servers_with_command_merges_args_into_command_list() { + let mut cmd = make_mcp_command("srv", "npx"); + cmd.args = vec!["-y".to_string(), "my-pkg".to_string()]; + let mcp = make_mcp(vec![cmd], vec![]); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcp"]["srv"]["command"][0], "npx"); + assert_eq!(json["mcp"]["srv"]["command"][1], "-y"); + assert_eq!(json["mcp"]["srv"]["command"][2], "my-pkg"); + } + + #[test] + fn set_mcp_servers_with_command_env_writes_environment_field() { + let mut cmd = make_mcp_command("srv", "node"); + cmd.env.insert("TOKEN".to_string(), "abc".to_string()); + let mcp = make_mcp(vec![cmd], vec![]); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcp"]["srv"]["environment"]["TOKEN"], "abc"); + } + + #[test] + fn set_mcp_servers_with_command_empty_env_omits_environment_field() { + let mcp = make_mcp(vec![make_mcp_command("srv", "cmd")], vec![]); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert!(json["mcp"]["srv"]["environment"].is_null()); + } + + #[test] + fn set_mcp_servers_with_server_writes_remote_entry() { + let mcp = make_mcp( + vec![], + vec![make_mcp_server("remote", "https://mcp.example.com")], + ); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["mcp"]["remote"]["type"], "remote"); + assert_eq!(json["mcp"]["remote"]["url"], "https://mcp.example.com"); + assert_eq!(json["mcp"]["remote"]["enabled"], true); + } + + #[test] + fn set_mcp_servers_with_server_headers_writes_headers_field() { + let mut srv = make_mcp_server("remote", "https://mcp.example.com"); + srv.headers + .insert("Authorization".to_string(), "Bearer tok".to_string()); + let mcp = make_mcp(vec![], vec![srv]); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!( + json["mcp"]["remote"]["headers"]["Authorization"], + "Bearer tok" + ); + } + + #[test] + fn set_mcp_servers_with_server_empty_headers_omits_headers_field() { + let mcp = make_mcp( + vec![], + vec![make_mcp_server("remote", "https://mcp.example.com")], + ); + let result = OpencodeAgent.set_mcp_servers(HashMap::new(), Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert!(json["mcp"]["remote"]["headers"].is_null()); + } + + #[test] + fn set_mcp_servers_preserves_existing_config_fields() { + let mut files = HashMap::new(); + files.insert( + OPENCODE_CONFIG_FILE.to_string(), + r#"{"$schema":"https://opencode.ai/config.json","model":"claude-opus-4-5"}"# + .to_string(), + ); + let mcp = make_mcp(vec![make_mcp_command("s", "cmd")], vec![]); + let result = OpencodeAgent.set_mcp_servers(files, Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert_eq!(json["model"], "claude-opus-4-5"); + assert!(json["mcp"]["s"].is_object()); + } + + #[test] + fn set_mcp_servers_merges_with_existing_mcp_entries() { + let mut files = HashMap::new(); + files.insert( + OPENCODE_CONFIG_FILE.to_string(), + r#"{"mcp":{"old":{"type":"remote","url":"https://old.example.com","enabled":true}}}"# + .to_string(), + ); + let mcp = make_mcp(vec![make_mcp_command("new-srv", "npx")], vec![]); + let result = OpencodeAgent.set_mcp_servers(files, Some(&mcp)); + let json: serde_json::Value = + serde_json::from_str(result[OPENCODE_CONFIG_FILE].as_str()).unwrap(); + assert!(json["mcp"]["old"].is_object()); + assert!(json["mcp"]["new-srv"].is_object()); + } } diff --git a/src/main.rs b/src/main.rs index 0385f04..3abc6fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1188,6 +1188,43 @@ mod tests { ); } + #[test] + fn stage_agent_settings_opencode_with_mcp_command_writes_local_entry_to_config() { + let context = tempfile::tempdir().unwrap(); + let mcp = kdn_workspace_configuration::McpConfiguration { + commands: vec![kdn_workspace_configuration::McpCommand { + name: "playwright".to_string(), + command: "npx".to_string(), + args: vec!["-y".to_string(), "@playwright/mcp@latest".to_string()], + env: Default::default(), + }], + servers: vec![], + }; + stage_agent_settings( + &agent::OpencodeAgent, + None, + Some(&inference::InferenceKind::Anthropic), + None, + Some("claude-opus-4-5"), + Some(&mcp), + context.path(), + ) + .unwrap(); + let content = std::fs::read_to_string( + context + .path() + .join("agent-settings") + .join(".config") + .join("opencode") + .join("config.json"), + ) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["mcp"]["playwright"]["type"], "local"); + assert_eq!(json["mcp"]["playwright"]["command"][0], "npx"); + assert_eq!(json["mcp"]["playwright"]["enabled"], true); + } + // parse_workspace_host #[test] From 6785fdb2e0107917e82e9afecd719285efd0f12f Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 12 Jun 2026 11:48:00 +0000 Subject: [PATCH 3/4] refactor(agent): use constant for opencode config file path Replace all hardcoded ".config/opencode/config.json" strings in the opencode sub-modules with a shared pub(crate) constant defined in mod.rs, imported via `use super::OPENCODE_CONFIG_FILE` in each module. Co-authored-by: Claude Sonnet 4.6 Signed-off-by: Philippe Martin --- src/agent/opencode/anthropic.rs | 18 ++++++++++-------- src/agent/opencode/mod.rs | 16 ++++++++-------- src/agent/opencode/ollama.rs | 18 ++++++++++-------- src/agent/opencode/openai.rs | 22 ++++++++++++---------- src/agent/opencode/vertexai.rs | 10 ++++++---- 5 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/agent/opencode/anthropic.rs b/src/agent/opencode/anthropic.rs index f962f33..208efd6 100644 --- a/src/agent/opencode/anthropic.rs +++ b/src/agent/opencode/anthropic.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; +use super::OPENCODE_CONFIG_FILE; + pub(super) fn configure( mut files: HashMap, base_url: Option<&str>, @@ -33,7 +35,7 @@ pub(super) fn configure( config["model"] = serde_json::json!(m); } files.insert( - ".config/opencode/config.json".to_string(), + OPENCODE_CONFIG_FILE.to_string(), serde_json::to_string_pretty(&config).expect("valid json value"), ); files @@ -50,7 +52,7 @@ mod tests { Some("https://my-anthropic-proxy.example.com"), None, ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -60,7 +62,7 @@ mod tests { Some("https://my-anthropic-proxy.example.com"), None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(config.contains("https://my-anthropic-proxy.example.com")); } @@ -72,7 +74,7 @@ mod tests { None, ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert!(config["provider"]["anthropic"].is_object()); } @@ -83,7 +85,7 @@ mod tests { Some("https://my-anthropic-proxy.example.com"), None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(serde_json::from_str::(config).is_ok()); } @@ -95,7 +97,7 @@ mod tests { Some("claude-opus-4-5"), ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "claude-opus-4-5"); } @@ -103,7 +105,7 @@ mod tests { fn configure_without_url_sets_model_only() { let result = configure(HashMap::new(), None, Some("claude-opus-4-5")); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "claude-opus-4-5"); assert!(config["provider"]["anthropic"]["options"].is_null()); } @@ -116,7 +118,7 @@ mod tests { Some("claude-opus-4-5"), ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "claude-opus-4-5"); assert_eq!( config["provider"]["anthropic"]["options"]["baseURL"], diff --git a/src/agent/opencode/mod.rs b/src/agent/opencode/mod.rs index e8533f9..09c58f3 100644 --- a/src/agent/opencode/mod.rs +++ b/src/agent/opencode/mod.rs @@ -26,7 +26,7 @@ use kdn_workspace_configuration::McpConfiguration; use super::Agent; use crate::inference; -const OPENCODE_CONFIG_FILE: &str = ".config/opencode/config.json"; +pub(crate) const OPENCODE_CONFIG_FILE: &str = ".config/opencode/config.json"; pub struct OpencodeAgent; @@ -274,7 +274,7 @@ mod tests { Some("http://host.openshell.internal:11434/v1"), None, ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -311,7 +311,7 @@ mod tests { Some("https://my-anthropic-proxy.example.com"), None, ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -322,7 +322,7 @@ mod tests { Some("https://my-anthropic-proxy.example.com"), None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(config.contains("https://my-anthropic-proxy.example.com")); } @@ -334,7 +334,7 @@ mod tests { None, Some("claude-opus-4-5"), ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -345,7 +345,7 @@ mod tests { None, Some("vertex/claude-opus-4-5"), ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -378,7 +378,7 @@ mod tests { None, Some("gpt-4o"), ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -389,7 +389,7 @@ mod tests { Some("https://my-openai-proxy.example.com"), None, ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] diff --git a/src/agent/opencode/ollama.rs b/src/agent/opencode/ollama.rs index de6b9cd..bada4a3 100644 --- a/src/agent/opencode/ollama.rs +++ b/src/agent/opencode/ollama.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; +use super::OPENCODE_CONFIG_FILE; + pub(super) fn configure( mut files: HashMap, base_url: &str, @@ -43,7 +45,7 @@ pub(super) fn configure( config["model"] = serde_json::json!(format!("ollama/{m}")); } files.insert( - ".config/opencode/config.json".to_string(), + OPENCODE_CONFIG_FILE.to_string(), serde_json::to_string_pretty(&config).expect("valid json value"), ); files @@ -60,7 +62,7 @@ mod tests { "http://host.openshell.internal:11434/v1", None, ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -70,7 +72,7 @@ mod tests { "http://host.openshell.internal:11434/v1", None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(config.contains("http://host.openshell.internal:11434/v1")); } @@ -81,7 +83,7 @@ mod tests { "http://host.openshell.internal:11434/v1", None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(config.contains("ollama")); assert!(config.contains("@ai-sdk/openai-compatible")); } @@ -94,7 +96,7 @@ mod tests { None, ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); let models = &config["provider"]["ollama"]["models"]; assert!(models["lfm2.5"]["tools"].as_bool().unwrap()); assert!(models["qwen3-coder:30b"]["tools"].as_bool().unwrap()); @@ -107,7 +109,7 @@ mod tests { "http://host.openshell.internal:11434/v1", None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(serde_json::from_str::(config).is_ok()); } @@ -119,7 +121,7 @@ mod tests { Some("qwen3-coder:30b"), ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "ollama/qwen3-coder:30b"); } @@ -131,7 +133,7 @@ mod tests { Some("my-custom-model"), ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); let models = &config["provider"]["ollama"]["models"]; assert!(models["my-custom-model"]["tools"].as_bool().unwrap()); assert!(models["lfm2.5"].is_null()); diff --git a/src/agent/opencode/openai.rs b/src/agent/opencode/openai.rs index 00456ca..687b0e9 100644 --- a/src/agent/opencode/openai.rs +++ b/src/agent/opencode/openai.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; +use super::OPENCODE_CONFIG_FILE; + pub(super) fn configure( mut files: HashMap, base_url: Option<&str>, @@ -41,7 +43,7 @@ pub(super) fn configure( config["model"] = serde_json::json!(format!("openai/{m}")); } files.insert( - ".config/opencode/config.json".to_string(), + OPENCODE_CONFIG_FILE.to_string(), serde_json::to_string_pretty(&config).expect("valid json value"), ); files @@ -54,14 +56,14 @@ mod tests { #[test] fn configure_with_model_creates_opencode_config() { let result = configure(HashMap::new(), None, Some("gpt-4o")); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] fn configure_with_model_sets_native_openai_prefix() { let result = configure(HashMap::new(), None, Some("gpt-4o")); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "openai/gpt-4o"); } @@ -69,7 +71,7 @@ mod tests { fn configure_with_model_does_not_add_provider_block() { let result = configure(HashMap::new(), None, Some("gpt-4o")); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert!(config["provider"].is_null()); } @@ -80,7 +82,7 @@ mod tests { Some("https://my-openai-proxy.example.com"), None, ); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] @@ -91,7 +93,7 @@ mod tests { None, ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert!(config["provider"]["custom"].is_object()); assert_eq!( config["provider"]["custom"]["npm"], @@ -106,7 +108,7 @@ mod tests { Some("https://my-openai-proxy.example.com"), None, ); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(config.contains("https://my-openai-proxy.example.com")); } @@ -118,7 +120,7 @@ mod tests { Some("gpt-4o"), ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "custom/gpt-4o"); } @@ -130,7 +132,7 @@ mod tests { None, ); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "custom/gpt-4o"); assert!(config["provider"]["custom"]["models"]["gpt-4o"].is_object()); } @@ -138,7 +140,7 @@ mod tests { #[test] fn configure_config_is_valid_json() { let result = configure(HashMap::new(), None, Some("gpt-4o")); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(serde_json::from_str::(config).is_ok()); } } diff --git a/src/agent/opencode/vertexai.rs b/src/agent/opencode/vertexai.rs index 11877df..7c47785 100644 --- a/src/agent/opencode/vertexai.rs +++ b/src/agent/opencode/vertexai.rs @@ -16,6 +16,8 @@ use std::collections::HashMap; +use super::OPENCODE_CONFIG_FILE; + pub(super) fn configure( mut files: HashMap, model: &str, @@ -25,7 +27,7 @@ pub(super) fn configure( "model": model }); files.insert( - ".config/opencode/config.json".to_string(), + OPENCODE_CONFIG_FILE.to_string(), serde_json::to_string_pretty(&config).expect("valid json value"), ); files @@ -38,21 +40,21 @@ mod tests { #[test] fn configure_creates_opencode_config() { let result = configure(HashMap::new(), "vertex/claude-opus-4-5"); - assert!(result.contains_key(".config/opencode/config.json")); + assert!(result.contains_key(OPENCODE_CONFIG_FILE)); } #[test] fn configure_sets_model_field() { let result = configure(HashMap::new(), "vertex/claude-opus-4-5"); let config: serde_json::Value = - serde_json::from_str(result.get(".config/opencode/config.json").unwrap()).unwrap(); + serde_json::from_str(result.get(OPENCODE_CONFIG_FILE).unwrap()).unwrap(); assert_eq!(config["model"], "vertex/claude-opus-4-5"); } #[test] fn configure_config_is_valid_json() { let result = configure(HashMap::new(), "vertex/claude-opus-4-5"); - let config = result.get(".config/opencode/config.json").unwrap(); + let config = result.get(OPENCODE_CONFIG_FILE).unwrap(); assert!(serde_json::from_str::(config).is_ok()); } } From fbda56985ba39d54a5f43ed842e90da769702910 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 15 Jun 2026 10:12:05 +0200 Subject: [PATCH 4/4] fix: review Signed-off-by: Philippe Martin --- src/agent/opencode/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent/opencode/mod.rs b/src/agent/opencode/mod.rs index 09c58f3..5abfd16 100644 --- a/src/agent/opencode/mod.rs +++ b/src/agent/opencode/mod.rs @@ -96,8 +96,9 @@ impl Agent for OpencodeAgent { let content = files.get(OPENCODE_CONFIG_FILE).cloned().unwrap_or_else(|| { serde_json::json!({ "$schema": "https://opencode.ai/config.json" }).to_string() }); - let mut config: serde_json::Value = - serde_json::from_str(&content).unwrap_or(serde_json::json!({})); + let mut config: serde_json::Value = serde_json::from_str(&content).unwrap_or_else( + |_| serde_json::json!({ "$schema": "https://opencode.ai/config.json" }), + ); let mut mcp_map = config .get("mcp")