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