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 let is_local_source =
128 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
129
130 if is_local_source {
131 std::path::PathBuf::from(url).join(&entry.path)
133 } else {
134 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 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 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 let (patched_content, applied_patches) = {
170 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 all_applied_patches.push((entry.name.clone(), applied_patches));
190
191 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 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 mcp_servers.insert(entry.lookup_name().to_string(), config);
208 }
209
210 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 super::clean_mcp_servers(project_root)
221 }
222}
223
224pub 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 let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
268 std::collections::HashMap::new();
269
270 for entry in &entries {
271 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 let is_local_source =
280 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
281
282 if is_local_source {
283 std::path::PathBuf::from(url).join(&entry.path)
285 } else {
286 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 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 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 let (patched_content, applied_patches) = {
322 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 all_applied_patches.push((entry.name.clone(), applied_patches));
342
343 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 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 mcp_servers.insert(entry.lookup_name().to_string(), config);
360 }
361
362 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 if !opencode_config.is_object() {
374 opencode_config = serde_json::json!({});
375 }
376
377 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 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 if let Ok(existing_config) =
391 serde_json::from_value::<super::McpServerConfig>(
392 existing_value.clone(),
393 )
394 {
395 let mut existing_without_time = existing_config;
397 let mut new_without_time = new_config.clone();
398
399 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 changed_count += 1;
413 }
414 }
415 None => {
416 changed_count += 1;
418 }
419 }
420 }
421 } else {
422 changed_count = mcp_servers.len();
424 }
425
426 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 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 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 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 mcp_obj.retain(|_name, server| {
484 if let Ok(config) =
486 serde_json::from_value::<super::McpServerConfig>(server.clone())
487 {
488 config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
490 } else {
491 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
516pub enum ConcreteMcpHandler {
520 ClaudeCode(ClaudeCodeMcpHandler),
522 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
578pub 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, }
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 let handler = get_mcp_handler("agpm");
625 assert!(handler.is_none());
626 }
627}