1use anyhow::{Context, Result};
7use std::future::Future;
8use std::path::Path;
9use std::pin::Pin;
10
11pub trait McpHandler: Send + Sync {
18 fn name(&self) -> &str;
20
21 fn configure_mcp_servers(
37 &self,
38 project_root: &Path,
39 artifact_base: &Path,
40 lockfile_entries: &[crate::lockfile::LockedResource],
41 cache: &crate::cache::Cache,
42 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
43
44 fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()>;
55}
56
57pub struct ClaudeCodeMcpHandler;
62
63impl McpHandler for ClaudeCodeMcpHandler {
64 fn name(&self) -> &str {
65 "claude-code"
66 }
67
68 fn configure_mcp_servers(
69 &self,
70 project_root: &Path,
71 _artifact_base: &Path,
72 lockfile_entries: &[crate::lockfile::LockedResource],
73 cache: &crate::cache::Cache,
74 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
75 let project_root = project_root.to_path_buf();
76 let entries = lockfile_entries.to_vec();
77 let cache = cache.clone();
78
79 Box::pin(async move {
80 if entries.is_empty() {
81 return Ok(());
82 }
83
84 let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
86 std::collections::HashMap::new();
87
88 for entry in &entries {
89 let source_path = if let Some(source_name) = &entry.source {
91 let url = entry
92 .url
93 .as_ref()
94 .ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
95
96 let is_local_source =
98 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
99
100 if is_local_source {
101 std::path::PathBuf::from(url).join(&entry.path)
103 } else {
104 let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
106 anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
107 })?;
108
109 let worktree = cache
110 .get_or_create_worktree_for_sha(
111 source_name,
112 url,
113 sha,
114 Some(&entry.name),
115 )
116 .await?;
117 worktree.join(&entry.path)
118 }
119 } else {
120 let candidate = Path::new(&entry.path);
122 if candidate.is_absolute() {
123 candidate.to_path_buf()
124 } else {
125 project_root.join(candidate)
126 }
127 };
128
129 let mut config: super::McpServerConfig = crate::utils::read_json_file(&source_path)
131 .with_context(|| {
132 format!("Failed to read MCP server file: {}", source_path.display())
133 })?;
134
135 config.agpm_metadata = Some(super::AgpmMetadata {
137 managed: true,
138 source: entry.source.clone(),
139 version: entry.version.clone(),
140 installed_at: chrono::Utc::now().to_rfc3339(),
141 dependency_name: Some(entry.name.clone()),
142 });
143
144 mcp_servers.insert(entry.name.clone(), config);
145 }
146
147 let mcp_config_path = project_root.join(".mcp.json");
149 super::merge_mcp_servers(&mcp_config_path, mcp_servers).await?;
150
151 Ok(())
152 })
153 }
154
155 fn clean_mcp_servers(&self, project_root: &Path, _artifact_base: &Path) -> Result<()> {
156 super::clean_mcp_servers(project_root)
158 }
159}
160
161pub struct OpenCodeMcpHandler;
166
167impl McpHandler for OpenCodeMcpHandler {
168 fn name(&self) -> &str {
169 "opencode"
170 }
171
172 fn configure_mcp_servers(
173 &self,
174 project_root: &Path,
175 artifact_base: &Path,
176 lockfile_entries: &[crate::lockfile::LockedResource],
177 cache: &crate::cache::Cache,
178 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
179 let project_root = project_root.to_path_buf();
180 let artifact_base = artifact_base.to_path_buf();
181 let entries = lockfile_entries.to_vec();
182 let cache = cache.clone();
183
184 Box::pin(async move {
185 if entries.is_empty() {
186 return Ok(());
187 }
188
189 let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
191 std::collections::HashMap::new();
192
193 for entry in &entries {
194 let source_path = if let Some(source_name) = &entry.source {
196 let url = entry
197 .url
198 .as_ref()
199 .ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
200
201 let is_local_source =
203 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
204
205 if is_local_source {
206 std::path::PathBuf::from(url).join(&entry.path)
208 } else {
209 let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
211 anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
212 })?;
213
214 let worktree = cache
215 .get_or_create_worktree_for_sha(
216 source_name,
217 url,
218 sha,
219 Some(&entry.name),
220 )
221 .await?;
222 worktree.join(&entry.path)
223 }
224 } else {
225 let candidate = Path::new(&entry.path);
227 if candidate.is_absolute() {
228 candidate.to_path_buf()
229 } else {
230 project_root.join(candidate)
231 }
232 };
233
234 let mut config: super::McpServerConfig = crate::utils::read_json_file(&source_path)
236 .with_context(|| {
237 format!("Failed to read MCP server file: {}", source_path.display())
238 })?;
239
240 config.agpm_metadata = Some(super::AgpmMetadata {
242 managed: true,
243 source: entry.source.clone(),
244 version: entry.version.clone(),
245 installed_at: chrono::Utc::now().to_rfc3339(),
246 dependency_name: Some(entry.name.clone()),
247 });
248
249 mcp_servers.insert(entry.name.clone(), config);
250 }
251
252 let opencode_config_path = artifact_base.join("opencode.json");
254 let mut opencode_config: serde_json::Value = if opencode_config_path.exists() {
255 crate::utils::read_json_file(&opencode_config_path).with_context(|| {
256 format!("Failed to read OpenCode config: {}", opencode_config_path.display())
257 })?
258 } else {
259 serde_json::json!({})
260 };
261
262 if !opencode_config.is_object() {
264 opencode_config = serde_json::json!({});
265 }
266
267 let config_obj = opencode_config
269 .as_object_mut()
270 .expect("opencode_config must be an object after is_object() check");
271 let mcp_section = config_obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
272
273 if let Some(mcp_obj) = mcp_section.as_object_mut() {
275 for (name, server_config) in mcp_servers {
276 let server_json = serde_json::to_value(&server_config)?;
277 mcp_obj.insert(name, server_json);
278 }
279 }
280
281 crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
283 .with_context(|| {
284 format!("Failed to write OpenCode config: {}", opencode_config_path.display())
285 })?;
286
287 Ok(())
288 })
289 }
290
291 fn clean_mcp_servers(&self, _project_root: &Path, artifact_base: &Path) -> Result<()> {
292 let opencode_config_path = artifact_base.join("opencode.json");
293 let mcp_servers_dir = artifact_base.join("agpm").join("mcp-servers");
294
295 let mut removed_count = 0;
297 if mcp_servers_dir.exists() {
298 for entry in std::fs::read_dir(&mcp_servers_dir)? {
299 let entry = entry?;
300 let path = entry.path();
301 if path.extension().is_some_and(|ext| ext == "json") {
302 std::fs::remove_file(&path).with_context(|| {
303 format!("Failed to remove MCP server file: {}", path.display())
304 })?;
305 removed_count += 1;
306 }
307 }
308 }
309
310 if opencode_config_path.exists() {
312 let mut opencode_config: serde_json::Value =
313 crate::utils::read_json_file(&opencode_config_path).with_context(|| {
314 format!("Failed to read OpenCode config: {}", opencode_config_path.display())
315 })?;
316
317 if let Some(config_obj) = opencode_config.as_object_mut()
318 && let Some(mcp_section) = config_obj.get_mut("mcp")
319 && let Some(mcp_obj) = mcp_section.as_object_mut()
320 {
321 mcp_obj.retain(|_name, server| {
323 if let Ok(config) =
325 serde_json::from_value::<super::McpServerConfig>(server.clone())
326 {
327 config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
329 } else {
330 true
332 }
333 });
334
335 crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
336 .with_context(|| {
337 format!(
338 "Failed to write OpenCode config: {}",
339 opencode_config_path.display()
340 )
341 })?;
342 }
343 }
344
345 if removed_count > 0 {
346 println!("✓ Removed {removed_count} MCP server(s) from OpenCode");
347 } else {
348 println!("No MCP servers found to remove");
349 }
350
351 Ok(())
352 }
353}
354
355pub enum ConcreteMcpHandler {
359 ClaudeCode(ClaudeCodeMcpHandler),
361 OpenCode(OpenCodeMcpHandler),
363}
364
365impl McpHandler for ConcreteMcpHandler {
366 fn name(&self) -> &str {
367 match self {
368 Self::ClaudeCode(h) => h.name(),
369 Self::OpenCode(h) => h.name(),
370 }
371 }
372
373 fn configure_mcp_servers(
374 &self,
375 project_root: &Path,
376 artifact_base: &Path,
377 lockfile_entries: &[crate::lockfile::LockedResource],
378 cache: &crate::cache::Cache,
379 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
380 match self {
381 Self::ClaudeCode(h) => {
382 h.configure_mcp_servers(project_root, artifact_base, lockfile_entries, cache)
383 }
384 Self::OpenCode(h) => {
385 h.configure_mcp_servers(project_root, artifact_base, lockfile_entries, cache)
386 }
387 }
388 }
389
390 fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()> {
391 match self {
392 Self::ClaudeCode(h) => h.clean_mcp_servers(project_root, artifact_base),
393 Self::OpenCode(h) => h.clean_mcp_servers(project_root, artifact_base),
394 }
395 }
396}
397
398pub fn get_mcp_handler(artifact_type: &str) -> Option<ConcreteMcpHandler> {
408 match artifact_type {
409 "claude-code" => Some(ConcreteMcpHandler::ClaudeCode(ClaudeCodeMcpHandler)),
410 "opencode" => Some(ConcreteMcpHandler::OpenCode(OpenCodeMcpHandler)),
411 _ => None, }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_get_mcp_handler_claude_code() {
421 let handler = get_mcp_handler("claude-code");
422 assert!(handler.is_some());
423 let handler = handler.unwrap();
424 assert_eq!(handler.name(), "claude-code");
425 }
426
427 #[test]
428 fn test_get_mcp_handler_opencode() {
429 let handler = get_mcp_handler("opencode");
430 assert!(handler.is_some());
431 let handler = handler.unwrap();
432 assert_eq!(handler.name(), "opencode");
433 }
434
435 #[test]
436 fn test_get_mcp_handler_unknown() {
437 let handler = get_mcp_handler("unknown");
438 assert!(handler.is_none());
439 }
440
441 #[test]
442 fn test_get_mcp_handler_agpm() {
443 let handler = get_mcp_handler("agpm");
445 assert!(handler.is_none());
446 }
447}