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    ///
26    /// # Arguments
27    ///
28    /// * `project_root` - The project root directory
29    /// * `artifact_base` - The base directory for this tool
30    /// * `lockfile_entries` - Locked MCP server resources with source information
31    /// * `cache` - Cache for accessing Git worktrees
32    ///
33    /// # Returns
34    ///
35    /// `Ok(())` on success, or an error if the configuration failed.
36    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    /// Clean/remove all managed MCP servers for this handler.
45    ///
46    /// # Arguments
47    ///
48    /// * `project_root` - The project root directory
49    /// * `artifact_base` - The base directory for this tool
50    ///
51    /// # Returns
52    ///
53    /// `Ok(())` on success, or an error if the cleanup failed.
54    fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()>;
55}
56
57/// MCP handler for Claude Code.
58///
59/// Claude Code configures MCP servers directly in `.mcp.json` at project root
60/// by reading from source locations (no intermediate file copying).
61pub 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            // Read MCP server configurations directly from source files
85            let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
86                std::collections::HashMap::new();
87
88            for entry in &entries {
89                // Get the source file path
90                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                    // Check if this is a local directory source
97                    let is_local_source =
98                        entry.resolved_commit.as_deref().is_none_or(str::is_empty);
99
100                    if is_local_source {
101                        // Local directory source - use URL as path directly
102                        std::path::PathBuf::from(url).join(&entry.path)
103                    } else {
104                        // Git-based source - get worktree
105                        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                    // Local file - resolve relative to project root
121                    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                // Read and parse the MCP server configuration
130                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                // Add AGPM metadata
136                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            // Configure MCP servers by merging into .mcp.json
148            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        // Use existing clean_mcp_servers function
157        super::clean_mcp_servers(project_root)
158    }
159}
160
161/// MCP handler for OpenCode.
162///
163/// OpenCode configures MCP servers directly in `.opencode/opencode.json`
164/// by reading from source locations (no intermediate file copying).
165pub 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            // Read MCP server configurations directly from source files
190            let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
191                std::collections::HashMap::new();
192
193            for entry in &entries {
194                // Get the source file path
195                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                    // Check if this is a local directory source
202                    let is_local_source =
203                        entry.resolved_commit.as_deref().is_none_or(str::is_empty);
204
205                    if is_local_source {
206                        // Local directory source - use URL as path directly
207                        std::path::PathBuf::from(url).join(&entry.path)
208                    } else {
209                        // Git-based source - get worktree
210                        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                    // Local file - resolve relative to project root
226                    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                // Read and parse the MCP server configuration
235                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                // Add AGPM metadata
241                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            // Load or create opencode.json
253            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            // Ensure opencode_config is an object
263            if !opencode_config.is_object() {
264                opencode_config = serde_json::json!({});
265            }
266
267            // Get or create "mcp" section
268            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            // Merge MCP servers into the mcp section
274            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            // Save the updated configuration
282            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        // Remove MCP server files from the staging directory
296        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        // Clean up opencode.json by removing only AGPM-managed servers
311        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                // Remove only AGPM-managed servers
322                mcp_obj.retain(|_name, server| {
323                    // Try to parse as McpServerConfig to check metadata
324                    if let Ok(config) =
325                        serde_json::from_value::<super::McpServerConfig>(server.clone())
326                    {
327                        // Keep if not managed by AGPM
328                        config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
329                    } else {
330                        // Keep if we can't parse it (preserve user data)
331                        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
355/// Concrete MCP handler enum for different tools.
356///
357/// This enum wraps all supported MCP handlers and provides a unified interface.
358pub enum ConcreteMcpHandler {
359    /// Claude Code MCP handler
360    ClaudeCode(ClaudeCodeMcpHandler),
361    /// OpenCode MCP handler
362    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
398/// Get the appropriate MCP handler for a tool.
399///
400/// # Arguments
401///
402/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
403///
404/// # Returns
405///
406/// An MCP handler for the given tool, or None if no handler exists.
407pub 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, // Other tools don't have MCP support
412    }
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        // AGPM doesn't support MCP servers
444        let handler = get_mcp_handler("agpm");
445        assert!(handler.is_none());
446    }
447}