agpm_cli/mcp/
handlers.rs

1//! MCP server installation handlers for different tools.
2//!
3//! This module provides a pluggable handler system for installing MCP servers
4//! into different tools' configuration formats (Claude Code, OpenCode, etc.).
5
6use anyhow::{Context, Result};
7use std::future::Future;
8use std::path::Path;
9use std::pin::Pin;
10
11/// Trait for handling MCP server installation for different tools.
12///
13/// Each tool (claude-code, opencode, etc.) may have different ways
14/// of managing MCP servers. This trait provides a common interface for:
15/// - Reading MCP server configurations directly from source
16/// - Merging configurations into tool-specific formats
17pub trait McpHandler: Send + Sync {
18    /// Get the name of this MCP handler (e.g., "claude-code", "opencode").
19    fn name(&self) -> &str;
20
21    /// Configure MCP servers by reading directly from source and merging into config file.
22    ///
23    /// This method reads MCP server configurations directly from source locations
24    /// (Git worktrees or local paths) and merges them into the tool's config file.
25    /// Patches from the manifest are applied to each server configuration before merging.
26    ///
27    /// # Arguments
28    ///
29    /// * `project_root` - The project root directory
30    /// * `artifact_base` - The base directory for this tool
31    /// * `lockfile_entries` - Locked MCP server resources with source information
32    /// * `cache` - Cache for accessing Git worktrees
33    /// * `manifest` - Manifest containing patch definitions
34    ///
35    /// # Returns
36    ///
37    /// `Ok(Vec<(name, applied_patches)>)` with applied patches for each server, or an error if the configuration failed.
38    #[allow(clippy::type_complexity)]
39    fn configure_mcp_servers(
40        &self,
41        project_root: &Path,
42        artifact_base: &Path,
43        lockfile_entries: &[crate::lockfile::LockedResource],
44        cache: &crate::cache::Cache,
45        manifest: &crate::manifest::Manifest,
46    ) -> Pin<
47        Box<
48            dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
49                + Send
50                + '_,
51        >,
52    >;
53
54    /// Clean/remove all managed MCP servers for this handler.
55    ///
56    /// # Arguments
57    ///
58    /// * `project_root` - The project root directory
59    /// * `artifact_base` - The base directory for this tool
60    ///
61    /// # Returns
62    ///
63    /// `Ok(())` on success, or an error if the cleanup failed.
64    fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()>;
65}
66
67/// MCP handler for Claude Code.
68///
69/// Claude Code configures MCP servers directly in `.mcp.json` at project root
70/// by reading from source locations (no intermediate file copying).
71pub struct ClaudeCodeMcpHandler;
72
73impl McpHandler for ClaudeCodeMcpHandler {
74    fn name(&self) -> &str {
75        "claude-code"
76    }
77
78    fn configure_mcp_servers(
79        &self,
80        project_root: &Path,
81        _artifact_base: &Path,
82        lockfile_entries: &[crate::lockfile::LockedResource],
83        cache: &crate::cache::Cache,
84        manifest: &crate::manifest::Manifest,
85    ) -> Pin<
86        Box<
87            dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
88                + Send
89                + '_,
90        >,
91    > {
92        let project_root = project_root.to_path_buf();
93        let entries = lockfile_entries.to_vec();
94        let cache = cache.clone();
95        let manifest = manifest.clone();
96
97        Box::pin(async move {
98            if entries.is_empty() {
99                return Ok(Vec::new());
100            }
101
102            // Read MCP server configurations directly from source files
103            let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
104                std::collections::HashMap::new();
105            let mut all_applied_patches = Vec::new();
106
107            for entry in &entries {
108                // Get the source file path
109                let source_path = if let Some(source_name) = &entry.source {
110                    let url = entry
111                        .url
112                        .as_ref()
113                        .ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
114
115                    // Check if this is a local directory source
116                    let is_local_source =
117                        entry.resolved_commit.as_deref().is_none_or(str::is_empty);
118
119                    if is_local_source {
120                        // Local directory source - use URL as path directly
121                        std::path::PathBuf::from(url).join(&entry.path)
122                    } else {
123                        // Git-based source - get worktree
124                        let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
125                            anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
126                        })?;
127
128                        let worktree = cache
129                            .get_or_create_worktree_for_sha(
130                                source_name,
131                                url,
132                                sha,
133                                Some(&entry.name),
134                            )
135                            .await?;
136                        worktree.join(&entry.path)
137                    }
138                } else {
139                    // Local file - resolve relative to project root
140                    let candidate = Path::new(&entry.path);
141                    if candidate.is_absolute() {
142                        candidate.to_path_buf()
143                    } else {
144                        project_root.join(candidate)
145                    }
146                };
147
148                // Read the MCP server configuration as string first (for patch application)
149                let json_content =
150                    tokio::fs::read_to_string(&source_path).await.with_context(|| {
151                        format!("Failed to read MCP server file: {}", source_path.display())
152                    })?;
153
154                // Apply patches if present
155                let (patched_content, applied_patches) = {
156                    // Look up patches for this MCP server
157                    let lookup_name = entry.manifest_alias.as_ref().unwrap_or(&entry.name);
158                    let project_patches = manifest.project_patches.get("mcp-servers", lookup_name);
159                    let private_patches = manifest.private_patches.get("mcp-servers", lookup_name);
160
161                    if project_patches.is_some() || private_patches.is_some() {
162                        use crate::manifest::patches::apply_patches_to_content_with_origin;
163                        apply_patches_to_content_with_origin(
164                            &json_content,
165                            &source_path.display().to_string(),
166                            project_patches.unwrap_or(&std::collections::HashMap::new()),
167                            private_patches.unwrap_or(&std::collections::HashMap::new()),
168                        )?
169                    } else {
170                        (json_content, crate::manifest::patches::AppliedPatches::default())
171                    }
172                };
173
174                // Collect applied patches for this server
175                all_applied_patches.push((entry.name.clone(), applied_patches));
176
177                // Parse the patched JSON
178                let mut config: super::McpServerConfig = serde_json::from_str(&patched_content)
179                    .with_context(|| {
180                        format!("Failed to parse MCP server JSON from {}", source_path.display())
181                    })?;
182
183                // Add AGPM metadata
184                config.agpm_metadata = Some(super::AgpmMetadata {
185                    managed: true,
186                    source: entry.source.clone(),
187                    version: entry.version.clone(),
188                    installed_at: chrono::Utc::now().to_rfc3339(),
189                    dependency_name: Some(entry.name.clone()),
190                });
191
192                mcp_servers.insert(entry.name.clone(), config);
193            }
194
195            // Configure MCP servers by merging into .mcp.json
196            let mcp_config_path = project_root.join(".mcp.json");
197            super::merge_mcp_servers(&mcp_config_path, mcp_servers).await?;
198
199            Ok(all_applied_patches)
200        })
201    }
202
203    fn clean_mcp_servers(&self, project_root: &Path, _artifact_base: &Path) -> Result<()> {
204        // Use existing clean_mcp_servers function
205        super::clean_mcp_servers(project_root)
206    }
207}
208
209/// MCP handler for OpenCode.
210///
211/// OpenCode configures MCP servers directly in `.opencode/opencode.json`
212/// by reading from source locations (no intermediate file copying).
213pub struct OpenCodeMcpHandler;
214
215impl McpHandler for OpenCodeMcpHandler {
216    fn name(&self) -> &str {
217        "opencode"
218    }
219
220    fn configure_mcp_servers(
221        &self,
222        project_root: &Path,
223        artifact_base: &Path,
224        lockfile_entries: &[crate::lockfile::LockedResource],
225        cache: &crate::cache::Cache,
226        manifest: &crate::manifest::Manifest,
227    ) -> Pin<
228        Box<
229            dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
230                + Send
231                + '_,
232        >,
233    > {
234        let project_root = project_root.to_path_buf();
235        let artifact_base = artifact_base.to_path_buf();
236        let entries = lockfile_entries.to_vec();
237        let cache = cache.clone();
238        let manifest = manifest.clone();
239
240        Box::pin(async move {
241            if entries.is_empty() {
242                return Ok(Vec::new());
243            }
244
245            let mut all_applied_patches = Vec::new();
246
247            // Read MCP server configurations directly from source files
248            let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
249                std::collections::HashMap::new();
250
251            for entry in &entries {
252                // Get the source file path
253                let source_path = if let Some(source_name) = &entry.source {
254                    let url = entry
255                        .url
256                        .as_ref()
257                        .ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
258
259                    // Check if this is a local directory source
260                    let is_local_source =
261                        entry.resolved_commit.as_deref().is_none_or(str::is_empty);
262
263                    if is_local_source {
264                        // Local directory source - use URL as path directly
265                        std::path::PathBuf::from(url).join(&entry.path)
266                    } else {
267                        // Git-based source - get worktree
268                        let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
269                            anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
270                        })?;
271
272                        let worktree = cache
273                            .get_or_create_worktree_for_sha(
274                                source_name,
275                                url,
276                                sha,
277                                Some(&entry.name),
278                            )
279                            .await?;
280                        worktree.join(&entry.path)
281                    }
282                } else {
283                    // Local file - resolve relative to project root
284                    let candidate = Path::new(&entry.path);
285                    if candidate.is_absolute() {
286                        candidate.to_path_buf()
287                    } else {
288                        project_root.join(candidate)
289                    }
290                };
291
292                // Read the MCP server configuration as string first (for patch application)
293                let json_content =
294                    tokio::fs::read_to_string(&source_path).await.with_context(|| {
295                        format!("Failed to read MCP server file: {}", source_path.display())
296                    })?;
297
298                // Apply patches if present
299                let (patched_content, applied_patches) = {
300                    // Look up patches for this MCP server
301                    let lookup_name = entry.manifest_alias.as_ref().unwrap_or(&entry.name);
302                    let project_patches = manifest.project_patches.get("mcp-servers", lookup_name);
303                    let private_patches = manifest.private_patches.get("mcp-servers", lookup_name);
304
305                    if project_patches.is_some() || private_patches.is_some() {
306                        use crate::manifest::patches::apply_patches_to_content_with_origin;
307                        apply_patches_to_content_with_origin(
308                            &json_content,
309                            &source_path.display().to_string(),
310                            project_patches.unwrap_or(&std::collections::HashMap::new()),
311                            private_patches.unwrap_or(&std::collections::HashMap::new()),
312                        )?
313                    } else {
314                        (json_content, crate::manifest::patches::AppliedPatches::default())
315                    }
316                };
317
318                // Collect applied patches for this server
319                all_applied_patches.push((entry.name.clone(), applied_patches));
320
321                // Parse the patched JSON
322                let mut config: super::McpServerConfig = serde_json::from_str(&patched_content)
323                    .with_context(|| {
324                        format!("Failed to parse MCP server JSON from {}", source_path.display())
325                    })?;
326
327                // Add AGPM metadata
328                config.agpm_metadata = Some(super::AgpmMetadata {
329                    managed: true,
330                    source: entry.source.clone(),
331                    version: entry.version.clone(),
332                    installed_at: chrono::Utc::now().to_rfc3339(),
333                    dependency_name: Some(entry.name.clone()),
334                });
335
336                mcp_servers.insert(entry.name.clone(), config);
337            }
338
339            // Load or create opencode.json
340            let opencode_config_path = artifact_base.join("opencode.json");
341            let mut opencode_config: serde_json::Value = if opencode_config_path.exists() {
342                crate::utils::read_json_file(&opencode_config_path).with_context(|| {
343                    format!("Failed to read OpenCode config: {}", opencode_config_path.display())
344                })?
345            } else {
346                serde_json::json!({})
347            };
348
349            // Ensure opencode_config is an object
350            if !opencode_config.is_object() {
351                opencode_config = serde_json::json!({});
352            }
353
354            // Get or create "mcp" section
355            let config_obj = opencode_config
356                .as_object_mut()
357                .expect("opencode_config must be an object after is_object() check");
358            let mcp_section = config_obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
359
360            // Merge MCP servers into the mcp section
361            if let Some(mcp_obj) = mcp_section.as_object_mut() {
362                for (name, server_config) in mcp_servers {
363                    let server_json = serde_json::to_value(&server_config)?;
364                    mcp_obj.insert(name, server_json);
365                }
366            }
367
368            // Save the updated configuration
369            crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
370                .with_context(|| {
371                    format!("Failed to write OpenCode config: {}", opencode_config_path.display())
372                })?;
373
374            Ok(all_applied_patches)
375        })
376    }
377
378    fn clean_mcp_servers(&self, _project_root: &Path, artifact_base: &Path) -> Result<()> {
379        let opencode_config_path = artifact_base.join("opencode.json");
380        let mcp_servers_dir = artifact_base.join("agpm").join("mcp-servers");
381
382        // Remove MCP server files from the staging directory
383        let mut removed_count = 0;
384        if mcp_servers_dir.exists() {
385            for entry in std::fs::read_dir(&mcp_servers_dir)? {
386                let entry = entry?;
387                let path = entry.path();
388                if path.extension().is_some_and(|ext| ext == "json") {
389                    std::fs::remove_file(&path).with_context(|| {
390                        format!("Failed to remove MCP server file: {}", path.display())
391                    })?;
392                    removed_count += 1;
393                }
394            }
395        }
396
397        // Clean up opencode.json by removing only AGPM-managed servers
398        if opencode_config_path.exists() {
399            let mut opencode_config: serde_json::Value =
400                crate::utils::read_json_file(&opencode_config_path).with_context(|| {
401                    format!("Failed to read OpenCode config: {}", opencode_config_path.display())
402                })?;
403
404            if let Some(config_obj) = opencode_config.as_object_mut()
405                && let Some(mcp_section) = config_obj.get_mut("mcp")
406                && let Some(mcp_obj) = mcp_section.as_object_mut()
407            {
408                // Remove only AGPM-managed servers
409                mcp_obj.retain(|_name, server| {
410                    // Try to parse as McpServerConfig to check metadata
411                    if let Ok(config) =
412                        serde_json::from_value::<super::McpServerConfig>(server.clone())
413                    {
414                        // Keep if not managed by AGPM
415                        config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
416                    } else {
417                        // Keep if we can't parse it (preserve user data)
418                        true
419                    }
420                });
421
422                crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
423                    .with_context(|| {
424                        format!(
425                            "Failed to write OpenCode config: {}",
426                            opencode_config_path.display()
427                        )
428                    })?;
429            }
430        }
431
432        if removed_count > 0 {
433            println!("✓ Removed {removed_count} MCP server(s) from OpenCode");
434        } else {
435            println!("No MCP servers found to remove");
436        }
437
438        Ok(())
439    }
440}
441
442/// Concrete MCP handler enum for different tools.
443///
444/// This enum wraps all supported MCP handlers and provides a unified interface.
445pub enum ConcreteMcpHandler {
446    /// Claude Code MCP handler
447    ClaudeCode(ClaudeCodeMcpHandler),
448    /// OpenCode MCP handler
449    OpenCode(OpenCodeMcpHandler),
450}
451
452impl McpHandler for ConcreteMcpHandler {
453    fn name(&self) -> &str {
454        match self {
455            Self::ClaudeCode(h) => h.name(),
456            Self::OpenCode(h) => h.name(),
457        }
458    }
459
460    fn configure_mcp_servers(
461        &self,
462        project_root: &Path,
463        artifact_base: &Path,
464        lockfile_entries: &[crate::lockfile::LockedResource],
465        cache: &crate::cache::Cache,
466        manifest: &crate::manifest::Manifest,
467    ) -> Pin<
468        Box<
469            dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
470                + Send
471                + '_,
472        >,
473    > {
474        match self {
475            Self::ClaudeCode(h) => h.configure_mcp_servers(
476                project_root,
477                artifact_base,
478                lockfile_entries,
479                cache,
480                manifest,
481            ),
482            Self::OpenCode(h) => h.configure_mcp_servers(
483                project_root,
484                artifact_base,
485                lockfile_entries,
486                cache,
487                manifest,
488            ),
489        }
490    }
491
492    fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()> {
493        match self {
494            Self::ClaudeCode(h) => h.clean_mcp_servers(project_root, artifact_base),
495            Self::OpenCode(h) => h.clean_mcp_servers(project_root, artifact_base),
496        }
497    }
498}
499
500/// Get the appropriate MCP handler for a tool.
501///
502/// # Arguments
503///
504/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
505///
506/// # Returns
507///
508/// An MCP handler for the given tool, or None if no handler exists.
509pub fn get_mcp_handler(artifact_type: &str) -> Option<ConcreteMcpHandler> {
510    match artifact_type {
511        "claude-code" => Some(ConcreteMcpHandler::ClaudeCode(ClaudeCodeMcpHandler)),
512        "opencode" => Some(ConcreteMcpHandler::OpenCode(OpenCodeMcpHandler)),
513        _ => None, // Other tools don't have MCP support
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn test_get_mcp_handler_claude_code() {
523        let handler = get_mcp_handler("claude-code");
524        assert!(handler.is_some());
525        let handler = handler.unwrap();
526        assert_eq!(handler.name(), "claude-code");
527    }
528
529    #[test]
530    fn test_get_mcp_handler_opencode() {
531        let handler = get_mcp_handler("opencode");
532        assert!(handler.is_some());
533        let handler = handler.unwrap();
534        assert_eq!(handler.name(), "opencode");
535    }
536
537    #[test]
538    fn test_get_mcp_handler_unknown() {
539        let handler = get_mcp_handler("unknown");
540        assert!(handler.is_none());
541    }
542
543    #[test]
544    fn test_get_mcp_handler_agpm() {
545        // AGPM doesn't support MCP servers
546        let handler = get_mcp_handler("agpm");
547        assert!(handler.is_none());
548    }
549}