claude-mpm 4.3.11__py3-none-any.whl → 4.3.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/templates/research.json +20 -8
  3. claude_mpm/agents/templates/web_qa.json +25 -10
  4. claude_mpm/cli/__init__.py +1 -0
  5. claude_mpm/cli/commands/mcp_command_router.py +11 -0
  6. claude_mpm/cli/commands/mcp_config.py +157 -0
  7. claude_mpm/cli/commands/mcp_external_commands.py +241 -0
  8. claude_mpm/cli/commands/mcp_install_commands.py +64 -23
  9. claude_mpm/cli/commands/mcp_setup_external.py +829 -0
  10. claude_mpm/cli/commands/run.py +70 -0
  11. claude_mpm/cli/commands/search.py +285 -0
  12. claude_mpm/cli/parsers/base_parser.py +13 -0
  13. claude_mpm/cli/parsers/mcp_parser.py +17 -0
  14. claude_mpm/cli/parsers/run_parser.py +5 -0
  15. claude_mpm/cli/parsers/search_parser.py +239 -0
  16. claude_mpm/constants.py +1 -0
  17. claude_mpm/core/unified_agent_registry.py +7 -0
  18. claude_mpm/services/agents/deployment/agent_deployment.py +28 -13
  19. claude_mpm/services/agents/deployment/agent_discovery_service.py +16 -6
  20. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +6 -4
  21. claude_mpm/services/cli/agent_cleanup_service.py +5 -0
  22. claude_mpm/services/mcp_config_manager.py +294 -0
  23. claude_mpm/services/mcp_gateway/config/configuration.py +17 -0
  24. claude_mpm/services/mcp_gateway/main.py +38 -0
  25. claude_mpm/services/mcp_gateway/tools/external_mcp_services.py +390 -0
  26. {claude_mpm-4.3.11.dist-info → claude_mpm-4.3.12.dist-info}/METADATA +4 -1
  27. {claude_mpm-4.3.11.dist-info → claude_mpm-4.3.12.dist-info}/RECORD +31 -24
  28. {claude_mpm-4.3.11.dist-info → claude_mpm-4.3.12.dist-info}/WHEEL +0 -0
  29. {claude_mpm-4.3.11.dist-info → claude_mpm-4.3.12.dist-info}/entry_points.txt +0 -0
  30. {claude_mpm-4.3.11.dist-info → claude_mpm-4.3.12.dist-info}/licenses/LICENSE +0 -0
  31. {claude_mpm-4.3.11.dist-info → claude_mpm-4.3.12.dist-info}/top_level.txt +0 -0
@@ -460,6 +460,13 @@ class UnifiedAgentRegistry:
460
460
  if agent_format == AgentFormat.JSON:
461
461
  data = json.loads(content)
462
462
 
463
+ # Ensure data is a dictionary, not a list
464
+ if not isinstance(data, dict):
465
+ logger.warning(
466
+ f"Invalid JSON structure in {file_path}: expected object, got {type(data).__name__}"
467
+ )
468
+ return "", []
469
+
463
470
  # Handle local agent JSON templates with metadata structure
464
471
  if "metadata" in data:
465
472
  metadata = data["metadata"]
@@ -776,16 +776,6 @@ class AgentDeploymentService(ConfigServiceBase, AgentDeploymentInterface):
776
776
  agent_sources=agent_sources,
777
777
  )
778
778
 
779
- # Log version upgrades and source changes
780
- if comparison_results.get("version_upgrades"):
781
- self.logger.info(
782
- f"Version upgrades available for {len(comparison_results['version_upgrades'])} agents"
783
- )
784
- if comparison_results.get("source_changes"):
785
- self.logger.info(
786
- f"Source changes for {len(comparison_results['source_changes'])} agents"
787
- )
788
-
789
779
  # Filter agents based on comparison results (unless force_rebuild is set)
790
780
  if not force_rebuild:
791
781
  # Only deploy agents that need updates or are new
@@ -804,11 +794,36 @@ class AgentDeploymentService(ConfigServiceBase, AgentDeploymentInterface):
804
794
  for name, path in agents_to_deploy.items()
805
795
  if name in agents_needing_update
806
796
  }
797
+
798
+ # Only log upgrade messages if we're actually going to deploy them
799
+ if filtered_agents and comparison_results.get("version_upgrades"):
800
+ # Filter upgrades to only those actually being deployed
801
+ deployed_upgrades = [
802
+ upgrade for upgrade in comparison_results["version_upgrades"]
803
+ if upgrade["name"] in filtered_agents
804
+ ]
805
+
806
+ if deployed_upgrades:
807
+ self.logger.info(
808
+ f"Deploying {len(deployed_upgrades)} agent upgrade(s):"
809
+ )
810
+ for upgrade in deployed_upgrades:
811
+ self.logger.info(
812
+ f" Upgrading: {upgrade['name']} "
813
+ f"{upgrade['deployed_version']} -> {upgrade['new_version']} "
814
+ f"(from {upgrade['source']})"
815
+ )
816
+
807
817
  agents_to_deploy = filtered_agents
808
818
 
809
- self.logger.info(
810
- f"Filtered to {len(agents_to_deploy)} agents needing deployment"
811
- )
819
+ if agents_to_deploy:
820
+ self.logger.info(
821
+ f"Deploying {len(agents_to_deploy)} agents that need updates"
822
+ )
823
+ else:
824
+ self.logger.debug(
825
+ f"All {len(comparison_results.get('up_to_date', []))} agents are up to date"
826
+ )
812
827
 
813
828
  # Convert to list of Path objects
814
829
  template_files = list(agents_to_deploy.values())
@@ -216,25 +216,35 @@ class AgentDiscoveryService:
216
216
  metadata = template_data.get("metadata", {})
217
217
  capabilities = template_data.get("capabilities", {})
218
218
 
219
+ # Handle capabilities as either dict or list
220
+ if isinstance(capabilities, list):
221
+ # If capabilities is a list (like in php-engineer.json), treat it as capabilities list
222
+ tools_list = template_data.get("tools", []) # Look for tools at root level
223
+ model_value = template_data.get("model", "sonnet")
224
+ else:
225
+ # If capabilities is a dict, extract tools and model from it
226
+ tools_list = capabilities.get("tools", [])
227
+ model_value = capabilities.get("model", "sonnet")
228
+
219
229
  agent_info = {
220
230
  "name": metadata.get("name", template_file.stem),
221
- "description": metadata.get("description", "No description available"),
231
+ "description": metadata.get("description", template_data.get("description", "No description available")),
222
232
  "type": template_data.get(
223
- "agent_type", metadata.get("category", "agent")
233
+ "agent_type", metadata.get("category", template_data.get("category", "agent"))
224
234
  ), # Extract agent type
225
235
  "version": template_data.get(
226
236
  "agent_version",
227
237
  template_data.get("version", metadata.get("version", "1.0.0")),
228
238
  ),
229
- "tools": capabilities.get("tools", []),
239
+ "tools": tools_list,
230
240
  "specializations": metadata.get(
231
- "tags", []
232
- ), # Use tags as specializations
241
+ "tags", template_data.get("tags", [])
242
+ ), # Use tags as specializations, fallback to root-level tags
233
243
  "file": template_file.name,
234
244
  "path": str(template_file),
235
245
  "file_path": str(template_file), # Keep for backward compatibility
236
246
  "size": template_file.stat().st_size,
237
- "model": capabilities.get("model", "sonnet"),
247
+ "model": model_value,
238
248
  "author": metadata.get("author", "unknown"),
239
249
  }
240
250
 
@@ -742,18 +742,20 @@ class MultiSourceAgentDeploymentService:
742
742
 
743
743
  self.logger.info(f"Version comparison complete: {', '.join(summary_parts)}")
744
744
 
745
+ # Don't log upgrades here - let the caller decide when to log
746
+ # This prevents repeated upgrade messages on every startup
745
747
  if comparison_results["version_upgrades"]:
746
748
  for upgrade in comparison_results["version_upgrades"]:
747
- self.logger.info(
748
- f" Upgrade: {upgrade['name']} "
749
+ self.logger.debug(
750
+ f" Upgrade available: {upgrade['name']} "
749
751
  f"{upgrade['deployed_version']} -> {upgrade['new_version']} "
750
752
  f"(from {upgrade['source']})"
751
753
  )
752
754
 
753
755
  if comparison_results["source_changes"]:
754
756
  for change in comparison_results["source_changes"]:
755
- self.logger.info(
756
- f" Source change: {change['name']} "
757
+ self.logger.debug(
758
+ f" Source change available: {change['name']} "
757
759
  f"from {change['from_source']} to {change['to_source']}"
758
760
  )
759
761
 
@@ -138,6 +138,11 @@ class AgentCleanupService(IAgentCleanupService):
138
138
  if not isinstance(result, dict):
139
139
  result = {"success": bool(result)}
140
140
 
141
+ # Add success flag based on whether there were errors
142
+ if "success" not in result:
143
+ # Consider it successful if no errors occurred
144
+ result["success"] = not bool(result.get("errors"))
145
+
141
146
  # Add cleaned_count for backward compatibility
142
147
  if "cleaned_count" not in result:
143
148
  removed_count = len(result.get("removed", []))
@@ -0,0 +1,294 @@
1
+ """
2
+ MCP Configuration Manager
3
+ ========================
4
+
5
+ Manages MCP service configurations, preferring pipx installations
6
+ over local virtual environments for better isolation and management.
7
+
8
+ This module provides utilities to detect, configure, and validate
9
+ MCP service installations.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Dict, Optional, Tuple
17
+
18
+ from ..core.logger import get_logger
19
+
20
+
21
+ class MCPConfigManager:
22
+ """Manages MCP service configurations with pipx preference."""
23
+
24
+ # Standard MCP services that should use pipx
25
+ PIPX_SERVICES = {
26
+ "mcp-vector-search",
27
+ "mcp-browser",
28
+ "mcp-ticketer",
29
+ }
30
+
31
+ def __init__(self):
32
+ """Initialize the MCP configuration manager."""
33
+ self.logger = get_logger(__name__)
34
+ self.pipx_base = Path.home() / ".local" / "pipx" / "venvs"
35
+ self.project_root = Path.cwd()
36
+
37
+ def detect_service_path(self, service_name: str) -> Optional[str]:
38
+ """
39
+ Detect the best path for an MCP service.
40
+
41
+ Priority order:
42
+ 1. Pipx installation (preferred)
43
+ 2. System PATH (likely from pipx)
44
+ 3. Local venv (fallback)
45
+
46
+ Args:
47
+ service_name: Name of the MCP service
48
+
49
+ Returns:
50
+ Path to the service executable or None if not found
51
+ """
52
+ # Check pipx installation first
53
+ pipx_path = self._check_pipx_installation(service_name)
54
+ if pipx_path:
55
+ self.logger.debug(f"Found {service_name} via pipx: {pipx_path}")
56
+ return pipx_path
57
+
58
+ # Check system PATH
59
+ system_path = self._check_system_path(service_name)
60
+ if system_path:
61
+ self.logger.debug(f"Found {service_name} in PATH: {system_path}")
62
+ return system_path
63
+
64
+ # Fallback to local venv
65
+ local_path = self._check_local_venv(service_name)
66
+ if local_path:
67
+ self.logger.warning(
68
+ f"Using local venv for {service_name} (consider installing via pipx)"
69
+ )
70
+ return local_path
71
+
72
+ self.logger.warning(f"Service {service_name} not found")
73
+ return None
74
+
75
+ def _check_pipx_installation(self, service_name: str) -> Optional[str]:
76
+ """Check if service is installed via pipx."""
77
+ pipx_venv = self.pipx_base / service_name
78
+
79
+ if not pipx_venv.exists():
80
+ return None
81
+
82
+ # Special handling for mcp-vector-search (needs Python interpreter)
83
+ if service_name == "mcp-vector-search":
84
+ python_bin = pipx_venv / "bin" / "python"
85
+ if python_bin.exists() and python_bin.is_file():
86
+ return str(python_bin)
87
+ else:
88
+ # Other services use direct binary
89
+ service_bin = pipx_venv / "bin" / service_name
90
+ if service_bin.exists() and service_bin.is_file():
91
+ return str(service_bin)
92
+
93
+ return None
94
+
95
+ def _check_system_path(self, service_name: str) -> Optional[str]:
96
+ """Check if service is available in system PATH."""
97
+ try:
98
+ result = subprocess.run(
99
+ ["which", service_name],
100
+ capture_output=True,
101
+ text=True,
102
+ check=False,
103
+ )
104
+ if result.returncode == 0:
105
+ path = result.stdout.strip()
106
+ # Verify it's from pipx
107
+ if "/.local/bin/" in path or "/pipx/" in path:
108
+ return path
109
+ except Exception as e:
110
+ self.logger.debug(f"Error checking system PATH: {e}")
111
+
112
+ return None
113
+
114
+ def _check_local_venv(self, service_name: str) -> Optional[str]:
115
+ """Check for local virtual environment installation (fallback)."""
116
+ # Common local development paths
117
+ possible_paths = [
118
+ Path.home() / "Projects" / "managed" / service_name / ".venv" / "bin",
119
+ self.project_root / ".venv" / "bin",
120
+ self.project_root / "venv" / "bin",
121
+ ]
122
+
123
+ for base_path in possible_paths:
124
+ if service_name == "mcp-vector-search":
125
+ python_bin = base_path / "python"
126
+ if python_bin.exists():
127
+ return str(python_bin)
128
+ else:
129
+ service_bin = base_path / service_name
130
+ if service_bin.exists():
131
+ return str(service_bin)
132
+
133
+ return None
134
+
135
+ def generate_service_config(self, service_name: str) -> Optional[Dict]:
136
+ """
137
+ Generate configuration for a specific MCP service.
138
+
139
+ Args:
140
+ service_name: Name of the MCP service
141
+
142
+ Returns:
143
+ Service configuration dict or None if service not found
144
+ """
145
+ service_path = self.detect_service_path(service_name)
146
+ if not service_path:
147
+ return None
148
+
149
+ config = {
150
+ "type": "stdio",
151
+ "command": service_path,
152
+ }
153
+
154
+ # Service-specific configurations
155
+ if service_name == "mcp-vector-search":
156
+ config["args"] = [
157
+ "-m",
158
+ "mcp_vector_search.mcp.server",
159
+ str(self.project_root),
160
+ ]
161
+ config["env"] = {}
162
+ elif service_name == "mcp-browser":
163
+ config["args"] = ["mcp"]
164
+ config["env"] = {
165
+ "MCP_BROWSER_HOME": str(Path.home() / ".mcp-browser")
166
+ }
167
+ elif service_name == "mcp-ticketer":
168
+ config["args"] = ["mcp"]
169
+ else:
170
+ # Generic config for unknown services
171
+ config["args"] = []
172
+
173
+ return config
174
+
175
+ def update_mcp_config(self, force_pipx: bool = True) -> Tuple[bool, str]:
176
+ """
177
+ Update the .mcp.json configuration file.
178
+
179
+ Args:
180
+ force_pipx: If True, only use pipx installations
181
+
182
+ Returns:
183
+ Tuple of (success, message)
184
+ """
185
+ mcp_config_path = self.project_root / ".mcp.json"
186
+
187
+ # Load existing config if it exists
188
+ existing_config = {}
189
+ if mcp_config_path.exists():
190
+ try:
191
+ with open(mcp_config_path, "r") as f:
192
+ existing_config = json.load(f)
193
+ except Exception as e:
194
+ self.logger.error(f"Error reading existing config: {e}")
195
+
196
+ # Generate new configurations
197
+ new_config = {"mcpServers": {}}
198
+ missing_services = []
199
+
200
+ for service_name in self.PIPX_SERVICES:
201
+ config = self.generate_service_config(service_name)
202
+ if config:
203
+ new_config["mcpServers"][service_name] = config
204
+ elif force_pipx:
205
+ missing_services.append(service_name)
206
+ else:
207
+ # Keep existing config if not forcing pipx
208
+ if service_name in existing_config.get("mcpServers", {}):
209
+ new_config["mcpServers"][service_name] = existing_config[
210
+ "mcpServers"
211
+ ][service_name]
212
+
213
+ # Add any additional services from existing config
214
+ for service_name, config in existing_config.get("mcpServers", {}).items():
215
+ if service_name not in new_config["mcpServers"]:
216
+ new_config["mcpServers"][service_name] = config
217
+
218
+ # Write the updated configuration
219
+ try:
220
+ with open(mcp_config_path, "w") as f:
221
+ json.dump(new_config, f, indent=2)
222
+
223
+ if missing_services:
224
+ message = f"Updated .mcp.json. Missing services (install via pipx): {', '.join(missing_services)}"
225
+ return True, message
226
+ else:
227
+ return True, "Successfully updated .mcp.json with pipx paths"
228
+ except Exception as e:
229
+ return False, f"Failed to update .mcp.json: {e}"
230
+
231
+ def validate_configuration(self) -> Dict[str, bool]:
232
+ """
233
+ Validate that all configured MCP services are accessible.
234
+
235
+ Returns:
236
+ Dict mapping service names to availability status
237
+ """
238
+ mcp_config_path = self.project_root / ".mcp.json"
239
+ if not mcp_config_path.exists():
240
+ return {}
241
+
242
+ try:
243
+ with open(mcp_config_path, "r") as f:
244
+ config = json.load(f)
245
+ except Exception as e:
246
+ self.logger.error(f"Error reading config: {e}")
247
+ return {}
248
+
249
+ results = {}
250
+ for service_name, service_config in config.get("mcpServers", {}).items():
251
+ command_path = service_config.get("command", "")
252
+ results[service_name] = Path(command_path).exists()
253
+
254
+ return results
255
+
256
+ def install_missing_services(self) -> Tuple[bool, str]:
257
+ """
258
+ Install missing MCP services via pipx.
259
+
260
+ Returns:
261
+ Tuple of (success, message)
262
+ """
263
+ missing = []
264
+ for service_name in self.PIPX_SERVICES:
265
+ if not self.detect_service_path(service_name):
266
+ missing.append(service_name)
267
+
268
+ if not missing:
269
+ return True, "All MCP services are already installed"
270
+
271
+ installed = []
272
+ failed = []
273
+
274
+ for service_name in missing:
275
+ try:
276
+ self.logger.info(f"Installing {service_name} via pipx...")
277
+ result = subprocess.run(
278
+ ["pipx", "install", service_name],
279
+ capture_output=True,
280
+ text=True,
281
+ check=True,
282
+ )
283
+ installed.append(service_name)
284
+ self.logger.info(f"Successfully installed {service_name}")
285
+ except subprocess.CalledProcessError as e:
286
+ failed.append(service_name)
287
+ self.logger.error(f"Failed to install {service_name}: {e.stderr}")
288
+
289
+ if failed:
290
+ return False, f"Failed to install: {', '.join(failed)}"
291
+ elif installed:
292
+ return True, f"Successfully installed: {', '.join(installed)}"
293
+ else:
294
+ return True, "No services needed installation"
@@ -60,6 +60,23 @@ class MCPConfiguration(BaseMCPService, IMCPConfiguration):
60
60
  "timeout_default": 30, # seconds
61
61
  "max_concurrent": 10,
62
62
  },
63
+ "external_services": {
64
+ "enabled": True,
65
+ "auto_install": True,
66
+ "services": [
67
+ {
68
+ "name": "mcp-vector-search",
69
+ "package": "mcp-vector-search",
70
+ "enabled": True,
71
+ "auto_index": True,
72
+ },
73
+ {
74
+ "name": "mcp-browser",
75
+ "package": "mcp-browser",
76
+ "enabled": True,
77
+ },
78
+ ],
79
+ },
63
80
  "logging": {
64
81
  "level": "INFO",
65
82
  "file": "~/.claude/logs/mcp_gateway.log",
@@ -115,6 +115,14 @@ except ImportError:
115
115
  # Unified ticket tool is optional
116
116
  UnifiedTicketTool = None
117
117
 
118
+ try:
119
+ from claude_mpm.services.mcp_gateway.tools.external_mcp_services import (
120
+ ExternalMCPServiceManager,
121
+ )
122
+ except ImportError:
123
+ # External MCP services are optional
124
+ ExternalMCPServiceManager = None
125
+
118
126
  # Manager module removed - using simplified architecture
119
127
 
120
128
 
@@ -148,6 +156,7 @@ class MCPGatewayOrchestrator:
148
156
  self.registry: Optional[ToolRegistry] = None
149
157
  self.communication: Optional[StdioHandler] = None
150
158
  self.configuration: Optional[MCPConfiguration] = None
159
+ self.external_services: Optional[ExternalMCPServiceManager] = None
151
160
 
152
161
  # Shutdown handling
153
162
  self._shutdown_event = asyncio.Event()
@@ -199,6 +208,28 @@ class MCPGatewayOrchestrator:
199
208
  self.logger.warning(f"Failed to register some tools: {e}")
200
209
  # Continue - server can run with partial tools
201
210
 
211
+ # Initialize external MCP services if available
212
+ if ExternalMCPServiceManager is not None:
213
+ try:
214
+ self.logger.info("Initializing external MCP services...")
215
+ self.external_services = ExternalMCPServiceManager()
216
+ external_services = await self.external_services.initialize_services()
217
+
218
+ if external_services and self.registry:
219
+ for service in external_services:
220
+ try:
221
+ if self.registry.register_tool(service, category="external"):
222
+ self.logger.info(f"Registered external service: {service.service_name}")
223
+ else:
224
+ self.logger.warning(f"Failed to register external service: {service.service_name}")
225
+ except Exception as e:
226
+ self.logger.warning(f"Error registering {service.service_name}: {e}")
227
+
228
+ self.logger.info(f"Initialized {len(external_services)} external MCP services")
229
+ except Exception as e:
230
+ self.logger.warning(f"Failed to initialize external MCP services: {e}")
231
+ self.external_services = None
232
+
202
233
  # Initialize communication handler with fallback
203
234
  try:
204
235
  self.communication = StdioHandler()
@@ -371,6 +402,13 @@ class MCPGatewayOrchestrator:
371
402
  except Exception as e:
372
403
  self.logger.warning(f"Error during communication shutdown: {e}")
373
404
 
405
+ # Shutdown external services
406
+ if self.external_services:
407
+ try:
408
+ await self.external_services.shutdown()
409
+ except Exception as e:
410
+ self.logger.warning(f"Error during external services shutdown: {e}")
411
+
374
412
  self.logger.info("MCP Gateway shutdown complete")
375
413
 
376
414
  except Exception as e: