Skip to content

Commit 655cf0a

Browse files
author
LittleCoinCoin
committed
feat: add host-specific MCP configuration models with type field
Add comprehensive Pydantic model hierarchy for MCP host configuration: - Add type field to MCPServerConfig for transport discrimination - Implement MCPServerConfigBase with universal fields and validation - Add host-specific models: Gemini, VS Code, Cursor, Claude - Add MCPServerConfigOmni as primary API interface - Implement HOST_MODEL_REGISTRY for dictionary dispatch - Add from_omni() conversion methods with dynamic field derivation Key features: - Type field enables explicit transport specification (stdio/sse/http) - Dynamic field derivation using cls.model_fields.keys() - Pydantic-native APIs (model_dump, model_validate) - Backward compatibility maintained for existing code
1 parent f21ec7d commit 655cf0a

File tree

2 files changed

+270
-11
lines changed

2 files changed

+270
-11
lines changed

hatch/mcp_host_config/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from .backup import MCPHostConfigBackupManager
99
from .models import (
1010
MCPHostType, MCPServerConfig, HostConfiguration, EnvironmentData,
11-
PackageHostConfiguration, EnvironmentPackageEntry, ConfigurationResult, SyncResult
11+
PackageHostConfiguration, EnvironmentPackageEntry, ConfigurationResult, SyncResult,
12+
# Host-specific configuration models
13+
MCPServerConfigBase, MCPServerConfigGemini, MCPServerConfigVSCode,
14+
MCPServerConfigCursor, MCPServerConfigClaude, MCPServerConfigOmni,
15+
HOST_MODEL_REGISTRY
1216
)
1317
from .host_management import (
1418
MCPHostRegistry, MCPHostStrategy, MCPHostConfigurationManager, register_host_strategy
@@ -21,5 +25,9 @@
2125
'MCPHostConfigBackupManager',
2226
'MCPHostType', 'MCPServerConfig', 'HostConfiguration', 'EnvironmentData',
2327
'PackageHostConfiguration', 'EnvironmentPackageEntry', 'ConfigurationResult', 'SyncResult',
28+
# Host-specific configuration models
29+
'MCPServerConfigBase', 'MCPServerConfigGemini', 'MCPServerConfigVSCode',
30+
'MCPServerConfigCursor', 'MCPServerConfigClaude', 'MCPServerConfigOmni',
31+
'HOST_MODEL_REGISTRY',
2432
'MCPHostRegistry', 'MCPHostStrategy', 'MCPHostConfigurationManager', 'register_host_strategy'
2533
]

hatch/mcp_host_config/models.py

Lines changed: 261 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
from 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
1111
from datetime import datetime
1212
from pathlib import Path
1313
from 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

Comments
 (0)