77"""
88
99from pydantic import BaseModel , Field , field_validator , model_validator , ConfigDict
10- from typing import Dict , List , Optional , Union
10+ from typing import Dict , List , Optional , Union , Literal
1111from datetime import datetime
1212from pathlib import Path
1313from enum import Enum
@@ -34,12 +34,18 @@ class MCPServerConfig(BaseModel):
3434 # Server identification
3535 name : Optional [str ] = Field (None , description = "Server name for identification" )
3636
37- # Local server configuration (Pattern A: Command-Based)
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)
3844 command : Optional [str ] = Field (None , description = "Executable path/name for local servers" )
3945 args : Optional [List [str ]] = Field (None , description = "Command arguments for local servers" )
40- env : Optional [Dict [str , str ]] = Field (None , description = "Environment variables for local servers " )
46+ env : Optional [Dict [str , str ]] = Field (None , description = "Environment variables for all transports " )
4147
42- # Remote server configuration (Pattern B: URL-Based)
48+ # Remote server configuration (Pattern B: URL-Based / sse/http transports )
4349 url : Optional [str ] = Field (None , description = "Server endpoint URL for remote servers" )
4450 headers : Optional [Dict [str , str ]] = Field (None , description = "HTTP headers for remote servers" )
4551
@@ -81,24 +87,46 @@ def validate_field_combinations(self):
8187 if self .args is not None and self .command is None :
8288 raise ValueError ("'args' can only be specified with 'command' for local servers" )
8389
84- # Validate env is only provided with command
85- if self .env is not None and self .command is None :
86- raise ValueError ("'env' can only be specified with 'command' for local servers" )
87-
8890 # Validate headers are only provided with URL
8991 if self .headers is not None and self .url is None :
9092 raise ValueError ("'headers' can only be specified with 'url' for remote servers" )
9193
9294 return self
93-
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+
94114 @property
95115 def is_local_server (self ) -> bool :
96116 """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
97121 return self .command is not None
98-
122+
99123 @property
100124 def is_remote_server (self ) -> bool :
101125 """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
102130 return self .url is not None
103131
104132
@@ -294,3 +322,226 @@ def success_rate(self) -> float:
294322 return 0.0
295323 successful = len ([r for r in self .results if r .success ])
296324 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+ # Check mutual exclusion - command and url cannot both be set
365+ if self .command is not None and self .url is not None :
366+ raise ValueError (
367+ "Cannot specify both 'command' and 'url' - use 'type' field to specify transport"
368+ )
369+
370+ # Validate based on type
371+ if self .type == "stdio" :
372+ if not self .command :
373+ raise ValueError ("'command' is required for stdio transport" )
374+ elif self .type in ("sse" , "http" ):
375+ if not self .url :
376+ raise ValueError ("'url' is required for sse/http transports" )
377+ elif self .type is None :
378+ # Infer type from fields if not specified
379+ if self .command :
380+ self .type = "stdio"
381+ elif self .url :
382+ self .type = "sse" # default to sse for remote
383+ else :
384+ raise ValueError ("Either 'command' or 'url' must be provided" )
385+
386+ return self
387+
388+
389+ class MCPServerConfigGemini (MCPServerConfigBase ):
390+ """Gemini CLI-specific MCP server configuration.
391+
392+ Extends base model with Gemini-specific fields including working directory,
393+ timeout, trust mode, tool filtering, and OAuth configuration.
394+ """
395+
396+ # Gemini-specific fields
397+ cwd : Optional [str ] = Field (None , description = "Working directory for stdio transport" )
398+ timeout : Optional [int ] = Field (None , description = "Request timeout in milliseconds" )
399+ trust : Optional [bool ] = Field (None , description = "Bypass tool call confirmations" )
400+ httpUrl : Optional [str ] = Field (None , description = "HTTP streaming endpoint URL" )
401+ includeTools : Optional [List [str ]] = Field (None , description = "Tools to include (allowlist)" )
402+ excludeTools : Optional [List [str ]] = Field (None , description = "Tools to exclude (blocklist)" )
403+
404+ # OAuth configuration (simplified - nested object would be better but keeping flat for now)
405+ oauth_enabled : Optional [bool ] = Field (None , description = "Enable OAuth for this server" )
406+ oauth_clientId : Optional [str ] = Field (None , description = "OAuth client identifier" )
407+ oauth_clientSecret : Optional [str ] = Field (None , description = "OAuth client secret" )
408+ oauth_authorizationUrl : Optional [str ] = Field (None , description = "OAuth authorization endpoint" )
409+ oauth_tokenUrl : Optional [str ] = Field (None , description = "OAuth token endpoint" )
410+ oauth_scopes : Optional [List [str ]] = Field (None , description = "Required OAuth scopes" )
411+ oauth_redirectUri : Optional [str ] = Field (None , description = "Custom redirect URI" )
412+ oauth_tokenParamName : Optional [str ] = Field (None , description = "Query parameter name for tokens" )
413+ oauth_audiences : Optional [List [str ]] = Field (None , description = "OAuth audiences" )
414+ authProviderType : Optional [str ] = Field (None , description = "Authentication provider type" )
415+
416+ @classmethod
417+ def from_omni (cls , omni : 'MCPServerConfigOmni' ) -> 'MCPServerConfigGemini' :
418+ """Convert Omni model to Gemini-specific model using Pydantic APIs."""
419+ # Get supported fields dynamically from model definition
420+ supported_fields = set (cls .model_fields .keys ())
421+
422+ # Use Pydantic's model_dump with include and exclude_unset
423+ gemini_data = omni .model_dump (include = supported_fields , exclude_unset = True )
424+
425+ # Use Pydantic's model_validate for type-safe creation
426+ return cls .model_validate (gemini_data )
427+
428+
429+ class MCPServerConfigVSCode (MCPServerConfigBase ):
430+ """VS Code-specific MCP server configuration.
431+
432+ Extends base model with VS Code-specific fields including environment file
433+ path and input variable definitions.
434+ """
435+
436+ # VS Code-specific fields
437+ envFile : Optional [str ] = Field (None , description = "Path to environment file" )
438+ inputs : Optional [List [Dict ]] = Field (None , description = "Input variable definitions" )
439+
440+ @classmethod
441+ def from_omni (cls , omni : 'MCPServerConfigOmni' ) -> 'MCPServerConfigVSCode' :
442+ """Convert Omni model to VS Code-specific model."""
443+ # Get supported fields dynamically
444+ supported_fields = set (cls .model_fields .keys ())
445+
446+ # Single-call field filtering
447+ vscode_data = omni .model_dump (include = supported_fields , exclude_unset = True )
448+
449+ return cls .model_validate (vscode_data )
450+
451+
452+ class MCPServerConfigCursor (MCPServerConfigBase ):
453+ """Cursor/LM Studio-specific MCP server configuration.
454+
455+ Extends base model with Cursor-specific fields including environment file path.
456+ Cursor handles config interpolation (${env:NAME}, ${userHome}, etc.) at runtime.
457+ """
458+
459+ # Cursor-specific fields
460+ envFile : Optional [str ] = Field (None , description = "Path to environment file" )
461+
462+ @classmethod
463+ def from_omni (cls , omni : 'MCPServerConfigOmni' ) -> 'MCPServerConfigCursor' :
464+ """Convert Omni model to Cursor-specific model."""
465+ # Get supported fields dynamically
466+ supported_fields = set (cls .model_fields .keys ())
467+
468+ # Single-call field filtering
469+ cursor_data = omni .model_dump (include = supported_fields , exclude_unset = True )
470+
471+ return cls .model_validate (cursor_data )
472+
473+
474+ class MCPServerConfigClaude (MCPServerConfigBase ):
475+ """Claude Desktop/Code-specific MCP server configuration.
476+
477+ Uses only universal fields from base model. Supports all transport types
478+ (stdio, sse, http). Claude handles environment variable expansion at runtime.
479+ """
480+
481+ # No host-specific fields - uses universal fields only
482+
483+ @classmethod
484+ def from_omni (cls , omni : 'MCPServerConfigOmni' ) -> 'MCPServerConfigClaude' :
485+ """Convert Omni model to Claude-specific model."""
486+ # Get supported fields dynamically
487+ supported_fields = set (cls .model_fields .keys ())
488+
489+ # Single-call field filtering
490+ claude_data = omni .model_dump (include = supported_fields , exclude_unset = True )
491+
492+ return cls .model_validate (claude_data )
493+
494+
495+ class MCPServerConfigOmni (BaseModel ):
496+ """Omni configuration supporting all host-specific fields.
497+
498+ This is the primary API interface for MCP server configuration. It contains
499+ all possible fields from all hosts. Use host-specific models' from_omni()
500+ methods to convert to host-specific configurations.
501+ """
502+
503+ model_config = ConfigDict (extra = "forbid" )
504+
505+ # Hatch-specific
506+ name : Optional [str ] = None
507+
508+ # Universal fields (all hosts)
509+ type : Optional [Literal ["stdio" , "sse" , "http" ]] = None
510+ command : Optional [str ] = None
511+ args : Optional [List [str ]] = None
512+ env : Optional [Dict [str , str ]] = None
513+ url : Optional [str ] = None
514+ headers : Optional [Dict [str , str ]] = None
515+
516+ # Gemini CLI specific
517+ cwd : Optional [str ] = None
518+ timeout : Optional [int ] = None
519+ trust : Optional [bool ] = None
520+ httpUrl : Optional [str ] = None
521+ includeTools : Optional [List [str ]] = None
522+ excludeTools : Optional [List [str ]] = None
523+ oauth_enabled : Optional [bool ] = None
524+ oauth_clientId : Optional [str ] = None
525+ oauth_clientSecret : Optional [str ] = None
526+ oauth_authorizationUrl : Optional [str ] = None
527+ oauth_tokenUrl : Optional [str ] = None
528+ oauth_scopes : Optional [List [str ]] = None
529+ oauth_redirectUri : Optional [str ] = None
530+ oauth_tokenParamName : Optional [str ] = None
531+ oauth_audiences : Optional [List [str ]] = None
532+ authProviderType : Optional [str ] = None
533+
534+ # VS Code specific
535+ envFile : Optional [str ] = None
536+ inputs : Optional [List [Dict ]] = None
537+
538+
539+ # HOST_MODEL_REGISTRY: Dictionary dispatch for host-specific models
540+ HOST_MODEL_REGISTRY : Dict [MCPHostType , type [MCPServerConfigBase ]] = {
541+ MCPHostType .GEMINI : MCPServerConfigGemini ,
542+ MCPHostType .CLAUDE_DESKTOP : MCPServerConfigClaude ,
543+ MCPHostType .CLAUDE_CODE : MCPServerConfigClaude , # Same as CLAUDE_DESKTOP
544+ MCPHostType .VSCODE : MCPServerConfigVSCode ,
545+ MCPHostType .CURSOR : MCPServerConfigCursor ,
546+ MCPHostType .LMSTUDIO : MCPServerConfigCursor , # Same as CURSOR
547+ }
0 commit comments