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