Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions src/agent/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

use std::collections::HashMap;

use kdn_workspace_configuration::McpConfiguration;

use super::Agent;
use crate::inference;

Expand Down Expand Up @@ -90,6 +92,55 @@ impl Agent for ClaudeAgent {
files
}

fn set_mcp_servers(
&self,
mut files: HashMap<String, String>,
mcp: Option<&McpConfiguration>,
) -> HashMap<String, String> {
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>,
Expand Down Expand Up @@ -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<kdn_workspace_configuration::McpCommand>,
servers: Vec<kdn_workspace_configuration::McpServer>,
) -> 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());
}
}
9 changes: 9 additions & 0 deletions src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ pub trait Agent {
) -> HashMap<String, String> {
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<String, String>,
_mcp: Option<&kdn_workspace_configuration::McpConfiguration>,
) -> HashMap<String, String> {
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`.
Expand Down
18 changes: 10 additions & 8 deletions src/agent/opencode/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

use std::collections::HashMap;

use super::OPENCODE_CONFIG_FILE;

pub(super) fn configure(
mut files: HashMap<String, String>,
base_url: Option<&str>,
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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"));
}

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

Expand All @@ -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::<serde_json::Value>(config).is_ok());
}

Expand All @@ -95,15 +97,15 @@ 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");
}

#[test]
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());
}
Expand All @@ -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"],
Expand Down
Loading