diff --git a/README.md b/README.md index b6cb5d5..9890844 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Hatch supports deployment to the following MCP host platforms: - **Codex** — OpenAI Codex with MCP server configuration support - **LM Studio** — Local LLM inference platform with MCP server integration - **Google Gemini CLI** — Command-line interface for Google's Gemini model with MCP support +- **Mistral Vibe** — Mistral Vibe CLI coding agent ## Quick Start diff --git a/__reports__/mistral_vibe/00-parameter_analysis_v0.md b/__reports__/mistral_vibe/00-parameter_analysis_v0.md new file mode 100644 index 0000000..750a7b7 --- /dev/null +++ b/__reports__/mistral_vibe/00-parameter_analysis_v0.md @@ -0,0 +1,55 @@ +# Mistral Vibe Parameter Analysis + +## Model + +| Item | Finding | +| --- | --- | +| Host | Mistral Vibe | +| Config path | `./.vibe/config.toml` first, fallback `~/.vibe/config.toml` | +| Config key | `mcp_servers` | +| Structure | TOML array-of-tables: `[[mcp_servers]]` | +| Server identity | Inline `name` field per entry | + +## Field Summary + +| Category | Fields | +| --- | --- | +| Transport | `transport`, `command`, `args`, `url` | +| Common | `headers`, `prompt`, `startup_timeout_sec`, `tool_timeout_sec`, `sampling_enabled` | +| Auth | `api_key_env`, `api_key_header`, `api_key_format` | +| Local-only | `env` | + +## Host Spec + +```yaml +host: mistral-vibe +format: toml +config_key: mcp_servers +config_paths: + - ./.vibe/config.toml + - ~/.vibe/config.toml +transport_discriminator: transport +supported_transports: + - stdio + - http + - streamable-http +canonical_mapping: + type_to_transport: + stdio: stdio + http: http + sse: streamable-http + httpUrl_to_url: true +extra_fields: + - prompt + - sampling_enabled + - api_key_env + - api_key_header + - api_key_format + - startup_timeout_sec + - tool_timeout_sec +``` + +## Sources + +- Mistral Vibe README and docs pages for config path precedence +- Upstream source definitions for MCP transport variants in `vibe/core/config` diff --git a/__reports__/mistral_vibe/01-architecture_analysis_v0.md b/__reports__/mistral_vibe/01-architecture_analysis_v0.md new file mode 100644 index 0000000..9cf392c --- /dev/null +++ b/__reports__/mistral_vibe/01-architecture_analysis_v0.md @@ -0,0 +1,26 @@ +# Mistral Vibe Architecture Analysis + +## Model + +| Layer | Change | +| --- | --- | +| Unified model | Add Vibe-native fields and host enum | +| Adapter | New `MistralVibeAdapter` to map canonical fields to Vibe TOML entries | +| Strategy | New TOML strategy for `[[mcp_servers]]` read/write with key preservation | +| Registries | Add adapter, strategy, backup/reporting, and fixture registration | +| Tests | Extend generic adapter suites and add focused TOML strategy tests | + +## Integration Notes + +| Concern | Decision | +| --- | --- | +| Local vs global config | Prefer existing project-local file, otherwise global fallback | +| Remote transport mapping | Canonical `type=sse` maps to Vibe `streamable-http` | +| Cross-host sync | Accept canonical `type` and `httpUrl`, serialize to `transport` + `url` | +| Non-MCP settings | Preserve other top-level TOML keys on write | + +## Assessment + +- **GO** — current adapter/strategy architecture already supports one more standalone TOML host. +- No dependency installation is required. +- Main regression surface is registry completeness and TOML round-tripping, covered by targeted tests. diff --git a/__reports__/mistral_vibe/README.md b/__reports__/mistral_vibe/README.md new file mode 100644 index 0000000..9e566e1 --- /dev/null +++ b/__reports__/mistral_vibe/README.md @@ -0,0 +1,12 @@ +# Mistral Vibe Reports + +## Status + +- Latest discovery: `00-parameter_analysis_v0.md` +- Latest architecture analysis: `01-architecture_analysis_v0.md` +- Current assessment: `GO` + +## Documents + +1. `00-parameter_analysis_v0.md` — upstream config path/schema discovery and host spec +2. `01-architecture_analysis_v0.md` — integration plan, touched files, and go/no-go assessment diff --git a/docs/articles/api/cli/mcp.md b/docs/articles/api/cli/mcp.md index a0b824f..252c9dd 100644 --- a/docs/articles/api/cli/mcp.md +++ b/docs/articles/api/cli/mcp.md @@ -24,6 +24,7 @@ This module provides handlers for: - codex: OpenAI Codex - lm-studio: LM Studio - gemini: Google Gemini +- mistral-vibe: Mistral Vibe CLI coding agent ## Handler Functions diff --git a/docs/articles/devs/architecture/mcp_backup_system.md b/docs/articles/devs/architecture/mcp_backup_system.md index 8aaafc6..34f167f 100644 --- a/docs/articles/devs/architecture/mcp_backup_system.md +++ b/docs/articles/devs/architecture/mcp_backup_system.md @@ -146,6 +146,7 @@ The system supports all MCP host platforms: | `cursor` | Cursor IDE MCP integration | | `lmstudio` | LM Studio MCP support | | `gemini` | Google Gemini MCP integration | +| `mistral-vibe` | Mistral Vibe CLI coding agent | ## Performance Characteristics diff --git a/docs/articles/devs/architecture/mcp_host_configuration.md b/docs/articles/devs/architecture/mcp_host_configuration.md index d8a7977..42de522 100644 --- a/docs/articles/devs/architecture/mcp_host_configuration.md +++ b/docs/articles/devs/architecture/mcp_host_configuration.md @@ -143,6 +143,7 @@ supported = registry.get_supported_hosts() # List all hosts - `claude-desktop`, `claude-code` - `vscode`, `cursor`, `lmstudio` - `gemini`, `kiro`, `codex` +- `mistral-vibe` ### BaseAdapter Protocol diff --git a/docs/articles/users/CLIReference.md b/docs/articles/users/CLIReference.md index 7c9c8d3..09ee038 100644 --- a/docs/articles/users/CLIReference.md +++ b/docs/articles/users/CLIReference.md @@ -1017,6 +1017,7 @@ Available MCP Host Platforms: claude-desktop ✓ Available /Users/user/.config/claude/... cursor ✓ Available /Users/user/.cursor/mcp.json vscode ✗ Not Found - + mistral-vibe ✓ Available /Users/user/.config/mistral/mcp.toml ``` **Key Details**: @@ -1039,6 +1040,8 @@ Available MCP host platforms: Config path: ~/.cursor/config.json vscode: ✗ Not detected Config path: ~/.vscode/config.json + mistral-vibe: ✓ Available + Config path: ~/.config/mistral/mcp.toml ``` #### `hatch mcp discover servers` diff --git a/docs/articles/users/MCPHostConfiguration.md b/docs/articles/users/MCPHostConfiguration.md index 9e2d2bf..47adab5 100644 --- a/docs/articles/users/MCPHostConfiguration.md +++ b/docs/articles/users/MCPHostConfiguration.md @@ -23,6 +23,7 @@ Hatch currently supports configuration for these MCP host platforms: - **Codex** - OpenAI Codex with MCP server configuration support - **LM Studio** - Local language model interface - **Gemini** - Google's AI development environment +- **Mistral Vibe** - Mistral Vibe CLI coding agent ## Hands-on Learning diff --git a/docs/articles/users/tutorials/04-mcp-host-configuration/01-host-platform-overview.md b/docs/articles/users/tutorials/04-mcp-host-configuration/01-host-platform-overview.md index 05a14f9..ec98fe2 100644 --- a/docs/articles/users/tutorials/04-mcp-host-configuration/01-host-platform-overview.md +++ b/docs/articles/users/tutorials/04-mcp-host-configuration/01-host-platform-overview.md @@ -57,6 +57,7 @@ Hatch currently supports configuration for these MCP host platforms: - [**Codex**](https://github.com/openai/codex) - OpenAI Codex with MCP server configuration support - [**LM Studio**](https://lmstudio.ai/) - Local language model interface - [**Gemini**](https://github.com/google-gemini/gemini-cli) - Google's AI Command Line Interface +- [**Mistral Vibe**](https://mistral.ai/vibe) - Mistral Vibe CLI coding agent ## Configuration Management Workflow diff --git a/hatch/cli/__main__.py b/hatch/cli/__main__.py index 18aa11b..746ada7 100644 --- a/hatch/cli/__main__.py +++ b/hatch/cli/__main__.py @@ -585,10 +585,10 @@ def _setup_mcp_commands(subparsers): ) server_type_group.add_argument( "--url", - help="Server URL for remote MCP servers (SSE transport) [hosts: all except claude-desktop, claude-code]", + help="Server URL for remote MCP servers (SSE/streamable transport) [hosts: all except claude-desktop, claude-code]", ) server_type_group.add_argument( - "--http-url", help="HTTP streaming endpoint URL [hosts: gemini]" + "--http-url", help="HTTP streaming endpoint URL [hosts: gemini, mistral-vibe]" ) mcp_configure_parser.add_argument( @@ -667,12 +667,12 @@ def _setup_mcp_commands(subparsers): mcp_configure_parser.add_argument( "--startup-timeout", type=int, - help="Server startup timeout in seconds (default: 10) [hosts: codex]", + help="Server startup timeout in seconds (default: 10) [hosts: codex, mistral-vibe]", ) mcp_configure_parser.add_argument( "--tool-timeout", type=int, - help="Tool execution timeout in seconds (default: 60) [hosts: codex]", + help="Tool execution timeout in seconds (default: 60) [hosts: codex, mistral-vibe]", ) mcp_configure_parser.add_argument( "--enabled", @@ -683,12 +683,35 @@ def _setup_mcp_commands(subparsers): mcp_configure_parser.add_argument( "--bearer-token-env-var", type=str, - help="Name of environment variable containing bearer token for Authorization header [hosts: codex]", + help="Name of environment variable containing bearer token for Authorization header [hosts: codex, mistral-vibe]", ) mcp_configure_parser.add_argument( "--env-header", action="append", - help="HTTP header from environment variable in KEY=ENV_VAR_NAME format [hosts: codex]", + help="HTTP header from environment variable in KEY=ENV_VAR_NAME format [hosts: codex, mistral-vibe]", + ) + + # Mistral Vibe-specific arguments + mcp_configure_parser.add_argument( + "--prompt", help="Per-server prompt override [hosts: mistral-vibe]" + ) + mcp_configure_parser.add_argument( + "--sampling-enabled", + action="store_true", + default=None, + help="Enable model sampling for tool calls [hosts: mistral-vibe]", + ) + mcp_configure_parser.add_argument( + "--api-key-env", + help="Environment variable containing API key for remote auth [hosts: mistral-vibe]", + ) + mcp_configure_parser.add_argument( + "--api-key-header", + help="HTTP header name used for API key injection [hosts: mistral-vibe]", + ) + mcp_configure_parser.add_argument( + "--api-key-format", + help="Formatting template for API key header values [hosts: mistral-vibe]", ) mcp_configure_parser.add_argument( diff --git a/hatch/cli/cli_mcp.py b/hatch/cli/cli_mcp.py index 457e2f4..a5c1fc3 100644 --- a/hatch/cli/cli_mcp.py +++ b/hatch/cli/cli_mcp.py @@ -13,6 +13,7 @@ - codex: OpenAI Codex - lm-studio: LM Studio - gemini: Google Gemini + - mistral-vibe: Mistral Vibe CLI Command Groups: Discovery: @@ -71,6 +72,72 @@ ) +def _apply_mistral_vibe_cli_mappings( + config_data: dict, + *, + command: Optional[str], + url: Optional[str], + http_url: Optional[str], + bearer_token_env_var: Optional[str], + env_header: Optional[list], + api_key_env: Optional[str], + api_key_header: Optional[str], + api_key_format: Optional[str], +) -> dict: + """Map generic CLI flags to Mistral Vibe's host-native MCP fields.""" + result = dict(config_data) + result.pop("cwd", None) + + if command is not None: + result["transport"] = "stdio" + elif http_url is not None: + result.pop("httpUrl", None) + result["url"] = http_url + result["transport"] = "http" + elif url is not None: + result["transport"] = "streamable-http" + + if env_header and len(env_header) > 1: + raise ValidationError( + "mistral-vibe supports at most one --env-header mapping", + field="--env-header", + suggestion=( + "Use a single KEY=ENV_VAR pair or the dedicated --api-key-* flags" + ), + ) + + mapped_api_key_env = api_key_env + mapped_api_key_header = api_key_header + mapped_api_key_format = api_key_format + + if env_header: + header_name, env_var_name = env_header[0].split("=", 1) + if mapped_api_key_header is None: + mapped_api_key_header = header_name + if mapped_api_key_env is None: + mapped_api_key_env = env_var_name + + if bearer_token_env_var is not None: + if mapped_api_key_env is None: + mapped_api_key_env = bearer_token_env_var + if mapped_api_key_header is None: + mapped_api_key_header = "Authorization" + if mapped_api_key_format is None: + mapped_api_key_format = "Bearer {api_key}" + + if mapped_api_key_env is not None: + result["api_key_env"] = mapped_api_key_env + if mapped_api_key_header is not None: + result["api_key_header"] = mapped_api_key_header + if mapped_api_key_format is not None: + result["api_key_format"] = mapped_api_key_format + + result.pop("bearer_token_env_var", None) + result.pop("env_http_headers", None) + + return result + + def handle_mcp_discover_hosts(args: Namespace) -> int: """Handle 'hatch mcp discover hosts' command. @@ -1493,6 +1560,11 @@ def handle_mcp_configure(args: Namespace) -> int: startup_timeout: Optional[int] = getattr(args, "startup_timeout", None) tool_timeout: Optional[int] = getattr(args, "tool_timeout", None) enabled: Optional[bool] = getattr(args, "enabled", None) + prompt: Optional[str] = getattr(args, "prompt", None) + sampling_enabled: Optional[bool] = getattr(args, "sampling_enabled", None) + api_key_env: Optional[str] = getattr(args, "api_key_env", None) + api_key_header: Optional[str] = getattr(args, "api_key_header", None) + api_key_format: Optional[str] = getattr(args, "api_key_format", None) bearer_token_env_var: Optional[str] = getattr( args, "bearer_token_env_var", None ) @@ -1514,18 +1586,6 @@ def handle_mcp_configure(args: Namespace) -> int: ) return EXIT_ERROR - # Validate Claude Desktop/Code transport restrictions (Issue 2) - if host_type in (MCPHostType.CLAUDE_DESKTOP, MCPHostType.CLAUDE_CODE): - if url is not None: - format_validation_error( - ValidationError( - f"{host} does not support remote servers (--url)", - field="--url", - suggestion="Only local servers with --command are supported for this host", - ) - ) - return EXIT_ERROR - # Validate argument dependencies if command and header: format_validation_error( @@ -1604,7 +1664,7 @@ def handle_mcp_configure(args: Namespace) -> int: config_data["timeout"] = timeout if trust: config_data["trust"] = trust - if cwd is not None: + if cwd is not None and host_type != MCPHostType.MISTRAL_VIBE: config_data["cwd"] = cwd if http_url is not None: config_data["httpUrl"] = http_url @@ -1636,11 +1696,21 @@ def handle_mcp_configure(args: Namespace) -> int: config_data["startup_timeout_sec"] = startup_timeout if tool_timeout is not None: config_data["tool_timeout_sec"] = tool_timeout + if prompt is not None: + config_data["prompt"] = prompt + if sampling_enabled is not None: + config_data["sampling_enabled"] = sampling_enabled + if api_key_env is not None: + config_data["api_key_env"] = api_key_env + if api_key_header is not None: + config_data["api_key_header"] = api_key_header + if api_key_format is not None: + config_data["api_key_format"] = api_key_format if enabled is not None: config_data["enabled"] = enabled - if bearer_token_env_var is not None: + if bearer_token_env_var is not None and host_type != MCPHostType.MISTRAL_VIBE: config_data["bearer_token_env_var"] = bearer_token_env_var - if env_header is not None: + if env_header is not None and host_type != MCPHostType.MISTRAL_VIBE: env_http_headers = {} for header_spec in env_header: if "=" in header_spec: @@ -1649,6 +1719,19 @@ def handle_mcp_configure(args: Namespace) -> int: if env_http_headers: config_data["env_http_headers"] = env_http_headers + if host_type == MCPHostType.MISTRAL_VIBE: + config_data = _apply_mistral_vibe_cli_mappings( + config_data, + command=command, + url=url, + http_url=http_url, + bearer_token_env_var=bearer_token_env_var, + env_header=env_header, + api_key_env=api_key_env, + api_key_header=api_key_header, + api_key_format=api_key_format, + ) + # Partial update merge logic if is_update: existing_data = existing_config.model_dump( @@ -1661,6 +1744,7 @@ def handle_mcp_configure(args: Namespace) -> int: existing_data.pop("command", None) existing_data.pop("args", None) existing_data.pop("type", None) + existing_data.pop("transport", None) if command is not None and ( existing_config.url is not None @@ -1670,6 +1754,10 @@ def handle_mcp_configure(args: Namespace) -> int: existing_data.pop("httpUrl", None) existing_data.pop("headers", None) existing_data.pop("type", None) + existing_data.pop("transport", None) + existing_data.pop("api_key_env", None) + existing_data.pop("api_key_header", None) + existing_data.pop("api_key_format", None) merged_data = {**existing_data, **config_data} config_data = merged_data diff --git a/hatch/mcp_host_config/adapters/__init__.py b/hatch/mcp_host_config/adapters/__init__.py index 8e5dc32..ec750fd 100644 --- a/hatch/mcp_host_config/adapters/__init__.py +++ b/hatch/mcp_host_config/adapters/__init__.py @@ -12,6 +12,7 @@ from hatch.mcp_host_config.adapters.gemini import GeminiAdapter from hatch.mcp_host_config.adapters.kiro import KiroAdapter from hatch.mcp_host_config.adapters.lmstudio import LMStudioAdapter +from hatch.mcp_host_config.adapters.mistral_vibe import MistralVibeAdapter from hatch.mcp_host_config.adapters.opencode import OpenCodeAdapter from hatch.mcp_host_config.adapters.registry import ( AdapterRegistry, @@ -36,6 +37,7 @@ "GeminiAdapter", "KiroAdapter", "LMStudioAdapter", + "MistralVibeAdapter", "OpenCodeAdapter", "VSCodeAdapter", ] diff --git a/hatch/mcp_host_config/adapters/claude.py b/hatch/mcp_host_config/adapters/claude.py index 9761080..a17daa5 100644 --- a/hatch/mcp_host_config/adapters/claude.py +++ b/hatch/mcp_host_config/adapters/claude.py @@ -161,5 +161,11 @@ def serialize(self, config: MCPServerConfig) -> Dict[str, Any]: # Validate filtered fields self.validate_filtered(filtered) - # Return filtered (no transformations needed for Claude) + # Claude's URL-based remote configs should explicitly declare HTTP + # transport in serialized output. + if "url" in filtered: + filtered = filtered.copy() + filtered["type"] = "http" + + # Return filtered Claude config return filtered diff --git a/hatch/mcp_host_config/adapters/mistral_vibe.py b/hatch/mcp_host_config/adapters/mistral_vibe.py new file mode 100644 index 0000000..8f51419 --- /dev/null +++ b/hatch/mcp_host_config/adapters/mistral_vibe.py @@ -0,0 +1,116 @@ +"""Mistral Vibe adapter for MCP host configuration. + +Mistral Vibe uses TOML `[[mcp_servers]]` entries with an explicit `transport` +field instead of the Claude-style `type` discriminator. +""" + +from typing import Any, Dict, FrozenSet + +from hatch.mcp_host_config.adapters.base import AdapterValidationError, BaseAdapter +from hatch.mcp_host_config.fields import MISTRAL_VIBE_FIELDS +from hatch.mcp_host_config.models import MCPServerConfig + + +class MistralVibeAdapter(BaseAdapter): + """Adapter for Mistral Vibe MCP server configuration.""" + + @property + def host_name(self) -> str: + """Return the host identifier.""" + return "mistral-vibe" + + def get_supported_fields(self) -> FrozenSet[str]: + """Return fields supported by Mistral Vibe.""" + return MISTRAL_VIBE_FIELDS + + def validate(self, config: MCPServerConfig) -> None: + """Deprecated compatibility wrapper for legacy adapter tests.""" + self.validate_filtered(self.filter_fields(config)) + + def validate_filtered(self, filtered: Dict[str, Any]) -> None: + """Validate Mistral Vibe transport rules on filtered fields.""" + has_command = "command" in filtered + has_url = "url" in filtered + transport_count = sum([has_command, has_url]) + + if transport_count == 0: + raise AdapterValidationError( + "Either 'command' or 'url' must be specified", + host_name=self.host_name, + ) + + if transport_count > 1: + raise AdapterValidationError( + "Cannot specify multiple transports - choose exactly one of 'command' or 'url'", + host_name=self.host_name, + ) + + transport = filtered.get("transport") + if transport == "stdio" and not has_command: + raise AdapterValidationError( + "transport='stdio' requires 'command' field", + field="transport", + host_name=self.host_name, + ) + if transport in ("http", "streamable-http") and not has_url: + raise AdapterValidationError( + f"transport='{transport}' requires 'url' field", + field="transport", + host_name=self.host_name, + ) + + def apply_transformations( + self, filtered: Dict[str, Any], transport_hint: str | None = None + ) -> Dict[str, Any]: + """Apply Mistral Vibe field/value transformations.""" + result = dict(filtered) + + transport = ( + result.get("transport") or transport_hint or self._infer_transport(result) + ) + result["transport"] = transport + + return result + + def serialize(self, config: MCPServerConfig) -> Dict[str, Any]: + """Serialize configuration for Mistral Vibe format.""" + filtered = self.filter_fields(config) + + # Support cross-host sync hints without advertising these as native fields. + if ( + "command" not in filtered + and "url" not in filtered + and config.httpUrl is not None + ): + filtered["url"] = config.httpUrl + + transport_hint = self._infer_transport(filtered, config=config) + if transport_hint is not None: + filtered["transport"] = transport_hint + + self.validate_filtered(filtered) + return self.apply_transformations(filtered) + + def _infer_transport( + self, filtered: Dict[str, Any], config: MCPServerConfig | None = None + ) -> str | None: + """Infer Vibe transport from canonical MCP fields.""" + if "transport" in filtered: + return filtered["transport"] + if "command" in filtered: + return "stdio" + + config_type = config.type if config is not None else None + if config_type == "stdio": + return "stdio" + if config_type == "http": + return "http" + if config_type == "sse": + return "streamable-http" + + if config is not None and config.httpUrl is not None: + return "http" + if "url" in filtered: + return "streamable-http" + + return None diff --git a/hatch/mcp_host_config/adapters/registry.py b/hatch/mcp_host_config/adapters/registry.py index 22a0122..53ae661 100644 --- a/hatch/mcp_host_config/adapters/registry.py +++ b/hatch/mcp_host_config/adapters/registry.py @@ -14,6 +14,7 @@ from hatch.mcp_host_config.adapters.gemini import GeminiAdapter from hatch.mcp_host_config.adapters.kiro import KiroAdapter from hatch.mcp_host_config.adapters.lmstudio import LMStudioAdapter +from hatch.mcp_host_config.adapters.mistral_vibe import MistralVibeAdapter from hatch.mcp_host_config.adapters.opencode import OpenCodeAdapter from hatch.mcp_host_config.adapters.vscode import VSCodeAdapter @@ -34,7 +35,7 @@ class AdapterRegistry: 'claude-desktop' >>> registry.get_supported_hosts() - ['augment', 'claude-code', 'claude-desktop', 'codex', 'cursor', 'gemini', 'kiro', 'lmstudio', 'opencode', 'vscode'] + ['augment', 'claude-code', 'claude-desktop', 'codex', 'cursor', 'gemini', 'kiro', 'lmstudio', 'mistral-vibe', 'opencode', 'vscode'] """ def __init__(self): @@ -55,6 +56,7 @@ def _register_defaults(self) -> None: self.register(GeminiAdapter()) self.register(KiroAdapter()) self.register(CodexAdapter()) + self.register(MistralVibeAdapter()) self.register(OpenCodeAdapter()) self.register(AugmentAdapter()) diff --git a/hatch/mcp_host_config/backup.py b/hatch/mcp_host_config/backup.py index 5ebac87..9c36ec8 100644 --- a/hatch/mcp_host_config/backup.py +++ b/hatch/mcp_host_config/backup.py @@ -48,6 +48,7 @@ def validate_hostname(cls, v): "gemini", "kiro", "codex", + "mistral-vibe", "opencode", "augment", } diff --git a/hatch/mcp_host_config/fields.py b/hatch/mcp_host_config/fields.py index b574c9c..942fef9 100644 --- a/hatch/mcp_host_config/fields.py +++ b/hatch/mcp_host_config/fields.py @@ -27,7 +27,7 @@ # ============================================================================ # Hosts that support the 'type' discriminator field (stdio/sse/http) -# Note: Gemini, Kiro, Codex do NOT support this field +# Note: Gemini, Kiro, Codex, and Mistral Vibe do NOT support this field TYPE_SUPPORTING_HOSTS: FrozenSet[str] = frozenset( { "claude-desktop", @@ -117,6 +117,21 @@ ) +# Fields supported by Mistral Vibe (TOML array-of-tables with explicit transport) +MISTRAL_VIBE_FIELDS: FrozenSet[str] = UNIVERSAL_FIELDS | frozenset( + { + "transport", # Vibe transport discriminator: stdio/http/streamable-http + "prompt", # Optional per-server prompt override + "sampling_enabled", # Enable model sampling for tool calls + "api_key_env", # Env var containing API key for remote servers + "api_key_header", # Header name for API key injection + "api_key_format", # Header formatting template for API key injection + "startup_timeout_sec", # Server startup timeout + "tool_timeout_sec", # Tool execution timeout + } +) + + # Fields supported by Augment Code (auggie CLI + extensions); same as Claude fields # Config: ~/.augment/settings.json, key: mcpServers AUGMENT_FIELDS: FrozenSet[str] = CLAUDE_FIELDS diff --git a/hatch/mcp_host_config/host_management.py b/hatch/mcp_host_config/host_management.py index d4177f0..1e577aa 100644 --- a/hatch/mcp_host_config/host_management.py +++ b/hatch/mcp_host_config/host_management.py @@ -30,6 +30,7 @@ class MCPHostRegistry: _family_mappings: Dict[str, List[MCPHostType]] = { "claude": [MCPHostType.CLAUDE_DESKTOP, MCPHostType.CLAUDE_CODE], "cursor": [MCPHostType.CURSOR, MCPHostType.LMSTUDIO], + "mistral": [MCPHostType.MISTRAL_VIBE], } @classmethod diff --git a/hatch/mcp_host_config/models.py b/hatch/mcp_host_config/models.py index 610971b..79b4116 100644 --- a/hatch/mcp_host_config/models.py +++ b/hatch/mcp_host_config/models.py @@ -30,6 +30,7 @@ class MCPHostType(str, Enum): GEMINI = "gemini" KIRO = "kiro" CODEX = "codex" + MISTRAL_VIBE = "mistral-vibe" OPENCODE = "opencode" AUGMENT = "augment" @@ -62,6 +63,10 @@ class MCPServerConfig(BaseModel): type: Optional[Literal["stdio", "sse", "http"]] = Field( None, description="Transport type (stdio for local, sse/http for remote)" ) + transport: Optional[Literal["stdio", "http", "streamable-http"]] = Field( + None, + description="Host-native transport discriminator (e.g. Mistral Vibe)", + ) # stdio transport (local server) command: Optional[str] = Field( @@ -138,15 +143,15 @@ class MCPServerConfig(BaseModel): disabledTools: Optional[List[str]] = Field(None, description="Disabled tool names") # ======================================================================== - # Codex-Specific Fields + # Codex / Mistral Vibe-Specific Fields # ======================================================================== env_vars: Optional[List[str]] = Field( None, description="Environment variables to whitelist/forward" ) - startup_timeout_sec: Optional[int] = Field( + startup_timeout_sec: Optional[float] = Field( None, description="Server startup timeout in seconds" ) - tool_timeout_sec: Optional[int] = Field( + tool_timeout_sec: Optional[float] = Field( None, description="Tool execution timeout in seconds" ) enabled: Optional[bool] = Field( @@ -167,6 +172,19 @@ class MCPServerConfig(BaseModel): env_http_headers: Optional[Dict[str, str]] = Field( None, description="Header names to env var names" ) + prompt: Optional[str] = Field(None, description="Per-server prompt override") + sampling_enabled: Optional[bool] = Field( + None, description="Whether sampling is enabled for tool calls" + ) + api_key_env: Optional[str] = Field( + None, description="Env var containing API key for remote server auth" + ) + api_key_header: Optional[str] = Field( + None, description="HTTP header name used for API key injection" + ) + api_key_format: Optional[str] = Field( + None, description="Formatting template for API key header values" + ) # ======================================================================== # OpenCode-Specific Fields @@ -239,6 +257,8 @@ def is_stdio(self) -> bool: 1. Explicit type="stdio" field takes precedence 2. Otherwise, presence of 'command' field indicates stdio """ + if self.transport is not None: + return self.transport == "stdio" if self.type is not None: return self.type == "stdio" return self.command is not None @@ -253,6 +273,8 @@ def is_sse(self) -> bool: 1. Explicit type="sse" field takes precedence 2. Otherwise, presence of 'url' field indicates SSE """ + if self.transport is not None: + return False if self.type is not None: return self.type == "sse" return self.url is not None @@ -267,6 +289,8 @@ def is_http(self) -> bool: 1. Explicit type="http" field takes precedence 2. Otherwise, presence of 'httpUrl' field indicates HTTP streaming """ + if self.transport is not None: + return self.transport in ("http", "streamable-http") if self.type is not None: return self.type == "http" return self.httpUrl is not None @@ -278,8 +302,12 @@ def get_transport_type(self) -> Optional[str]: "stdio" for command-based local servers "sse" for URL-based remote servers (SSE transport) "http" for httpUrl-based remote servers (Gemini HTTP streaming) + "streamable-http" for hosts that expose that transport natively None if transport cannot be determined """ + if self.transport is not None: + return self.transport + # Explicit type takes precedence if self.type is not None: return self.type @@ -367,6 +395,7 @@ def validate_host_names(cls, v): "gemini", "kiro", "codex", + "mistral-vibe", "opencode", "augment", } diff --git a/hatch/mcp_host_config/reporting.py b/hatch/mcp_host_config/reporting.py index d4744aa..fd9724d 100644 --- a/hatch/mcp_host_config/reporting.py +++ b/hatch/mcp_host_config/reporting.py @@ -73,6 +73,7 @@ def _get_adapter_host_name(host_type: MCPHostType) -> str: MCPHostType.GEMINI: "gemini", MCPHostType.KIRO: "kiro", MCPHostType.CODEX: "codex", + MCPHostType.MISTRAL_VIBE: "mistral-vibe", MCPHostType.OPENCODE: "opencode", MCPHostType.AUGMENT: "augment", } diff --git a/hatch/mcp_host_config/strategies.py b/hatch/mcp_host_config/strategies.py index 69ebcd2..a6c8925 100644 --- a/hatch/mcp_host_config/strategies.py +++ b/hatch/mcp_host_config/strategies.py @@ -1055,6 +1055,154 @@ def write_configuration( return False +@register_host_strategy(MCPHostType.MISTRAL_VIBE) +class MistralVibeHostStrategy(MCPHostStrategy): + """Configuration strategy for Mistral Vibe's TOML-based MCP settings.""" + + def get_adapter_host_name(self) -> str: + """Return the adapter host name for Mistral Vibe.""" + return "mistral-vibe" + + def _project_config_path(self) -> Path: + return Path.cwd() / ".vibe" / "config.toml" + + def _global_config_path(self) -> Path: + return Path.home() / ".vibe" / "config.toml" + + def get_config_path(self) -> Optional[Path]: + """Get Mistral Vibe configuration path. + + Vibe prefers project-local `./.vibe/config.toml` when it exists, and + otherwise falls back to the user-global `~/.vibe/config.toml`. + """ + project_path = self._project_config_path() + global_path = self._global_config_path() + + if project_path.exists(): + return project_path + if global_path.exists(): + return global_path + if project_path.parent.exists(): + return project_path + return global_path + + def get_config_key(self) -> str: + """Mistral Vibe uses the `mcp_servers` top-level key.""" + return "mcp_servers" + + def is_host_available(self) -> bool: + """Check if Mistral Vibe is available by checking config directories.""" + return ( + self._project_config_path().parent.exists() + or self._global_config_path().parent.exists() + ) + + def validate_server_config(self, server_config: MCPServerConfig) -> bool: + """Vibe supports local stdio and remote HTTP transports.""" + return any( + value is not None + for value in ( + server_config.command, + server_config.url, + server_config.httpUrl, + ) + ) + + def read_configuration(self) -> HostConfiguration: + """Read Mistral Vibe TOML configuration.""" + config_path = self.get_config_path() + if not config_path or not config_path.exists(): + return HostConfiguration(servers={}) + + try: + with open(config_path, "rb") as f: + toml_data = tomllib.load(f) + + raw_servers = toml_data.get(self.get_config_key(), []) + if not isinstance(raw_servers, list): + logger.warning( + "Invalid Mistral Vibe configuration: mcp_servers must be a list" + ) + return HostConfiguration(servers={}) + + servers = {} + for server_data in raw_servers: + try: + normalized = dict(server_data) + name = normalized.pop("name", None) + if not name: + logger.warning("Skipping unnamed Mistral Vibe MCP server entry") + continue + + transport = normalized.get("transport") + if transport == "stdio": + normalized.setdefault("type", "stdio") + elif transport in ("http", "streamable-http"): + normalized.setdefault("type", "http") + + servers[name] = MCPServerConfig(name=name, **normalized) + except Exception as e: + logger.warning(f"Invalid Mistral Vibe server config: {e}") + continue + + return HostConfiguration(servers=servers) + except Exception as e: + logger.error(f"Failed to read Mistral Vibe configuration: {e}") + return HostConfiguration(servers={}) + + def write_configuration( + self, config: HostConfiguration, no_backup: bool = False + ) -> bool: + """Write Mistral Vibe TOML configuration while preserving other keys.""" + config_path = self.get_config_path() + if not config_path: + return False + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + + existing_data: Dict[str, Any] = {} + if config_path.exists(): + try: + with open(config_path, "rb") as f: + existing_data = tomllib.load(f) + except Exception: + pass + + adapter = get_adapter(self.get_adapter_host_name()) + servers_data = [] + for name, server_config in config.servers.items(): + serialized = adapter.serialize(server_config) + servers_data.append({"name": name, **serialized}) + + final_data = { + key: value + for key, value in existing_data.items() + if key != self.get_config_key() + } + final_data[self.get_config_key()] = servers_data + + backup_manager = MCPHostConfigBackupManager() + atomic_ops = AtomicFileOperations() + + def toml_serializer(data: Any, f: TextIO) -> None: + f.write(tomli_w.dumps(data)) + + atomic_ops.atomic_write_with_serializer( + file_path=config_path, + data=final_data, + serializer=toml_serializer, + backup_manager=backup_manager, + hostname="mistral-vibe", + skip_backup=no_backup, + ) + + return True + except Exception as e: + logger.error(f"Failed to write Mistral Vibe configuration: {e}") + return False + + @register_host_strategy(MCPHostType.AUGMENT) class AugmentHostStrategy(ClaudeHostStrategy): """Configuration strategy for Augment Code (auggie CLI + extensions). diff --git a/tests/integration/cli/test_cli_reporter_integration.py b/tests/integration/cli/test_cli_reporter_integration.py index a0490f5..373ad5f 100644 --- a/tests/integration/cli/test_cli_reporter_integration.py +++ b/tests/integration/cli/test_cli_reporter_integration.py @@ -30,6 +30,46 @@ def _handler_uses_result_reporter(handler_module_source: str) -> bool: class TestMCPConfigureHandlerIntegration: """Integration tests for handle_mcp_configure → ResultReporter flow.""" + @staticmethod + def _base_configure_args(**overrides): + """Create a baseline Namespace for handle_mcp_configure tests.""" + data = dict( + host="claude-desktop", + server_name="test-server", + server_command="python", + args=["server.py"], + env_var=None, + url=None, + header=None, + timeout=None, + trust=False, + cwd=None, + env_file=None, + http_url=None, + include_tools=None, + exclude_tools=None, + input=None, + disabled=None, + auto_approve_tools=None, + disable_tools=None, + env_vars=None, + startup_timeout=None, + tool_timeout=None, + enabled=None, + prompt=None, + sampling_enabled=None, + api_key_env=None, + api_key_header=None, + api_key_format=None, + bearer_token_env_var=None, + env_header=None, + no_backup=True, + dry_run=False, + auto_approve=True, + ) + data.update(overrides) + return Namespace(**data) + def test_handler_imports_result_reporter(self): """Handler module should import ResultReporter from cli_utils. @@ -60,35 +100,7 @@ def test_handler_uses_result_reporter_for_output(self): from hatch.cli.cli_mcp import handle_mcp_configure # Create mock args for a simple configure operation - args = Namespace( - host="claude-desktop", - server_name="test-server", - server_command="python", - args=["server.py"], - env_var=None, - url=None, - header=None, - timeout=None, - trust=False, - cwd=None, - env_file=None, - http_url=None, - include_tools=None, - exclude_tools=None, - input=None, - disabled=None, - auto_approve_tools=None, - disable_tools=None, - env_vars=None, - startup_timeout=None, - tool_timeout=None, - enabled=None, - bearer_token_env_var=None, - env_header=None, - no_backup=True, - dry_run=False, - auto_approve=True, # Skip confirmation - ) + args = self._base_configure_args(auto_approve=True) # Mock the MCPHostConfigurationManager with patch( @@ -123,35 +135,7 @@ def test_handler_dry_run_shows_preview(self): """ from hatch.cli.cli_mcp import handle_mcp_configure - args = Namespace( - host="claude-desktop", - server_name="test-server", - server_command="python", - args=["server.py"], - env_var=None, - url=None, - header=None, - timeout=None, - trust=False, - cwd=None, - env_file=None, - http_url=None, - include_tools=None, - exclude_tools=None, - input=None, - disabled=None, - auto_approve_tools=None, - disable_tools=None, - env_vars=None, - startup_timeout=None, - tool_timeout=None, - enabled=None, - bearer_token_env_var=None, - env_header=None, - no_backup=True, - dry_run=True, # Dry-run enabled - auto_approve=True, - ) + args = self._base_configure_args(dry_run=True, auto_approve=True) with patch( "hatch.cli.cli_mcp.MCPHostConfigurationManager" @@ -185,35 +169,7 @@ def test_handler_shows_prompt_before_confirmation(self): """ from hatch.cli.cli_mcp import handle_mcp_configure - args = Namespace( - host="claude-desktop", - server_name="test-server", - server_command="python", - args=["server.py"], - env_var=None, - url=None, - header=None, - timeout=None, - trust=False, - cwd=None, - env_file=None, - http_url=None, - include_tools=None, - exclude_tools=None, - input=None, - disabled=None, - auto_approve_tools=None, - disable_tools=None, - env_vars=None, - startup_timeout=None, - tool_timeout=None, - enabled=None, - bearer_token_env_var=None, - env_header=None, - no_backup=True, - dry_run=False, - auto_approve=False, # Will prompt for confirmation - ) + args = self._base_configure_args(auto_approve=False) with patch( "hatch.cli.cli_mcp.MCPHostConfigurationManager" @@ -240,6 +196,112 @@ def test_handler_shows_prompt_before_confirmation(self): "hatch mcp configure" in output or "[CONFIGURE]" in output ), "Handler should show consequence preview before confirmation" + def test_mistral_vibe_maps_http_and_api_key_flags(self): + """Mistral Vibe should map reusable CLI flags to host-native fields.""" + from hatch.cli.cli_mcp import handle_mcp_configure + + args = self._base_configure_args( + host="mistral-vibe", + server_command=None, + args=None, + http_url="https://example.com/mcp", + startup_timeout=15, + tool_timeout=90, + bearer_token_env_var="MISTRAL_API_KEY", + prompt="Be concise.", + sampling_enabled=True, + auto_approve=True, + ) + + with patch( + "hatch.cli.cli_mcp.MCPHostConfigurationManager" + ) as mock_manager_class: + mock_manager = MagicMock() + mock_manager.get_server_config.return_value = None + mock_result = MagicMock(success=True, backup_path=None) + mock_manager.configure_server.return_value = mock_result + mock_manager_class.return_value = mock_manager + + result = handle_mcp_configure(args) + + assert result == EXIT_SUCCESS + + passed_config = mock_manager.configure_server.call_args.kwargs["server_config"] + assert passed_config.url == "https://example.com/mcp" + assert passed_config.transport == "http" + assert passed_config.httpUrl is None + assert passed_config.startup_timeout_sec == 15 + assert passed_config.tool_timeout_sec == 90 + assert passed_config.prompt == "Be concise." + assert passed_config.sampling_enabled is True + assert passed_config.api_key_env == "MISTRAL_API_KEY" + assert passed_config.api_key_header == "Authorization" + assert passed_config.api_key_format == "Bearer {api_key}" + assert passed_config.cwd is None + + def test_mistral_vibe_maps_env_header_to_api_key_fields(self): + """Single --env-header should map to Mistral Vibe api_key_* fields.""" + from hatch.cli.cli_mcp import handle_mcp_configure + + args = self._base_configure_args( + host="mistral-vibe", + server_command=None, + args=None, + url="https://example.com/mcp", + env_header=["X-API-Key=MISTRAL_TOKEN"], + api_key_format="Token {api_key}", + auto_approve=True, + ) + + with patch( + "hatch.cli.cli_mcp.MCPHostConfigurationManager" + ) as mock_manager_class: + mock_manager = MagicMock() + mock_manager.get_server_config.return_value = None + mock_result = MagicMock(success=True, backup_path=None) + mock_manager.configure_server.return_value = mock_result + mock_manager_class.return_value = mock_manager + + result = handle_mcp_configure(args) + + assert result == EXIT_SUCCESS + + passed_config = mock_manager.configure_server.call_args.kwargs["server_config"] + assert passed_config.url == "https://example.com/mcp" + assert passed_config.transport == "streamable-http" + assert passed_config.api_key_env == "MISTRAL_TOKEN" + assert passed_config.api_key_header == "X-API-Key" + assert passed_config.api_key_format == "Token {api_key}" + + def test_mistral_vibe_does_not_forward_cwd(self): + """Mistral Vibe should ignore --cwd because the host config has no cwd field.""" + from hatch.cli.cli_mcp import handle_mcp_configure + + args = self._base_configure_args( + host="mistral-vibe", + server_command="python", + args=["server.py"], + cwd="/tmp/mistral", + auto_approve=True, + ) + + with patch( + "hatch.cli.cli_mcp.MCPHostConfigurationManager" + ) as mock_manager_class: + mock_manager = MagicMock() + mock_manager.get_server_config.return_value = None + mock_result = MagicMock(success=True, backup_path=None) + mock_manager.configure_server.return_value = mock_result + mock_manager_class.return_value = mock_manager + + result = handle_mcp_configure(args) + + assert result == EXIT_SUCCESS + passed_config = mock_manager.configure_server.call_args.kwargs["server_config"] + assert passed_config.command == "python" + assert passed_config.transport == "stdio" + assert passed_config.cwd is None + class TestMCPSyncHandlerIntegration: """Integration tests for handle_mcp_sync → ResultReporter flow.""" diff --git a/tests/regression/mcp/test_claude_transport_serialization.py b/tests/regression/mcp/test_claude_transport_serialization.py new file mode 100644 index 0000000..ffae1de --- /dev/null +++ b/tests/regression/mcp/test_claude_transport_serialization.py @@ -0,0 +1,66 @@ +"""Regression tests for Claude-family transport serialization.""" + +import json +from pathlib import Path + +import pytest + +from hatch.mcp_host_config.adapters.claude import ClaudeAdapter +from hatch.mcp_host_config.models import MCPServerConfig + +try: + from wobble.decorators import regression_test +except ImportError: + + def regression_test(func): + return func + + +FIXTURES_PATH = ( + Path(__file__).resolve().parents[2] + / "test_data" + / "mcp_adapters" + / "claude_transport_regressions.json" +) + +with open(FIXTURES_PATH) as f: + FIXTURES = json.load(f) + + +def get_variant(host_name: str) -> str: + """Return Claude adapter variant from host name.""" + return host_name.removeprefix("claude-") + + +class TestClaudeTransportSerialization: + """Regression coverage for Claude Desktop/Code transport serialization.""" + + @pytest.mark.parametrize( + "test_case", + FIXTURES["remote_http"], + ids=lambda tc: f'{tc["host"]}-{tc["case"]}', + ) + @regression_test + def test_remote_url_defaults_to_http_type(self, test_case): + """URL-based Claude configs serialize with explicit HTTP transport.""" + adapter = ClaudeAdapter(variant=get_variant(test_case["host"])) + config = MCPServerConfig(**test_case["config"]) + + result = adapter.serialize(config) + + assert result == test_case["expected"] + + @pytest.mark.parametrize( + "test_case", + FIXTURES["stdio_without_type"], + ids=lambda tc: f'{tc["host"]}-{tc["case"]}', + ) + @regression_test + def test_stdio_config_does_not_require_type_input(self, test_case): + """Stdio Claude configs still serialize when type is omitted.""" + adapter = ClaudeAdapter(variant=get_variant(test_case["host"])) + config = MCPServerConfig(**test_case["config"]) + + result = adapter.serialize(config) + + assert result == test_case["expected"] diff --git a/tests/regression/mcp/test_field_filtering_v2.py b/tests/regression/mcp/test_field_filtering_v2.py index c511532..9ca2929 100644 --- a/tests/regression/mcp/test_field_filtering_v2.py +++ b/tests/regression/mcp/test_field_filtering_v2.py @@ -57,6 +57,11 @@ def regression_test(func): "oauth_redirectUri": "http://localhost:3000/callback", "oauth_tokenParamName": "access_token", "bearer_token_env_var": "BEARER_TOKEN", + "prompt": "Be concise.", + "api_key_env": "MISTRAL_API_KEY", + "api_key_header": "Authorization", + "api_key_format": "Bearer {api_key}", + "transport": "streamable-http", # Integer fields "timeout": 30000, "startup_timeout_sec": 10, @@ -66,6 +71,7 @@ def regression_test(func): "oauth_enabled": False, "disabled": False, "enabled": True, + "sampling_enabled": True, # List[str] fields "args": ["--test"], "includeTools": ["tool1"], diff --git a/tests/test_data/mcp_adapters/canonical_configs.json b/tests/test_data/mcp_adapters/canonical_configs.json index 63dde7f..1d53c30 100644 --- a/tests/test_data/mcp_adapters/canonical_configs.json +++ b/tests/test_data/mcp_adapters/canonical_configs.json @@ -75,6 +75,21 @@ "enabled_tools": ["tool1", "tool2"], "disabled_tools": ["tool3"] }, + "mistral-vibe": { + "command": null, + "args": null, + "env": null, + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer test-token"}, + "transport": "streamable-http", + "prompt": "Use concise answers.", + "startup_timeout_sec": 15, + "tool_timeout_sec": 90, + "sampling_enabled": true, + "api_key_env": "MISTRAL_API_KEY", + "api_key_header": "Authorization", + "api_key_format": "Bearer {api_key}" + }, "augment": { "command": "python", "args": ["-m", "mcp_server"], diff --git a/tests/test_data/mcp_adapters/claude_transport_regressions.json b/tests/test_data/mcp_adapters/claude_transport_regressions.json new file mode 100644 index 0000000..f3a4589 --- /dev/null +++ b/tests/test_data/mcp_adapters/claude_transport_regressions.json @@ -0,0 +1,118 @@ +{ + "remote_http": [ + { + "case": "input_type_omitted", + "host": "claude-desktop", + "config": { + "name": "remote-server", + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + } + }, + "expected": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "type": "http" + } + }, + { + "case": "input_type_omitted", + "host": "claude-code", + "config": { + "name": "remote-server", + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + } + }, + "expected": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "type": "http" + } + }, + { + "case": "input_type_sse", + "host": "claude-desktop", + "config": { + "name": "remote-server", + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "type": "sse" + }, + "expected": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "type": "http" + } + }, + { + "case": "input_type_sse", + "host": "claude-code", + "config": { + "name": "remote-server", + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "type": "sse" + }, + "expected": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "type": "http" + } + } + ], + "stdio_without_type": [ + { + "case": "input_type_omitted", + "host": "claude-desktop", + "config": { + "name": "stdio-server", + "command": "python", + "args": ["-m", "mcp_server"], + "env": { + "API_KEY": "secret" + } + }, + "expected": { + "command": "python", + "args": ["-m", "mcp_server"], + "env": { + "API_KEY": "secret" + } + } + }, + { + "case": "input_type_omitted", + "host": "claude-code", + "config": { + "name": "stdio-server", + "command": "python", + "args": ["-m", "mcp_server"], + "env": { + "API_KEY": "secret" + } + }, + "expected": { + "command": "python", + "args": ["-m", "mcp_server"], + "env": { + "API_KEY": "secret" + } + } + } + ] +} diff --git a/tests/test_data/mcp_adapters/host_registry.py b/tests/test_data/mcp_adapters/host_registry.py index 502e2ab..e8fb5bb 100644 --- a/tests/test_data/mcp_adapters/host_registry.py +++ b/tests/test_data/mcp_adapters/host_registry.py @@ -27,6 +27,7 @@ from hatch.mcp_host_config.adapters.gemini import GeminiAdapter from hatch.mcp_host_config.adapters.kiro import KiroAdapter from hatch.mcp_host_config.adapters.lmstudio import LMStudioAdapter +from hatch.mcp_host_config.adapters.mistral_vibe import MistralVibeAdapter from hatch.mcp_host_config.adapters.opencode import OpenCodeAdapter from hatch.mcp_host_config.adapters.vscode import VSCodeAdapter from hatch.mcp_host_config.fields import ( @@ -39,6 +40,7 @@ GEMINI_FIELDS, KIRO_FIELDS, LMSTUDIO_FIELDS, + MISTRAL_VIBE_FIELDS, OPENCODE_FIELDS, TYPE_SUPPORTING_HOSTS, VSCODE_FIELDS, @@ -59,6 +61,7 @@ "gemini": GEMINI_FIELDS, "kiro": KIRO_FIELDS, "codex": CODEX_FIELDS, + "mistral-vibe": MISTRAL_VIBE_FIELDS, "opencode": OPENCODE_FIELDS, "augment": AUGMENT_FIELDS, } @@ -99,6 +102,7 @@ def get_adapter(self) -> BaseAdapter: "gemini": GeminiAdapter, "kiro": KiroAdapter, "codex": CodexAdapter, + "mistral-vibe": MistralVibeAdapter, "opencode": OpenCodeAdapter, "augment": AugmentAdapter, } @@ -358,6 +362,7 @@ def generate_unsupported_field_test_cases( | GEMINI_FIELDS | KIRO_FIELDS | CODEX_FIELDS + | MISTRAL_VIBE_FIELDS | OPENCODE_FIELDS | AUGMENT_FIELDS ) diff --git a/tests/unit/mcp/test_adapter_protocol.py b/tests/unit/mcp/test_adapter_protocol.py index 4878878..1e6c24e 100644 --- a/tests/unit/mcp/test_adapter_protocol.py +++ b/tests/unit/mcp/test_adapter_protocol.py @@ -16,6 +16,7 @@ GeminiAdapter, KiroAdapter, LMStudioAdapter, + MistralVibeAdapter, VSCodeAdapter, ) from hatch.mcp_host_config.adapters.opencode import OpenCodeAdapter @@ -29,6 +30,7 @@ GeminiAdapter, KiroAdapter, LMStudioAdapter, + MistralVibeAdapter, OpenCodeAdapter, VSCodeAdapter, ] @@ -43,6 +45,7 @@ MCPHostType.GEMINI: GeminiAdapter, MCPHostType.KIRO: KiroAdapter, MCPHostType.LMSTUDIO: LMStudioAdapter, + MCPHostType.MISTRAL_VIBE: MistralVibeAdapter, MCPHostType.OPENCODE: OpenCodeAdapter, MCPHostType.VSCODE: VSCodeAdapter, } diff --git a/tests/unit/mcp/test_adapter_registry.py b/tests/unit/mcp/test_adapter_registry.py index a393461..204f6c7 100644 --- a/tests/unit/mcp/test_adapter_registry.py +++ b/tests/unit/mcp/test_adapter_registry.py @@ -17,6 +17,7 @@ GeminiAdapter, KiroAdapter, LMStudioAdapter, + MistralVibeAdapter, VSCodeAdapter, ) @@ -39,6 +40,7 @@ def test_AR01_registry_has_all_default_hosts(self): "gemini", "kiro", "lmstudio", + "mistral-vibe", "opencode", "vscode", } @@ -57,6 +59,7 @@ def test_AR02_get_adapter_returns_correct_type(self): ("gemini", GeminiAdapter), ("kiro", KiroAdapter), ("lmstudio", LMStudioAdapter), + ("mistral-vibe", MistralVibeAdapter), ("vscode", VSCodeAdapter), ] diff --git a/tests/unit/mcp/test_config_model.py b/tests/unit/mcp/test_config_model.py index 5e60df5..b3fc0d7 100644 --- a/tests/unit/mcp/test_config_model.py +++ b/tests/unit/mcp/test_config_model.py @@ -37,6 +37,18 @@ def test_UM03_valid_http_config_gemini(self): # httpUrl is considered remote self.assertTrue(config.is_remote_server) + def test_UM03b_valid_streamable_http_transport(self): + """UM-03b: Valid remote config with native transport field.""" + config = MCPServerConfig( + name="test", + url="https://example.com/mcp", + transport="streamable-http", + ) + + self.assertEqual(config.transport, "streamable-http") + self.assertEqual(config.get_transport_type(), "streamable-http") + self.assertTrue(config.is_remote_server) + def test_UM04_allows_command_and_url(self): """UM-04: Unified model allows both command and url (adapters validate).""" # The unified model is permissive - adapters enforce host-specific rules diff --git a/tests/unit/mcp/test_mistral_vibe_adapter.py b/tests/unit/mcp/test_mistral_vibe_adapter.py new file mode 100644 index 0000000..08e4b8a --- /dev/null +++ b/tests/unit/mcp/test_mistral_vibe_adapter.py @@ -0,0 +1,41 @@ +"""Unit tests for the Mistral Vibe adapter.""" + +import unittest + +from hatch.mcp_host_config.adapters.mistral_vibe import MistralVibeAdapter +from hatch.mcp_host_config.models import MCPServerConfig + + +class TestMistralVibeAdapter(unittest.TestCase): + """Verify Mistral-specific filtering and transport mapping.""" + + def test_serialize_filters_type_but_preserves_sse_transport_hint(self): + """Canonical type hints should map to transport without serializing type.""" + result = MistralVibeAdapter().serialize( + MCPServerConfig( + name="weather", + url="https://example.com/mcp", + type="sse", + ) + ) + + self.assertEqual(result["url"], "https://example.com/mcp") + self.assertEqual(result["transport"], "streamable-http") + self.assertNotIn("type", result) + + def test_serialize_maps_http_url_without_exposing_httpUrl(self): + """httpUrl input should serialize as Mistral's url+transport format.""" + result = MistralVibeAdapter().serialize( + MCPServerConfig( + name="weather", + httpUrl="https://example.com/http", + ) + ) + + self.assertEqual(result["url"], "https://example.com/http") + self.assertEqual(result["transport"], "http") + self.assertNotIn("httpUrl", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/mcp/test_mistral_vibe_strategy.py b/tests/unit/mcp/test_mistral_vibe_strategy.py new file mode 100644 index 0000000..7ffdc75 --- /dev/null +++ b/tests/unit/mcp/test_mistral_vibe_strategy.py @@ -0,0 +1,91 @@ +"""Unit tests for Mistral Vibe host strategy.""" + +import os +import tempfile +import tomllib +import unittest +from pathlib import Path + +from hatch.mcp_host_config.models import HostConfiguration, MCPServerConfig +from hatch.mcp_host_config.strategies import MistralVibeHostStrategy + + +class TestMistralVibeHostStrategy(unittest.TestCase): + """Verify Mistral Vibe TOML read/write behavior.""" + + def test_read_configuration_parses_array_of_tables(self): + """Reads [[mcp_servers]] entries into HostConfiguration.""" + with tempfile.TemporaryDirectory() as tmpdir: + cwd = os.getcwd() + try: + os.chdir(tmpdir) + config_dir = Path(tmpdir) / ".vibe" + config_dir.mkdir() + (config_dir / "config.toml").write_text( + 'model = "mistral-medium"\n\n' + "[[mcp_servers]]\n" + 'name = "weather"\n' + 'transport = "streamable-http"\n' + 'url = "https://example.com/mcp"\n' + 'prompt = "Be concise"\n', + encoding="utf-8", + ) + + strategy = MistralVibeHostStrategy() + result = strategy.read_configuration() + + self.assertIn("weather", result.servers) + server = result.servers["weather"] + self.assertEqual(server.transport, "streamable-http") + self.assertEqual(server.type, "http") + self.assertEqual(server.url, "https://example.com/mcp") + self.assertEqual(server.prompt, "Be concise") + finally: + os.chdir(cwd) + + def test_write_configuration_preserves_other_top_level_keys(self): + """Writes mcp_servers while preserving unrelated Vibe settings.""" + with tempfile.TemporaryDirectory() as tmpdir: + cwd = os.getcwd() + try: + os.chdir(tmpdir) + config_dir = Path(tmpdir) / ".vibe" + config_dir.mkdir() + config_path = config_dir / "config.toml" + config_path.write_text( + 'model = "mistral-medium"\n' 'theme = "dark"\n', + encoding="utf-8", + ) + + strategy = MistralVibeHostStrategy() + config = HostConfiguration( + servers={ + "weather": MCPServerConfig( + name="weather", + url="https://example.com/mcp", + transport="streamable-http", + headers={"Authorization": "Bearer token"}, + ) + } + ) + + self.assertTrue(strategy.write_configuration(config, no_backup=True)) + + with open(config_path, "rb") as f: + written = tomllib.load(f) + + self.assertEqual(written["model"], "mistral-medium") + self.assertEqual(written["theme"], "dark") + self.assertEqual(written["mcp_servers"][0]["name"], "weather") + self.assertEqual( + written["mcp_servers"][0]["transport"], "streamable-http" + ) + self.assertEqual( + written["mcp_servers"][0]["url"], "https://example.com/mcp" + ) + finally: + os.chdir(cwd) + + +if __name__ == "__main__": + unittest.main()