use anyhow::{Context, Result};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
pub trait McpHandler: Send + Sync {
fn name(&self) -> &str;
fn configure_mcp_servers(
&self,
project_root: &Path,
artifact_base: &Path,
lockfile_entries: &[crate::lockfile::LockedResource],
cache: &crate::cache::Cache,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()>;
}
pub struct ClaudeCodeMcpHandler;
impl McpHandler for ClaudeCodeMcpHandler {
fn name(&self) -> &str {
"claude-code"
}
fn configure_mcp_servers(
&self,
project_root: &Path,
_artifact_base: &Path,
lockfile_entries: &[crate::lockfile::LockedResource],
cache: &crate::cache::Cache,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
let project_root = project_root.to_path_buf();
let entries = lockfile_entries.to_vec();
let cache = cache.clone();
Box::pin(async move {
if entries.is_empty() {
return Ok(());
}
let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
std::collections::HashMap::new();
for entry in &entries {
let source_path = if let Some(source_name) = &entry.source {
let url = entry
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
let is_local_source =
entry.resolved_commit.as_deref().is_none_or(str::is_empty);
if is_local_source {
std::path::PathBuf::from(url).join(&entry.path)
} else {
let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
})?;
let worktree = cache
.get_or_create_worktree_for_sha(
source_name,
url,
sha,
Some(&entry.name),
)
.await?;
worktree.join(&entry.path)
}
} else {
let candidate = Path::new(&entry.path);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
project_root.join(candidate)
}
};
let mut config: super::McpServerConfig = crate::utils::read_json_file(&source_path)
.with_context(|| {
format!("Failed to read MCP server file: {}", source_path.display())
})?;
config.agpm_metadata = Some(super::AgpmMetadata {
managed: true,
source: entry.source.clone(),
version: entry.version.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
dependency_name: Some(entry.name.clone()),
});
mcp_servers.insert(entry.name.clone(), config);
}
let mcp_config_path = project_root.join(".mcp.json");
super::merge_mcp_servers(&mcp_config_path, mcp_servers).await?;
Ok(())
})
}
fn clean_mcp_servers(&self, project_root: &Path, _artifact_base: &Path) -> Result<()> {
super::clean_mcp_servers(project_root)
}
}
pub struct OpenCodeMcpHandler;
impl McpHandler for OpenCodeMcpHandler {
fn name(&self) -> &str {
"opencode"
}
fn configure_mcp_servers(
&self,
project_root: &Path,
artifact_base: &Path,
lockfile_entries: &[crate::lockfile::LockedResource],
cache: &crate::cache::Cache,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
let project_root = project_root.to_path_buf();
let artifact_base = artifact_base.to_path_buf();
let entries = lockfile_entries.to_vec();
let cache = cache.clone();
Box::pin(async move {
if entries.is_empty() {
return Ok(());
}
let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
std::collections::HashMap::new();
for entry in &entries {
let source_path = if let Some(source_name) = &entry.source {
let url = entry
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
let is_local_source =
entry.resolved_commit.as_deref().is_none_or(str::is_empty);
if is_local_source {
std::path::PathBuf::from(url).join(&entry.path)
} else {
let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
})?;
let worktree = cache
.get_or_create_worktree_for_sha(
source_name,
url,
sha,
Some(&entry.name),
)
.await?;
worktree.join(&entry.path)
}
} else {
let candidate = Path::new(&entry.path);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
project_root.join(candidate)
}
};
let mut config: super::McpServerConfig = crate::utils::read_json_file(&source_path)
.with_context(|| {
format!("Failed to read MCP server file: {}", source_path.display())
})?;
config.agpm_metadata = Some(super::AgpmMetadata {
managed: true,
source: entry.source.clone(),
version: entry.version.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
dependency_name: Some(entry.name.clone()),
});
mcp_servers.insert(entry.name.clone(), config);
}
let opencode_config_path = artifact_base.join("opencode.json");
let mut opencode_config: serde_json::Value = if opencode_config_path.exists() {
crate::utils::read_json_file(&opencode_config_path).with_context(|| {
format!("Failed to read OpenCode config: {}", opencode_config_path.display())
})?
} else {
serde_json::json!({})
};
if !opencode_config.is_object() {
opencode_config = serde_json::json!({});
}
let config_obj = opencode_config
.as_object_mut()
.expect("opencode_config must be an object after is_object() check");
let mcp_section = config_obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
if let Some(mcp_obj) = mcp_section.as_object_mut() {
for (name, server_config) in mcp_servers {
let server_json = serde_json::to_value(&server_config)?;
mcp_obj.insert(name, server_json);
}
}
crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
.with_context(|| {
format!("Failed to write OpenCode config: {}", opencode_config_path.display())
})?;
Ok(())
})
}
fn clean_mcp_servers(&self, _project_root: &Path, artifact_base: &Path) -> Result<()> {
let opencode_config_path = artifact_base.join("opencode.json");
let mcp_servers_dir = artifact_base.join("agpm").join("mcp-servers");
let mut removed_count = 0;
if mcp_servers_dir.exists() {
for entry in std::fs::read_dir(&mcp_servers_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
std::fs::remove_file(&path).with_context(|| {
format!("Failed to remove MCP server file: {}", path.display())
})?;
removed_count += 1;
}
}
}
if opencode_config_path.exists() {
let mut opencode_config: serde_json::Value =
crate::utils::read_json_file(&opencode_config_path).with_context(|| {
format!("Failed to read OpenCode config: {}", opencode_config_path.display())
})?;
if let Some(config_obj) = opencode_config.as_object_mut()
&& let Some(mcp_section) = config_obj.get_mut("mcp")
&& let Some(mcp_obj) = mcp_section.as_object_mut()
{
mcp_obj.retain(|_name, server| {
if let Ok(config) =
serde_json::from_value::<super::McpServerConfig>(server.clone())
{
config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
} else {
true
}
});
crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
.with_context(|| {
format!(
"Failed to write OpenCode config: {}",
opencode_config_path.display()
)
})?;
}
}
if removed_count > 0 {
println!("✓ Removed {removed_count} MCP server(s) from OpenCode");
} else {
println!("No MCP servers found to remove");
}
Ok(())
}
}
pub enum ConcreteMcpHandler {
ClaudeCode(ClaudeCodeMcpHandler),
OpenCode(OpenCodeMcpHandler),
}
impl McpHandler for ConcreteMcpHandler {
fn name(&self) -> &str {
match self {
Self::ClaudeCode(h) => h.name(),
Self::OpenCode(h) => h.name(),
}
}
fn configure_mcp_servers(
&self,
project_root: &Path,
artifact_base: &Path,
lockfile_entries: &[crate::lockfile::LockedResource],
cache: &crate::cache::Cache,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
match self {
Self::ClaudeCode(h) => {
h.configure_mcp_servers(project_root, artifact_base, lockfile_entries, cache)
}
Self::OpenCode(h) => {
h.configure_mcp_servers(project_root, artifact_base, lockfile_entries, cache)
}
}
}
fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()> {
match self {
Self::ClaudeCode(h) => h.clean_mcp_servers(project_root, artifact_base),
Self::OpenCode(h) => h.clean_mcp_servers(project_root, artifact_base),
}
}
}
pub fn get_mcp_handler(artifact_type: &str) -> Option<ConcreteMcpHandler> {
match artifact_type {
"claude-code" => Some(ConcreteMcpHandler::ClaudeCode(ClaudeCodeMcpHandler)),
"opencode" => Some(ConcreteMcpHandler::OpenCode(OpenCodeMcpHandler)),
_ => None, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_mcp_handler_claude_code() {
let handler = get_mcp_handler("claude-code");
assert!(handler.is_some());
let handler = handler.unwrap();
assert_eq!(handler.name(), "claude-code");
}
#[test]
fn test_get_mcp_handler_opencode() {
let handler = get_mcp_handler("opencode");
assert!(handler.is_some());
let handler = handler.unwrap();
assert_eq!(handler.name(), "opencode");
}
#[test]
fn test_get_mcp_handler_unknown() {
let handler = get_mcp_handler("unknown");
assert!(handler.is_none());
}
#[test]
fn test_get_mcp_handler_agpm() {
let handler = get_mcp_handler("agpm");
assert!(handler.is_none());
}
}