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.manifest_alias.as_ref().unwrap_or(&entry.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::HashMap::new()),
167 private_patches.unwrap_or(&std::collections::HashMap::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.name.clone(), config);
193 }
194
195 let mcp_config_path = project_root.join(".mcp.json");
197 super::merge_mcp_servers(&mcp_config_path, mcp_servers).await?;
198
199 Ok(all_applied_patches)
200 })
201 }
202
203 fn clean_mcp_servers(&self, project_root: &Path, _artifact_base: &Path) -> Result<()> {
204 super::clean_mcp_servers(project_root)
206 }
207}
208
209pub struct OpenCodeMcpHandler;
214
215impl McpHandler for OpenCodeMcpHandler {
216 fn name(&self) -> &str {
217 "opencode"
218 }
219
220 fn configure_mcp_servers(
221 &self,
222 project_root: &Path,
223 artifact_base: &Path,
224 lockfile_entries: &[crate::lockfile::LockedResource],
225 cache: &crate::cache::Cache,
226 manifest: &crate::manifest::Manifest,
227 ) -> Pin<
228 Box<
229 dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
230 + Send
231 + '_,
232 >,
233 > {
234 let project_root = project_root.to_path_buf();
235 let artifact_base = artifact_base.to_path_buf();
236 let entries = lockfile_entries.to_vec();
237 let cache = cache.clone();
238 let manifest = manifest.clone();
239
240 Box::pin(async move {
241 if entries.is_empty() {
242 return Ok(Vec::new());
243 }
244
245 let mut all_applied_patches = Vec::new();
246
247 let mut mcp_servers: std::collections::HashMap<String, super::McpServerConfig> =
249 std::collections::HashMap::new();
250
251 for entry in &entries {
252 let source_path = if let Some(source_name) = &entry.source {
254 let url = entry
255 .url
256 .as_ref()
257 .ok_or_else(|| anyhow::anyhow!("MCP server {} has no URL", entry.name))?;
258
259 let is_local_source =
261 entry.resolved_commit.as_deref().is_none_or(str::is_empty);
262
263 if is_local_source {
264 std::path::PathBuf::from(url).join(&entry.path)
266 } else {
267 let sha = entry.resolved_commit.as_deref().ok_or_else(|| {
269 anyhow::anyhow!("MCP server {} missing resolved commit SHA", entry.name)
270 })?;
271
272 let worktree = cache
273 .get_or_create_worktree_for_sha(
274 source_name,
275 url,
276 sha,
277 Some(&entry.name),
278 )
279 .await?;
280 worktree.join(&entry.path)
281 }
282 } else {
283 let candidate = Path::new(&entry.path);
285 if candidate.is_absolute() {
286 candidate.to_path_buf()
287 } else {
288 project_root.join(candidate)
289 }
290 };
291
292 let json_content =
294 tokio::fs::read_to_string(&source_path).await.with_context(|| {
295 format!("Failed to read MCP server file: {}", source_path.display())
296 })?;
297
298 let (patched_content, applied_patches) = {
300 let lookup_name = entry.manifest_alias.as_ref().unwrap_or(&entry.name);
302 let project_patches = manifest.project_patches.get("mcp-servers", lookup_name);
303 let private_patches = manifest.private_patches.get("mcp-servers", lookup_name);
304
305 if project_patches.is_some() || private_patches.is_some() {
306 use crate::manifest::patches::apply_patches_to_content_with_origin;
307 apply_patches_to_content_with_origin(
308 &json_content,
309 &source_path.display().to_string(),
310 project_patches.unwrap_or(&std::collections::HashMap::new()),
311 private_patches.unwrap_or(&std::collections::HashMap::new()),
312 )?
313 } else {
314 (json_content, crate::manifest::patches::AppliedPatches::default())
315 }
316 };
317
318 all_applied_patches.push((entry.name.clone(), applied_patches));
320
321 let mut config: super::McpServerConfig = serde_json::from_str(&patched_content)
323 .with_context(|| {
324 format!("Failed to parse MCP server JSON from {}", source_path.display())
325 })?;
326
327 config.agpm_metadata = Some(super::AgpmMetadata {
329 managed: true,
330 source: entry.source.clone(),
331 version: entry.version.clone(),
332 installed_at: chrono::Utc::now().to_rfc3339(),
333 dependency_name: Some(entry.name.clone()),
334 });
335
336 mcp_servers.insert(entry.name.clone(), config);
337 }
338
339 let opencode_config_path = artifact_base.join("opencode.json");
341 let mut opencode_config: serde_json::Value = if opencode_config_path.exists() {
342 crate::utils::read_json_file(&opencode_config_path).with_context(|| {
343 format!("Failed to read OpenCode config: {}", opencode_config_path.display())
344 })?
345 } else {
346 serde_json::json!({})
347 };
348
349 if !opencode_config.is_object() {
351 opencode_config = serde_json::json!({});
352 }
353
354 let config_obj = opencode_config
356 .as_object_mut()
357 .expect("opencode_config must be an object after is_object() check");
358 let mcp_section = config_obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
359
360 if let Some(mcp_obj) = mcp_section.as_object_mut() {
362 for (name, server_config) in mcp_servers {
363 let server_json = serde_json::to_value(&server_config)?;
364 mcp_obj.insert(name, server_json);
365 }
366 }
367
368 crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
370 .with_context(|| {
371 format!("Failed to write OpenCode config: {}", opencode_config_path.display())
372 })?;
373
374 Ok(all_applied_patches)
375 })
376 }
377
378 fn clean_mcp_servers(&self, _project_root: &Path, artifact_base: &Path) -> Result<()> {
379 let opencode_config_path = artifact_base.join("opencode.json");
380 let mcp_servers_dir = artifact_base.join("agpm").join("mcp-servers");
381
382 let mut removed_count = 0;
384 if mcp_servers_dir.exists() {
385 for entry in std::fs::read_dir(&mcp_servers_dir)? {
386 let entry = entry?;
387 let path = entry.path();
388 if path.extension().is_some_and(|ext| ext == "json") {
389 std::fs::remove_file(&path).with_context(|| {
390 format!("Failed to remove MCP server file: {}", path.display())
391 })?;
392 removed_count += 1;
393 }
394 }
395 }
396
397 if opencode_config_path.exists() {
399 let mut opencode_config: serde_json::Value =
400 crate::utils::read_json_file(&opencode_config_path).with_context(|| {
401 format!("Failed to read OpenCode config: {}", opencode_config_path.display())
402 })?;
403
404 if let Some(config_obj) = opencode_config.as_object_mut()
405 && let Some(mcp_section) = config_obj.get_mut("mcp")
406 && let Some(mcp_obj) = mcp_section.as_object_mut()
407 {
408 mcp_obj.retain(|_name, server| {
410 if let Ok(config) =
412 serde_json::from_value::<super::McpServerConfig>(server.clone())
413 {
414 config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed)
416 } else {
417 true
419 }
420 });
421
422 crate::utils::write_json_file(&opencode_config_path, &opencode_config, true)
423 .with_context(|| {
424 format!(
425 "Failed to write OpenCode config: {}",
426 opencode_config_path.display()
427 )
428 })?;
429 }
430 }
431
432 if removed_count > 0 {
433 println!("✓ Removed {removed_count} MCP server(s) from OpenCode");
434 } else {
435 println!("No MCP servers found to remove");
436 }
437
438 Ok(())
439 }
440}
441
442pub enum ConcreteMcpHandler {
446 ClaudeCode(ClaudeCodeMcpHandler),
448 OpenCode(OpenCodeMcpHandler),
450}
451
452impl McpHandler for ConcreteMcpHandler {
453 fn name(&self) -> &str {
454 match self {
455 Self::ClaudeCode(h) => h.name(),
456 Self::OpenCode(h) => h.name(),
457 }
458 }
459
460 fn configure_mcp_servers(
461 &self,
462 project_root: &Path,
463 artifact_base: &Path,
464 lockfile_entries: &[crate::lockfile::LockedResource],
465 cache: &crate::cache::Cache,
466 manifest: &crate::manifest::Manifest,
467 ) -> Pin<
468 Box<
469 dyn Future<Output = Result<Vec<(String, crate::manifest::patches::AppliedPatches)>>>
470 + Send
471 + '_,
472 >,
473 > {
474 match self {
475 Self::ClaudeCode(h) => h.configure_mcp_servers(
476 project_root,
477 artifact_base,
478 lockfile_entries,
479 cache,
480 manifest,
481 ),
482 Self::OpenCode(h) => h.configure_mcp_servers(
483 project_root,
484 artifact_base,
485 lockfile_entries,
486 cache,
487 manifest,
488 ),
489 }
490 }
491
492 fn clean_mcp_servers(&self, project_root: &Path, artifact_base: &Path) -> Result<()> {
493 match self {
494 Self::ClaudeCode(h) => h.clean_mcp_servers(project_root, artifact_base),
495 Self::OpenCode(h) => h.clean_mcp_servers(project_root, artifact_base),
496 }
497 }
498}
499
500pub fn get_mcp_handler(artifact_type: &str) -> Option<ConcreteMcpHandler> {
510 match artifact_type {
511 "claude-code" => Some(ConcreteMcpHandler::ClaudeCode(ClaudeCodeMcpHandler)),
512 "opencode" => Some(ConcreteMcpHandler::OpenCode(OpenCodeMcpHandler)),
513 _ => None, }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_get_mcp_handler_claude_code() {
523 let handler = get_mcp_handler("claude-code");
524 assert!(handler.is_some());
525 let handler = handler.unwrap();
526 assert_eq!(handler.name(), "claude-code");
527 }
528
529 #[test]
530 fn test_get_mcp_handler_opencode() {
531 let handler = get_mcp_handler("opencode");
532 assert!(handler.is_some());
533 let handler = handler.unwrap();
534 assert_eq!(handler.name(), "opencode");
535 }
536
537 #[test]
538 fn test_get_mcp_handler_unknown() {
539 let handler = get_mcp_handler("unknown");
540 assert!(handler.is_none());
541 }
542
543 #[test]
544 fn test_get_mcp_handler_agpm() {
545 let handler = get_mcp_handler("agpm");
547 assert!(handler.is_none());
548 }
549}