1use crate::core::file_error::{FileOperation, FileResultExt};
7use anyhow::{Context, Result};
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11
12pub trait McpHandler: Send + Sync {
19 fn name(&self) -> &str;
21
22 #[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 fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()>;
72}
73
74pub 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 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 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 if entry.is_local() {
128 std::path::PathBuf::from(url).join(&entry.path)
130 } else {
131 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 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 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 let (patched_content, applied_patches) = {
167 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 all_applied_patches.push((entry.name.clone(), applied_patches));
187
188 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 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 mcp_servers.insert(entry.lookup_name().to_string(), config);
205 }
206
207 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 super::clean_mcp_servers(project_root)
218 }
219}
220
221pub 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 let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
265 std::collections::HashMap::new();
266
267 for entry in &entries {
268 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 if entry.is_local() {
277 std::path::PathBuf::from(url).join(&entry.path)
279 } else {
280 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 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 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 let (patched_content, applied_patches) = {
316 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 all_applied_patches.push((entry.name.clone(), applied_patches));
336
337 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 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 mcp_servers.insert(entry.lookup_name().to_string(), config);
354 }
355
356 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 if !opencode_config.is_object() {
368 opencode_config = serde_json::json!({});
369 }
370
371 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 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 if let Ok(existing_config) =
385 serde_json::from_value::<super::McpServerConfig>(
386 existing_value.clone(),
387 )
388 {
389 let mut existing_without_time = existing_config;
391 let mut new_without_time = new_config.clone();
392
393 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 changed_count += 1;
407 }
408 }
409 None => {
410 changed_count += 1;
412 }
413 }
414 }
415 } else {
416 changed_count = mcp_servers.len();
418 }
419
420 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 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 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 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 mcp_obj.retain(|_name, server| {
478 if let Ok(config) =
480 serde_json::from_value::<super::McpServerConfig>(server.clone())
481 {
482 config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
484 } else {
485 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
510pub enum ConcreteMcpHandler {
514 ClaudeCode(ClaudeCodeMcpHandler),
516 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
572pub 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, }
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 let handler = get_mcp_handler("agpm");
619 assert!(handler.is_none());
620 }
621}