Skip to content

Commit 7b725c8

Browse files
feat(adapters): create host-specific adapters
- ClaudeAdapter: Claude Desktop/Code with strict command XOR url - VSCodeAdapter: VSCode with envFile and inputs support - CursorAdapter: Cursor with envFile (no inputs) - GeminiAdapter: Triple transport support (command, url, httpUrl) - KiroAdapter: Server disable, autoApprove, disabledTools - CodexAdapter: Field mappings (args→arguments, headers→http_headers) Each adapter implements: - host_name property - get_supported_fields() returning host's field set - validate() with host-specific rules - serialize() with field filtering and mapping
1 parent 4d9833c commit 7b725c8

File tree

7 files changed

+544
-1
lines changed

7 files changed

+544
-1
lines changed

hatch/mcp_host_config/adapters/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
"""
66

77
from hatch.mcp_host_config.adapters.base import AdapterValidationError, BaseAdapter
8+
from hatch.mcp_host_config.adapters.claude import ClaudeAdapter
9+
from hatch.mcp_host_config.adapters.codex import CodexAdapter
10+
from hatch.mcp_host_config.adapters.cursor import CursorAdapter
11+
from hatch.mcp_host_config.adapters.gemini import GeminiAdapter
12+
from hatch.mcp_host_config.adapters.kiro import KiroAdapter
13+
from hatch.mcp_host_config.adapters.vscode import VSCodeAdapter
814

9-
__all__ = ["AdapterValidationError", "BaseAdapter"]
15+
__all__ = [
16+
"AdapterValidationError",
17+
"BaseAdapter",
18+
"ClaudeAdapter",
19+
"CodexAdapter",
20+
"CursorAdapter",
21+
"GeminiAdapter",
22+
"KiroAdapter",
23+
"VSCodeAdapter",
24+
]
1025

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Claude Desktop/Code adapter for MCP host configuration.
2+
3+
Claude Desktop and Claude Code share the same configuration format:
4+
- Supports 'type' field for transport discrimination
5+
- Mutually exclusive: command XOR url (never both)
6+
- Standard field set: command, args, env, url, headers, type
7+
"""
8+
9+
from typing import Any, Dict, FrozenSet
10+
11+
from hatch.mcp_host_config.adapters.base import AdapterValidationError, BaseAdapter
12+
from hatch.mcp_host_config.fields import CLAUDE_FIELDS
13+
from hatch.mcp_host_config.models import MCPServerConfig
14+
15+
16+
class ClaudeAdapter(BaseAdapter):
17+
"""Adapter for Claude Desktop and Claude Code hosts.
18+
19+
Claude uses a strict validation model:
20+
- Local servers: command (required), args, env
21+
- Remote servers: url (required), headers, env
22+
- Never both command and url
23+
24+
Supports the 'type' field for explicit transport discrimination.
25+
"""
26+
27+
def __init__(self, variant: str = "desktop"):
28+
"""Initialize Claude adapter.
29+
30+
Args:
31+
variant: Either "desktop" or "code" to specify the Claude variant.
32+
"""
33+
if variant not in ("desktop", "code"):
34+
raise ValueError(f"Invalid Claude variant: {variant}. Must be 'desktop' or 'code'")
35+
self._variant = variant
36+
37+
@property
38+
def host_name(self) -> str:
39+
"""Return the host identifier."""
40+
return f"claude-{self._variant}"
41+
42+
def get_supported_fields(self) -> FrozenSet[str]:
43+
"""Return fields supported by Claude."""
44+
return CLAUDE_FIELDS
45+
46+
def validate(self, config: MCPServerConfig) -> None:
47+
"""Validate configuration for Claude.
48+
49+
Claude requires exactly one transport:
50+
- stdio (command)
51+
- sse (url)
52+
53+
Having both command and url is invalid.
54+
"""
55+
has_command = config.command is not None
56+
has_url = config.url is not None
57+
has_http_url = config.httpUrl is not None
58+
59+
# Claude doesn't support httpUrl
60+
if has_http_url:
61+
raise AdapterValidationError(
62+
"httpUrl is not supported (use 'url' for remote servers)",
63+
field="httpUrl",
64+
host_name=self.host_name
65+
)
66+
67+
# Must have exactly one transport
68+
if not has_command and not has_url:
69+
raise AdapterValidationError(
70+
"Either 'command' (local) or 'url' (remote) must be specified",
71+
host_name=self.host_name
72+
)
73+
74+
if has_command and has_url:
75+
raise AdapterValidationError(
76+
"Cannot specify both 'command' and 'url' - choose one transport",
77+
host_name=self.host_name
78+
)
79+
80+
# Validate type consistency if specified
81+
if config.type is not None:
82+
if config.type == "stdio" and not has_command:
83+
raise AdapterValidationError(
84+
"type='stdio' requires 'command' field",
85+
field="type",
86+
host_name=self.host_name
87+
)
88+
if config.type in ("sse", "http") and not has_url:
89+
raise AdapterValidationError(
90+
f"type='{config.type}' requires 'url' field",
91+
field="type",
92+
host_name=self.host_name
93+
)
94+
95+
def serialize(self, config: MCPServerConfig) -> Dict[str, Any]:
96+
"""Serialize configuration for Claude format.
97+
98+
Returns a dictionary suitable for Claude's config.json format.
99+
"""
100+
# Validate before serializing
101+
self.validate(config)
102+
103+
# Filter to supported fields
104+
return self.filter_fields(config)
105+
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Codex CLI adapter for MCP host configuration.
2+
3+
Codex CLI has unique features:
4+
- No 'type' field support
5+
- Field name mappings: args→arguments, headers→http_headers
6+
- Rich configuration: timeouts, env_vars, tool management, bearer tokens
7+
"""
8+
9+
from typing import Any, Dict, FrozenSet
10+
11+
from hatch.mcp_host_config.adapters.base import AdapterValidationError, BaseAdapter
12+
from hatch.mcp_host_config.fields import CODEX_FIELDS, CODEX_FIELD_MAPPINGS
13+
from hatch.mcp_host_config.models import MCPServerConfig
14+
15+
16+
class CodexAdapter(BaseAdapter):
17+
"""Adapter for Codex CLI MCP host.
18+
19+
Codex uses different field names than other hosts:
20+
- 'args' → 'arguments'
21+
- 'headers' → 'http_headers'
22+
23+
Codex also has:
24+
- Working directory support (cwd)
25+
- Timeout configuration (startup_timeout_sec, tool_timeout_sec)
26+
- Server enable/disable (enabled)
27+
- Tool filtering (enabled_tools, disabled_tools)
28+
- Bearer token support (bearer_token_env_var)
29+
"""
30+
31+
@property
32+
def host_name(self) -> str:
33+
"""Return the host identifier."""
34+
return "codex"
35+
36+
def get_supported_fields(self) -> FrozenSet[str]:
37+
"""Return fields supported by Codex."""
38+
return CODEX_FIELDS
39+
40+
def validate(self, config: MCPServerConfig) -> None:
41+
"""Validate configuration for Codex.
42+
43+
Codex requires exactly one transport (command XOR url).
44+
Does not support 'type' field.
45+
"""
46+
has_command = config.command is not None
47+
has_url = config.url is not None
48+
has_http_url = config.httpUrl is not None
49+
50+
# Codex doesn't support httpUrl
51+
if has_http_url:
52+
raise AdapterValidationError(
53+
"httpUrl is not supported (use 'url' for remote servers)",
54+
field="httpUrl",
55+
host_name=self.host_name
56+
)
57+
58+
# Must have exactly one transport
59+
if not has_command and not has_url:
60+
raise AdapterValidationError(
61+
"Either 'command' (local) or 'url' (remote) must be specified",
62+
host_name=self.host_name
63+
)
64+
65+
if has_command and has_url:
66+
raise AdapterValidationError(
67+
"Cannot specify both 'command' and 'url' - choose one transport",
68+
host_name=self.host_name
69+
)
70+
71+
# 'type' field is not supported by Codex
72+
if config.type is not None:
73+
raise AdapterValidationError(
74+
"'type' field is not supported by Codex CLI",
75+
field="type",
76+
host_name=self.host_name
77+
)
78+
79+
# Validate enabled_tools and disabled_tools mutual exclusion
80+
if config.enabled_tools is not None and config.disabled_tools is not None:
81+
raise AdapterValidationError(
82+
"Cannot specify both 'enabled_tools' and 'disabled_tools'",
83+
host_name=self.host_name
84+
)
85+
86+
def serialize(self, config: MCPServerConfig) -> Dict[str, Any]:
87+
"""Serialize configuration for Codex format.
88+
89+
Applies field mappings:
90+
- args → arguments
91+
- headers → http_headers
92+
"""
93+
self.validate(config)
94+
95+
# Get base filtered fields
96+
result = self.filter_fields(config)
97+
98+
# Apply field mappings
99+
for universal_name, codex_name in CODEX_FIELD_MAPPINGS.items():
100+
if universal_name in result:
101+
result[codex_name] = result.pop(universal_name)
102+
103+
return result
104+
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Cursor adapter for MCP host configuration.
2+
3+
Cursor is similar to VSCode but with limited additional fields:
4+
- envFile: Path to environment file (like VSCode)
5+
- No 'inputs' field support (VSCode only)
6+
"""
7+
8+
from typing import Any, Dict, FrozenSet
9+
10+
from hatch.mcp_host_config.adapters.base import AdapterValidationError, BaseAdapter
11+
from hatch.mcp_host_config.fields import CURSOR_FIELDS
12+
from hatch.mcp_host_config.models import MCPServerConfig
13+
14+
15+
class CursorAdapter(BaseAdapter):
16+
"""Adapter for Cursor MCP host.
17+
18+
Cursor is like a simplified VSCode:
19+
- Supports Claude base fields + envFile
20+
- Does NOT support inputs (VSCode-only feature)
21+
- Requires exactly one transport (command XOR url)
22+
"""
23+
24+
@property
25+
def host_name(self) -> str:
26+
"""Return the host identifier."""
27+
return "cursor"
28+
29+
def get_supported_fields(self) -> FrozenSet[str]:
30+
"""Return fields supported by Cursor."""
31+
return CURSOR_FIELDS
32+
33+
def validate(self, config: MCPServerConfig) -> None:
34+
"""Validate configuration for Cursor.
35+
36+
Same rules as Claude: exactly one transport required.
37+
Warns if 'inputs' is specified (not supported).
38+
"""
39+
has_command = config.command is not None
40+
has_url = config.url is not None
41+
has_http_url = config.httpUrl is not None
42+
43+
# Cursor doesn't support httpUrl
44+
if has_http_url:
45+
raise AdapterValidationError(
46+
"httpUrl is not supported (use 'url' for remote servers)",
47+
field="httpUrl",
48+
host_name=self.host_name
49+
)
50+
51+
# Must have exactly one transport
52+
if not has_command and not has_url:
53+
raise AdapterValidationError(
54+
"Either 'command' (local) or 'url' (remote) must be specified",
55+
host_name=self.host_name
56+
)
57+
58+
if has_command and has_url:
59+
raise AdapterValidationError(
60+
"Cannot specify both 'command' and 'url' - choose one transport",
61+
host_name=self.host_name
62+
)
63+
64+
# Validate type consistency if specified
65+
if config.type is not None:
66+
if config.type == "stdio" and not has_command:
67+
raise AdapterValidationError(
68+
"type='stdio' requires 'command' field",
69+
field="type",
70+
host_name=self.host_name
71+
)
72+
if config.type in ("sse", "http") and not has_url:
73+
raise AdapterValidationError(
74+
f"type='{config.type}' requires 'url' field",
75+
field="type",
76+
host_name=self.host_name
77+
)
78+
79+
def serialize(self, config: MCPServerConfig) -> Dict[str, Any]:
80+
"""Serialize configuration for Cursor format."""
81+
self.validate(config)
82+
return self.filter_fields(config)
83+
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Gemini CLI adapter for MCP host configuration.
2+
3+
Gemini has unique features:
4+
- Triple transport: command (stdio), url (SSE), httpUrl (HTTP streaming)
5+
- Multiple transports can coexist (not mutually exclusive)
6+
- No 'type' field support
7+
- Rich OAuth configuration
8+
- Working directory, timeout, trust settings
9+
"""
10+
11+
from typing import Any, Dict, FrozenSet
12+
13+
from hatch.mcp_host_config.adapters.base import AdapterValidationError, BaseAdapter
14+
from hatch.mcp_host_config.fields import GEMINI_FIELDS
15+
from hatch.mcp_host_config.models import MCPServerConfig
16+
17+
18+
class GeminiAdapter(BaseAdapter):
19+
"""Adapter for Gemini CLI MCP host.
20+
21+
Gemini is unique among MCP hosts:
22+
- Supports THREE transport types (stdio, SSE, HTTP streaming)
23+
- Transports are NOT mutually exclusive (can have multiple)
24+
- Does NOT support 'type' field
25+
- Has rich configuration: OAuth, timeout, trust, tool filtering
26+
"""
27+
28+
@property
29+
def host_name(self) -> str:
30+
"""Return the host identifier."""
31+
return "gemini"
32+
33+
def get_supported_fields(self) -> FrozenSet[str]:
34+
"""Return fields supported by Gemini."""
35+
return GEMINI_FIELDS
36+
37+
def validate(self, config: MCPServerConfig) -> None:
38+
"""Validate configuration for Gemini.
39+
40+
Gemini is flexible:
41+
- At least one transport is required (command, url, or httpUrl)
42+
- Multiple transports are allowed
43+
- 'type' field is not supported
44+
"""
45+
has_command = config.command is not None
46+
has_url = config.url is not None
47+
has_http_url = config.httpUrl is not None
48+
49+
# Must have at least one transport
50+
if not has_command and not has_url and not has_http_url:
51+
raise AdapterValidationError(
52+
"At least one transport must be specified: 'command', 'url', or 'httpUrl'",
53+
host_name=self.host_name
54+
)
55+
56+
# 'type' field is not supported by Gemini
57+
if config.type is not None:
58+
raise AdapterValidationError(
59+
"'type' field is not supported by Gemini CLI",
60+
field="type",
61+
host_name=self.host_name
62+
)
63+
64+
# Validate includeTools and excludeTools are mutually exclusive
65+
if config.includeTools is not None and config.excludeTools is not None:
66+
raise AdapterValidationError(
67+
"Cannot specify both 'includeTools' and 'excludeTools'",
68+
host_name=self.host_name
69+
)
70+
71+
def serialize(self, config: MCPServerConfig) -> Dict[str, Any]:
72+
"""Serialize configuration for Gemini format."""
73+
self.validate(config)
74+
return self.filter_fields(config)
75+

0 commit comments

Comments
 (0)