hatch-xclam 0.7.0__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.
- hatch/__init__.py +21 -0
- hatch/cli_hatch.py +2748 -0
- hatch/environment_manager.py +1375 -0
- hatch/installers/__init__.py +25 -0
- hatch/installers/dependency_installation_orchestrator.py +636 -0
- hatch/installers/docker_installer.py +545 -0
- hatch/installers/hatch_installer.py +198 -0
- hatch/installers/installation_context.py +109 -0
- hatch/installers/installer_base.py +195 -0
- hatch/installers/python_installer.py +342 -0
- hatch/installers/registry.py +179 -0
- hatch/installers/system_installer.py +588 -0
- hatch/mcp_host_config/__init__.py +38 -0
- hatch/mcp_host_config/backup.py +458 -0
- hatch/mcp_host_config/host_management.py +572 -0
- hatch/mcp_host_config/models.py +602 -0
- hatch/mcp_host_config/reporting.py +181 -0
- hatch/mcp_host_config/strategies.py +513 -0
- hatch/package_loader.py +263 -0
- hatch/python_environment_manager.py +734 -0
- hatch/registry_explorer.py +171 -0
- hatch/registry_retriever.py +335 -0
- hatch/template_generator.py +179 -0
- hatch_xclam-0.7.0.dist-info/METADATA +150 -0
- hatch_xclam-0.7.0.dist-info/RECORD +93 -0
- hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
- hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
- hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
- hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/run_environment_tests.py +124 -0
- tests/test_cli_version.py +122 -0
- tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
- tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
- tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
- tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
- tests/test_data_utils.py +472 -0
- tests/test_dependency_orchestrator_consent.py +266 -0
- tests/test_docker_installer.py +524 -0
- tests/test_env_manip.py +991 -0
- tests/test_hatch_installer.py +179 -0
- tests/test_installer_base.py +221 -0
- tests/test_mcp_atomic_operations.py +276 -0
- tests/test_mcp_backup_integration.py +308 -0
- tests/test_mcp_cli_all_host_specific_args.py +303 -0
- tests/test_mcp_cli_backup_management.py +295 -0
- tests/test_mcp_cli_direct_management.py +453 -0
- tests/test_mcp_cli_discovery_listing.py +582 -0
- tests/test_mcp_cli_host_config_integration.py +823 -0
- tests/test_mcp_cli_package_management.py +360 -0
- tests/test_mcp_cli_partial_updates.py +859 -0
- tests/test_mcp_environment_integration.py +520 -0
- tests/test_mcp_host_config_backup.py +257 -0
- tests/test_mcp_host_configuration_manager.py +331 -0
- tests/test_mcp_host_registry_decorator.py +348 -0
- tests/test_mcp_pydantic_architecture_v4.py +603 -0
- tests/test_mcp_server_config_models.py +242 -0
- tests/test_mcp_server_config_type_field.py +221 -0
- tests/test_mcp_sync_functionality.py +316 -0
- tests/test_mcp_user_feedback_reporting.py +359 -0
- tests/test_non_tty_integration.py +281 -0
- tests/test_online_package_loader.py +202 -0
- tests/test_python_environment_manager.py +882 -0
- tests/test_python_installer.py +327 -0
- tests/test_registry.py +51 -0
- tests/test_registry_retriever.py +250 -0
- tests/test_system_installer.py +733 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User feedback reporting system for MCP configuration operations.
|
|
3
|
+
|
|
4
|
+
This module provides models and functions for generating and displaying
|
|
5
|
+
user-friendly reports about MCP configuration changes, including field-level
|
|
6
|
+
operations and conversion summaries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Literal, Optional, Any, List
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
from .models import MCPServerConfigOmni, MCPHostType, HOST_MODEL_REGISTRY
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FieldOperation(BaseModel):
|
|
16
|
+
"""Single field operation in a conversion.
|
|
17
|
+
|
|
18
|
+
Represents a single field-level change during MCP configuration conversion,
|
|
19
|
+
including the operation type (UPDATED, UNSUPPORTED, UNCHANGED) and values.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
field_name: str
|
|
23
|
+
operation: Literal["UPDATED", "UNSUPPORTED", "UNCHANGED"]
|
|
24
|
+
old_value: Optional[Any] = None
|
|
25
|
+
new_value: Optional[Any] = None
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
"""Return formatted string representation for console output.
|
|
29
|
+
|
|
30
|
+
Uses ASCII arrow (-->) for terminal compatibility instead of Unicode.
|
|
31
|
+
"""
|
|
32
|
+
if self.operation == "UPDATED":
|
|
33
|
+
return f"{self.field_name}: UPDATED {repr(self.old_value)} --> {repr(self.new_value)}"
|
|
34
|
+
elif self.operation == "UNSUPPORTED":
|
|
35
|
+
return f"{self.field_name}: UNSUPPORTED"
|
|
36
|
+
elif self.operation == "UNCHANGED":
|
|
37
|
+
return f"{self.field_name}: UNCHANGED {repr(self.new_value)}"
|
|
38
|
+
return f"{self.field_name}: {self.operation}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConversionReport(BaseModel):
|
|
42
|
+
"""Complete conversion report for a configuration operation.
|
|
43
|
+
|
|
44
|
+
Contains metadata about the operation (create, update, delete, migrate)
|
|
45
|
+
and a list of field-level operations that occurred during conversion.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(validate_assignment=False)
|
|
49
|
+
|
|
50
|
+
operation: Literal["create", "update", "delete", "migrate"]
|
|
51
|
+
server_name: str
|
|
52
|
+
source_host: Optional[MCPHostType] = None
|
|
53
|
+
target_host: MCPHostType
|
|
54
|
+
success: bool = True
|
|
55
|
+
error_message: Optional[str] = None
|
|
56
|
+
field_operations: List[FieldOperation] = []
|
|
57
|
+
dry_run: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_conversion_report(
|
|
61
|
+
operation: Literal["create", "update", "delete", "migrate"],
|
|
62
|
+
server_name: str,
|
|
63
|
+
target_host: MCPHostType,
|
|
64
|
+
omni: MCPServerConfigOmni,
|
|
65
|
+
source_host: Optional[MCPHostType] = None,
|
|
66
|
+
old_config: Optional[MCPServerConfigOmni] = None,
|
|
67
|
+
dry_run: bool = False
|
|
68
|
+
) -> ConversionReport:
|
|
69
|
+
"""Generate conversion report for a configuration operation.
|
|
70
|
+
|
|
71
|
+
Analyzes the conversion from Omni model to host-specific configuration,
|
|
72
|
+
identifying which fields were updated, which are unsupported, and which
|
|
73
|
+
remained unchanged.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
operation: Type of operation being performed
|
|
77
|
+
server_name: Name of the server being configured
|
|
78
|
+
target_host: Target host for the configuration (MCPHostType enum)
|
|
79
|
+
omni: New/updated configuration (Omni model)
|
|
80
|
+
source_host: Source host (for migrate operation, MCPHostType enum)
|
|
81
|
+
old_config: Existing configuration (for update operation)
|
|
82
|
+
dry_run: Whether this is a dry-run preview
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ConversionReport with field-level operations
|
|
86
|
+
"""
|
|
87
|
+
# Derive supported fields dynamically from model class
|
|
88
|
+
model_class = HOST_MODEL_REGISTRY[target_host]
|
|
89
|
+
supported_fields = set(model_class.model_fields.keys())
|
|
90
|
+
|
|
91
|
+
field_operations = []
|
|
92
|
+
set_fields = omni.model_dump(exclude_unset=True)
|
|
93
|
+
|
|
94
|
+
for field_name, new_value in set_fields.items():
|
|
95
|
+
if field_name in supported_fields:
|
|
96
|
+
# Field is supported by target host
|
|
97
|
+
if old_config:
|
|
98
|
+
# Update operation - check if field changed
|
|
99
|
+
old_fields = old_config.model_dump(exclude_unset=True)
|
|
100
|
+
if field_name in old_fields:
|
|
101
|
+
old_value = old_fields[field_name]
|
|
102
|
+
if old_value != new_value:
|
|
103
|
+
# Field was modified
|
|
104
|
+
field_operations.append(FieldOperation(
|
|
105
|
+
field_name=field_name,
|
|
106
|
+
operation="UPDATED",
|
|
107
|
+
old_value=old_value,
|
|
108
|
+
new_value=new_value
|
|
109
|
+
))
|
|
110
|
+
else:
|
|
111
|
+
# Field unchanged
|
|
112
|
+
field_operations.append(FieldOperation(
|
|
113
|
+
field_name=field_name,
|
|
114
|
+
operation="UNCHANGED",
|
|
115
|
+
new_value=new_value
|
|
116
|
+
))
|
|
117
|
+
else:
|
|
118
|
+
# Field was added
|
|
119
|
+
field_operations.append(FieldOperation(
|
|
120
|
+
field_name=field_name,
|
|
121
|
+
operation="UPDATED",
|
|
122
|
+
old_value=None,
|
|
123
|
+
new_value=new_value
|
|
124
|
+
))
|
|
125
|
+
else:
|
|
126
|
+
# Create operation - all fields are new
|
|
127
|
+
field_operations.append(FieldOperation(
|
|
128
|
+
field_name=field_name,
|
|
129
|
+
operation="UPDATED",
|
|
130
|
+
old_value=None,
|
|
131
|
+
new_value=new_value
|
|
132
|
+
))
|
|
133
|
+
else:
|
|
134
|
+
# Field is not supported by target host
|
|
135
|
+
field_operations.append(FieldOperation(
|
|
136
|
+
field_name=field_name,
|
|
137
|
+
operation="UNSUPPORTED",
|
|
138
|
+
new_value=new_value
|
|
139
|
+
))
|
|
140
|
+
|
|
141
|
+
return ConversionReport(
|
|
142
|
+
operation=operation,
|
|
143
|
+
server_name=server_name,
|
|
144
|
+
source_host=source_host,
|
|
145
|
+
target_host=target_host,
|
|
146
|
+
field_operations=field_operations,
|
|
147
|
+
dry_run=dry_run
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def display_report(report: ConversionReport) -> None:
|
|
152
|
+
"""Display conversion report to console.
|
|
153
|
+
|
|
154
|
+
Prints a formatted report showing the operation performed and all
|
|
155
|
+
field-level changes. Uses FieldOperation.__str__() for consistent
|
|
156
|
+
formatting.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
report: ConversionReport to display
|
|
160
|
+
"""
|
|
161
|
+
# Header
|
|
162
|
+
if report.dry_run:
|
|
163
|
+
print(f"[DRY RUN] Preview of changes for server '{report.server_name}':")
|
|
164
|
+
else:
|
|
165
|
+
if report.operation == "create":
|
|
166
|
+
print(f"Server '{report.server_name}' created for host '{report.target_host.value}':")
|
|
167
|
+
elif report.operation == "update":
|
|
168
|
+
print(f"Server '{report.server_name}' updated for host '{report.target_host.value}':")
|
|
169
|
+
elif report.operation == "migrate":
|
|
170
|
+
print(f"Server '{report.server_name}' migrated from '{report.source_host.value}' to '{report.target_host.value}':")
|
|
171
|
+
elif report.operation == "delete":
|
|
172
|
+
print(f"Server '{report.server_name}' deleted from host '{report.target_host.value}':")
|
|
173
|
+
|
|
174
|
+
# Field operations
|
|
175
|
+
for field_op in report.field_operations:
|
|
176
|
+
print(f" {field_op}")
|
|
177
|
+
|
|
178
|
+
# Footer
|
|
179
|
+
if report.dry_run:
|
|
180
|
+
print("\nNo changes were made.")
|
|
181
|
+
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP host strategy implementations with decorator-based registration.
|
|
3
|
+
|
|
4
|
+
This module provides concrete implementations of host strategies for all
|
|
5
|
+
supported MCP hosts including Claude family, Cursor family, and independent
|
|
6
|
+
strategies with decorator registration following Hatchling patterns.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import platform
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Dict, Any
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
from .host_management import MCPHostStrategy, register_host_strategy
|
|
16
|
+
from .models import MCPHostType, MCPServerConfig, HostConfiguration
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClaudeHostStrategy(MCPHostStrategy):
|
|
22
|
+
"""Base strategy for Claude family hosts with shared patterns."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.company_origin = "Anthropic"
|
|
26
|
+
self.config_format = "claude_format"
|
|
27
|
+
|
|
28
|
+
def get_config_key(self) -> str:
|
|
29
|
+
"""Claude family uses 'mcpServers' key."""
|
|
30
|
+
return "mcpServers"
|
|
31
|
+
|
|
32
|
+
def validate_server_config(self, server_config: MCPServerConfig) -> bool:
|
|
33
|
+
"""Claude family validation - accepts any valid command or URL.
|
|
34
|
+
|
|
35
|
+
Claude Desktop accepts both absolute and relative paths for commands.
|
|
36
|
+
Commands are resolved at runtime using the system PATH, similar to
|
|
37
|
+
how shell commands work. This validation only checks that either a
|
|
38
|
+
command or URL is provided, not the path format.
|
|
39
|
+
"""
|
|
40
|
+
# Accept local servers (command-based)
|
|
41
|
+
if server_config.command:
|
|
42
|
+
return True
|
|
43
|
+
# Accept remote servers (URL-based)
|
|
44
|
+
if server_config.url:
|
|
45
|
+
return True
|
|
46
|
+
# Reject if neither command nor URL is provided
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def _preserve_claude_settings(self, existing_config: Dict, new_servers: Dict) -> Dict:
|
|
50
|
+
"""Preserve Claude-specific settings when updating configuration."""
|
|
51
|
+
# Preserve non-MCP settings like theme, auto_update, etc.
|
|
52
|
+
preserved_config = existing_config.copy()
|
|
53
|
+
preserved_config[self.get_config_key()] = new_servers
|
|
54
|
+
return preserved_config
|
|
55
|
+
|
|
56
|
+
def read_configuration(self) -> HostConfiguration:
|
|
57
|
+
"""Read Claude configuration file."""
|
|
58
|
+
config_path = self.get_config_path()
|
|
59
|
+
if not config_path or not config_path.exists():
|
|
60
|
+
return HostConfiguration()
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with open(config_path, 'r') as f:
|
|
64
|
+
config_data = json.load(f)
|
|
65
|
+
|
|
66
|
+
# Extract MCP servers from Claude configuration
|
|
67
|
+
mcp_servers = config_data.get(self.get_config_key(), {})
|
|
68
|
+
|
|
69
|
+
# Convert to MCPServerConfig objects
|
|
70
|
+
servers = {}
|
|
71
|
+
for name, server_data in mcp_servers.items():
|
|
72
|
+
try:
|
|
73
|
+
servers[name] = MCPServerConfig(**server_data)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning(f"Invalid server config for {name}: {e}")
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
return HostConfiguration(servers=servers)
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Failed to read Claude configuration: {e}")
|
|
82
|
+
return HostConfiguration()
|
|
83
|
+
|
|
84
|
+
def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool:
|
|
85
|
+
"""Write Claude configuration file."""
|
|
86
|
+
config_path = self.get_config_path()
|
|
87
|
+
if not config_path:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Ensure parent directory exists
|
|
92
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
# Read existing configuration to preserve non-MCP settings
|
|
95
|
+
existing_config = {}
|
|
96
|
+
if config_path.exists():
|
|
97
|
+
try:
|
|
98
|
+
with open(config_path, 'r') as f:
|
|
99
|
+
existing_config = json.load(f)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass # Start with empty config if read fails
|
|
102
|
+
|
|
103
|
+
# Convert MCPServerConfig objects to dict
|
|
104
|
+
servers_dict = {}
|
|
105
|
+
for name, server_config in config.servers.items():
|
|
106
|
+
servers_dict[name] = server_config.model_dump(exclude_none=True)
|
|
107
|
+
|
|
108
|
+
# Preserve Claude-specific settings
|
|
109
|
+
updated_config = self._preserve_claude_settings(existing_config, servers_dict)
|
|
110
|
+
|
|
111
|
+
# Write atomically
|
|
112
|
+
temp_path = config_path.with_suffix('.tmp')
|
|
113
|
+
with open(temp_path, 'w') as f:
|
|
114
|
+
json.dump(updated_config, f, indent=2)
|
|
115
|
+
|
|
116
|
+
temp_path.replace(config_path)
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Failed to write Claude configuration: {e}")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@register_host_strategy(MCPHostType.CLAUDE_DESKTOP)
|
|
125
|
+
class ClaudeDesktopStrategy(ClaudeHostStrategy):
|
|
126
|
+
"""Configuration strategy for Claude Desktop."""
|
|
127
|
+
|
|
128
|
+
def get_config_path(self) -> Optional[Path]:
|
|
129
|
+
"""Get Claude Desktop configuration path."""
|
|
130
|
+
system = platform.system()
|
|
131
|
+
|
|
132
|
+
if system == "Darwin": # macOS
|
|
133
|
+
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
134
|
+
elif system == "Windows":
|
|
135
|
+
return Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
|
|
136
|
+
elif system == "Linux":
|
|
137
|
+
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def is_host_available(self) -> bool:
|
|
141
|
+
"""Check if Claude Desktop is installed."""
|
|
142
|
+
config_path = self.get_config_path()
|
|
143
|
+
return config_path is not None and config_path.parent.exists()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@register_host_strategy(MCPHostType.CLAUDE_CODE)
|
|
147
|
+
class ClaudeCodeStrategy(ClaudeHostStrategy):
|
|
148
|
+
"""Configuration strategy for Claude for VS Code."""
|
|
149
|
+
|
|
150
|
+
def get_config_path(self) -> Optional[Path]:
|
|
151
|
+
"""Get Claude Code configuration path (workspace-specific)."""
|
|
152
|
+
# Claude Code uses workspace-specific configuration
|
|
153
|
+
# This would be determined at runtime based on current workspace
|
|
154
|
+
return Path.home() / ".claude.json"
|
|
155
|
+
|
|
156
|
+
def is_host_available(self) -> bool:
|
|
157
|
+
"""Check if Claude Code is available."""
|
|
158
|
+
# Check for Claude Code user configuration file
|
|
159
|
+
vscode_dir = Path.home() / ".claude.json"
|
|
160
|
+
return vscode_dir.exists()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CursorBasedHostStrategy(MCPHostStrategy):
|
|
164
|
+
"""Base strategy for Cursor-based hosts (Cursor and LM Studio)."""
|
|
165
|
+
|
|
166
|
+
def __init__(self):
|
|
167
|
+
self.config_format = "cursor_format"
|
|
168
|
+
self.supports_remote_servers = True
|
|
169
|
+
|
|
170
|
+
def get_config_key(self) -> str:
|
|
171
|
+
"""Cursor family uses 'mcpServers' key."""
|
|
172
|
+
return "mcpServers"
|
|
173
|
+
|
|
174
|
+
def validate_server_config(self, server_config: MCPServerConfig) -> bool:
|
|
175
|
+
"""Cursor family validation - supports both local and remote servers."""
|
|
176
|
+
# Cursor family is more flexible with paths and supports remote servers
|
|
177
|
+
if server_config.command:
|
|
178
|
+
return True # Local server
|
|
179
|
+
elif server_config.url:
|
|
180
|
+
return True # Remote server
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def _format_cursor_server_config(self, server_config: MCPServerConfig) -> Dict:
|
|
184
|
+
"""Format server configuration for Cursor family."""
|
|
185
|
+
config = {}
|
|
186
|
+
|
|
187
|
+
if server_config.command:
|
|
188
|
+
# Local server configuration
|
|
189
|
+
config["command"] = server_config.command
|
|
190
|
+
if server_config.args:
|
|
191
|
+
config["args"] = server_config.args
|
|
192
|
+
if server_config.env:
|
|
193
|
+
config["env"] = server_config.env
|
|
194
|
+
elif server_config.url:
|
|
195
|
+
# Remote server configuration
|
|
196
|
+
config["url"] = server_config.url
|
|
197
|
+
if server_config.headers:
|
|
198
|
+
config["headers"] = server_config.headers
|
|
199
|
+
|
|
200
|
+
return config
|
|
201
|
+
|
|
202
|
+
def read_configuration(self) -> HostConfiguration:
|
|
203
|
+
"""Read Cursor-based configuration file."""
|
|
204
|
+
config_path = self.get_config_path()
|
|
205
|
+
if not config_path or not config_path.exists():
|
|
206
|
+
return HostConfiguration()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with open(config_path, 'r') as f:
|
|
210
|
+
config_data = json.load(f)
|
|
211
|
+
|
|
212
|
+
# Extract MCP servers
|
|
213
|
+
mcp_servers = config_data.get(self.get_config_key(), {})
|
|
214
|
+
|
|
215
|
+
# Convert to MCPServerConfig objects
|
|
216
|
+
servers = {}
|
|
217
|
+
for name, server_data in mcp_servers.items():
|
|
218
|
+
try:
|
|
219
|
+
servers[name] = MCPServerConfig(**server_data)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Invalid server config for {name}: {e}")
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
return HostConfiguration(servers=servers)
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error(f"Failed to read Cursor configuration: {e}")
|
|
228
|
+
return HostConfiguration()
|
|
229
|
+
|
|
230
|
+
def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool:
|
|
231
|
+
"""Write Cursor-based configuration file."""
|
|
232
|
+
config_path = self.get_config_path()
|
|
233
|
+
if not config_path:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Ensure parent directory exists
|
|
238
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
|
|
240
|
+
# Read existing configuration
|
|
241
|
+
existing_config = {}
|
|
242
|
+
if config_path.exists():
|
|
243
|
+
try:
|
|
244
|
+
with open(config_path, 'r') as f:
|
|
245
|
+
existing_config = json.load(f)
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
# Convert MCPServerConfig objects to dict
|
|
250
|
+
servers_dict = {}
|
|
251
|
+
for name, server_config in config.servers.items():
|
|
252
|
+
servers_dict[name] = server_config.model_dump(exclude_none=True)
|
|
253
|
+
|
|
254
|
+
# Update configuration
|
|
255
|
+
existing_config[self.get_config_key()] = servers_dict
|
|
256
|
+
|
|
257
|
+
# Write atomically
|
|
258
|
+
temp_path = config_path.with_suffix('.tmp')
|
|
259
|
+
with open(temp_path, 'w') as f:
|
|
260
|
+
json.dump(existing_config, f, indent=2)
|
|
261
|
+
|
|
262
|
+
temp_path.replace(config_path)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(f"Failed to write Cursor configuration: {e}")
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@register_host_strategy(MCPHostType.CURSOR)
|
|
271
|
+
class CursorHostStrategy(CursorBasedHostStrategy):
|
|
272
|
+
"""Configuration strategy for Cursor IDE."""
|
|
273
|
+
|
|
274
|
+
def get_config_path(self) -> Optional[Path]:
|
|
275
|
+
"""Get Cursor configuration path."""
|
|
276
|
+
return Path.home() / ".cursor" / "mcp.json"
|
|
277
|
+
|
|
278
|
+
def is_host_available(self) -> bool:
|
|
279
|
+
"""Check if Cursor IDE is installed."""
|
|
280
|
+
cursor_dir = Path.home() / ".cursor"
|
|
281
|
+
return cursor_dir.exists()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@register_host_strategy(MCPHostType.LMSTUDIO)
|
|
285
|
+
class LMStudioHostStrategy(CursorBasedHostStrategy):
|
|
286
|
+
"""Configuration strategy for LM Studio (follows Cursor format)."""
|
|
287
|
+
|
|
288
|
+
def get_config_path(self) -> Optional[Path]:
|
|
289
|
+
"""Get LM Studio configuration path."""
|
|
290
|
+
return Path.home() / ".lmstudio" / "mcp.json"
|
|
291
|
+
|
|
292
|
+
def is_host_available(self) -> bool:
|
|
293
|
+
"""Check if LM Studio is installed."""
|
|
294
|
+
config_path = self.get_config_path()
|
|
295
|
+
return self.get_config_path().parent.exists()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@register_host_strategy(MCPHostType.VSCODE)
|
|
299
|
+
class VSCodeHostStrategy(MCPHostStrategy):
|
|
300
|
+
"""Configuration strategy for VS Code MCP extension with user-wide mcp support."""
|
|
301
|
+
|
|
302
|
+
def get_config_path(self) -> Optional[Path]:
|
|
303
|
+
"""Get VS Code user mcp configuration path (cross-platform)."""
|
|
304
|
+
try:
|
|
305
|
+
system = platform.system()
|
|
306
|
+
if system == "Windows":
|
|
307
|
+
# Windows: %APPDATA%\Code\User\mcp.json
|
|
308
|
+
appdata = Path.home() / "AppData" / "Roaming"
|
|
309
|
+
return appdata / "Code" / "User" / "mcp.json"
|
|
310
|
+
elif system == "Darwin": # macOS
|
|
311
|
+
# macOS: $HOME/Library/Application Support/Code/User/mcp.json
|
|
312
|
+
return Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
|
313
|
+
elif system == "Linux":
|
|
314
|
+
# Linux: $HOME/.config/Code/User/mcp.json
|
|
315
|
+
return Path.home() / ".config" / "Code" / "User" / "mcp.json"
|
|
316
|
+
else:
|
|
317
|
+
logger.warning(f"Unsupported platform for VS Code: {system}")
|
|
318
|
+
return None
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.error(f"Failed to determine VS Code user mcp path: {e}")
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
def get_config_key(self) -> str:
|
|
324
|
+
"""VS Code uses direct servers configuration structure."""
|
|
325
|
+
return "servers" # VS Code specific direct key
|
|
326
|
+
|
|
327
|
+
def is_host_available(self) -> bool:
|
|
328
|
+
"""Check if VS Code is installed by checking for user directory."""
|
|
329
|
+
try:
|
|
330
|
+
config_path = self.get_config_path()
|
|
331
|
+
if not config_path:
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
# Check if VS Code user directory exists (indicates VS Code installation)
|
|
335
|
+
user_dir = config_path.parent
|
|
336
|
+
return user_dir.exists()
|
|
337
|
+
except Exception:
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
def validate_server_config(self, server_config: MCPServerConfig) -> bool:
|
|
341
|
+
"""VS Code validation - flexible path handling."""
|
|
342
|
+
return server_config.command is not None or server_config.url is not None
|
|
343
|
+
|
|
344
|
+
def read_configuration(self) -> HostConfiguration:
|
|
345
|
+
"""Read VS Code mcp.json configuration."""
|
|
346
|
+
config_path = self.get_config_path()
|
|
347
|
+
if not config_path or not config_path.exists():
|
|
348
|
+
return HostConfiguration()
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
with open(config_path, 'r') as f:
|
|
352
|
+
config_data = json.load(f)
|
|
353
|
+
|
|
354
|
+
# Extract MCP servers from direct structure
|
|
355
|
+
mcp_servers = config_data.get(self.get_config_key(), {})
|
|
356
|
+
|
|
357
|
+
# Convert to MCPServerConfig objects
|
|
358
|
+
servers = {}
|
|
359
|
+
for name, server_data in mcp_servers.items():
|
|
360
|
+
try:
|
|
361
|
+
servers[name] = MCPServerConfig(**server_data)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.warning(f"Invalid server config for {name}: {e}")
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
return HostConfiguration(servers=servers)
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.error(f"Failed to read VS Code configuration: {e}")
|
|
370
|
+
return HostConfiguration()
|
|
371
|
+
|
|
372
|
+
def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool:
|
|
373
|
+
"""Write VS Code mcp.json configuration."""
|
|
374
|
+
config_path = self.get_config_path()
|
|
375
|
+
if not config_path:
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
# Ensure parent directory exists
|
|
380
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
|
|
382
|
+
# Read existing configuration to preserve non-MCP settings
|
|
383
|
+
existing_config = {}
|
|
384
|
+
if config_path.exists():
|
|
385
|
+
try:
|
|
386
|
+
with open(config_path, 'r') as f:
|
|
387
|
+
existing_config = json.load(f)
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
# Convert MCPServerConfig objects to dict
|
|
392
|
+
servers_dict = {}
|
|
393
|
+
for name, server_config in config.servers.items():
|
|
394
|
+
servers_dict[name] = server_config.model_dump(exclude_none=True)
|
|
395
|
+
|
|
396
|
+
# Update configuration with new servers (preserves non-MCP settings)
|
|
397
|
+
existing_config[self.get_config_key()] = servers_dict
|
|
398
|
+
|
|
399
|
+
# Write atomically
|
|
400
|
+
temp_path = config_path.with_suffix('.tmp')
|
|
401
|
+
with open(temp_path, 'w') as f:
|
|
402
|
+
json.dump(existing_config, f, indent=2)
|
|
403
|
+
|
|
404
|
+
temp_path.replace(config_path)
|
|
405
|
+
return True
|
|
406
|
+
|
|
407
|
+
except Exception as e:
|
|
408
|
+
logger.error(f"Failed to write VS Code configuration: {e}")
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@register_host_strategy(MCPHostType.GEMINI)
|
|
413
|
+
class GeminiHostStrategy(MCPHostStrategy):
|
|
414
|
+
"""Configuration strategy for Google Gemini CLI MCP integration."""
|
|
415
|
+
|
|
416
|
+
def get_config_path(self) -> Optional[Path]:
|
|
417
|
+
"""Get Gemini configuration path based on official documentation."""
|
|
418
|
+
# Based on official Gemini CLI documentation: ~/.gemini/settings.json
|
|
419
|
+
return Path.home() / ".gemini" / "settings.json"
|
|
420
|
+
|
|
421
|
+
def get_config_key(self) -> str:
|
|
422
|
+
"""Gemini uses 'mcpServers' key in settings.json."""
|
|
423
|
+
return "mcpServers"
|
|
424
|
+
|
|
425
|
+
def is_host_available(self) -> bool:
|
|
426
|
+
"""Check if Gemini CLI is available."""
|
|
427
|
+
# Check if Gemini CLI directory exists
|
|
428
|
+
gemini_dir = Path.home() / ".gemini"
|
|
429
|
+
return gemini_dir.exists()
|
|
430
|
+
|
|
431
|
+
def validate_server_config(self, server_config: MCPServerConfig) -> bool:
|
|
432
|
+
"""Gemini validation - supports both local and remote servers."""
|
|
433
|
+
# Gemini CLI supports both command-based and URL-based servers
|
|
434
|
+
return server_config.command is not None or server_config.url is not None
|
|
435
|
+
|
|
436
|
+
def read_configuration(self) -> HostConfiguration:
|
|
437
|
+
"""Read Gemini settings.json configuration."""
|
|
438
|
+
config_path = self.get_config_path()
|
|
439
|
+
if not config_path or not config_path.exists():
|
|
440
|
+
return HostConfiguration()
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
with open(config_path, 'r') as f:
|
|
444
|
+
config_data = json.load(f)
|
|
445
|
+
|
|
446
|
+
# Extract MCP servers from Gemini configuration
|
|
447
|
+
mcp_servers = config_data.get(self.get_config_key(), {})
|
|
448
|
+
|
|
449
|
+
# Convert to MCPServerConfig objects
|
|
450
|
+
servers = {}
|
|
451
|
+
for name, server_data in mcp_servers.items():
|
|
452
|
+
try:
|
|
453
|
+
servers[name] = MCPServerConfig(**server_data)
|
|
454
|
+
except Exception as e:
|
|
455
|
+
logger.warning(f"Invalid server config for {name}: {e}")
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
return HostConfiguration(servers=servers)
|
|
459
|
+
|
|
460
|
+
except Exception as e:
|
|
461
|
+
logger.error(f"Failed to read Gemini configuration: {e}")
|
|
462
|
+
return HostConfiguration()
|
|
463
|
+
|
|
464
|
+
def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool:
|
|
465
|
+
"""Write Gemini settings.json configuration."""
|
|
466
|
+
config_path = self.get_config_path()
|
|
467
|
+
if not config_path:
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
# Ensure parent directory exists
|
|
472
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
|
|
474
|
+
# Read existing configuration to preserve non-MCP settings
|
|
475
|
+
existing_config = {}
|
|
476
|
+
if config_path.exists():
|
|
477
|
+
try:
|
|
478
|
+
with open(config_path, 'r') as f:
|
|
479
|
+
existing_config = json.load(f)
|
|
480
|
+
except Exception:
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
# Convert MCPServerConfig objects to dict (REPLACE, don't merge)
|
|
484
|
+
servers_dict = {}
|
|
485
|
+
for name, server_config in config.servers.items():
|
|
486
|
+
servers_dict[name] = server_config.model_dump(exclude_none=True)
|
|
487
|
+
|
|
488
|
+
# Update configuration with new servers (preserves non-MCP settings)
|
|
489
|
+
existing_config[self.get_config_key()] = servers_dict
|
|
490
|
+
|
|
491
|
+
# Write atomically with enhanced error handling
|
|
492
|
+
temp_path = config_path.with_suffix('.tmp')
|
|
493
|
+
try:
|
|
494
|
+
with open(temp_path, 'w') as f:
|
|
495
|
+
json.dump(existing_config, f, indent=2, ensure_ascii=False)
|
|
496
|
+
|
|
497
|
+
# Verify the JSON is valid by reading it back
|
|
498
|
+
with open(temp_path, 'r') as f:
|
|
499
|
+
json.load(f) # This will raise an exception if JSON is invalid
|
|
500
|
+
|
|
501
|
+
# Only replace if verification succeeds
|
|
502
|
+
temp_path.replace(config_path)
|
|
503
|
+
return True
|
|
504
|
+
except Exception as json_error:
|
|
505
|
+
# Clean up temp file on JSON error
|
|
506
|
+
if temp_path.exists():
|
|
507
|
+
temp_path.unlink()
|
|
508
|
+
logger.error(f"JSON serialization/verification failed: {json_error}")
|
|
509
|
+
raise
|
|
510
|
+
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.error(f"Failed to write Gemini configuration: {e}")
|
|
513
|
+
return False
|