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.lookup_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::BTreeMap::new()),
167                            private_patches.unwrap_or(&std::collections::BTreeMap::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                // Use lookup_name for the MCP server key (manifest alias if present, otherwise canonical name)
193                mcp_servers.insert(entry.lookup_name().to_string(), config);
194            }
195
196            // Configure MCP servers by merging into .mcp.json
197            let mcp_config_path = project_root.join(".mcp.json");
198            super::merge_mcp_servers(&mcp_config_path, mcp_servers).await?;
199
200            Ok(all_applied_patches)
201        })
202    }
203
204    fn clean_mcp_servers(&self, project_root: &Path, _artifact_base: &Path) -> Result<()> {
205        // Use existing clean_mcp_servers function
206        super::clean_mcp_servers(project_root)
207    }
208}
209
210/// MCP handler for OpenCode.
211///
212/// OpenCode configures MCP servers directly in `.opencode/opencode.json`
213/// by reading from source locations (no intermediate file copying).
214pub struct OpenCodeMcpHandler;
215
216impl McpHandler for OpenCodeMcpHandler {
217    fn name(&self) -> &str {
218        "opencode"
219    }
220
221    fn configure_mcp_servers(
222        &self,
223        project_root: &Path,
224        artifact_base: &Path,
225        lockfile_entries: &[crate::lockfile::LockedResource],
226        cache: &crate::cache::Cache,
227        manifest: &crate::manifest::Manifest,
228    ) -> Pin<
229        Box<
230            dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
231                + Send
232                + '_,
233        >,
234    > {
235        let project_root = project_root.to_path_buf();
236        let artifact_base = artifact_base.to_path_buf();
237        let entries = lockfile_entries.to_vec();
238        let cache = cache.clone();
239        let manifest = manifest.clone();
240
241        Box::pin(async move {
242            if entries.is_empty() {
243                return Ok(Vec::new());
244            }
245
246            let mut all_applied_patches = Vec::new();
247
248            // Read MCP server configurations directly from source files
249            let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
250                std::collections::HashMap::new();
251
252            for entry in &entries {
253                // Get the source file path
254                let source_path = if let Some(source_name) = &entry.source {
255                    let url = entry
256                        .url
257                        .as_ref()
258                        .ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
259
260                    // Check if this is a local directory source
261                    let is_local_source =
262                        entry.resolved_commit.as_deref().is_none_or(str::is_empty);
263
264                    if is_local_source {
265                        // Local directory source - use URL as path directly
266                        std::path::PathBuf::from(url).join(&entry.path)
267                    } else {
268                        // Git-based source - get worktree
269                        let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
270                            anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
271                        })?;
272
273                        let worktree = cache
274                            .get_or_create_worktree_for_sha(
275                                source_name,
276                                url,
277                                sha,
278                                Some(&entry.name),
279                            )
280                            .await?;
281                        worktree.join(&entry.path)
282                    }
283                } else {
284                    // Local file - resolve relative to project root
285                    let candidate = Path::new(&entry.path);
286                    if candidate.is_absolute() {
287                        candidate.to_path_buf()
288                    } else {
289                        project_root.join(candidate)
290                    }
291                };
292
293                // Read the MCP server configuration as string first (for patch application)
294                let json_content =
295                    tokio::fs::read_to_string(&source_path).await.with_context(|| {
296                        format!("Failed to read MCP server file: {}", source_path.display())
297                    })?;
298
299                // Apply patches if present
300                let (patched_content, applied_patches) = {
301                    // Look up patches for this MCP server
302                    let lookup_name = entry.lookup_name();
303                    let project_patches = manifest.project_patches.get("mcp-servers", lookup_name);
304                    let private_patches = manifest.private_patches.get("mcp-servers", lookup_name);
305
306                    if project_patches.is_some() || private_patches.is_some() {
307                        use crate::manifest::patches::apply_patches_to_content_with_origin;
308                        apply_patches_to_content_with_origin(
309                            &json_content,
310                            &source_path.display().to_string(),
311                            project_patches.unwrap_or(&std::collections::BTreeMap::new()),
312                            private_patches.unwrap_or(&std::collections::BTreeMap::new()),
313                        )?
314                    } else {
315                        (json_content, crate::manifest::patches::AppliedPatches::default())
316                    }
317                };
318
319                // Collect applied patches for this server
320                all_applied_patches.push((entry.name.clone(), applied_patches));
321
322                // Parse the patched JSON
323                let mut config: super::McpServerConfig = serde_json::from_str(&patched_content)
324                    .with_context(|| {
325                        format!("Failed to parse MCP server JSON from {}", source_path.display())
326                    })?;
327
328                // Add AGPM metadata
329                config.agpm_metadata = Some(super::AgpmMetadata {
330                    managed: true,
331                    source: entry.source.clone(),
332                    version: entry.version.clone(),
333                    installed_at: chrono::Utc::now().to_rfc3339(),
334                    dependency_name: Some(entry.name.clone()),
335                });
336
337                // Use lookup_name for the MCP server key (manifest alias if present, otherwise canonical name)
338                mcp_servers.insert(entry.lookup_name().to_string(), config);
339            }
340
341            // Load or create opencode.json
342            let opencode_config_path = artifact_base.join("opencode.json");
343            let mut opencode_config: serde_json::Value = if opencode_config_path.exists() {
344                crate::utils::read_json_file(&opencode_config_path).with_context(|| {
345                    format!("Failed to read OpenCode config: {}", opencode_config_path.display())
346                })?
347            } else {
348                serde_json::json!({})
349            };
350
351            // Ensure opencode_config is an object
352            if !opencode_config.is_object() {
353                opencode_config = serde_json::json!({});
354            }
355
356            // Get or create "mcp" section
357            let config_obj = opencode_config
358                .as_object_mut()
359                .expect("opencode_config must be an object after is_object() check");
360            let mcp_section = config_obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
361
362            // Merge MCP servers into the mcp section
363            if let Some(mcp_obj) = mcp_section.as_object_mut() {
364                for (name, server_config) in mcp_servers {
365                    let server_json = serde_json::to_value(&server_config)?;
366                    mcp_obj.insert(name, server_json);
367                }
368            }
369
370            // Save the updated configuration
371            crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
372                .with_context(|| {
373                    format!("Failed to write OpenCode config: {}", opencode_config_path.display())
374                })?;
375
376            Ok(all_applied_patches)
377        })
378    }
379
380    fn clean_mcp_servers(&self, _project_root: &Path, artifact_base: &Path) -> Result<()> {
381        let opencode_config_path = artifact_base.join("opencode.json");
382        let mcp_servers_dir = artifact_base.join("agpm").join("mcp-servers");
383
384        // Remove MCP server files from the staging directory
385        let mut removed_count = 0;
386        if mcp_servers_dir.exists() {
387            for entry in std::fs::read_dir(&mcp_servers_dir)? {
388                let entry = entry?;
389                let path = entry.path();
390                if path.extension().is_some_and(|ext| ext == "json") {
391                    std::fs::remove_file(&path).with_context(|| {
392                        format!("Failed to remove MCP server file: {}", path.display())
393                    })?;
394                    removed_count += 1;
395                }
396            }
397        }
398
399        // Clean up opencode.json by removing only AGPM-managed servers
400        if opencode_config_path.exists() {
401            let mut opencode_config: serde_json::Value =
402                crate::utils::read_json_file(&opencode_config_path).with_context(|| {
403                    format!("Failed to read OpenCode config: {}", opencode_config_path.display())
404                })?;
405
406            if let Some(config_obj) = opencode_config.as_object_mut()
407                && let Some(mcp_section) = config_obj.get_mut("mcp")
408                && let Some(mcp_obj) = mcp_section.as_object_mut()
409            {
410                // Remove only AGPM-managed servers
411                mcp_obj.retain(|_name, server| {
412                    // Try to parse as McpServerConfig to check metadata
413                    if let Ok(config) =
414                        serde_json::from_value::<super::McpServerConfig>(server.clone())
415                    {
416                        // Keep if not managed by AGPM
417                        config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
418                    } else {
419                        // Keep if we can't parse it (preserve user data)
420                        true
421                    }
422                });
423
424                crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
425                    .with_context(|| {
426                        format!(
427                            "Failed to write OpenCode config: {}",
428                            opencode_config_path.display()
429                        )
430                    })?;
431            }
432        }
433
434        if removed_count > 0 {
435            println!("✓ Removed {removed_count} MCP server(s) from OpenCode");
436        } else {
437            println!("No MCP servers found to remove");
438        }
439
440        Ok(())
441    }
442}
443
444/// Concrete MCP handler enum for different tools.
445///
446/// This enum wraps all supported MCP handlers and provides a unified interface.
447pub enum ConcreteMcpHandler {
448    /// Claude Code MCP handler
449    ClaudeCode(ClaudeCodeMcpHandler),
450    /// OpenCode MCP handler
451    OpenCode(OpenCodeMcpHandler),
452}
453
454impl McpHandler for ConcreteMcpHandler {
455    fn name(&self) -> &str {
456        match self {
457            Self::ClaudeCode(h) => h.name(),
458            Self::OpenCode(h) => h.name(),
459        }
460    }
461
462    fn configure_mcp_servers(
463        &self,
464        project_root: &Path,
465        artifact_base: &Path,
466        lockfile_entries: &[crate::lockfile::LockedResource],
467        cache: &crate::cache::Cache,
468        manifest: &crate::manifest::Manifest,
469    ) -> Pin<
470        Box<
471            dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
472                + Send
473                + '_,
474        >,
475    > {
476        match self {
477            Self::ClaudeCode(h) => h.configure_mcp_servers(
478                project_root,
479                artifact_base,
480                lockfile_entries,
481                cache,
482                manifest,
483            ),
484            Self::OpenCode(h) => h.configure_mcp_servers(
485                project_root,
486                artifact_base,
487                lockfile_entries,
488                cache,
489                manifest,
490            ),
491        }
492    }
493
494    fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()> {
495        match self {
496            Self::ClaudeCode(h) => h.clean_mcp_servers(project_root, artifact_base),
497            Self::OpenCode(h) => h.clean_mcp_servers(project_root, artifact_base),
498        }
499    }
500}
501
502/// Get the appropriate MCP handler for a tool.
503///
504/// # Arguments
505///
506/// * `artifact_type` - The tool name (e.g., "claude-code", "opencode")
507///
508/// # Returns
509///
510/// An MCP handler for the given tool, or None if no handler exists.
511pub fn get_mcp_handler(artifact_type: &str) -> Option<ConcreteMcpHandler> {
512    match artifact_type {
513        "claude-code" => Some(ConcreteMcpHandler::ClaudeCode(ClaudeCodeMcpHandler)),
514        "opencode" => Some(ConcreteMcpHandler::OpenCode(OpenCodeMcpHandler)),
515        _ => None, // Other tools don't have MCP support
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_get_mcp_handler_claude_code() {
525        let handler = get_mcp_handler("claude-code");
526        assert!(handler.is_some());
527        let handler = handler.unwrap();
528        assert_eq!(handler.name(), "claude-code");
529    }
530
531    #[test]
532    fn test_get_mcp_handler_opencode() {
533        let handler = get_mcp_handler("opencode");
534        assert!(handler.is_some());
535        let handler = handler.unwrap();
536        assert_eq!(handler.name(), "opencode");
537    }
538
539    #[test]
540    fn test_get_mcp_handler_unknown() {
541        let handler = get_mcp_handler("unknown");
542        assert!(handler.is_none());
543    }
544
545    #[test]
546    fn test_get_mcp_handler_agpm() {
547        // AGPM doesn't support MCP servers
548        let handler = get_mcp_handler("agpm");
549        assert!(handler.is_none());
550    }
551}