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.
Files changed (93) hide show
  1. hatch/__init__.py +21 -0
  2. hatch/cli_hatch.py +2748 -0
  3. hatch/environment_manager.py +1375 -0
  4. hatch/installers/__init__.py +25 -0
  5. hatch/installers/dependency_installation_orchestrator.py +636 -0
  6. hatch/installers/docker_installer.py +545 -0
  7. hatch/installers/hatch_installer.py +198 -0
  8. hatch/installers/installation_context.py +109 -0
  9. hatch/installers/installer_base.py +195 -0
  10. hatch/installers/python_installer.py +342 -0
  11. hatch/installers/registry.py +179 -0
  12. hatch/installers/system_installer.py +588 -0
  13. hatch/mcp_host_config/__init__.py +38 -0
  14. hatch/mcp_host_config/backup.py +458 -0
  15. hatch/mcp_host_config/host_management.py +572 -0
  16. hatch/mcp_host_config/models.py +602 -0
  17. hatch/mcp_host_config/reporting.py +181 -0
  18. hatch/mcp_host_config/strategies.py +513 -0
  19. hatch/package_loader.py +263 -0
  20. hatch/python_environment_manager.py +734 -0
  21. hatch/registry_explorer.py +171 -0
  22. hatch/registry_retriever.py +335 -0
  23. hatch/template_generator.py +179 -0
  24. hatch_xclam-0.7.0.dist-info/METADATA +150 -0
  25. hatch_xclam-0.7.0.dist-info/RECORD +93 -0
  26. hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
  27. hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
  28. hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
  29. hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
  30. tests/__init__.py +1 -0
  31. tests/run_environment_tests.py +124 -0
  32. tests/test_cli_version.py +122 -0
  33. tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
  34. tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
  35. tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
  36. tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
  37. tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
  38. tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
  39. tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
  40. tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
  41. tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
  42. tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
  43. tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
  44. tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
  45. tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
  46. tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
  47. tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
  48. tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
  49. tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
  50. tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
  51. tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
  52. tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
  53. tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
  54. tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
  55. tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
  56. tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
  57. tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
  58. tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
  59. tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
  60. tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
  61. tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
  62. tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
  63. tests/test_data_utils.py +472 -0
  64. tests/test_dependency_orchestrator_consent.py +266 -0
  65. tests/test_docker_installer.py +524 -0
  66. tests/test_env_manip.py +991 -0
  67. tests/test_hatch_installer.py +179 -0
  68. tests/test_installer_base.py +221 -0
  69. tests/test_mcp_atomic_operations.py +276 -0
  70. tests/test_mcp_backup_integration.py +308 -0
  71. tests/test_mcp_cli_all_host_specific_args.py +303 -0
  72. tests/test_mcp_cli_backup_management.py +295 -0
  73. tests/test_mcp_cli_direct_management.py +453 -0
  74. tests/test_mcp_cli_discovery_listing.py +582 -0
  75. tests/test_mcp_cli_host_config_integration.py +823 -0
  76. tests/test_mcp_cli_package_management.py +360 -0
  77. tests/test_mcp_cli_partial_updates.py +859 -0
  78. tests/test_mcp_environment_integration.py +520 -0
  79. tests/test_mcp_host_config_backup.py +257 -0
  80. tests/test_mcp_host_configuration_manager.py +331 -0
  81. tests/test_mcp_host_registry_decorator.py +348 -0
  82. tests/test_mcp_pydantic_architecture_v4.py +603 -0
  83. tests/test_mcp_server_config_models.py +242 -0
  84. tests/test_mcp_server_config_type_field.py +221 -0
  85. tests/test_mcp_sync_functionality.py +316 -0
  86. tests/test_mcp_user_feedback_reporting.py +359 -0
  87. tests/test_non_tty_integration.py +281 -0
  88. tests/test_online_package_loader.py +202 -0
  89. tests/test_python_environment_manager.py +882 -0
  90. tests/test_python_installer.py +327 -0
  91. tests/test_registry.py +51 -0
  92. tests/test_registry_retriever.py +250 -0
  93. 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