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,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
+ }