1use anyhow::{Context, Result};
7use std::future::Future;
8use std::path::Path;
9use std::pin::Pin;
10
11pub trait McpHandler: Send + Sync {
18 fn name(&self) -> &str;
20
21 #[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 fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()>;
65}
66
67pub 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 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 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 let is_local_source =
117 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
118
119 if is_local_source {
120 std::path::PathBuf::from(url).join(&entry.path)
122 } else {
123 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 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 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 let (patched_content, applied_patches) = {
156 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 all_applied_patches.push((entry.name.clone(), applied_patches));
176
177 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 config.agpm_metadata = Some(super::AgpmMetadata {
185 managed: true,
186 source: entry.source.clone(),
187 version: entry.version.clone(),
188 installed_at: chrono::Utc::now().to_rfc3339(),
189 dependency_name: Some(entry.name.clone()),
190 });
191
192 mcp_servers.insert(entry.lookup_name().to_string(), config);
194 }
195
196 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 super::clean_mcp_servers(project_root)
207 }
208}
209
210pub 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 let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
250 std::collections::HashMap::new();
251
252 for entry in &entries {
253 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 let is_local_source =
262 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
263
264 if is_local_source {
265 std::path::PathBuf::from(url).join(&entry.path)
267 } else {
268 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 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 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 let (patched_content, applied_patches) = {
301 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 all_applied_patches.push((entry.name.clone(), applied_patches));
321
322 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 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 mcp_servers.insert(entry.lookup_name().to_string(), config);
339 }
340
341 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 if !opencode_config.is_object() {
353 opencode_config = serde_json::json!({});
354 }
355
356 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 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 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 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 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 mcp_obj.retain(|_name, server| {
412 if let Ok(config) =
414 serde_json::from_value::<super::McpServerConfig>(server.clone())
415 {
416 config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
418 } else {
419 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
444pub enum ConcreteMcpHandler {
448 ClaudeCode(ClaudeCodeMcpHandler),
450 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
502pub 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, }
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 let handler = get_mcp_handler("agpm");
549 assert!(handler.is_none());
550 }
551}