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,602 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consolidated Pydantic models for MCP host configuration management.
|
|
3
|
+
|
|
4
|
+
This module provides the core data models for MCP server configuration,
|
|
5
|
+
environment data structures, and host configuration management following
|
|
6
|
+
the v2 design specification with consolidated MCPServerConfig model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
|
|
10
|
+
from typing import Dict, List, Optional, Union, Literal
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from enum import Enum
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCPHostType(str, Enum):
|
|
20
|
+
"""Enumeration of supported MCP host types."""
|
|
21
|
+
CLAUDE_DESKTOP = "claude-desktop"
|
|
22
|
+
CLAUDE_CODE = "claude-code"
|
|
23
|
+
VSCODE = "vscode"
|
|
24
|
+
CURSOR = "cursor"
|
|
25
|
+
LMSTUDIO = "lmstudio"
|
|
26
|
+
GEMINI = "gemini"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MCPServerConfig(BaseModel):
|
|
30
|
+
"""Consolidated MCP server configuration supporting local and remote servers."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="allow")
|
|
33
|
+
|
|
34
|
+
# Server identification
|
|
35
|
+
name: Optional[str] = Field(None, description="Server name for identification")
|
|
36
|
+
|
|
37
|
+
# Transport type (PRIMARY DISCRIMINATOR)
|
|
38
|
+
type: Optional[Literal["stdio", "sse", "http"]] = Field(
|
|
39
|
+
None,
|
|
40
|
+
description="Transport type (stdio for local, sse/http for remote)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Local server configuration (Pattern A: Command-Based / stdio transport)
|
|
44
|
+
command: Optional[str] = Field(None, description="Executable path/name for local servers")
|
|
45
|
+
args: Optional[List[str]] = Field(None, description="Command arguments for local servers")
|
|
46
|
+
env: Optional[Dict[str, str]] = Field(None, description="Environment variables for all transports")
|
|
47
|
+
|
|
48
|
+
# Remote server configuration (Pattern B: URL-Based / sse/http transports)
|
|
49
|
+
url: Optional[str] = Field(None, description="Server endpoint URL for remote servers")
|
|
50
|
+
headers: Optional[Dict[str, str]] = Field(None, description="HTTP headers for remote servers")
|
|
51
|
+
|
|
52
|
+
@model_validator(mode='after')
|
|
53
|
+
def validate_server_type(self):
|
|
54
|
+
"""Validate that either local or remote configuration is provided, not both."""
|
|
55
|
+
command = self.command
|
|
56
|
+
url = self.url
|
|
57
|
+
|
|
58
|
+
if not command and not url:
|
|
59
|
+
raise ValueError("Either 'command' (local server) or 'url' (remote server) must be provided")
|
|
60
|
+
|
|
61
|
+
if command and url:
|
|
62
|
+
raise ValueError("Cannot specify both 'command' and 'url' - choose local or remote server")
|
|
63
|
+
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
@field_validator('command')
|
|
67
|
+
@classmethod
|
|
68
|
+
def validate_command_not_empty(cls, v):
|
|
69
|
+
"""Validate command is not empty when provided."""
|
|
70
|
+
if v is not None and not v.strip():
|
|
71
|
+
raise ValueError("Command cannot be empty")
|
|
72
|
+
return v.strip() if v else v
|
|
73
|
+
|
|
74
|
+
@field_validator('url')
|
|
75
|
+
@classmethod
|
|
76
|
+
def validate_url_format(cls, v):
|
|
77
|
+
"""Validate URL format when provided."""
|
|
78
|
+
if v is not None:
|
|
79
|
+
if not v.startswith(('http://', 'https://')):
|
|
80
|
+
raise ValueError("URL must start with http:// or https://")
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
@model_validator(mode='after')
|
|
84
|
+
def validate_field_combinations(self):
|
|
85
|
+
"""Validate field combinations for local vs remote servers."""
|
|
86
|
+
# Validate args are only provided with command
|
|
87
|
+
if self.args is not None and self.command is None:
|
|
88
|
+
raise ValueError("'args' can only be specified with 'command' for local servers")
|
|
89
|
+
|
|
90
|
+
# Validate headers are only provided with URL
|
|
91
|
+
if self.headers is not None and self.url is None:
|
|
92
|
+
raise ValueError("'headers' can only be specified with 'url' for remote servers")
|
|
93
|
+
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
@model_validator(mode='after')
|
|
97
|
+
def validate_type_field(self):
|
|
98
|
+
"""Validate type field consistency with command/url fields."""
|
|
99
|
+
# Only validate if type field is explicitly set
|
|
100
|
+
if self.type is not None:
|
|
101
|
+
if self.type == "stdio":
|
|
102
|
+
if not self.command:
|
|
103
|
+
raise ValueError("'type=stdio' requires 'command' field")
|
|
104
|
+
if self.url:
|
|
105
|
+
raise ValueError("'type=stdio' cannot be used with 'url' field")
|
|
106
|
+
elif self.type in ("sse", "http"):
|
|
107
|
+
if not self.url:
|
|
108
|
+
raise ValueError(f"'type={self.type}' requires 'url' field")
|
|
109
|
+
if self.command:
|
|
110
|
+
raise ValueError(f"'type={self.type}' cannot be used with 'command' field")
|
|
111
|
+
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_local_server(self) -> bool:
|
|
116
|
+
"""Check if this is a local server configuration."""
|
|
117
|
+
# Prioritize type field if present
|
|
118
|
+
if self.type is not None:
|
|
119
|
+
return self.type == "stdio"
|
|
120
|
+
# Fall back to command detection for backward compatibility
|
|
121
|
+
return self.command is not None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def is_remote_server(self) -> bool:
|
|
125
|
+
"""Check if this is a remote server configuration."""
|
|
126
|
+
# Prioritize type field if present
|
|
127
|
+
if self.type is not None:
|
|
128
|
+
return self.type in ("sse", "http")
|
|
129
|
+
# Fall back to url detection for backward compatibility
|
|
130
|
+
return self.url is not None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class HostConfigurationMetadata(BaseModel):
|
|
136
|
+
"""Metadata for host configuration tracking."""
|
|
137
|
+
config_path: str = Field(..., description="Path to host configuration file")
|
|
138
|
+
configured_at: datetime = Field(..., description="Initial configuration timestamp")
|
|
139
|
+
last_synced: datetime = Field(..., description="Last synchronization timestamp")
|
|
140
|
+
|
|
141
|
+
@field_validator('config_path')
|
|
142
|
+
@classmethod
|
|
143
|
+
def validate_config_path_not_empty(cls, v):
|
|
144
|
+
"""Validate config path is not empty."""
|
|
145
|
+
if not v.strip():
|
|
146
|
+
raise ValueError("Config path cannot be empty")
|
|
147
|
+
return v.strip()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PackageHostConfiguration(BaseModel):
|
|
151
|
+
"""Host configuration for a single package (corrected structure)."""
|
|
152
|
+
config_path: str = Field(..., description="Path to host configuration file")
|
|
153
|
+
configured_at: datetime = Field(..., description="Initial configuration timestamp")
|
|
154
|
+
last_synced: datetime = Field(..., description="Last synchronization timestamp")
|
|
155
|
+
server_config: MCPServerConfig = Field(..., description="Server configuration for this host")
|
|
156
|
+
|
|
157
|
+
@field_validator('config_path')
|
|
158
|
+
@classmethod
|
|
159
|
+
def validate_config_path_format(cls, v):
|
|
160
|
+
"""Validate config path format."""
|
|
161
|
+
if not v.strip():
|
|
162
|
+
raise ValueError("Config path cannot be empty")
|
|
163
|
+
return v.strip()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class EnvironmentPackageEntry(BaseModel):
|
|
167
|
+
"""Package entry within environment with corrected MCP structure."""
|
|
168
|
+
name: str = Field(..., description="Package name")
|
|
169
|
+
version: str = Field(..., description="Package version")
|
|
170
|
+
type: str = Field(..., description="Package type (hatch, mcp_standalone, etc.)")
|
|
171
|
+
source: str = Field(..., description="Package source")
|
|
172
|
+
installed_at: datetime = Field(..., description="Installation timestamp")
|
|
173
|
+
configured_hosts: Dict[str, PackageHostConfiguration] = Field(
|
|
174
|
+
default_factory=dict,
|
|
175
|
+
description="Host configurations for this package's MCP server"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@field_validator('name')
|
|
179
|
+
@classmethod
|
|
180
|
+
def validate_package_name(cls, v):
|
|
181
|
+
"""Validate package name format."""
|
|
182
|
+
if not v.strip():
|
|
183
|
+
raise ValueError("Package name cannot be empty")
|
|
184
|
+
# Allow standard package naming patterns
|
|
185
|
+
if not v.replace('-', '').replace('_', '').replace('.', '').isalnum():
|
|
186
|
+
raise ValueError(f"Invalid package name format: {v}")
|
|
187
|
+
return v.strip()
|
|
188
|
+
|
|
189
|
+
@field_validator('configured_hosts')
|
|
190
|
+
@classmethod
|
|
191
|
+
def validate_host_names(cls, v):
|
|
192
|
+
"""Validate host names are supported."""
|
|
193
|
+
supported_hosts = {
|
|
194
|
+
'claude-desktop', 'claude-code', 'vscode',
|
|
195
|
+
'cursor', 'lmstudio', 'gemini'
|
|
196
|
+
}
|
|
197
|
+
for host_name in v.keys():
|
|
198
|
+
if host_name not in supported_hosts:
|
|
199
|
+
raise ValueError(f"Unsupported host: {host_name}. Supported: {supported_hosts}")
|
|
200
|
+
return v
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class EnvironmentData(BaseModel):
|
|
204
|
+
"""Complete environment data structure with corrected MCP integration."""
|
|
205
|
+
name: str = Field(..., description="Environment name")
|
|
206
|
+
description: str = Field(..., description="Environment description")
|
|
207
|
+
created_at: datetime = Field(..., description="Environment creation timestamp")
|
|
208
|
+
packages: List[EnvironmentPackageEntry] = Field(
|
|
209
|
+
default_factory=list,
|
|
210
|
+
description="Packages installed in this environment"
|
|
211
|
+
)
|
|
212
|
+
python_environment: bool = Field(True, description="Whether this is a Python environment")
|
|
213
|
+
python_env: Dict = Field(default_factory=dict, description="Python environment data")
|
|
214
|
+
|
|
215
|
+
@field_validator('name')
|
|
216
|
+
@classmethod
|
|
217
|
+
def validate_environment_name(cls, v):
|
|
218
|
+
"""Validate environment name format."""
|
|
219
|
+
if not v.strip():
|
|
220
|
+
raise ValueError("Environment name cannot be empty")
|
|
221
|
+
return v.strip()
|
|
222
|
+
|
|
223
|
+
def get_mcp_packages(self) -> List[EnvironmentPackageEntry]:
|
|
224
|
+
"""Get packages that have MCP server configurations."""
|
|
225
|
+
return [pkg for pkg in self.packages if pkg.configured_hosts]
|
|
226
|
+
|
|
227
|
+
def get_standalone_mcp_package(self) -> Optional[EnvironmentPackageEntry]:
|
|
228
|
+
"""Get the standalone MCP servers package if it exists."""
|
|
229
|
+
for pkg in self.packages:
|
|
230
|
+
if pkg.name == "__standalone_mcp_servers__":
|
|
231
|
+
return pkg
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def add_standalone_mcp_server(self, server_name: str, host_config: PackageHostConfiguration):
|
|
235
|
+
"""Add a standalone MCP server configuration."""
|
|
236
|
+
standalone_pkg = self.get_standalone_mcp_package()
|
|
237
|
+
|
|
238
|
+
if standalone_pkg is None:
|
|
239
|
+
# Create standalone package entry
|
|
240
|
+
standalone_pkg = EnvironmentPackageEntry(
|
|
241
|
+
name="__standalone_mcp_servers__",
|
|
242
|
+
version="1.0.0",
|
|
243
|
+
type="mcp_standalone",
|
|
244
|
+
source="user_configured",
|
|
245
|
+
installed_at=datetime.now(),
|
|
246
|
+
configured_hosts={}
|
|
247
|
+
)
|
|
248
|
+
self.packages.append(standalone_pkg)
|
|
249
|
+
|
|
250
|
+
# Add host configuration (single server per package constraint)
|
|
251
|
+
for host_name, config in host_config.items():
|
|
252
|
+
standalone_pkg.configured_hosts[host_name] = config
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class HostConfiguration(BaseModel):
|
|
256
|
+
"""Host configuration file structure using consolidated MCPServerConfig."""
|
|
257
|
+
servers: Dict[str, MCPServerConfig] = Field(
|
|
258
|
+
default_factory=dict,
|
|
259
|
+
description="Configured MCP servers"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@field_validator('servers')
|
|
263
|
+
@classmethod
|
|
264
|
+
def validate_servers_not_empty_when_present(cls, v):
|
|
265
|
+
"""Validate servers dict structure."""
|
|
266
|
+
for server_name, config in v.items():
|
|
267
|
+
if not isinstance(config, (dict, MCPServerConfig)):
|
|
268
|
+
raise ValueError(f"Invalid server config for {server_name}")
|
|
269
|
+
return v
|
|
270
|
+
|
|
271
|
+
def add_server(self, name: str, config: MCPServerConfig):
|
|
272
|
+
"""Add server configuration."""
|
|
273
|
+
self.servers[name] = config
|
|
274
|
+
|
|
275
|
+
def remove_server(self, name: str) -> bool:
|
|
276
|
+
"""Remove server configuration."""
|
|
277
|
+
if name in self.servers:
|
|
278
|
+
del self.servers[name]
|
|
279
|
+
return True
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
class Config:
|
|
283
|
+
"""Pydantic configuration."""
|
|
284
|
+
arbitrary_types_allowed = True
|
|
285
|
+
extra = "allow" # Allow additional host-specific fields
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class ConfigurationResult(BaseModel):
|
|
289
|
+
"""Result of a configuration operation."""
|
|
290
|
+
success: bool = Field(..., description="Whether operation succeeded")
|
|
291
|
+
hostname: str = Field(..., description="Target hostname")
|
|
292
|
+
server_name: Optional[str] = Field(None, description="Server name if applicable")
|
|
293
|
+
backup_created: bool = Field(False, description="Whether backup was created")
|
|
294
|
+
backup_path: Optional[Path] = Field(None, description="Path to backup file")
|
|
295
|
+
error_message: Optional[str] = Field(None, description="Error message if failed")
|
|
296
|
+
|
|
297
|
+
@model_validator(mode='after')
|
|
298
|
+
def validate_result_consistency(self):
|
|
299
|
+
"""Validate result consistency."""
|
|
300
|
+
if not self.success and not self.error_message:
|
|
301
|
+
raise ValueError("Error message required when success=False")
|
|
302
|
+
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class SyncResult(BaseModel):
|
|
307
|
+
"""Result of environment synchronization operation."""
|
|
308
|
+
success: bool = Field(..., description="Whether overall sync succeeded")
|
|
309
|
+
results: List[ConfigurationResult] = Field(..., description="Individual host results")
|
|
310
|
+
servers_synced: int = Field(..., description="Total servers synchronized")
|
|
311
|
+
hosts_updated: int = Field(..., description="Number of hosts updated")
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def failed_hosts(self) -> List[str]:
|
|
315
|
+
"""Get list of hosts that failed synchronization."""
|
|
316
|
+
return [r.hostname for r in self.results if not r.success]
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def success_rate(self) -> float:
|
|
320
|
+
"""Calculate success rate percentage."""
|
|
321
|
+
if not self.results:
|
|
322
|
+
return 0.0
|
|
323
|
+
successful = len([r for r in self.results if r.success])
|
|
324
|
+
return (successful / len(self.results)) * 100.0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ============================================================================
|
|
328
|
+
# MCP Host-Specific Configuration Models
|
|
329
|
+
# ============================================================================
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class MCPServerConfigBase(BaseModel):
|
|
333
|
+
"""Base class for MCP server configurations with universal fields.
|
|
334
|
+
|
|
335
|
+
This model contains fields supported by ALL MCP hosts and provides
|
|
336
|
+
transport validation logic. Host-specific models inherit from this base.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
model_config = ConfigDict(extra="forbid")
|
|
340
|
+
|
|
341
|
+
# Hatch-specific field
|
|
342
|
+
name: Optional[str] = Field(None, description="Server name for identification")
|
|
343
|
+
|
|
344
|
+
# Transport type (PRIMARY DISCRIMINATOR)
|
|
345
|
+
type: Optional[Literal["stdio", "sse", "http"]] = Field(
|
|
346
|
+
None,
|
|
347
|
+
description="Transport type (stdio for local, sse/http for remote)"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# stdio transport fields
|
|
351
|
+
command: Optional[str] = Field(None, description="Server executable command")
|
|
352
|
+
args: Optional[List[str]] = Field(None, description="Command arguments")
|
|
353
|
+
|
|
354
|
+
# All transports
|
|
355
|
+
env: Optional[Dict[str, str]] = Field(None, description="Environment variables")
|
|
356
|
+
|
|
357
|
+
# Remote transport fields (sse/http)
|
|
358
|
+
url: Optional[str] = Field(None, description="Remote server endpoint")
|
|
359
|
+
headers: Optional[Dict[str, str]] = Field(None, description="HTTP headers")
|
|
360
|
+
|
|
361
|
+
@model_validator(mode='after')
|
|
362
|
+
def validate_transport(self) -> 'MCPServerConfigBase':
|
|
363
|
+
"""Validate transport configuration using type field.
|
|
364
|
+
|
|
365
|
+
Note: Gemini subclass overrides this with dual-transport support.
|
|
366
|
+
"""
|
|
367
|
+
# Skip validation for Gemini which has its own dual-transport validator
|
|
368
|
+
if self.__class__.__name__ == 'MCPServerConfigGemini':
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
# Check mutual exclusion - command and url cannot both be set
|
|
372
|
+
if self.command is not None and self.url is not None:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"Cannot specify both 'command' and 'url' - use 'type' field to specify transport"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Validate based on type
|
|
378
|
+
if self.type == "stdio":
|
|
379
|
+
if not self.command:
|
|
380
|
+
raise ValueError("'command' is required for stdio transport")
|
|
381
|
+
elif self.type in ("sse", "http"):
|
|
382
|
+
if not self.url:
|
|
383
|
+
raise ValueError("'url' is required for sse/http transports")
|
|
384
|
+
elif self.type is None:
|
|
385
|
+
# Infer type from fields if not specified
|
|
386
|
+
if self.command:
|
|
387
|
+
self.type = "stdio"
|
|
388
|
+
elif self.url:
|
|
389
|
+
self.type = "sse" # default to sse for remote
|
|
390
|
+
else:
|
|
391
|
+
raise ValueError("Either 'command' or 'url' must be provided")
|
|
392
|
+
|
|
393
|
+
return self
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class MCPServerConfigGemini(MCPServerConfigBase):
|
|
397
|
+
"""Gemini CLI-specific MCP server configuration.
|
|
398
|
+
|
|
399
|
+
Extends base model with Gemini-specific fields including working directory,
|
|
400
|
+
timeout, trust mode, tool filtering, and OAuth configuration.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
# Gemini-specific fields
|
|
404
|
+
cwd: Optional[str] = Field(None, description="Working directory for stdio transport")
|
|
405
|
+
timeout: Optional[int] = Field(None, description="Request timeout in milliseconds")
|
|
406
|
+
trust: Optional[bool] = Field(None, description="Bypass tool call confirmations")
|
|
407
|
+
httpUrl: Optional[str] = Field(None, description="HTTP streaming endpoint URL")
|
|
408
|
+
includeTools: Optional[List[str]] = Field(None, description="Tools to include (allowlist)")
|
|
409
|
+
excludeTools: Optional[List[str]] = Field(None, description="Tools to exclude (blocklist)")
|
|
410
|
+
|
|
411
|
+
# OAuth configuration (simplified - nested object would be better but keeping flat for now)
|
|
412
|
+
oauth_enabled: Optional[bool] = Field(None, description="Enable OAuth for this server")
|
|
413
|
+
oauth_clientId: Optional[str] = Field(None, description="OAuth client identifier")
|
|
414
|
+
oauth_clientSecret: Optional[str] = Field(None, description="OAuth client secret")
|
|
415
|
+
oauth_authorizationUrl: Optional[str] = Field(None, description="OAuth authorization endpoint")
|
|
416
|
+
oauth_tokenUrl: Optional[str] = Field(None, description="OAuth token endpoint")
|
|
417
|
+
oauth_scopes: Optional[List[str]] = Field(None, description="Required OAuth scopes")
|
|
418
|
+
oauth_redirectUri: Optional[str] = Field(None, description="Custom redirect URI")
|
|
419
|
+
oauth_tokenParamName: Optional[str] = Field(None, description="Query parameter name for tokens")
|
|
420
|
+
oauth_audiences: Optional[List[str]] = Field(None, description="OAuth audiences")
|
|
421
|
+
authProviderType: Optional[str] = Field(None, description="Authentication provider type")
|
|
422
|
+
|
|
423
|
+
@model_validator(mode='after')
|
|
424
|
+
def validate_gemini_dual_transport(self):
|
|
425
|
+
"""Override transport validation to support Gemini's dual-transport capability.
|
|
426
|
+
|
|
427
|
+
Gemini supports both:
|
|
428
|
+
- SSE transport with 'url' field
|
|
429
|
+
- HTTP transport with 'httpUrl' field
|
|
430
|
+
|
|
431
|
+
Validates that:
|
|
432
|
+
1. Either url or httpUrl is provided (not both)
|
|
433
|
+
2. Type field matches the transport being used
|
|
434
|
+
"""
|
|
435
|
+
# Check if both url and httpUrl are provided
|
|
436
|
+
if self.url is not None and self.httpUrl is not None:
|
|
437
|
+
raise ValueError("Cannot specify both 'url' and 'httpUrl' - choose one transport")
|
|
438
|
+
|
|
439
|
+
# Validate based on type
|
|
440
|
+
if self.type == "stdio":
|
|
441
|
+
if not self.command:
|
|
442
|
+
raise ValueError("'command' is required for stdio transport")
|
|
443
|
+
elif self.type == "sse":
|
|
444
|
+
if not self.url:
|
|
445
|
+
raise ValueError("'url' is required for sse transport")
|
|
446
|
+
elif self.type == "http":
|
|
447
|
+
if not self.httpUrl:
|
|
448
|
+
raise ValueError("'httpUrl' is required for http transport")
|
|
449
|
+
elif self.type is None:
|
|
450
|
+
# Infer type from fields if not specified
|
|
451
|
+
if self.command:
|
|
452
|
+
self.type = "stdio"
|
|
453
|
+
elif self.url:
|
|
454
|
+
self.type = "sse" # default to sse for url
|
|
455
|
+
elif self.httpUrl:
|
|
456
|
+
self.type = "http" # http for httpUrl
|
|
457
|
+
else:
|
|
458
|
+
raise ValueError("Either 'command', 'url', or 'httpUrl' must be provided")
|
|
459
|
+
|
|
460
|
+
return self
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigGemini':
|
|
464
|
+
"""Convert Omni model to Gemini-specific model using Pydantic APIs."""
|
|
465
|
+
# Get supported fields dynamically from model definition
|
|
466
|
+
supported_fields = set(cls.model_fields.keys())
|
|
467
|
+
|
|
468
|
+
# Use Pydantic's model_dump with include and exclude_unset
|
|
469
|
+
gemini_data = omni.model_dump(include=supported_fields, exclude_unset=True)
|
|
470
|
+
|
|
471
|
+
# Use Pydantic's model_validate for type-safe creation
|
|
472
|
+
return cls.model_validate(gemini_data)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class MCPServerConfigVSCode(MCPServerConfigBase):
|
|
476
|
+
"""VS Code-specific MCP server configuration.
|
|
477
|
+
|
|
478
|
+
Extends base model with VS Code-specific fields including environment file
|
|
479
|
+
path and input variable definitions.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
# VS Code-specific fields
|
|
483
|
+
envFile: Optional[str] = Field(None, description="Path to environment file")
|
|
484
|
+
inputs: Optional[List[Dict]] = Field(None, description="Input variable definitions")
|
|
485
|
+
|
|
486
|
+
@classmethod
|
|
487
|
+
def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigVSCode':
|
|
488
|
+
"""Convert Omni model to VS Code-specific model."""
|
|
489
|
+
# Get supported fields dynamically
|
|
490
|
+
supported_fields = set(cls.model_fields.keys())
|
|
491
|
+
|
|
492
|
+
# Single-call field filtering
|
|
493
|
+
vscode_data = omni.model_dump(include=supported_fields, exclude_unset=True)
|
|
494
|
+
|
|
495
|
+
return cls.model_validate(vscode_data)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class MCPServerConfigCursor(MCPServerConfigBase):
|
|
499
|
+
"""Cursor/LM Studio-specific MCP server configuration.
|
|
500
|
+
|
|
501
|
+
Extends base model with Cursor-specific fields including environment file path.
|
|
502
|
+
Cursor handles config interpolation (${env:NAME}, ${userHome}, etc.) at runtime.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
# Cursor-specific fields
|
|
506
|
+
envFile: Optional[str] = Field(None, description="Path to environment file")
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigCursor':
|
|
510
|
+
"""Convert Omni model to Cursor-specific model."""
|
|
511
|
+
# Get supported fields dynamically
|
|
512
|
+
supported_fields = set(cls.model_fields.keys())
|
|
513
|
+
|
|
514
|
+
# Single-call field filtering
|
|
515
|
+
cursor_data = omni.model_dump(include=supported_fields, exclude_unset=True)
|
|
516
|
+
|
|
517
|
+
return cls.model_validate(cursor_data)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class MCPServerConfigClaude(MCPServerConfigBase):
|
|
521
|
+
"""Claude Desktop/Code-specific MCP server configuration.
|
|
522
|
+
|
|
523
|
+
Uses only universal fields from base model. Supports all transport types
|
|
524
|
+
(stdio, sse, http). Claude handles environment variable expansion at runtime.
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
# No host-specific fields - uses universal fields only
|
|
528
|
+
|
|
529
|
+
@classmethod
|
|
530
|
+
def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigClaude':
|
|
531
|
+
"""Convert Omni model to Claude-specific model."""
|
|
532
|
+
# Get supported fields dynamically
|
|
533
|
+
supported_fields = set(cls.model_fields.keys())
|
|
534
|
+
|
|
535
|
+
# Single-call field filtering
|
|
536
|
+
claude_data = omni.model_dump(include=supported_fields, exclude_unset=True)
|
|
537
|
+
|
|
538
|
+
return cls.model_validate(claude_data)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class MCPServerConfigOmni(BaseModel):
|
|
542
|
+
"""Omni configuration supporting all host-specific fields.
|
|
543
|
+
|
|
544
|
+
This is the primary API interface for MCP server configuration. It contains
|
|
545
|
+
all possible fields from all hosts. Use host-specific models' from_omni()
|
|
546
|
+
methods to convert to host-specific configurations.
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
model_config = ConfigDict(extra="forbid")
|
|
550
|
+
|
|
551
|
+
# Hatch-specific
|
|
552
|
+
name: Optional[str] = None
|
|
553
|
+
|
|
554
|
+
# Universal fields (all hosts)
|
|
555
|
+
type: Optional[Literal["stdio", "sse", "http"]] = None
|
|
556
|
+
command: Optional[str] = None
|
|
557
|
+
args: Optional[List[str]] = None
|
|
558
|
+
env: Optional[Dict[str, str]] = None
|
|
559
|
+
url: Optional[str] = None
|
|
560
|
+
headers: Optional[Dict[str, str]] = None
|
|
561
|
+
|
|
562
|
+
# Gemini CLI specific
|
|
563
|
+
cwd: Optional[str] = None
|
|
564
|
+
timeout: Optional[int] = None
|
|
565
|
+
trust: Optional[bool] = None
|
|
566
|
+
httpUrl: Optional[str] = None
|
|
567
|
+
includeTools: Optional[List[str]] = None
|
|
568
|
+
excludeTools: Optional[List[str]] = None
|
|
569
|
+
oauth_enabled: Optional[bool] = None
|
|
570
|
+
oauth_clientId: Optional[str] = None
|
|
571
|
+
oauth_clientSecret: Optional[str] = None
|
|
572
|
+
oauth_authorizationUrl: Optional[str] = None
|
|
573
|
+
oauth_tokenUrl: Optional[str] = None
|
|
574
|
+
oauth_scopes: Optional[List[str]] = None
|
|
575
|
+
oauth_redirectUri: Optional[str] = None
|
|
576
|
+
oauth_tokenParamName: Optional[str] = None
|
|
577
|
+
oauth_audiences: Optional[List[str]] = None
|
|
578
|
+
authProviderType: Optional[str] = None
|
|
579
|
+
|
|
580
|
+
# VS Code specific
|
|
581
|
+
envFile: Optional[str] = None
|
|
582
|
+
inputs: Optional[List[Dict]] = None
|
|
583
|
+
|
|
584
|
+
@field_validator('url')
|
|
585
|
+
@classmethod
|
|
586
|
+
def validate_url_format(cls, v):
|
|
587
|
+
"""Validate URL format when provided."""
|
|
588
|
+
if v is not None:
|
|
589
|
+
if not v.startswith(('http://', 'https://')):
|
|
590
|
+
raise ValueError("URL must start with http:// or https://")
|
|
591
|
+
return v
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# HOST_MODEL_REGISTRY: Dictionary dispatch for host-specific models
|
|
595
|
+
HOST_MODEL_REGISTRY: Dict[MCPHostType, type[MCPServerConfigBase]] = {
|
|
596
|
+
MCPHostType.GEMINI: MCPServerConfigGemini,
|
|
597
|
+
MCPHostType.CLAUDE_DESKTOP: MCPServerConfigClaude,
|
|
598
|
+
MCPHostType.CLAUDE_CODE: MCPServerConfigClaude, # Same as CLAUDE_DESKTOP
|
|
599
|
+
MCPHostType.VSCODE: MCPServerConfigVSCode,
|
|
600
|
+
MCPHostType.CURSOR: MCPServerConfigCursor,
|
|
601
|
+
MCPHostType.LMSTUDIO: MCPServerConfigCursor, # Same as CURSOR
|
|
602
|
+
}
|