diff --git a/.augmentignore b/.augmentignore new file mode 100644 index 0000000..a031c5b --- /dev/null +++ b/.augmentignore @@ -0,0 +1 @@ +__reports__/ \ No newline at end of file diff --git a/.github/workflows/prerelease-discord-notification.yml b/.github/workflows/prerelease-discord-notification.yml index 809871d..abdd13e 100644 --- a/.github/workflows/prerelease-discord-notification.yml +++ b/.github/workflows/prerelease-discord-notification.yml @@ -22,6 +22,11 @@ jobs: ⚠️ **This is a pre-release** - expect potential bugs and breaking changes 🔬 Perfect for testing new features and providing feedback 📋 Click [here](${{ github.event.release.html_url }}) to view what's new and download + + 💻 Install with pip: + ```bash + pip install hatch-xclam=${{ github.event.release.tag_name }} + ``` Help us make *Hatch!* better by testing and reporting [issues](https://github.com/CrackingShells/Hatch/issues)! 🐛➡️✨ color: 0xff9500 # Orange color for pre-release diff --git a/.github/workflows/release-discord-notification.yml b/.github/workflows/release-discord-notification.yml index cd63017..1d46259 100644 --- a/.github/workflows/release-discord-notification.yml +++ b/.github/workflows/release-discord-notification.yml @@ -21,9 +21,14 @@ jobs: 🚀 Get the latest features and improvements 📚 Click [here](${{ github.event.release.html_url }}) to view the changelog and download + + 💻 Install with pip: + ```bash + pip install hatch-xclam + ``` Happy MCP coding with *Hatch!* 🐣 color: 0x00ff88 username: "Cracking Shells Release Bot" - image: "https://raw.githubusercontent.com/CrackingShells/.github/main/resources/images/hatch_icon_dark_bg_transparent.png" - avatar_url: "https://raw.githubusercontent.com/CrackingShells/.github/main/resources/images/cs_core_dark_bg.png" + image: "https://raw.githubusercontent.com/CrackingShells/.github/main/resources/images/hatch_icon_light_bg_transparent.png" + avatar_url: "https://raw.githubusercontent.com/CrackingShells/.github/main/resources/images/cs_icon_light_bg.png" diff --git a/.gitignore b/.gitignore index 57dff48..7a12ef8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,14 @@ envs/ Laghari/ __temp__/ +# IDEs +## Kiro +.kiro/ + +## VS Code +.vscode/ + + # vvvvvvv Default Python Ignore vvvvvvvv # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b5227..4d009c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +## 0.7.1-dev.3 (2025-12-18) + +* fix(cli): prevent unwanted defaults ([8a9441b](https://github.com/CrackingShells/Hatch/commit/8a9441b)) + +## 0.7.1-dev.2 (2025-12-15) + +* Merge branch 'feat/codex-support' into dev ([b82bf0f](https://github.com/CrackingShells/Hatch/commit/b82bf0f)) +* chore: augment code ignore __reports__/ ([bed11cd](https://github.com/CrackingShells/Hatch/commit/bed11cd)) +* chore: remove dev debug scripts ([f1880ce](https://github.com/CrackingShells/Hatch/commit/f1880ce)) +* chore: remove dev reports ([8c3f455](https://github.com/CrackingShells/Hatch/commit/8c3f455)) +* chore: update gitignore ([cd1934a](https://github.com/CrackingShells/Hatch/commit/cd1934a)) +* docs(cli): add host labels to configure command help ([842e771](https://github.com/CrackingShells/Hatch/commit/842e771)) +* docs(codex): add CLI reference and usage examples ([a68e932](https://github.com/CrackingShells/Hatch/commit/a68e932)) +* docs(codex): update to mention support for Codex ([7fa2bdb](https://github.com/CrackingShells/Hatch/commit/7fa2bdb)) +* docs(reports): add implementation completion report ([7b67225](https://github.com/CrackingShells/Hatch/commit/7b67225)) +* docs(reports): codex CLI enhancement analysis and implementation ([c5327d2](https://github.com/CrackingShells/Hatch/commit/c5327d2)) +* docs(reports): dev specs for Codex MCP config support via Hatch! ([330c683](https://github.com/CrackingShells/Hatch/commit/330c683)) +* test(codex): add comprehensive CLI argument tests ([0e15301](https://github.com/CrackingShells/Hatch/commit/0e15301)) +* test(codex): fix Omni model field name in conversion test ([21efc10](https://github.com/CrackingShells/Hatch/commit/21efc10)) +* feat(codex): add CLI arguments for Codex ([88e81fe](https://github.com/CrackingShells/Hatch/commit/88e81fe)) +* feat(codex): add MCPServerConfigCodex model and infrastructure ([061ae53](https://github.com/CrackingShells/Hatch/commit/061ae53)) +* feat(codex): add tomli-w dependency for TOML support ([00b960f](https://github.com/CrackingShells/Hatch/commit/00b960f)) +* feat(codex): implement CodexHostStrategy with TOML support ([4e55b34](https://github.com/CrackingShells/Hatch/commit/4e55b34)) +* feat(mcp-models): map shared tool filtering flags to Codex ([b2e6103](https://github.com/CrackingShells/Hatch/commit/b2e6103)) +* fix(backup): preserve original filename in backup creation ([c2dde46](https://github.com/CrackingShells/Hatch/commit/c2dde46)) +* fix(codex): map http_headers to universal headers field ([7c5e2cb](https://github.com/CrackingShells/Hatch/commit/7c5e2cb)) +* tests(codex): add comprehensive Codex host strategy test suite ([2858ba5](https://github.com/CrackingShells/Hatch/commit/2858ba5)) + +## 0.7.1-dev.1 (2025-12-15) + +* Merge branch 'feat/kiro-support' into dev ([d9c11ca](https://github.com/CrackingShells/Hatch/commit/d9c11ca)) +* docs: add Kiro to supported MCP hosts across all documentation ([1b1dd1a](https://github.com/CrackingShells/Hatch/commit/1b1dd1a)) +* docs(dev): enhance MCP host configuration extension guidance ([3bdae9c](https://github.com/CrackingShells/Hatch/commit/3bdae9c)) +* fix: config path handling ([63efad7](https://github.com/CrackingShells/Hatch/commit/63efad7)) +* test(kiro): add comprehensive backup integration tests ([65b4a29](https://github.com/CrackingShells/Hatch/commit/65b4a29)) +* test(kiro): implement comprehensive test suite for Kiro MCP integration ([a55b48a](https://github.com/CrackingShells/Hatch/commit/a55b48a)) +* test(kiro): implement test data infrastructure for Kiro MCP integration ([744219f](https://github.com/CrackingShells/Hatch/commit/744219f)) +* feat(cli): add Kiro-specific arguments to mcp configure command ([23c1e9d](https://github.com/CrackingShells/Hatch/commit/23c1e9d)) +* feat(kiro): add configuration file backup support ([49007dd](https://github.com/CrackingShells/Hatch/commit/49007dd)) +* feat(mcp-host-config): add Kiro IDE support to model layer ([f8ede12](https://github.com/CrackingShells/Hatch/commit/f8ede12)) +* feat(mcp-host-config): implement KiroHostStrategy for configuration management ([ab69e2a](https://github.com/CrackingShells/Hatch/commit/ab69e2a)) + ## 0.7.0 (2025-12-11) * Merge pull request #42 from CrackingShells/dev ([be3a9a3](https://github.com/CrackingShells/Hatch/commit/be3a9a3)), closes [#42](https://github.com/CrackingShells/Hatch/issues/42) diff --git a/README.md b/README.md index 4ded445..b6cb5d5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Introduction -Hatch is the package manager for managing Model Context Protocol (MCP) servers with environment isolation, multi-type dependency resolution, and multi-host deployment. Deploy MCP servers to Claude Desktop, VS Code, Cursor, and other platforms with automatic dependency management. +Hatch is the package manager for managing Model Context Protocol (MCP) servers with environment isolation, multi-type dependency resolution, and multi-host deployment. Deploy MCP servers to Claude Desktop, VS Code, Cursor, Kiro, Codex, and other platforms with automatic dependency management. The canonical documentation is at `docs/index.md` and published at . @@ -12,7 +12,7 @@ The canonical documentation is at `docs/index.md` and published at **Adding a new host?** See the [Implementation Guide](../implementation_guides/mcp_host_configuration_extension.md) for step-by-step instructions. ## Core Architecture @@ -45,8 +47,9 @@ Host strategies are organized into families for code reuse: - **Implementations**: Cursor, LM Studio #### Independent Strategies -- **VSCode**: Nested configuration structure (`mcp.servers`) +- **VSCode**: User-wide configuration (`~/.config/Code/User/mcp.json`), uses `servers` key - **Gemini**: Official configuration path (`~/.gemini/settings.json`) +- **Kiro**: User-level configuration (`~/.kiro/settings/mcp.json`), full backup manager integration ### Consolidated Data Model @@ -111,15 +114,51 @@ class MCPHostStrategy(ABC): ## Integration Points -### Backup System Integration +Every host strategy must integrate with these systems. Missing any integration point will result in incomplete functionality. + +### Backup System Integration (Required) -All configuration operations integrate with the backup system: +All configuration write operations **must** integrate with the backup system via `MCPHostConfigBackupManager` and `AtomicFileOperations`: +```python +from .backup import MCPHostConfigBackupManager, AtomicFileOperations + +def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool: + # ... prepare data ... + backup_manager = MCPHostConfigBackupManager() + atomic_ops = AtomicFileOperations() + atomic_ops.atomic_write_with_backup( + file_path=config_path, + data=existing_data, + backup_manager=backup_manager, + hostname="your-host", # Must match MCPHostType value + skip_backup=no_backup + ) +``` + +**Key requirements:** - **Atomic operations**: Configuration changes are backed up before modification -- **Rollback capability**: Failed operations can be reverted -- **Multi-host support**: Separate backups per host platform +- **Rollback capability**: Failed operations can be reverted automatically +- **Hostname identification**: Each host uses its `MCPHostType` value for backup tracking - **Timestamped retention**: Backup files include timestamps for tracking +### Model Registry Integration (Required for host-specific fields) + +If your host has unique configuration fields (like Kiro's `disabled`, `autoApprove`, `disabledTools`): + +1. Create host-specific model class in `models.py` +2. Register in `HOST_MODEL_REGISTRY` +3. Extend `MCPServerConfigOmni` with new fields +4. Implement `from_omni()` conversion method + +### CLI Integration (Required for host-specific arguments) + +If your host has unique CLI arguments: + +1. Extend `handle_mcp_configure()` function signature in `cli_hatch.py` +2. Add argument parser entries for new flags +3. Update omni model population logic + ### Environment Manager Integration The system integrates with environment management through corrected data structures: @@ -132,27 +171,31 @@ The system integrates with environment management through corrected data structu ### Adding New Host Platforms -To add support for a new host platform: +To add support for a new host platform, complete these integration points: -1. **Define host type** in `MCPHostType` enum -2. **Create strategy class** inheriting from appropriate family base or `MCPHostStrategy` -3. **Implement required methods** for configuration path, validation, read/write operations -4. **Add decorator registration** with `@register_host_strategy(MCPHostType.NEW_HOST)` -5. **Add tests** following existing test patterns +| Integration Point | Required? | Files to Modify | +|-------------------|-----------|-----------------| +| Host type enum | Always | `models.py` | +| Strategy class | Always | `strategies.py` | +| Backup integration | Always | `strategies.py` (in `write_configuration`) | +| Host-specific model | If unique fields | `models.py`, `HOST_MODEL_REGISTRY` | +| CLI arguments | If unique fields | `cli_hatch.py` | +| Test infrastructure | Always | `tests/` | -Example: +**Minimal implementation** (standard host, no unique fields): ```python @register_host_strategy(MCPHostType.NEW_HOST) -class NewHostStrategy(MCPHostStrategy): +class NewHostStrategy(ClaudeHostStrategy): # Inherit backup integration def get_config_path(self) -> Optional[Path]: return Path.home() / ".new_host" / "config.json" - def validate_server_config(self, server_config: MCPServerConfig) -> bool: - # Host-specific validation logic - return True + def is_host_available(self) -> bool: + return self.get_config_path().parent.exists() ``` +**Full implementation** (host with unique fields): See [Implementation Guide](../implementation_guides/mcp_host_configuration_extension.md). + ### Extending Validation Rules Host strategies can implement custom validation: diff --git a/docs/articles/devs/implementation_guides/mcp_host_configuration_extension.md b/docs/articles/devs/implementation_guides/mcp_host_configuration_extension.md index cac61d2..e5fad58 100644 --- a/docs/articles/devs/implementation_guides/mcp_host_configuration_extension.md +++ b/docs/articles/devs/implementation_guides/mcp_host_configuration_extension.md @@ -2,6 +2,21 @@ **Quick Start:** Copy an existing strategy, modify configuration paths and validation, add decorator. Most strategies are 50-100 lines. +## Before You Start: Integration Checklist + +Use this checklist to plan your implementation. Missing integration points cause incomplete functionality. + +| Integration Point | Required? | When Needed | +|-------------------|-----------|-------------| +| ☐ Host type enum | Always | All hosts | +| ☐ Strategy class | Always | All hosts | +| ☐ Backup integration | Always | All hosts - **commonly missed** | +| ☐ Host-specific model | Sometimes | Host has unique config fields | +| ☐ CLI arguments | Sometimes | Host has unique config fields | +| ☐ Test infrastructure | Always | All hosts | + +> **Lesson learned:** The backup system integration is frequently overlooked during planning but is mandatory for all hosts. Plan for it upfront. + ## When You Need This You want Hatch to configure MCP servers on a new host platform: @@ -59,6 +74,7 @@ class YourHostStrategy(MCPHostStrategy): - `CURSOR` - Cursor IDE - `LMSTUDIO` - LM Studio - `GEMINI` - Google Gemini CLI +- `KIRO` - Kiro IDE ### 2. Add Host Type @@ -194,9 +210,65 @@ class YourHostStrategy(MCPHostStrategy): return False ``` -### 4. Handle Configuration Format +### 4. Integrate Backup System (Required) + +All host strategies must integrate with the backup system for data safety. This is **mandatory** - don't skip it. + +**Current implementation status:** +- Family base classes (`ClaudeHostStrategy`, `CursorBasedHostStrategy`) use atomic temp-file writes but not the full backup manager +- `KiroHostStrategy` demonstrates full backup manager integration with `MCPHostConfigBackupManager` and `AtomicFileOperations` + +**For new implementations**: Add backup integration to `write_configuration()`: + +```python +from .backup import MCPHostConfigBackupManager, AtomicFileOperations + +def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool: + config_path = self.get_config_path() + if not config_path: + return False + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing config to preserve non-MCP settings + existing_data = {} + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + + # Update MCP servers section + servers_data = { + name: server.model_dump(exclude_unset=True) + for name, server in config.servers.items() + } + existing_data[self.get_config_key()] = servers_data + + # Use atomic write with backup support + backup_manager = MCPHostConfigBackupManager() + atomic_ops = AtomicFileOperations() + atomic_ops.atomic_write_with_backup( + file_path=config_path, + data=existing_data, + backup_manager=backup_manager, + hostname="your-host", # Must match your MCPHostType value + skip_backup=no_backup + ) + return True + + except Exception as e: + logger.error(f"Failed to write configuration: {e}") + return False +``` + +**Key points:** +- `hostname` parameter must match your `MCPHostType` enum value (e.g., `"kiro"` for `MCPHostType.KIRO`) +- `skip_backup` respects the `no_backup` parameter passed to `write_configuration()` +- Atomic operations ensure config file integrity even if the process crashes + +### 5. Handle Configuration Format (Optional) -Implement configuration reading/writing for your host's format: +Override configuration reading/writing only if your host has a non-standard format: ```python def read_configuration(self) -> HostConfiguration: @@ -393,17 +465,25 @@ def get_config_path(self) -> Optional[Path]: ## Testing Your Strategy -### 1. Add Unit Tests +### Test Categories -Create tests in `tests/test_mcp_your_host_strategy.py`. **Important:** Import strategies to trigger registration: +Your implementation needs tests in these categories: + +| Category | Purpose | Location | +|----------|---------|----------| +| Strategy tests | Registration, paths, validation | `tests/regression/test_mcp_yourhost_host_strategy.py` | +| Backup tests | Backup creation, restoration | `tests/regression/test_mcp_yourhost_backup_integration.py` | +| Model tests | Field validation (if host-specific model) | `tests/regression/test_mcp_yourhost_model_validation.py` | +| CLI tests | Argument handling (if host-specific args) | `tests/regression/test_mcp_yourhost_cli_integration.py` | +| Integration tests | End-to-end workflows | `tests/integration/test_mcp_yourhost_integration.py` | + +### 1. Strategy Tests (Required) ```python import unittest from pathlib import Path from hatch.mcp_host_config import MCPHostRegistry, MCPHostType, MCPServerConfig, HostConfiguration - -# Import strategies to trigger registration -import hatch.mcp_host_config.strategies +import hatch.mcp_host_config.strategies # Triggers registration class TestYourHostStrategy(unittest.TestCase): def test_strategy_registration(self): @@ -414,43 +494,32 @@ class TestYourHostStrategy(unittest.TestCase): def test_config_path(self): """Test configuration path detection.""" strategy = MCPHostRegistry.get_strategy(MCPHostType.YOUR_HOST) - config_path = strategy.get_config_path() - self.assertIsNotNone(config_path) - - def test_is_host_available(self): - """Test host availability detection.""" - strategy = MCPHostRegistry.get_strategy(MCPHostType.YOUR_HOST) - # This may return False if host isn't installed - is_available = strategy.is_host_available() - self.assertIsInstance(is_available, bool) + self.assertIsNotNone(strategy.get_config_path()) def test_server_validation(self): """Test server configuration validation.""" strategy = MCPHostRegistry.get_strategy(MCPHostType.YOUR_HOST) - - # Test valid config with command valid_config = MCPServerConfig(command="python", args=["server.py"]) self.assertTrue(strategy.validate_server_config(valid_config)) - - # Test valid config with URL - valid_url_config = MCPServerConfig(url="http://localhost:8000") - self.assertTrue(strategy.validate_server_config(valid_url_config)) - - # Test invalid config (neither command nor URL) - with self.assertRaises(ValueError): - MCPServerConfig() # Will fail validation - - def test_read_configuration(self): - """Test reading configuration.""" - strategy = MCPHostRegistry.get_strategy(MCPHostType.YOUR_HOST) - config = strategy.read_configuration() - self.assertIsInstance(config, HostConfiguration) - self.assertIsInstance(config.servers, dict) ``` -### 2. Integration Testing +### 2. Backup Integration Tests (Required) + +```python +class TestYourHostBackupIntegration(unittest.TestCase): + def test_write_creates_backup(self): + """Test that write_configuration creates backup when no_backup=False.""" + # Setup temp config file + # Call write_configuration(config, no_backup=False) + # Verify backup file was created + + def test_write_skips_backup_when_requested(self): + """Test that write_configuration skips backup when no_backup=True.""" + # Call write_configuration(config, no_backup=True) + # Verify no backup file was created +``` -Test with the configuration manager: +### 3. Integration Testing ```python def test_configuration_manager_integration(self): @@ -507,8 +576,65 @@ Different hosts have different validation rules. The codebase provides host-spec - `MCPServerConfigCursor` - Cursor/LM Studio - `MCPServerConfigVSCode` - VS Code - `MCPServerConfigGemini` - Google Gemini +- `MCPServerConfigKiro` - Kiro IDE (with `disabled`, `autoApprove`, `disabledTools`) + +**When to create a host-specific model:** Only if your host has unique configuration fields not present in other hosts. + +**Implementation steps** (if needed): + +1. **Add model class** in `models.py`: +```python +class MCPServerConfigYourHost(MCPServerConfigBase): + your_field: Optional[str] = None + + @classmethod + def from_omni(cls, omni: "MCPServerConfigOmni") -> "MCPServerConfigYourHost": + return cls(**omni.model_dump(exclude_unset=True)) +``` + +2. **Register in `HOST_MODEL_REGISTRY`**: +```python +HOST_MODEL_REGISTRY = { + # ... existing entries ... + MCPHostType.YOUR_HOST: MCPServerConfigYourHost, +} +``` + +3. **Extend `MCPServerConfigOmni`** with your fields (for CLI integration) + +4. **Add CLI arguments** in `cli_hatch.py` (see next section) + +For most cases, the generic `MCPServerConfig` works fine - only add a host-specific model if truly needed. + +### CLI Integration for Host-Specific Fields -If your host has unique requirements, you can create a host-specific model and register it in `HOST_MODEL_REGISTRY` (in `models.py`). However, for most cases, the generic `MCPServerConfig` works fine. +If your host has unique configuration fields, extend the CLI to support them: + +1. **Update function signature** in `handle_mcp_configure()`: +```python +def handle_mcp_configure( + # ... existing params ... + your_field: Optional[str] = None, # Add your field +): +``` + +2. **Add argument parser entry**: +```python +configure_parser.add_argument( + '--your-field', + help='Description of your field' +) +``` + +3. **Update omni model population**: +```python +omni_config_data = { + # ... existing fields ... + 'your_field': your_field, +} +``` + +The conversion reporting system automatically handles new fields - no additional changes needed there. ### Multi-File Configuration @@ -721,3 +847,15 @@ hatch mcp remove my-server --host your-host 2. The CLI imports `hatch.mcp_host_config.strategies` (which it does) The CLI automatically discovers your strategy through the `@register_host_strategy` decorator registration system. + +## Implementation Summary + +After completing your implementation, verify all integration points: + +- [ ] Host type added to `MCPHostType` enum +- [ ] Strategy class implemented with `@register_host_strategy` decorator +- [ ] Backup integration working (test with `no_backup=False` and `no_backup=True`) +- [ ] Host-specific model created (if needed) and registered in `HOST_MODEL_REGISTRY` +- [ ] CLI arguments added (if needed) with omni model population +- [ ] All test categories implemented and passing +- [ ] Strategy exported from `__init__.py` (if in separate file) diff --git a/docs/articles/users/CLIReference.md b/docs/articles/users/CLIReference.md index 1fa74d7..fbbc0d5 100644 --- a/docs/articles/users/CLIReference.md +++ b/docs/articles/users/CLIReference.md @@ -395,26 +395,35 @@ Syntax: `hatch mcp configure --host (--command CMD | --url URL) [--args ARGS] [--env-var ENV] [--header HEADER] [--dry-run] [--auto-approve] [--no-backup]` -| Argument / Flag | Type | Description | Default | -|---:|---|---|---| -| `server-name` | string (positional) | Name of the MCP server to configure | n/a | -| `--host` | string | Target host platform (claude-desktop, cursor, etc.) | n/a | -| `--command` | string | Command to execute for local servers (mutually exclusive with --url) | none | -| `--url` | string | URL for remote MCP servers (mutually exclusive with --command) | none | -| `--http-url` | string | HTTP streaming endpoint URL (Gemini only) | none | -| `--args` | string | Arguments for MCP server command (only with --command) | none | -| `--env-var` | string | Environment variables format: KEY=VALUE (can be used multiple times) | none | -| `--header` | string | HTTP headers format: KEY=VALUE (only with --url) | none | -| `--timeout` | int | Request timeout in milliseconds (Gemini) | none | -| `--trust` | flag | Bypass tool call confirmations (Gemini) | false | -| `--cwd` | string | Working directory for stdio transport (Gemini) | none | -| `--include-tools` | multiple | Tool allowlist - only these tools will be available (Gemini). Space-separated values. | none | -| `--exclude-tools` | multiple | Tool blocklist - these tools will be excluded (Gemini). Space-separated values. | none | -| `--env-file` | string | Path to environment file (Cursor, VS Code, LM Studio) | none | -| `--input` | multiple | Input variable definitions format: type,id,description[,password=true] (VS Code) | none | -| `--dry-run` | flag | Preview configuration without applying changes | false | -| `--auto-approve` | flag | Skip confirmation prompts | false | -| `--no-backup` | flag | Skip backup creation before configuration | false | +| Argument / Flag | Hosts | Type | Description | Default | +|---:|---|---|---|---| +| `server-name` | all | string (positional) | Name of the MCP server to configure | n/a | +| `--host` | all | string | Target host platform (claude-desktop, cursor, etc.) | n/a | +| `--command` | all | string | Command to execute for local servers (mutually exclusive with --url) | none | +| `--url` | all except Claude Desktop/Code | string | URL for remote MCP servers (mutually exclusive with --command) | none | +| `--http-url` | gemini | string | HTTP streaming endpoint URL | none | +| `--args` | all | string | Arguments for MCP server command (only with --command) | none | +| `--env-var` | all | string | Environment variables format: KEY=VALUE (can be used multiple times) | none | +| `--header` | all except Claude Desktop/Code | string | HTTP headers format: KEY=VALUE (only with --url) | none | +| `--timeout` | gemini | int | Request timeout in milliseconds | none | +| `--trust` | gemini | flag | Bypass tool call confirmations | false | +| `--cwd` | gemini, codex | string | Working directory for stdio transport | none | +| `--include-tools` | gemini, codex | multiple | Tool allowlist / enabled tools. Space-separated values. | none | +| `--exclude-tools` | gemini, codex | multiple | Tool blocklist / disabled tools. Space-separated values. | none | +| `--env-file` | cursor, vscode, lmstudio | string | Path to environment file | none | +| `--input` | vscode | multiple | Input variable definitions format: type,id,description[,password=true] | none | +| `--disabled` | kiro | flag | Disable the MCP server | false | +| `--auto-approve-tools` | kiro | multiple | Tool names to auto-approve. Can be used multiple times. | none | +| `--disable-tools` | kiro | multiple | Tool names to disable. Can be used multiple times. | none | +| `--env-vars` | codex | multiple | Environment variable names to whitelist/forward. Can be used multiple times. | none | +| `--startup-timeout` | codex | int | Server startup timeout in seconds (default: 10) | none | +| `--tool-timeout` | codex | int | Tool execution timeout in seconds (default: 60) | none | +| `--enabled` | codex | flag | Enable the MCP server | false | +| `--bearer-token-env-var` | codex | string | Name of env var containing bearer token for Authorization header | none | +| `--env-header` | codex | multiple | HTTP header from env var format: KEY=ENV_VAR_NAME. Can be used multiple times. | none | +| `--dry-run` | all | flag | Preview configuration without applying changes | false | +| `--auto-approve` | all | flag | Skip confirmation prompts | false | +| `--no-backup` | all | flag | Skip backup creation before configuration | false | **Behavior**: @@ -475,6 +484,73 @@ Configure MCP server 'my-server' on host 'gemini'? [y/N]: y [SUCCESS] Successfully configured MCP server 'my-server' on host 'gemini' ``` +**Example - Kiro Configuration**: + +```bash +$ hatch mcp configure my-server --host kiro --command python --args server.py --auto-approve-tools weather,calculator --disable-tools debug + +Server 'my-server' created for host 'kiro': + name: UPDATED None --> 'my-server' + command: UPDATED None --> 'python' + args: UPDATED None --> ['server.py'] + autoApprove: UPDATED None --> ['weather', 'calculator'] + disabledTools: UPDATED None --> ['debug'] + +Configure MCP server 'my-server' on host 'kiro'? [y/N]: y +[SUCCESS] Successfully configured MCP server 'my-server' on host 'kiro' +``` + +**Example - Kiro with Disabled Server**: + +```bash +$ hatch mcp configure my-server --host kiro --command python --args server.py --disabled + +Server 'my-server' created for host 'kiro': + name: UPDATED None --> 'my-server' + command: UPDATED None --> 'python' + args: UPDATED None --> ['server.py'] + disabled: UPDATED None --> True + +Configure MCP server 'my-server' on host 'kiro'? [y/N]: y +[SUCCESS] Successfully configured MCP server 'my-server' on host 'kiro' +``` + +**Example - Codex Configuration with Timeouts and Tool Filtering**: + +```bash +$ hatch mcp configure context7 --host codex --command npx --args "-y" "@upstash/context7-mcp" --env-vars PATH --env-vars HOME --startup-timeout 15 --tool-timeout 120 --enabled --include-tools read write --exclude-tools delete + +Server 'context7' created for host 'codex': + name: UPDATED None --> 'context7' + command: UPDATED None --> 'npx' + args: UPDATED None --> ['-y', '@upstash/context7-mcp'] + env_vars: UPDATED None --> ['PATH', 'HOME'] + startup_timeout_sec: UPDATED None --> 15 + tool_timeout_sec: UPDATED None --> 120 + enabled: UPDATED None --> True + enabled_tools: UPDATED None --> ['read', 'write'] + disabled_tools: UPDATED None --> ['delete'] + +Configure MCP server 'context7' on host 'codex'? [y/N]: y +[SUCCESS] Successfully configured MCP server 'context7' on host 'codex' +``` + +**Example - Codex HTTP Server with Authentication**: + +```bash +$ hatch mcp configure figma --host codex --url https://mcp.figma.com/mcp --bearer-token-env-var FIGMA_OAUTH_TOKEN --env-header "X-Figma-Region=FIGMA_REGION" --header "X-Custom=static-value" + +Server 'figma' created for host 'codex': + name: UPDATED None --> 'figma' + url: UPDATED None --> 'https://mcp.figma.com/mcp' + bearer_token_env_var: UPDATED None --> 'FIGMA_OAUTH_TOKEN' + env_http_headers: UPDATED None --> {'X-Figma-Region': 'FIGMA_REGION'} + http_headers: UPDATED None --> {'X-Custom': 'static-value'} + +Configure MCP server 'figma' on host 'codex'? [y/N]: y +[SUCCESS] Successfully configured MCP server 'figma' on host 'codex' +``` + **Example - Remote Server Configuration**: ```bash @@ -516,6 +592,7 @@ Different MCP hosts support different configuration fields. The conversion repor - **Cursor / LM Studio**: Supports universal fields + envFile - **VS Code**: Supports universal fields + envFile, inputs - **Gemini CLI**: Supports universal fields + 14 additional fields (cwd, timeout, trust, OAuth settings, etc.) +- **Codex**: Supports universal fields + Codex-specific fields for URL-based servers (http_headers, env_http_headers, bearer_token_env_var, enabled, startup_timeout_sec, tool_timeout_sec, env_vars) When configuring a server with fields not supported by the target host, those fields are marked as UNSUPPORTED in the report and automatically excluded from the configuration. diff --git a/docs/articles/users/MCPHostConfiguration.md b/docs/articles/users/MCPHostConfiguration.md index 669e349..86ed277 100644 --- a/docs/articles/users/MCPHostConfiguration.md +++ b/docs/articles/users/MCPHostConfiguration.md @@ -19,6 +19,8 @@ Hatch currently supports configuration for these MCP host platforms: - **Claude Code** - Anthropic's VS Code extension - **VS Code** - Microsoft Visual Studio Code with MCP extensions - **Cursor** - AI-powered code editor +- **Kiro** - Kiro IDE with MCP support +- **Codex** - OpenAI Codex with MCP server configuration support - **LM Studio** - Local language model interface - **Gemini** - Google's AI development environment @@ -184,7 +186,7 @@ hatch mcp configure my-server --host claude-desktop --command python --args serv hatch mcp configure my-server --host claude-desktop --command python --args server.py --no-backup ``` -### Manual Backup Management +### Restore From Backups ```bash # List available backups @@ -201,267 +203,13 @@ Backups are stored in `~/.hatch/mcp_host_config_backups/` with the naming patter mcp.json.. ``` -## Troubleshooting - -### Host Not Available - -If a host is not detected: - -```bash -# Check which hosts are available -hatch mcp hosts - -# Get detailed host information -hatch mcp hosts --verbose -``` - -**Common solutions:** -- Ensure the host application is installed -- Check that configuration directories exist -- Verify file permissions for configuration files - -### Configuration Validation Errors - -If server configuration is rejected: - -```bash -# Validate configuration before applying -hatch mcp validate my-server \ - --host claude-desktop \ - --command python \ - --args server.py -``` - -**Common issues:** -- Claude hosts require absolute paths for commands -- Some hosts don't support environment variables -- URL format must include protocol (http:// or https://) - -### Backup and Recovery Issues - -If configuration changes fail: - -```bash -# Check backup status -hatch mcp backup list --host claude-desktop - -# Restore previous working configuration -hatch mcp restore --host claude-desktop --latest -``` - -### Permission Issues - -If you encounter permission errors: - -```bash -# Check configuration file permissions -ls -la ~/.config/Code/User/settings.json # VS Code example - -# Fix permissions if needed -chmod 644 ~/.config/Code/User/settings.json -``` - -## Advanced Usage - -### Batch Operations - -Configure multiple servers efficiently: - -```bash -# Configure multiple servers from a configuration file -hatch mcp configure --from-file servers.json --host claude-desktop - -# Remove multiple servers -hatch mcp remove server1,server2,server3 --host claude-desktop -``` - -### Environment Integration - -Integrate with Hatch environment management: - -```bash -# Configure servers for current environment -hatch env use my-project -hatch mcp sync --all-hosts - -# Configure servers when switching environments -hatch env use production -hatch mcp sync --hosts claude-desktop,cursor -``` - -### Automation and Scripting - -Use Hatch MCP configuration in automation: - -```bash -# Non-interactive configuration -hatch mcp configure my-server \ - --host claude-desktop \ - --command python \ - --args server.py \ - --auto-approve - -# Check configuration status in scripts -if hatch mcp list --host claude-desktop | grep -q "my-server"; then - echo "Server is configured" -fi -``` - -## Best Practices - -### Development Workflow - -1. **Start with one host** - Configure and test on your primary development host first -2. **Use absolute paths** - Especially for Claude hosts, use absolute paths to avoid issues -3. **Test configurations** - Use `--dry-run` to preview changes before applying -4. **Keep backups** - Don't use `--no-backup` unless you're certain about changes - -### Production Considerations - -1. **Environment synchronization** - Use `hatch mcp sync` to maintain consistency across hosts -2. **Backup management** - Regularly clean up old backups to manage disk space -3. **Configuration validation** - Validate configurations before deployment -4. **Host availability** - Check host availability before attempting configuration - -### Security Considerations - -1. **Credential management** - Avoid storing sensitive credentials in configuration files -2. **File permissions** - Ensure configuration files have appropriate permissions -3. **Backup security** - Protect backup files containing configuration data -4. **Network security** - Use HTTPS for remote server configurations - -## Integration with Other Hatch Features - -### Package Management - -MCP host configuration integrates with Hatch package management: - -```bash -# Install package and configure MCP server -hatch package add weather-toolkit -hatch mcp sync --all-hosts # Sync package's MCP server to hosts -``` - -### Environment Management - -Configuration follows environment boundaries: - -```bash -# Different environments can have different MCP configurations -hatch env create development -hatch env use development -hatch mcp configure dev-server --host claude-desktop --command python --args dev_server.py - -hatch env create production -hatch env use production -hatch mcp configure prod-server --host claude-desktop --command python --args prod_server.py -``` - -This ensures that MCP server configurations are isolated between different project environments, maintaining clean separation of development, testing, and production setups. - -## Advanced Synchronization Patterns - -### Pattern-Based Server Selection - -Use regular expressions for flexible server selection during synchronization: - -```bash -# All API servers -hatch mcp sync --from-env my_hatch_env --to-host claude-desktop --pattern ".*api.*" - -# Development tools -hatch mcp sync --from-env my_hatch_env --to-host cursor --pattern "^dev-" - -# Production servers -hatch mcp sync --from-host production-host --to-host staging-host --pattern ".*prod.*" -``` - -### Multi-Host Batch Operations - -Efficiently manage configurations across multiple host platforms: - -```bash -# Replicate configuration across all hosts -hatch mcp sync --from-host claude-desktop --to-host all - -# Selective multi-host deployment -hatch mcp sync --from-env production --to-host claude-desktop,cursor,vscode - -# Environment-specific multi-host sync -hatch mcp sync --from-env development --to-host all --pattern "^dev-" -``` - -### Complex Filtering Scenarios - -Combine filtering options for precise control: - -```bash -# Multiple specific servers -hatch mcp sync --from-env my_hatch_env --to-host all --servers api-server,db-server,cache-server - -# Pattern-based with host filtering -hatch mcp sync --from-host claude-desktop --to-host cursor --pattern ".*tool.*" -``` - -## Management Operations - -### Server Removal Workflows - -Remove MCP servers from host configurations with safety features: - -```bash -# Remove from single host -hatch mcp remove server --host - -# Remove from multiple hosts -hatch mcp remove server --host ,, - -# Remove from all configured hosts -hatch mcp remove server --host all -``` - -### Host Configuration Management - -Complete host configuration removal and management: - -```bash -# Remove all MCP configuration for a host -hatch mcp remove host - -# Remove with environment specification -hatch mcp remove server --host --env-var -``` - -### Safety and Backup Features - -All management operations include comprehensive safety features: - -**Automatic Backup Creation**: -```bash -# Backup created automatically -hatch mcp remove server test-server --host claude-desktop -# Output: Backup created: ~/.hatch/mcp_backups/claude-desktop_20231201_143022.json -``` - -**Dry-Run Mode**: -```bash -# Preview changes without executing -hatch mcp remove server test-server --host claude-desktop --dry-run -hatch mcp sync --from-env prod --to-host all --dry-run -``` - -**Skip Backup (Advanced)**: -```bash -# Skip backup creation (use with caution) -hatch mcp remove server test-server --host claude-desktop --no-backup -``` - ### Host Validation and Error Handling The system validates host names against available MCP host types: - `claude-desktop` - `cursor` - `vscode` +- `kiro` - `lmstudio` - `gemini` - Additional hosts as configured diff --git a/docs/articles/users/tutorials/03-author-package/05-checkpoint.md b/docs/articles/users/tutorials/03-author-package/05-checkpoint.md index 923e88a..77a3c1e 100644 --- a/docs/articles/users/tutorials/03-author-package/05-checkpoint.md +++ b/docs/articles/users/tutorials/03-author-package/05-checkpoint.md @@ -18,6 +18,6 @@ You now have the fundamental skills to create, validate, and install Hatch packages. -**Continue to**: [Tutorial 04: MCP Host Configuration](../04-mcp-host-configuration/01-host-platform-overview.md) to learn how to deploy your packages to host platforms like Claude Desktop, VS Code, and Cursor with automatic dependency resolution. +**Continue to**: [Tutorial 04: MCP Host Configuration](../04-mcp-host-configuration/01-host-platform-overview.md) to learn how to deploy your packages to host platforms like Claude Desktop, VS Code, Cursor, and Kiro with automatic dependency resolution. For more advanced topics, explore the [CLI Reference](../../CLIReference.md) and [Security and Trust](../../SecurityAndTrust.md) guides. 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 752a380..64f271d 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 @@ -3,7 +3,7 @@ --- **Concepts covered:** -- MCP host platforms (Claude Desktop, VS Code, Cursor, etc.) +- MCP host platforms (Claude Desktop, VS Code, Cursor, Kiro, etc.) - Hatch's role as package manager with host configuration features - Host platform configuration files and formats - Package-first vs. direct configuration approaches @@ -53,6 +53,8 @@ Hatch currently supports configuration for these MCP host platforms: - [**Claude Code**](https://claude.com/product/claude-code) - Anthropic's AI Command Line Interface - [**Cursor**](https://cursor.com/) - AI-powered code editor - [**VS Code**](https://code.visualstudio.com/) - Microsoft Visual Studio Code +- [**Kiro**](https://kiro.ai/) - Kiro IDE with MCP support +- [**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 diff --git a/docs/articles/users/tutorials/04-mcp-host-configuration/04-environment-synchronization.md b/docs/articles/users/tutorials/04-mcp-host-configuration/04-environment-synchronization.md index c2a90c4..4a545ad 100644 --- a/docs/articles/users/tutorials/04-mcp-host-configuration/04-environment-synchronization.md +++ b/docs/articles/users/tutorials/04-mcp-host-configuration/04-environment-synchronization.md @@ -17,7 +17,7 @@ --- -This tutorial teaches you how to deploy MCP servers to multiple host platforms using environments as project isolation containers. You'll learn to maintain clean separation between different projects while efficiently deploying their servers to host applications like Claude Desktop, Cursor, and VS Code. +This tutorial teaches you how to deploy MCP servers to multiple host platforms using environments as project isolation containers. You'll learn to maintain clean separation between different projects while efficiently deploying their servers to host applications like Claude Desktop, Cursor, Kiro, and VS Code. ## Understanding Project Isolation with Environments diff --git a/docs/index.md b/docs/index.md index 295e5d8..78eb741 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,8 @@ Welcome to the documentation for Hatch, the official package manager for the Hat Hatch provides powerful tools for managing MCP server packages, environments, and interacting with the Hatch registry. It serves as the package management foundation for [Hatchling](https://github.com/CrackingShells/Hatchling) and other projects in the ecosystem. +Hatch also supports MCP host configuration across popular platforms including Claude Desktop/Code, VS Code, Cursor, Kiro, Codex, LM Studio, and Gemini. + ## Documentation Sections ### For Users diff --git a/hatch/cli_hatch.py b/hatch/cli_hatch.py index 87a3318..4747206 100644 --- a/hatch/cli_hatch.py +++ b/hatch/cli_hatch.py @@ -713,6 +713,15 @@ def handle_mcp_configure( include_tools: Optional[list] = None, exclude_tools: Optional[list] = None, input: Optional[list] = None, + disabled: Optional[bool] = None, + auto_approve_tools: Optional[list] = None, + disable_tools: Optional[list] = None, + env_vars: Optional[list] = None, + startup_timeout: Optional[int] = None, + tool_timeout: Optional[int] = None, + enabled: Optional[bool] = None, + bearer_token_env_var: Optional[str] = None, + env_header: Optional[list] = None, no_backup: bool = False, dry_run: bool = False, auto_approve: bool = False, @@ -826,6 +835,35 @@ def handle_mcp_configure( if inputs_list is not None: omni_config_data["inputs"] = inputs_list + # Host-specific fields (Kiro) + if disabled is not None: + omni_config_data["disabled"] = disabled + if auto_approve_tools is not None: + omni_config_data["autoApprove"] = auto_approve_tools + if disable_tools is not None: + omni_config_data["disabledTools"] = disable_tools + + # Host-specific fields (Codex) + if env_vars is not None: + omni_config_data["env_vars"] = env_vars + if startup_timeout is not None: + omni_config_data["startup_timeout_sec"] = startup_timeout + if tool_timeout is not None: + omni_config_data["tool_timeout_sec"] = tool_timeout + if enabled is not None: + omni_config_data["enabled"] = enabled + if bearer_token_env_var is not None: + omni_config_data["bearer_token_env_var"] = bearer_token_env_var + if env_header is not None: + # Parse KEY=ENV_VAR_NAME format into dict + env_http_headers = {} + for header_spec in env_header: + if '=' in header_spec: + key, env_var_name = header_spec.split('=', 1) + env_http_headers[key] = env_var_name + if env_http_headers: + omni_config_data["env_http_headers"] = env_http_headers + # Partial update merge logic if is_update: # Merge with existing configuration @@ -1557,11 +1595,13 @@ def main(): mcp_configure_parser = mcp_subparsers.add_parser( "configure", help="Configure MCP server directly on host" ) - mcp_configure_parser.add_argument("server_name", help="Name for the MCP server") + mcp_configure_parser.add_argument( + "server_name", help="Name for the MCP server [hosts: all]" + ) mcp_configure_parser.add_argument( "--host", required=True, - help="Host platform to configure (e.g., claude-desktop, cursor)", + help="Host platform to configure (e.g., claude-desktop, cursor) [hosts: all]", ) # Create mutually exclusive group for server type @@ -1569,72 +1609,125 @@ def main(): server_type_group.add_argument( "--command", dest="server_command", - help="Command to execute the MCP server (for local servers)", + help="Command to execute the MCP server (for local servers) [hosts: all]", ) server_type_group.add_argument( - "--url", help="Server URL for remote MCP servers (SSE transport)" + "--url", help="Server URL for remote MCP servers (SSE transport) [hosts: all except claude-desktop, claude-code]" ) server_type_group.add_argument( - "--http-url", help="HTTP streaming endpoint URL (Gemini only)" + "--http-url", help="HTTP streaming endpoint URL [hosts: gemini]" ) mcp_configure_parser.add_argument( "--args", nargs="*", - help="Arguments for the MCP server command (only with --command)", + help="Arguments for the MCP server command (only with --command) [hosts: all]", ) mcp_configure_parser.add_argument( - "--env-var", action="append", help="Environment variables (format: KEY=VALUE)" + "--env-var", + action="append", + help="Environment variables (format: KEY=VALUE) [hosts: all]", ) mcp_configure_parser.add_argument( "--header", action="append", - help="HTTP headers for remote servers (format: KEY=VALUE, only with --url)", + help="HTTP headers for remote servers (format: KEY=VALUE, only with --url) [hosts: all except claude-desktop, claude-code]", ) # Host-specific arguments (Gemini) mcp_configure_parser.add_argument( - "--timeout", type=int, help="Request timeout in milliseconds (Gemini)" + "--timeout", type=int, help="Request timeout in milliseconds [hosts: gemini]" ) mcp_configure_parser.add_argument( - "--trust", action="store_true", help="Bypass tool call confirmations (Gemini)" + "--trust", action="store_true", help="Bypass tool call confirmations [hosts: gemini]" ) mcp_configure_parser.add_argument( - "--cwd", help="Working directory for stdio transport (Gemini)" + "--cwd", help="Working directory for stdio transport [hosts: gemini, codex]" ) mcp_configure_parser.add_argument( "--include-tools", nargs="*", - help="Tool allowlist - only these tools will be available (Gemini)", + help="Tool allowlist / enabled tools [hosts: gemini, codex]", ) mcp_configure_parser.add_argument( "--exclude-tools", nargs="*", - help="Tool blocklist - these tools will be excluded (Gemini)", + help="Tool blocklist / disabled tools [hosts: gemini, codex]", ) # Host-specific arguments (Cursor/VS Code/LM Studio) mcp_configure_parser.add_argument( - "--env-file", help="Path to environment file (Cursor, VS Code, LM Studio)" + "--env-file", help="Path to environment file [hosts: cursor, vscode, lmstudio]" ) # Host-specific arguments (VS Code) mcp_configure_parser.add_argument( "--input", action="append", - help="Input variable definitions in format: type,id,description[,password=true] (VS Code)", + help="Input variable definitions in format: type,id,description[,password=true] [hosts: vscode]", + ) + + # Host-specific arguments (Kiro) + mcp_configure_parser.add_argument( + "--disabled", + action="store_true", + default=None, + help="Disable the MCP server [hosts: kiro]" + ) + mcp_configure_parser.add_argument( + "--auto-approve-tools", + action="append", + help="Tool names to auto-approve without prompting [hosts: kiro]" + ) + mcp_configure_parser.add_argument( + "--disable-tools", + action="append", + help="Tool names to disable [hosts: kiro]" + ) + + # Codex-specific arguments + mcp_configure_parser.add_argument( + "--env-vars", + action="append", + help="Environment variable names to whitelist/forward [hosts: codex]" + ) + mcp_configure_parser.add_argument( + "--startup-timeout", + type=int, + help="Server startup timeout in seconds (default: 10) [hosts: codex]" + ) + mcp_configure_parser.add_argument( + "--tool-timeout", + type=int, + help="Tool execution timeout in seconds (default: 60) [hosts: codex]" + ) + mcp_configure_parser.add_argument( + "--enabled", + action="store_true", + default=None, + help="Enable the MCP server [hosts: codex]" + ) + mcp_configure_parser.add_argument( + "--bearer-token-env-var", + type=str, + help="Name of environment variable containing bearer token for Authorization header [hosts: codex]" + ) + mcp_configure_parser.add_argument( + "--env-header", + action="append", + help="HTTP header from environment variable in KEY=ENV_VAR_NAME format [hosts: codex]" ) mcp_configure_parser.add_argument( "--no-backup", action="store_true", - help="Skip backup creation before configuration", + help="Skip backup creation before configuration [hosts: all]", ) mcp_configure_parser.add_argument( - "--dry-run", action="store_true", help="Preview configuration without execution" + "--dry-run", action="store_true", help="Preview configuration without execution [hosts: all]" ) mcp_configure_parser.add_argument( - "--auto-approve", action="store_true", help="Skip confirmation prompts" + "--auto-approve", action="store_true", help="Skip confirmation prompts [hosts: all]" ) # Remove MCP commands (object-action pattern) @@ -2693,6 +2786,15 @@ def main(): getattr(args, "include_tools", None), getattr(args, "exclude_tools", None), getattr(args, "input", None), + getattr(args, "disabled", None), + getattr(args, "auto_approve_tools", None), + getattr(args, "disable_tools", None), + getattr(args, "env_vars", None), + getattr(args, "startup_timeout", None), + getattr(args, "tool_timeout", None), + getattr(args, "enabled", None), + getattr(args, "bearer_token_env_var", None), + getattr(args, "env_header", None), args.no_backup, args.dry_run, args.auto_approve, diff --git a/hatch/mcp_host_config/__init__.py b/hatch/mcp_host_config/__init__.py index 03c8178..8f79bcd 100644 --- a/hatch/mcp_host_config/__init__.py +++ b/hatch/mcp_host_config/__init__.py @@ -11,7 +11,8 @@ PackageHostConfiguration, EnvironmentPackageEntry, ConfigurationResult, SyncResult, # Host-specific configuration models MCPServerConfigBase, MCPServerConfigGemini, MCPServerConfigVSCode, - MCPServerConfigCursor, MCPServerConfigClaude, MCPServerConfigOmni, + MCPServerConfigCursor, MCPServerConfigClaude, MCPServerConfigKiro, + MCPServerConfigCodex, MCPServerConfigOmni, HOST_MODEL_REGISTRY ) from .host_management import ( @@ -30,7 +31,8 @@ 'PackageHostConfiguration', 'EnvironmentPackageEntry', 'ConfigurationResult', 'SyncResult', # Host-specific configuration models 'MCPServerConfigBase', 'MCPServerConfigGemini', 'MCPServerConfigVSCode', - 'MCPServerConfigCursor', 'MCPServerConfigClaude', 'MCPServerConfigOmni', + 'MCPServerConfigCursor', 'MCPServerConfigClaude', 'MCPServerConfigKiro', + 'MCPServerConfigCodex', 'MCPServerConfigOmni', 'HOST_MODEL_REGISTRY', # User feedback reporting 'FieldOperation', 'ConversionReport', 'generate_conversion_report', 'display_report', diff --git a/hatch/mcp_host_config/backup.py b/hatch/mcp_host_config/backup.py index bd4f0f8..7e1ca75 100644 --- a/hatch/mcp_host_config/backup.py +++ b/hatch/mcp_host_config/backup.py @@ -9,7 +9,7 @@ import tempfile from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Any +from typing import Dict, List, Optional, Any, Callable, TextIO from pydantic import BaseModel, Field, validator @@ -36,8 +36,8 @@ class BackupInfo(BaseModel): def validate_hostname(cls, v): """Validate hostname is supported.""" supported_hosts = { - 'claude-desktop', 'claude-code', 'vscode', - 'cursor', 'lmstudio', 'gemini' + 'claude-desktop', 'claude-code', 'vscode', + 'cursor', 'lmstudio', 'gemini', 'kiro', 'codex' } if v not in supported_hosts: raise ValueError(f"Unsupported hostname: {v}. Supported: {supported_hosts}") @@ -53,7 +53,9 @@ def validate_file_exists(cls, v): @property def backup_name(self) -> str: """Get backup filename.""" - return f"mcp.json.{self.hostname}.{self.timestamp.strftime('%Y%m%d_%H%M%S_%f')}" + # Extract original filename from backup path if available + # Backup filename format: {original_name}.{hostname}.{timestamp} + return self.file_path.name @property def age_days(self) -> int: @@ -101,22 +103,29 @@ class Config: class AtomicFileOperations: """Atomic file operations for safe configuration updates.""" - - def atomic_write_with_backup(self, file_path: Path, data: Dict[str, Any], - backup_manager: "MCPHostConfigBackupManager", - hostname: str, skip_backup: bool = False) -> bool: - """Atomic write with automatic backup creation. - + + def atomic_write_with_serializer( + self, + file_path: Path, + data: Any, + serializer: Callable[[Any, TextIO], None], + backup_manager: "MCPHostConfigBackupManager", + hostname: str, + skip_backup: bool = False + ) -> bool: + """Atomic write with custom serializer and automatic backup creation. + Args: - file_path (Path): Target file path for writing - data (Dict[str, Any]): Data to write as JSON - backup_manager (MCPHostConfigBackupManager): Backup manager instance - hostname (str): Host identifier for backup - skip_backup (bool, optional): Skip backup creation. Defaults to False. - + file_path: Target file path for writing + data: Data to serialize and write + serializer: Function that writes data to file handle + backup_manager: Backup manager instance + hostname: Host identifier for backup + skip_backup: Skip backup creation + Returns: - bool: True if operation successful, False otherwise - + bool: True if operation successful + Raises: BackupError: If backup creation fails and skip_backup is False """ @@ -126,32 +135,52 @@ def atomic_write_with_backup(self, file_path: Path, data: Dict[str, Any], backup_result = backup_manager.create_backup(file_path, hostname) if not backup_result.success: raise BackupError(f"Required backup failed: {backup_result.error_message}") - - # Create temporary file for atomic write + temp_file = None try: - # Write to temporary file first temp_file = file_path.with_suffix(f"{file_path.suffix}.tmp") with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - # Atomic move to target location + serializer(data, f) + temp_file.replace(file_path) return True - + except Exception as e: - # Clean up temporary file on failure if temp_file and temp_file.exists(): temp_file.unlink() - - # Restore from backup if available + if backup_result and backup_result.backup_path: try: backup_manager.restore_backup(hostname, backup_result.backup_path.name) except Exception: - pass # Log but don't raise - original error is more important - + pass + raise BackupError(f"Atomic write failed: {str(e)}") + + def atomic_write_with_backup(self, file_path: Path, data: Dict[str, Any], + backup_manager: "MCPHostConfigBackupManager", + hostname: str, skip_backup: bool = False) -> bool: + """Atomic write with JSON serialization (backward compatible). + + Args: + file_path (Path): Target file path for writing + data (Dict[str, Any]): Data to write as JSON + backup_manager (MCPHostConfigBackupManager): Backup manager instance + hostname (str): Host identifier for backup + skip_backup (bool, optional): Skip backup creation. Defaults to False. + + Returns: + bool: True if operation successful, False otherwise + + Raises: + BackupError: If backup creation fails and skip_backup is False + """ + def json_serializer(data: Any, f: TextIO) -> None: + json.dump(data, f, indent=2, ensure_ascii=False) + + return self.atomic_write_with_serializer( + file_path, data, json_serializer, backup_manager, hostname, skip_backup + ) def atomic_copy(self, source: Path, target: Path) -> bool: """Atomic file copy operation. @@ -228,8 +257,10 @@ def create_backup(self, config_path: Path, hostname: str) -> BackupResult: host_backup_dir.mkdir(exist_ok=True) # Generate timestamped backup filename with microseconds for uniqueness + # Preserve original filename instead of hardcoding 'mcp.json' timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - backup_name = f"mcp.json.{hostname}.{timestamp}" + original_filename = config_path.name + backup_name = f"{original_filename}.{hostname}.{timestamp}" backup_path = host_backup_dir / backup_name # Get original file size diff --git a/hatch/mcp_host_config/models.py b/hatch/mcp_host_config/models.py index b265370..b45079c 100644 --- a/hatch/mcp_host_config/models.py +++ b/hatch/mcp_host_config/models.py @@ -24,6 +24,8 @@ class MCPHostType(str, Enum): CURSOR = "cursor" LMSTUDIO = "lmstudio" GEMINI = "gemini" + KIRO = "kiro" + CODEX = "codex" class MCPServerConfig(BaseModel): @@ -192,7 +194,7 @@ def validate_host_names(cls, v): """Validate host names are supported.""" supported_hosts = { 'claude-desktop', 'claude-code', 'vscode', - 'cursor', 'lmstudio', 'gemini' + 'cursor', 'lmstudio', 'gemini', 'kiro' } for host_name in v.keys(): if host_name not in supported_hosts: @@ -538,6 +540,110 @@ def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigClaude': return cls.model_validate(claude_data) +class MCPServerConfigKiro(MCPServerConfigBase): + """Kiro IDE-specific MCP server configuration. + + Extends base model with Kiro-specific fields for server management + and tool control. + """ + + # Kiro-specific fields + disabled: Optional[bool] = Field(None, description="Whether server is disabled") + autoApprove: Optional[List[str]] = Field(None, description="Auto-approved tool names") + disabledTools: Optional[List[str]] = Field(None, description="Disabled tool names") + + @classmethod + def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigKiro': + """Convert Omni model to Kiro-specific model.""" + # Get supported fields dynamically + supported_fields = set(cls.model_fields.keys()) + + # Single-call field filtering + kiro_data = omni.model_dump(include=supported_fields, exclude_unset=True) + + return cls.model_validate(kiro_data) + + +class MCPServerConfigCodex(MCPServerConfigBase): + """Codex-specific MCP server configuration. + + Extends base model with Codex-specific fields including timeouts, + tool filtering, environment variable forwarding, and HTTP authentication. + """ + + model_config = ConfigDict(extra="forbid") + + # Codex-specific STDIO fields + env_vars: Optional[List[str]] = Field( + None, + description="Environment variables to whitelist/forward" + ) + cwd: Optional[str] = Field( + None, + description="Working directory to launch server from" + ) + + # Timeout configuration + startup_timeout_sec: Optional[int] = Field( + None, + description="Server startup timeout in seconds (default: 10)" + ) + tool_timeout_sec: Optional[int] = Field( + None, + description="Tool execution timeout in seconds (default: 60)" + ) + + # Server control + enabled: Optional[bool] = Field( + None, + description="Enable/disable server without deleting config" + ) + enabled_tools: Optional[List[str]] = Field( + None, + description="Allow-list of tools to expose from server" + ) + disabled_tools: Optional[List[str]] = Field( + None, + description="Deny-list of tools to hide (applied after enabled_tools)" + ) + + # HTTP authentication fields + bearer_token_env_var: Optional[str] = Field( + None, + description="Name of env var containing bearer token for Authorization header" + ) + http_headers: Optional[Dict[str, str]] = Field( + None, + description="Map of header names to static values" + ) + env_http_headers: Optional[Dict[str, str]] = Field( + None, + description="Map of header names to env var names (values pulled from env)" + ) + + @classmethod + def from_omni(cls, omni: 'MCPServerConfigOmni') -> 'MCPServerConfigCodex': + """Convert Omni model to Codex-specific model. + + Maps universal 'headers' field to Codex-specific 'http_headers' field. + """ + supported_fields = set(cls.model_fields.keys()) + codex_data = omni.model_dump(include=supported_fields, exclude_unset=True) + + # Map shared CLI tool filtering flags (Gemini naming) to Codex naming. + # This lets `--include-tools/--exclude-tools` work for both Gemini and Codex. + if getattr(omni, 'includeTools', None) is not None and codex_data.get('enabled_tools') is None: + codex_data['enabled_tools'] = omni.includeTools + if getattr(omni, 'excludeTools', None) is not None and codex_data.get('disabled_tools') is None: + codex_data['disabled_tools'] = omni.excludeTools + + # Map universal 'headers' to Codex 'http_headers' + if hasattr(omni, 'headers') and omni.headers is not None: + codex_data['http_headers'] = omni.headers + + return cls.model_validate(codex_data) + + class MCPServerConfigOmni(BaseModel): """Omni configuration supporting all host-specific fields. @@ -580,6 +686,22 @@ class MCPServerConfigOmni(BaseModel): # VS Code specific envFile: Optional[str] = None inputs: Optional[List[Dict]] = None + + # Kiro specific + disabled: Optional[bool] = None + autoApprove: Optional[List[str]] = None + disabledTools: Optional[List[str]] = None + + # Codex specific + env_vars: Optional[List[str]] = None + startup_timeout_sec: Optional[int] = None + tool_timeout_sec: Optional[int] = None + enabled: Optional[bool] = None + enabled_tools: Optional[List[str]] = None + disabled_tools: Optional[List[str]] = None + bearer_token_env_var: Optional[str] = None + env_http_headers: Optional[Dict[str, str]] = None + # Note: http_headers maps to universal 'headers' field, not a separate Codex field @field_validator('url') @classmethod @@ -599,4 +721,6 @@ def validate_url_format(cls, v): MCPHostType.VSCODE: MCPServerConfigVSCode, MCPHostType.CURSOR: MCPServerConfigCursor, MCPHostType.LMSTUDIO: MCPServerConfigCursor, # Same as CURSOR + MCPHostType.KIRO: MCPServerConfigKiro, + MCPHostType.CODEX: MCPServerConfigCodex, } diff --git a/hatch/mcp_host_config/strategies.py b/hatch/mcp_host_config/strategies.py index bb63035..c5345af 100644 --- a/hatch/mcp_host_config/strategies.py +++ b/hatch/mcp_host_config/strategies.py @@ -8,12 +8,15 @@ import platform import json +import tomllib # Python 3.11+ built-in +import tomli_w # TOML writing from pathlib import Path -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, TextIO import logging from .host_management import MCPHostStrategy, register_host_strategy from .models import MCPHostType, MCPServerConfig, HostConfiguration +from .backup import MCPHostConfigBackupManager, AtomicFileOperations logger = logging.getLogger(__name__) @@ -409,6 +412,101 @@ def write_configuration(self, config: HostConfiguration, no_backup: bool = False return False +@register_host_strategy(MCPHostType.KIRO) +class KiroHostStrategy(MCPHostStrategy): + """Configuration strategy for Kiro IDE.""" + + def get_config_path(self) -> Optional[Path]: + """Get Kiro configuration path (user-level only per constraint).""" + return Path.home() / ".kiro" / "settings" / "mcp.json" + + def get_config_key(self) -> str: + """Kiro uses 'mcpServers' key.""" + return "mcpServers" + + def is_host_available(self) -> bool: + """Check if Kiro is available by checking for settings directory.""" + kiro_dir = Path.home() / ".kiro" / "settings" + return kiro_dir.exists() + + def validate_server_config(self, server_config: MCPServerConfig) -> bool: + """Kiro validation - supports both local and remote servers.""" + return server_config.command is not None or server_config.url is not None + + def read_configuration(self) -> HostConfiguration: + """Read Kiro configuration file.""" + config_path_str = self.get_config_path() + if not config_path_str: + return HostConfiguration(servers={}) + + config_path = Path(config_path_str) + if not config_path.exists(): + return HostConfiguration(servers={}) + + try: + with open(config_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + servers = {} + mcp_servers = data.get(self.get_config_key(), {}) + + for name, config in mcp_servers.items(): + try: + servers[name] = MCPServerConfig(**config) + except Exception as e: + logger.warning(f"Invalid server config for {name}: {e}") + continue + + return HostConfiguration(servers=servers) + + except Exception as e: + logger.error(f"Failed to read Kiro configuration: {e}") + return HostConfiguration(servers={}) + + def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool: + """Write configuration to Kiro with backup support.""" + config_path_str = self.get_config_path() + if not config_path_str: + return False + + config_path = Path(config_path_str) + + try: + # Ensure directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing configuration to preserve other settings + existing_data = {} + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + + # Update MCP servers section + servers_data = {} + for name, server_config in config.servers.items(): + servers_data[name] = server_config.model_dump(exclude_unset=True) + + existing_data[self.get_config_key()] = servers_data + + # Use atomic write with backup support + backup_manager = MCPHostConfigBackupManager() + atomic_ops = AtomicFileOperations() + + atomic_ops.atomic_write_with_backup( + file_path=config_path, + data=existing_data, + backup_manager=backup_manager, + hostname="kiro", + skip_backup=no_backup + ) + + return True + + except Exception as e: + logger.error(f"Failed to write Kiro configuration: {e}") + return False + + @register_host_strategy(MCPHostType.GEMINI) class GeminiHostStrategy(MCPHostStrategy): """Configuration strategy for Google Gemini CLI MCP integration.""" @@ -511,3 +609,172 @@ def write_configuration(self, config: HostConfiguration, no_backup: bool = False except Exception as e: logger.error(f"Failed to write Gemini configuration: {e}") return False + + +@register_host_strategy(MCPHostType.CODEX) +class CodexHostStrategy(MCPHostStrategy): + """Configuration strategy for Codex IDE with TOML support. + + Codex uses TOML configuration at ~/.codex/config.toml with a unique + structure using [mcp_servers.] tables. + """ + + def __init__(self): + self.config_format = "toml" + self._preserved_features = {} # Preserve [features] section + + def get_config_path(self) -> Optional[Path]: + """Get Codex configuration path.""" + return Path.home() / ".codex" / "config.toml" + + def get_config_key(self) -> str: + """Codex uses 'mcp_servers' key (note: underscore, not camelCase).""" + return "mcp_servers" + + def is_host_available(self) -> bool: + """Check if Codex is available by checking for config directory.""" + codex_dir = Path.home() / ".codex" + return codex_dir.exists() + + def validate_server_config(self, server_config: MCPServerConfig) -> bool: + """Codex validation - supports both STDIO and HTTP servers.""" + return server_config.command is not None or server_config.url is not None + + def read_configuration(self) -> HostConfiguration: + """Read Codex TOML configuration file.""" + 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) + + # Preserve [features] section for later write + self._preserved_features = toml_data.get('features', {}) + + # Extract MCP servers from [mcp_servers.*] tables + mcp_servers = toml_data.get(self.get_config_key(), {}) + + servers = {} + for name, server_data in mcp_servers.items(): + try: + # Flatten nested env section if present + flat_data = self._flatten_toml_server(server_data) + servers[name] = MCPServerConfig(**flat_data) + except Exception as e: + logger.warning(f"Invalid server config for {name}: {e}") + continue + + return HostConfiguration(servers=servers) + + except Exception as e: + logger.error(f"Failed to read Codex configuration: {e}") + return HostConfiguration(servers={}) + + def write_configuration(self, config: HostConfiguration, no_backup: bool = False) -> bool: + """Write Codex TOML configuration file with backup support.""" + config_path = self.get_config_path() + if not config_path: + return False + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing configuration to preserve non-MCP settings + existing_data = {} + if config_path.exists(): + try: + with open(config_path, 'rb') as f: + existing_data = tomllib.load(f) + except Exception: + pass + + # Preserve [features] section + if 'features' in existing_data: + self._preserved_features = existing_data['features'] + + # Convert servers to TOML structure + servers_data = {} + for name, server_config in config.servers.items(): + servers_data[name] = self._to_toml_server(server_config) + + # Build final TOML structure + final_data = {} + + # Preserve [features] at top + if self._preserved_features: + final_data['features'] = self._preserved_features + + # Add MCP servers + final_data[self.get_config_key()] = servers_data + + # Preserve other top-level keys + for key, value in existing_data.items(): + if key not in ('features', self.get_config_key()): + final_data[key] = value + + # Use atomic write with TOML serializer + backup_manager = MCPHostConfigBackupManager() + atomic_ops = AtomicFileOperations() + + def toml_serializer(data: Any, f: TextIO) -> None: + # tomli_w.dumps returns a string, write it to the file + toml_str = tomli_w.dumps(data) + f.write(toml_str) + + atomic_ops.atomic_write_with_serializer( + file_path=config_path, + data=final_data, + serializer=toml_serializer, + backup_manager=backup_manager, + hostname="codex", + skip_backup=no_backup + ) + + return True + + except Exception as e: + logger.error(f"Failed to write Codex configuration: {e}") + return False + + def _flatten_toml_server(self, server_data: Dict[str, Any]) -> Dict[str, Any]: + """Flatten nested TOML server structure to flat dict. + + TOML structure: + [mcp_servers.name] + command = "npx" + args = ["-y", "package"] + [mcp_servers.name.env] + VAR = "value" + + Becomes: + {"command": "npx", "args": [...], "env": {"VAR": "value"}} + + Also maps Codex-specific 'http_headers' to universal 'headers' field. + """ + # TOML already parses nested tables into nested dicts + # So [mcp_servers.name.env] becomes {"env": {...}} + data = dict(server_data) + + # Map Codex 'http_headers' to universal 'headers' for MCPServerConfig + if 'http_headers' in data: + data['headers'] = data.pop('http_headers') + + return data + + def _to_toml_server(self, server_config: MCPServerConfig) -> Dict[str, Any]: + """Convert MCPServerConfig to TOML-compatible dict structure. + + Maps universal 'headers' field back to Codex-specific 'http_headers'. + """ + data = server_config.model_dump(exclude_unset=True) + + # Remove 'name' field as it's the table key in TOML + data.pop('name', None) + + # Map universal 'headers' to Codex 'http_headers' for TOML + if 'headers' in data: + data['http_headers'] = data.pop('headers') + + return data diff --git a/pyproject.toml b/pyproject.toml index 56c0ff9..e0dae3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hatch-xclam" -version = "0.7.0" +version = "0.7.1-dev.3" description = "Package manager for the Cracking Shells ecosystem" readme = "README.md" requires-python = ">=3.12" @@ -19,7 +19,8 @@ dependencies = [ "packaging>=20.0", "docker>=7.1.0", "pydantic>=2.0.0", - "hatch-validator>=0.8.0" + "hatch-validator>=0.8.0", + "tomli-w>=1.0.0" ] [[project.authors]] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..b256412 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,5 @@ +""" +Integration tests for Hatch MCP functionality. + +These tests validate component interactions and end-to-end workflows. +""" \ No newline at end of file diff --git a/tests/integration/test_mcp_kiro_integration.py b/tests/integration/test_mcp_kiro_integration.py new file mode 100644 index 0000000..a3336c6 --- /dev/null +++ b/tests/integration/test_mcp_kiro_integration.py @@ -0,0 +1,153 @@ +""" +Kiro MCP Integration Tests + +End-to-end integration tests combining CLI, model conversion, and strategy operations. +""" + +import unittest +from unittest.mock import patch, MagicMock + +from wobble.decorators import integration_test + +from hatch.cli_hatch import handle_mcp_configure +from hatch.mcp_host_config.models import ( + HOST_MODEL_REGISTRY, + MCPHostType, + MCPServerConfigKiro +) + + +class TestKiroIntegration(unittest.TestCase): + """Test suite for end-to-end Kiro integration.""" + + @integration_test(scope="component") + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + def test_kiro_end_to_end_configuration(self, mock_manager_class): + """Test complete Kiro configuration workflow.""" + # Setup mocks + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_manager.configure_server.return_value = mock_result + + # Execute CLI command with Kiro-specific arguments + result = handle_mcp_configure( + host='kiro', + server_name='augment-server', + command='auggie', + args=['--mcp', '-m', 'default'], + disabled=False, + auto_approve_tools=['codebase-retrieval', 'fetch'], + disable_tools=['dangerous-tool'], + auto_approve=True + ) + + # Verify success + self.assertEqual(result, 0) + + # Verify configuration manager was called + mock_manager.configure_server.assert_called_once() + + # Verify server configuration + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + + # Verify all Kiro-specific fields + self.assertFalse(server_config.disabled) + self.assertEqual(len(server_config.autoApprove), 2) + self.assertEqual(len(server_config.disabledTools), 1) + self.assertIn('codebase-retrieval', server_config.autoApprove) + self.assertIn('dangerous-tool', server_config.disabledTools) + + @integration_test(scope="system") + def test_kiro_host_model_registry_integration(self): + """Test Kiro integration with HOST_MODEL_REGISTRY.""" + # Verify Kiro is in registry + self.assertIn(MCPHostType.KIRO, HOST_MODEL_REGISTRY) + + # Verify correct model class + model_class = HOST_MODEL_REGISTRY[MCPHostType.KIRO] + self.assertEqual(model_class.__name__, "MCPServerConfigKiro") + + # Test model instantiation + model_instance = model_class( + name="test-server", + command="auggie", + disabled=True + ) + self.assertTrue(model_instance.disabled) + + @integration_test(scope="component") + def test_kiro_model_to_strategy_workflow(self): + """Test workflow from model creation to strategy operations.""" + # Import to trigger registration + import hatch.mcp_host_config.strategies + from hatch.mcp_host_config.host_management import MCPHostRegistry + + # Create Kiro model + kiro_model = MCPServerConfigKiro( + name="workflow-test", + command="auggie", + args=["--mcp"], + disabled=False, + autoApprove=["codebase-retrieval"] + ) + + # Get Kiro strategy + strategy = MCPHostRegistry.get_strategy(MCPHostType.KIRO) + + # Verify strategy can validate the model + self.assertTrue(strategy.validate_server_config(kiro_model)) + + # Verify model fields are accessible + self.assertEqual(kiro_model.command, "auggie") + self.assertFalse(kiro_model.disabled) + self.assertIn("codebase-retrieval", kiro_model.autoApprove) + + @integration_test(scope="end_to_end") + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + def test_kiro_complete_lifecycle(self, mock_manager_class): + """Test complete Kiro server lifecycle: create, configure, validate.""" + # Setup mocks + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_manager.configure_server.return_value = mock_result + + # Step 1: Configure server via CLI + result = handle_mcp_configure( + host='kiro', + server_name='lifecycle-test', + command='auggie', + args=['--mcp', '-w', '.'], + disabled=False, + auto_approve_tools=['codebase-retrieval'], + auto_approve=True + ) + + # Verify CLI success + self.assertEqual(result, 0) + + # Step 2: Verify configuration manager interaction + mock_manager.configure_server.assert_called_once() + call_args = mock_manager.configure_server.call_args + + # Step 3: Verify server configuration structure + server_config = call_args.kwargs['server_config'] + self.assertEqual(server_config.name, 'lifecycle-test') + self.assertEqual(server_config.command, 'auggie') + self.assertIn('--mcp', server_config.args) + self.assertIn('-w', server_config.args) + self.assertFalse(server_config.disabled) + self.assertIn('codebase-retrieval', server_config.autoApprove) + + # Step 4: Verify model type + self.assertIsInstance(server_config, MCPServerConfigKiro) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/regression/__init__.py b/tests/regression/__init__.py new file mode 100644 index 0000000..a43416b --- /dev/null +++ b/tests/regression/__init__.py @@ -0,0 +1,5 @@ +""" +Regression tests for Hatch MCP functionality. + +These tests validate existing functionality to prevent breaking changes. +""" \ No newline at end of file diff --git a/tests/regression/test_mcp_codex_backup_integration.py b/tests/regression/test_mcp_codex_backup_integration.py new file mode 100644 index 0000000..1737ab0 --- /dev/null +++ b/tests/regression/test_mcp_codex_backup_integration.py @@ -0,0 +1,162 @@ +""" +Codex MCP Backup Integration Tests + +Tests for Codex TOML backup integration including backup creation, +restoration, and the no_backup parameter. +""" + +import unittest +import tempfile +import tomllib +from pathlib import Path + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.strategies import CodexHostStrategy +from hatch.mcp_host_config.models import MCPServerConfig, HostConfiguration +from hatch.mcp_host_config.backup import MCPHostConfigBackupManager, BackupInfo + + +class TestCodexBackupIntegration(unittest.TestCase): + """Test suite for Codex backup integration.""" + + def setUp(self): + """Set up test environment.""" + self.strategy = CodexHostStrategy() + + @regression_test + def test_write_configuration_creates_backup_by_default(self): + """Test that write_configuration creates backup by default when file exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "config.toml" + backup_dir = Path(tmpdir) / "backups" + + # Create initial config + initial_toml = """[mcp_servers.old-server] +command = "old-command" +""" + config_path.write_text(initial_toml) + + # Create new configuration + new_config = HostConfiguration(servers={ + 'new-server': MCPServerConfig( + command='new-command', + args=['--test'] + ) + }) + + # Patch paths + from unittest.mock import patch + with patch.object(self.strategy, 'get_config_path', return_value=config_path): + with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as MockBackupManager: + # Create a real backup manager with custom backup dir + real_backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir) + MockBackupManager.return_value = real_backup_manager + + # Write configuration (should create backup) + success = self.strategy.write_configuration(new_config, no_backup=False) + self.assertTrue(success) + + # Verify backup was created + backup_files = list(backup_dir.glob('codex/*.toml.*')) + self.assertGreater(len(backup_files), 0, "Backup file should be created") + + @regression_test + def test_write_configuration_skips_backup_when_requested(self): + """Test that write_configuration skips backup when no_backup=True.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "config.toml" + backup_dir = Path(tmpdir) / "backups" + + # Create initial config + initial_toml = """[mcp_servers.old-server] +command = "old-command" +""" + config_path.write_text(initial_toml) + + # Create new configuration + new_config = HostConfiguration(servers={ + 'new-server': MCPServerConfig( + command='new-command' + ) + }) + + # Patch paths + from unittest.mock import patch + with patch.object(self.strategy, 'get_config_path', return_value=config_path): + with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as MockBackupManager: + real_backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir) + MockBackupManager.return_value = real_backup_manager + + # Write configuration with no_backup=True + success = self.strategy.write_configuration(new_config, no_backup=True) + self.assertTrue(success) + + # Verify no backup was created + if backup_dir.exists(): + backup_files = list(backup_dir.glob('codex/*.toml.*')) + self.assertEqual(len(backup_files), 0, "No backup should be created when no_backup=True") + + @regression_test + def test_write_configuration_no_backup_for_new_file(self): + """Test that no backup is created when writing to a new file.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "config.toml" + backup_dir = Path(tmpdir) / "backups" + + # Don't create initial file - this is a new file + + # Create new configuration + new_config = HostConfiguration(servers={ + 'new-server': MCPServerConfig( + command='new-command' + ) + }) + + # Patch paths + from unittest.mock import patch + with patch.object(self.strategy, 'get_config_path', return_value=config_path): + with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as MockBackupManager: + real_backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir) + MockBackupManager.return_value = real_backup_manager + + # Write configuration to new file + success = self.strategy.write_configuration(new_config, no_backup=False) + self.assertTrue(success) + + # Verify file was created + self.assertTrue(config_path.exists()) + + # Verify no backup was created (nothing to backup) + if backup_dir.exists(): + backup_files = list(backup_dir.glob('codex/*.toml.*')) + self.assertEqual(len(backup_files), 0, "No backup for new file") + + @regression_test + def test_codex_hostname_supported_in_backup_system(self): + """Test that 'codex' hostname is supported by the backup system.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "config.toml" + backup_dir = Path(tmpdir) / "backups" + + # Create a config file + config_path.write_text("[mcp_servers.test]\ncommand = 'test'\n") + + # Create backup manager + backup_manager = MCPHostConfigBackupManager(backup_root=backup_dir) + + # Create backup with 'codex' hostname - should not raise validation error + result = backup_manager.create_backup(config_path, 'codex') + + # Verify backup succeeded + self.assertTrue(result.success, "Backup with 'codex' hostname should succeed") + self.assertIsNotNone(result.backup_path) + + # Verify backup filename follows pattern + backup_filename = result.backup_path.name + self.assertTrue(backup_filename.startswith('config.toml.codex.')) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/regression/test_mcp_codex_host_strategy.py b/tests/regression/test_mcp_codex_host_strategy.py new file mode 100644 index 0000000..c72a623 --- /dev/null +++ b/tests/regression/test_mcp_codex_host_strategy.py @@ -0,0 +1,163 @@ +""" +Codex MCP Host Strategy Tests + +Tests for CodexHostStrategy implementation including path resolution, +configuration read/write, TOML handling, and host detection. +""" + +import unittest +import tempfile +import tomllib +from unittest.mock import patch, mock_open, MagicMock +from pathlib import Path + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.strategies import CodexHostStrategy +from hatch.mcp_host_config.models import MCPServerConfig, HostConfiguration + +# Import test data loader from local tests module +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) +from test_data_utils import MCPHostConfigTestDataLoader + + +class TestCodexHostStrategy(unittest.TestCase): + """Test suite for CodexHostStrategy implementation.""" + + def setUp(self): + """Set up test environment.""" + self.strategy = CodexHostStrategy() + self.test_data_loader = MCPHostConfigTestDataLoader() + + @regression_test + def test_codex_config_path_resolution(self): + """Test Codex configuration path resolution.""" + config_path = self.strategy.get_config_path() + + # Verify path structure (use normalized path for cross-platform compatibility) + self.assertIsNotNone(config_path) + normalized_path = str(config_path).replace('\\', '/') + self.assertTrue(normalized_path.endswith('.codex/config.toml')) + self.assertEqual(config_path.name, 'config.toml') + self.assertEqual(config_path.suffix, '.toml') # Verify TOML extension + + @regression_test + def test_codex_config_key(self): + """Test Codex configuration key.""" + config_key = self.strategy.get_config_key() + # Codex uses underscore, not camelCase + self.assertEqual(config_key, "mcp_servers") + self.assertNotEqual(config_key, "mcpServers") # Verify different from other hosts + + @regression_test + def test_codex_server_config_validation_stdio(self): + """Test Codex STDIO server configuration validation.""" + # Test local server validation + local_config = MCPServerConfig( + command="npx", + args=["-y", "package"] + ) + self.assertTrue(self.strategy.validate_server_config(local_config)) + + @regression_test + def test_codex_server_config_validation_http(self): + """Test Codex HTTP server configuration validation.""" + # Test remote server validation + remote_config = MCPServerConfig( + url="https://api.example.com/mcp" + ) + self.assertTrue(self.strategy.validate_server_config(remote_config)) + + @patch('pathlib.Path.exists') + @regression_test + def test_codex_host_availability_detection(self, mock_exists): + """Test Codex host availability detection.""" + # Test when Codex directory exists + mock_exists.return_value = True + self.assertTrue(self.strategy.is_host_available()) + + # Test when Codex directory doesn't exist + mock_exists.return_value = False + self.assertFalse(self.strategy.is_host_available()) + + @regression_test + def test_codex_read_configuration_success(self): + """Test successful Codex TOML configuration reading.""" + # Load test data + test_toml_path = Path(__file__).parent.parent / "test_data" / "codex" / "valid_config.toml" + + with patch.object(self.strategy, 'get_config_path', return_value=test_toml_path): + config = self.strategy.read_configuration() + + # Verify configuration was read + self.assertIsInstance(config, HostConfiguration) + self.assertIn('context7', config.servers) + + # Verify server details + server = config.servers['context7'] + self.assertEqual(server.command, 'npx') + self.assertEqual(server.args, ['-y', '@upstash/context7-mcp']) + + # Verify nested env section was parsed correctly + self.assertIsNotNone(server.env) + self.assertEqual(server.env.get('MY_VAR'), 'value') + + @regression_test + def test_codex_read_configuration_file_not_exists(self): + """Test Codex configuration reading when file doesn't exist.""" + non_existent_path = Path("/non/existent/path/config.toml") + + with patch.object(self.strategy, 'get_config_path', return_value=non_existent_path): + config = self.strategy.read_configuration() + + # Should return empty configuration without error + self.assertIsInstance(config, HostConfiguration) + self.assertEqual(len(config.servers), 0) + + @regression_test + def test_codex_write_configuration_preserves_features(self): + """Test that write_configuration preserves [features] section.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = Path(tmpdir) / "config.toml" + + # Create initial config with features section + initial_toml = """[features] +rmcp_client = true + +[mcp_servers.existing] +command = "old-command" +""" + config_path.write_text(initial_toml) + + # Create new configuration to write + new_config = HostConfiguration(servers={ + 'new-server': MCPServerConfig( + command='new-command', + args=['--test'] + ) + }) + + # Write configuration + with patch.object(self.strategy, 'get_config_path', return_value=config_path): + success = self.strategy.write_configuration(new_config, no_backup=True) + self.assertTrue(success) + + # Read back and verify features section preserved + with open(config_path, 'rb') as f: + result_data = tomllib.load(f) + + # Verify features section preserved + self.assertIn('features', result_data) + self.assertTrue(result_data['features'].get('rmcp_client')) + + # Verify new server added + self.assertIn('mcp_servers', result_data) + self.assertIn('new-server', result_data['mcp_servers']) + self.assertEqual(result_data['mcp_servers']['new-server']['command'], 'new-command') + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/regression/test_mcp_codex_model_validation.py b/tests/regression/test_mcp_codex_model_validation.py new file mode 100644 index 0000000..b952f70 --- /dev/null +++ b/tests/regression/test_mcp_codex_model_validation.py @@ -0,0 +1,117 @@ +""" +Codex MCP Model Validation Tests + +Tests for MCPServerConfigCodex model validation including Codex-specific fields, +Omni conversion, and registry integration. +""" + +import unittest +from wobble.decorators import regression_test + +from hatch.mcp_host_config.models import ( + MCPServerConfigCodex, MCPServerConfigOmni, MCPHostType, HOST_MODEL_REGISTRY +) + + +class TestCodexModelValidation(unittest.TestCase): + """Test suite for Codex model validation.""" + + @regression_test + def test_codex_specific_fields_accepted(self): + """Test that Codex-specific fields are accepted in MCPServerConfigCodex.""" + # Create model with Codex-specific fields + config = MCPServerConfigCodex( + command="npx", + args=["-y", "package"], + env={"API_KEY": "test"}, + # Codex-specific fields + env_vars=["PATH", "HOME"], + cwd="/workspace", + startup_timeout_sec=10, + tool_timeout_sec=60, + enabled=True, + enabled_tools=["read", "write"], + disabled_tools=["delete"], + bearer_token_env_var="AUTH_TOKEN", + http_headers={"X-Custom": "value"}, + env_http_headers={"X-Auth": "AUTH_VAR"} + ) + + # Verify all fields are accessible + self.assertEqual(config.command, "npx") + self.assertEqual(config.env_vars, ["PATH", "HOME"]) + self.assertEqual(config.cwd, "/workspace") + self.assertEqual(config.startup_timeout_sec, 10) + self.assertEqual(config.tool_timeout_sec, 60) + self.assertTrue(config.enabled) + self.assertEqual(config.enabled_tools, ["read", "write"]) + self.assertEqual(config.disabled_tools, ["delete"]) + self.assertEqual(config.bearer_token_env_var, "AUTH_TOKEN") + self.assertEqual(config.http_headers, {"X-Custom": "value"}) + self.assertEqual(config.env_http_headers, {"X-Auth": "AUTH_VAR"}) + + @regression_test + def test_codex_from_omni_conversion(self): + """Test MCPServerConfigCodex.from_omni() conversion.""" + # Create Omni model with Codex-specific fields + omni = MCPServerConfigOmni( + command="npx", + args=["-y", "package"], + env={"API_KEY": "test"}, + # Codex-specific fields + env_vars=["PATH"], + startup_timeout_sec=15, + tool_timeout_sec=90, + enabled=True, + enabled_tools=["read"], + disabled_tools=["write"], + bearer_token_env_var="TOKEN", + headers={"X-Test": "value"}, # Universal field (maps to http_headers in Codex) + env_http_headers={"X-Env": "VAR"}, + # Non-Codex fields (should be excluded) + envFile="/path/to/env", # VS Code specific + disabled=True # Kiro specific + ) + + # Convert to Codex model + codex = MCPServerConfigCodex.from_omni(omni) + + # Verify Codex fields transferred correctly + self.assertEqual(codex.command, "npx") + self.assertEqual(codex.env_vars, ["PATH"]) + self.assertEqual(codex.startup_timeout_sec, 15) + self.assertEqual(codex.tool_timeout_sec, 90) + self.assertTrue(codex.enabled) + self.assertEqual(codex.enabled_tools, ["read"]) + self.assertEqual(codex.disabled_tools, ["write"]) + self.assertEqual(codex.bearer_token_env_var, "TOKEN") + self.assertEqual(codex.http_headers, {"X-Test": "value"}) + self.assertEqual(codex.env_http_headers, {"X-Env": "VAR"}) + + # Verify non-Codex fields excluded (should not have these attributes) + with self.assertRaises(AttributeError): + _ = codex.envFile + with self.assertRaises(AttributeError): + _ = codex.disabled + + @regression_test + def test_host_model_registry_contains_codex(self): + """Test that HOST_MODEL_REGISTRY contains Codex model.""" + # Verify CODEX is in registry + self.assertIn(MCPHostType.CODEX, HOST_MODEL_REGISTRY) + + # Verify it maps to correct model class + self.assertEqual( + HOST_MODEL_REGISTRY[MCPHostType.CODEX], + MCPServerConfigCodex + ) + + # Verify we can instantiate from registry + model_class = HOST_MODEL_REGISTRY[MCPHostType.CODEX] + instance = model_class(command="test") + self.assertIsInstance(instance, MCPServerConfigCodex) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/regression/test_mcp_kiro_backup_integration.py b/tests/regression/test_mcp_kiro_backup_integration.py new file mode 100644 index 0000000..72b8d79 --- /dev/null +++ b/tests/regression/test_mcp_kiro_backup_integration.py @@ -0,0 +1,241 @@ +"""Tests for Kiro MCP backup integration. + +This module tests the integration between KiroHostStrategy and the backup system, +ensuring that Kiro configurations are properly backed up during write operations. +""" + +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.strategies import KiroHostStrategy +from hatch.mcp_host_config.models import HostConfiguration, MCPServerConfig +from hatch.mcp_host_config.backup import MCPHostConfigBackupManager, BackupResult + + +class TestKiroBackupIntegration(unittest.TestCase): + """Test Kiro backup integration with host strategy.""" + + def setUp(self): + """Set up test environment.""" + self.temp_dir = Path(tempfile.mkdtemp(prefix="test_kiro_backup_")) + self.config_dir = self.temp_dir / ".kiro" / "settings" + self.config_dir.mkdir(parents=True) + self.config_file = self.config_dir / "mcp.json" + + self.backup_dir = self.temp_dir / "backups" + self.backup_manager = MCPHostConfigBackupManager(backup_root=self.backup_dir) + + self.strategy = KiroHostStrategy() + + def tearDown(self): + """Clean up test environment.""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @regression_test + def test_write_configuration_creates_backup_by_default(self): + """Test that write_configuration creates backup by default when file exists.""" + # Create initial configuration + initial_config = { + "mcpServers": { + "existing-server": { + "command": "uvx", + "args": ["existing-package"] + } + }, + "otherSettings": { + "theme": "dark" + } + } + + with open(self.config_file, 'w') as f: + json.dump(initial_config, f, indent=2) + + # Create new configuration to write + server_config = MCPServerConfig( + command="uvx", + args=["new-package"] + ) + host_config = HostConfiguration(servers={"new-server": server_config}) + + # Mock the strategy's get_config_path to return our test file + # Mock the backup manager creation to use our test backup manager + with patch.object(self.strategy, 'get_config_path', return_value=self.config_file), \ + patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager', return_value=self.backup_manager): + # Write configuration (should create backup) + result = self.strategy.write_configuration(host_config, no_backup=False) + + # Verify write succeeded + self.assertTrue(result) + + # Verify backup was created + backups = self.backup_manager.list_backups("kiro") + self.assertEqual(len(backups), 1) + + # Verify backup contains original content + backup_content = json.loads(backups[0].file_path.read_text()) + self.assertEqual(backup_content, initial_config) + + # Verify new configuration was written + new_content = json.loads(self.config_file.read_text()) + self.assertIn("new-server", new_content["mcpServers"]) + self.assertEqual(new_content["otherSettings"], {"theme": "dark"}) # Preserved + + @regression_test + def test_write_configuration_skips_backup_when_requested(self): + """Test that write_configuration skips backup when no_backup=True.""" + # Create initial configuration + initial_config = { + "mcpServers": { + "existing-server": { + "command": "uvx", + "args": ["existing-package"] + } + } + } + + with open(self.config_file, 'w') as f: + json.dump(initial_config, f, indent=2) + + # Create new configuration to write + server_config = MCPServerConfig( + command="uvx", + args=["new-package"] + ) + host_config = HostConfiguration(servers={"new-server": server_config}) + + # Mock the strategy's get_config_path to return our test file + # Mock the backup manager creation to use our test backup manager + with patch.object(self.strategy, 'get_config_path', return_value=self.config_file), \ + patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager', return_value=self.backup_manager): + # Write configuration with no_backup=True + result = self.strategy.write_configuration(host_config, no_backup=True) + + # Verify write succeeded + self.assertTrue(result) + + # Verify no backup was created + backups = self.backup_manager.list_backups("kiro") + self.assertEqual(len(backups), 0) + + # Verify new configuration was written + new_content = json.loads(self.config_file.read_text()) + self.assertIn("new-server", new_content["mcpServers"]) + + @regression_test + def test_write_configuration_no_backup_for_new_file(self): + """Test that no backup is created when writing to a new file.""" + # Ensure config file doesn't exist + self.assertFalse(self.config_file.exists()) + + # Create configuration to write + server_config = MCPServerConfig( + command="uvx", + args=["new-package"] + ) + host_config = HostConfiguration(servers={"new-server": server_config}) + + # Mock the strategy's get_config_path to return our test file + # Mock the backup manager creation to use our test backup manager + with patch.object(self.strategy, 'get_config_path', return_value=self.config_file), \ + patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager', return_value=self.backup_manager): + # Write configuration + result = self.strategy.write_configuration(host_config, no_backup=False) + + # Verify write succeeded + self.assertTrue(result) + + # Verify no backup was created (file didn't exist) + backups = self.backup_manager.list_backups("kiro") + self.assertEqual(len(backups), 0) + + # Verify configuration was written + self.assertTrue(self.config_file.exists()) + new_content = json.loads(self.config_file.read_text()) + self.assertIn("new-server", new_content["mcpServers"]) + + @regression_test + def test_backup_failure_prevents_write(self): + """Test that backup failure prevents configuration write.""" + # Create initial configuration + initial_config = { + "mcpServers": { + "existing-server": { + "command": "uvx", + "args": ["existing-package"] + } + } + } + + with open(self.config_file, 'w') as f: + json.dump(initial_config, f, indent=2) + + # Create new configuration to write + server_config = MCPServerConfig( + command="uvx", + args=["new-package"] + ) + host_config = HostConfiguration(servers={"new-server": server_config}) + + # Mock backup manager to fail + with patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') as mock_backup_class: + mock_backup_manager = MagicMock() + mock_backup_manager.create_backup.return_value = BackupResult( + success=False, + error_message="Backup failed" + ) + mock_backup_class.return_value = mock_backup_manager + + # Mock the strategy's get_config_path to return our test file + with patch.object(self.strategy, 'get_config_path', return_value=self.config_file): + # Write configuration (should fail due to backup failure) + result = self.strategy.write_configuration(host_config, no_backup=False) + + # Verify write failed + self.assertFalse(result) + + # Verify original configuration is unchanged + current_content = json.loads(self.config_file.read_text()) + self.assertEqual(current_content, initial_config) + + @regression_test + def test_kiro_hostname_supported_in_backup_system(self): + """Test that 'kiro' hostname is supported by the backup system.""" + # Create test configuration file + test_config = { + "mcpServers": { + "test-server": { + "command": "uvx", + "args": ["test-package"] + } + } + } + + with open(self.config_file, 'w') as f: + json.dump(test_config, f, indent=2) + + # Test backup creation with 'kiro' hostname + result = self.backup_manager.create_backup(self.config_file, "kiro") + + # Verify backup succeeded + self.assertTrue(result.success) + self.assertIsNotNone(result.backup_path) + self.assertTrue(result.backup_path.exists()) + + # Verify backup filename format + expected_pattern = r"mcp\.json\.kiro\.\d{8}_\d{6}_\d{6}" + import re + self.assertRegex(result.backup_path.name, expected_pattern) + + # Verify backup content + backup_content = json.loads(result.backup_path.read_text()) + self.assertEqual(backup_content, test_config) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/regression/test_mcp_kiro_cli_integration.py b/tests/regression/test_mcp_kiro_cli_integration.py new file mode 100644 index 0000000..575f16a --- /dev/null +++ b/tests/regression/test_mcp_kiro_cli_integration.py @@ -0,0 +1,141 @@ +""" +Kiro MCP CLI Integration Tests + +Tests for CLI argument parsing and integration with Kiro-specific arguments. +""" + +import unittest +from unittest.mock import patch, MagicMock + +from wobble.decorators import regression_test + +from hatch.cli_hatch import handle_mcp_configure + + +class TestKiroCLIIntegration(unittest.TestCase): + """Test suite for Kiro CLI argument integration.""" + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @regression_test + def test_kiro_cli_with_disabled_flag(self, mock_manager_class): + """Test CLI with --disabled flag for Kiro.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='kiro', + server_name='test-server', + command='auggie', + args=['--mcp'], + disabled=True, # Kiro-specific argument + auto_approve=True + ) + + self.assertEqual(result, 0) + + # Verify configure_server was called with Kiro model + mock_manager.configure_server.assert_called_once() + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + + # Verify Kiro-specific field was set + self.assertTrue(server_config.disabled) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @regression_test + def test_kiro_cli_with_auto_approve_tools(self, mock_manager_class): + """Test CLI with --auto-approve-tools for Kiro.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='kiro', + server_name='test-server', + command='auggie', + args=['--mcp'], # Required parameter + auto_approve_tools=['codebase-retrieval', 'fetch'], + auto_approve=True + ) + + self.assertEqual(result, 0) + + # Verify autoApprove field was set correctly + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertEqual(len(server_config.autoApprove), 2) + self.assertIn('codebase-retrieval', server_config.autoApprove) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @regression_test + def test_kiro_cli_with_disable_tools(self, mock_manager_class): + """Test CLI with --disable-tools for Kiro.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='kiro', + server_name='test-server', + command='python', + args=['server.py'], # Required parameter + disable_tools=['dangerous-tool', 'risky-tool'], + auto_approve=True + ) + + self.assertEqual(result, 0) + + # Verify disabledTools field was set correctly + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertEqual(len(server_config.disabledTools), 2) + self.assertIn('dangerous-tool', server_config.disabledTools) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @regression_test + def test_kiro_cli_combined_arguments(self, mock_manager_class): + """Test CLI with multiple Kiro-specific arguments combined.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='kiro', + server_name='comprehensive-server', + command='auggie', + args=['--mcp', '-m', 'default'], + disabled=False, + auto_approve_tools=['codebase-retrieval'], + disable_tools=['dangerous-tool'], + auto_approve=True + ) + + self.assertEqual(result, 0) + + # Verify all Kiro fields were set correctly + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + + self.assertFalse(server_config.disabled) + self.assertEqual(len(server_config.autoApprove), 1) + self.assertEqual(len(server_config.disabledTools), 1) + self.assertIn('codebase-retrieval', server_config.autoApprove) + self.assertIn('dangerous-tool', server_config.disabledTools) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/regression/test_mcp_kiro_decorator_registration.py b/tests/regression/test_mcp_kiro_decorator_registration.py new file mode 100644 index 0000000..e6e4d06 --- /dev/null +++ b/tests/regression/test_mcp_kiro_decorator_registration.py @@ -0,0 +1,71 @@ +""" +Kiro MCP Decorator Registration Tests + +Tests for automatic strategy registration via @register_host_strategy decorator. +""" + +import unittest + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.host_management import MCPHostRegistry +from hatch.mcp_host_config.models import MCPHostType + + +class TestKiroDecoratorRegistration(unittest.TestCase): + """Test suite for Kiro decorator registration.""" + + @regression_test + def test_kiro_strategy_registration(self): + """Test that KiroHostStrategy is properly registered.""" + # Import strategies to trigger registration + import hatch.mcp_host_config.strategies + + # Verify Kiro is registered + self.assertIn(MCPHostType.KIRO, MCPHostRegistry._strategies) + + # Verify correct strategy class + strategy_class = MCPHostRegistry._strategies[MCPHostType.KIRO] + self.assertEqual(strategy_class.__name__, "KiroHostStrategy") + + @regression_test + def test_kiro_strategy_instantiation(self): + """Test that Kiro strategy can be instantiated.""" + # Import strategies to trigger registration + import hatch.mcp_host_config.strategies + + strategy = MCPHostRegistry.get_strategy(MCPHostType.KIRO) + + # Verify strategy instance + self.assertIsNotNone(strategy) + self.assertEqual(strategy.__class__.__name__, "KiroHostStrategy") + + @regression_test + def test_kiro_in_host_detection(self): + """Test that Kiro appears in host detection.""" + # Import strategies to trigger registration + import hatch.mcp_host_config.strategies + + # Get all registered host types + registered_hosts = list(MCPHostRegistry._strategies.keys()) + + # Verify Kiro is included + self.assertIn(MCPHostType.KIRO, registered_hosts) + + @regression_test + def test_kiro_registry_consistency(self): + """Test that Kiro registration is consistent across calls.""" + # Import strategies to trigger registration + import hatch.mcp_host_config.strategies + + # Get strategy multiple times + strategy1 = MCPHostRegistry.get_strategy(MCPHostType.KIRO) + strategy2 = MCPHostRegistry.get_strategy(MCPHostType.KIRO) + + # Verify same class (not necessarily same instance) + self.assertEqual(strategy1.__class__, strategy2.__class__) + self.assertEqual(strategy1.__class__.__name__, "KiroHostStrategy") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/regression/test_mcp_kiro_host_strategy.py b/tests/regression/test_mcp_kiro_host_strategy.py new file mode 100644 index 0000000..00afc66 --- /dev/null +++ b/tests/regression/test_mcp_kiro_host_strategy.py @@ -0,0 +1,214 @@ +""" +Kiro MCP Host Strategy Tests + +Tests for KiroHostStrategy implementation including path resolution, +configuration read/write, and host detection. +""" + +import unittest +import json +from unittest.mock import patch, mock_open, MagicMock +from pathlib import Path + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.strategies import KiroHostStrategy +from hatch.mcp_host_config.models import MCPServerConfig, HostConfiguration + +# Import test data loader from local tests module +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) +from test_data_utils import MCPHostConfigTestDataLoader + + +class TestKiroHostStrategy(unittest.TestCase): + """Test suite for KiroHostStrategy implementation.""" + + def setUp(self): + """Set up test environment.""" + self.strategy = KiroHostStrategy() + self.test_data_loader = MCPHostConfigTestDataLoader() + + @regression_test + def test_kiro_config_path_resolution(self): + """Test Kiro configuration path resolution.""" + config_path = self.strategy.get_config_path() + + # Verify path structure (use normalized path for cross-platform compatibility) + self.assertIsNotNone(config_path) + normalized_path = str(config_path).replace('\\', '/') + self.assertTrue(normalized_path.endswith('.kiro/settings/mcp.json')) + self.assertEqual(config_path.name, 'mcp.json') + + @regression_test + def test_kiro_config_key(self): + """Test Kiro configuration key.""" + config_key = self.strategy.get_config_key() + self.assertEqual(config_key, "mcpServers") + + @regression_test + def test_kiro_server_config_validation(self): + """Test Kiro server configuration validation.""" + # Test local server validation + local_config = MCPServerConfig( + command="auggie", + args=["--mcp"] + ) + self.assertTrue(self.strategy.validate_server_config(local_config)) + + # Test remote server validation + remote_config = MCPServerConfig( + url="https://api.example.com/mcp" + ) + self.assertTrue(self.strategy.validate_server_config(remote_config)) + + # Test invalid configuration (should raise ValidationError during creation) + with self.assertRaises(Exception): # Pydantic ValidationError + invalid_config = MCPServerConfig() + self.strategy.validate_server_config(invalid_config) + + @patch('pathlib.Path.exists') + @regression_test + def test_kiro_host_availability_detection(self, mock_exists): + """Test Kiro host availability detection.""" + # Test when Kiro directory exists + mock_exists.return_value = True + self.assertTrue(self.strategy.is_host_available()) + + # Test when Kiro directory doesn't exist + mock_exists.return_value = False + self.assertFalse(self.strategy.is_host_available()) + + @patch('builtins.open', new_callable=mock_open) + @patch('pathlib.Path.exists') + @patch('json.load') + @regression_test + def test_kiro_read_configuration_success(self, mock_json_load, mock_exists, mock_file): + """Test successful Kiro configuration reading.""" + # Mock file exists and JSON content + mock_exists.return_value = True + mock_json_load.return_value = { + "mcpServers": { + "augment": { + "command": "auggie", + "args": ["--mcp", "-m", "default"], + "autoApprove": ["codebase-retrieval"] + } + } + } + + config = self.strategy.read_configuration() + + # Verify configuration structure + self.assertIsInstance(config, HostConfiguration) + self.assertIn("augment", config.servers) + + server = config.servers["augment"] + self.assertEqual(server.command, "auggie") + self.assertEqual(len(server.args), 3) + + @patch('pathlib.Path.exists') + @regression_test + def test_kiro_read_configuration_file_not_exists(self, mock_exists): + """Test Kiro configuration reading when file doesn't exist.""" + mock_exists.return_value = False + + config = self.strategy.read_configuration() + + # Should return empty configuration + self.assertIsInstance(config, HostConfiguration) + self.assertEqual(len(config.servers), 0) + + @patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') + @patch('hatch.mcp_host_config.strategies.AtomicFileOperations') + @patch('builtins.open', new_callable=mock_open) + @patch('pathlib.Path.exists') + @patch('pathlib.Path.mkdir') + @patch('json.load') + @regression_test + def test_kiro_write_configuration_success(self, mock_json_load, mock_mkdir, + mock_exists, mock_file, mock_atomic_ops_class, mock_backup_manager_class): + """Test successful Kiro configuration writing.""" + # Mock existing file with other settings + mock_exists.return_value = True + mock_json_load.return_value = { + "otherSettings": {"theme": "dark"}, + "mcpServers": {} + } + + # Mock backup and atomic operations + mock_backup_manager = MagicMock() + mock_backup_manager_class.return_value = mock_backup_manager + + mock_atomic_ops = MagicMock() + mock_atomic_ops_class.return_value = mock_atomic_ops + + # Create test configuration + server_config = MCPServerConfig( + command="auggie", + args=["--mcp"] + ) + config = HostConfiguration(servers={"test-server": server_config}) + + result = self.strategy.write_configuration(config) + + # Verify success + self.assertTrue(result) + + # Verify atomic write was called + mock_atomic_ops.atomic_write_with_backup.assert_called_once() + + # Verify configuration structure in the call + call_args = mock_atomic_ops.atomic_write_with_backup.call_args + written_data = call_args[1]['data'] # keyword argument 'data' + self.assertIn("otherSettings", written_data) # Preserved + self.assertIn("mcpServers", written_data) # Updated + self.assertIn("test-server", written_data["mcpServers"]) + + @patch('hatch.mcp_host_config.strategies.MCPHostConfigBackupManager') + @patch('hatch.mcp_host_config.strategies.AtomicFileOperations') + @patch('builtins.open', new_callable=mock_open) + @patch('pathlib.Path.exists') + @patch('pathlib.Path.mkdir') + @regression_test + def test_kiro_write_configuration_new_file(self, mock_mkdir, mock_exists, + mock_file, mock_atomic_ops_class, mock_backup_manager_class): + """Test Kiro configuration writing when file doesn't exist.""" + # Mock file doesn't exist + mock_exists.return_value = False + + # Mock backup and atomic operations + mock_backup_manager = MagicMock() + mock_backup_manager_class.return_value = mock_backup_manager + + mock_atomic_ops = MagicMock() + mock_atomic_ops_class.return_value = mock_atomic_ops + + # Create test configuration + server_config = MCPServerConfig( + command="auggie", + args=["--mcp"] + ) + config = HostConfiguration(servers={"new-server": server_config}) + + result = self.strategy.write_configuration(config) + + # Verify success + self.assertTrue(result) + + # Verify directory creation was attempted + mock_mkdir.assert_called_once() + + # Verify atomic write was called + mock_atomic_ops.atomic_write_with_backup.assert_called_once() + + # Verify configuration structure + call_args = mock_atomic_ops.atomic_write_with_backup.call_args + written_data = call_args[1]['data'] # keyword argument 'data' + self.assertIn("mcpServers", written_data) + self.assertIn("new-server", written_data["mcpServers"]) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/regression/test_mcp_kiro_model_validation.py b/tests/regression/test_mcp_kiro_model_validation.py new file mode 100644 index 0000000..2e8ea05 --- /dev/null +++ b/tests/regression/test_mcp_kiro_model_validation.py @@ -0,0 +1,116 @@ +""" +Kiro MCP Model Validation Tests + +Tests for MCPServerConfigKiro Pydantic model behavior, field validation, +and Kiro-specific field combinations. +""" + +import unittest +from typing import Optional, List + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.models import ( + MCPServerConfigKiro, + MCPServerConfigOmni, + MCPHostType +) + + +class TestMCPServerConfigKiro(unittest.TestCase): + """Test suite for MCPServerConfigKiro model validation.""" + + @regression_test + def test_kiro_model_with_disabled_field(self): + """Test Kiro model with disabled field.""" + config = MCPServerConfigKiro( + name="kiro-server", + command="auggie", + args=["--mcp", "-m", "default"], + disabled=True + ) + + self.assertEqual(config.command, "auggie") + self.assertTrue(config.disabled) + self.assertEqual(config.type, "stdio") # Inferred + + @regression_test + def test_kiro_model_with_auto_approve_tools(self): + """Test Kiro model with autoApprove field.""" + config = MCPServerConfigKiro( + name="kiro-server", + command="auggie", + autoApprove=["codebase-retrieval", "fetch"] + ) + + self.assertEqual(config.command, "auggie") + self.assertEqual(len(config.autoApprove), 2) + self.assertIn("codebase-retrieval", config.autoApprove) + self.assertIn("fetch", config.autoApprove) + + @regression_test + def test_kiro_model_with_disabled_tools(self): + """Test Kiro model with disabledTools field.""" + config = MCPServerConfigKiro( + name="kiro-server", + command="python", + disabledTools=["dangerous-tool", "risky-tool"] + ) + + self.assertEqual(config.command, "python") + self.assertEqual(len(config.disabledTools), 2) + self.assertIn("dangerous-tool", config.disabledTools) + + @regression_test + def test_kiro_model_all_fields_combined(self): + """Test Kiro model with all Kiro-specific fields.""" + config = MCPServerConfigKiro( + name="kiro-server", + command="auggie", + args=["--mcp"], + env={"DEBUG": "true"}, + disabled=False, + autoApprove=["codebase-retrieval"], + disabledTools=["dangerous-tool"] + ) + + # Verify all fields + self.assertEqual(config.command, "auggie") + self.assertFalse(config.disabled) + self.assertEqual(len(config.autoApprove), 1) + self.assertEqual(len(config.disabledTools), 1) + self.assertEqual(config.env["DEBUG"], "true") + + @regression_test + def test_kiro_model_minimal_configuration(self): + """Test Kiro model with minimal configuration.""" + config = MCPServerConfigKiro( + name="kiro-server", + command="auggie" + ) + + self.assertEqual(config.command, "auggie") + self.assertEqual(config.type, "stdio") # Inferred + self.assertIsNone(config.disabled) + self.assertIsNone(config.autoApprove) + self.assertIsNone(config.disabledTools) + + @regression_test + def test_kiro_model_remote_server_with_kiro_fields(self): + """Test Kiro model with remote server and Kiro-specific fields.""" + config = MCPServerConfigKiro( + name="kiro-remote", + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer token"}, + disabled=True, + autoApprove=["safe-tool"] + ) + + self.assertEqual(config.url, "https://api.example.com/mcp") + self.assertTrue(config.disabled) + self.assertEqual(len(config.autoApprove), 1) + self.assertEqual(config.type, "sse") # Inferred for remote + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/regression/test_mcp_kiro_omni_conversion.py b/tests/regression/test_mcp_kiro_omni_conversion.py new file mode 100644 index 0000000..8c223ec --- /dev/null +++ b/tests/regression/test_mcp_kiro_omni_conversion.py @@ -0,0 +1,104 @@ +""" +Kiro MCP Omni Conversion Tests + +Tests for conversion from MCPServerConfigOmni to MCPServerConfigKiro +using the from_omni() method. +""" + +import unittest + +from wobble.decorators import regression_test + +from hatch.mcp_host_config.models import ( + MCPServerConfigKiro, + MCPServerConfigOmni +) + + +class TestKiroFromOmniConversion(unittest.TestCase): + """Test suite for Kiro from_omni() conversion method.""" + + @regression_test + def test_kiro_from_omni_with_supported_fields(self): + """Test Kiro from_omni with supported fields.""" + omni = MCPServerConfigOmni( + name="kiro-server", + command="auggie", + args=["--mcp", "-m", "default"], + disabled=True, + autoApprove=["codebase-retrieval", "fetch"], + disabledTools=["dangerous-tool"] + ) + + # Convert to Kiro model + kiro = MCPServerConfigKiro.from_omni(omni) + + # Verify all supported fields transferred + self.assertEqual(kiro.name, "kiro-server") + self.assertEqual(kiro.command, "auggie") + self.assertEqual(len(kiro.args), 3) + self.assertTrue(kiro.disabled) + self.assertEqual(len(kiro.autoApprove), 2) + self.assertEqual(len(kiro.disabledTools), 1) + + @regression_test + def test_kiro_from_omni_with_unsupported_fields(self): + """Test Kiro from_omni excludes unsupported fields.""" + omni = MCPServerConfigOmni( + name="kiro-server", + command="python", + disabled=True, # Kiro field + envFile=".env", # VS Code field (unsupported by Kiro) + timeout=30000 # Gemini field (unsupported by Kiro) + ) + + # Convert to Kiro model + kiro = MCPServerConfigKiro.from_omni(omni) + + # Verify Kiro fields transferred + self.assertEqual(kiro.command, "python") + self.assertTrue(kiro.disabled) + + # Verify unsupported fields NOT transferred + self.assertFalse(hasattr(kiro, 'envFile') and kiro.envFile is not None) + self.assertFalse(hasattr(kiro, 'timeout') and kiro.timeout is not None) + + @regression_test + def test_kiro_from_omni_exclude_unset_behavior(self): + """Test that from_omni respects exclude_unset=True.""" + omni = MCPServerConfigOmni( + name="kiro-server", + command="auggie" + # disabled, autoApprove, disabledTools not set + ) + + kiro = MCPServerConfigKiro.from_omni(omni) + + # Verify unset fields remain None + self.assertIsNone(kiro.disabled) + self.assertIsNone(kiro.autoApprove) + self.assertIsNone(kiro.disabledTools) + + @regression_test + def test_kiro_from_omni_remote_server_conversion(self): + """Test Kiro from_omni with remote server configuration.""" + omni = MCPServerConfigOmni( + name="kiro-remote", + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer token"}, + disabled=False, + autoApprove=["safe-tool"] + ) + + kiro = MCPServerConfigKiro.from_omni(omni) + + # Verify remote server fields + self.assertEqual(kiro.url, "https://api.example.com/mcp") + self.assertEqual(kiro.headers["Authorization"], "Bearer token") + self.assertFalse(kiro.disabled) + self.assertEqual(len(kiro.autoApprove), 1) + self.assertEqual(kiro.type, "sse") # Inferred for remote + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_data/codex/http_server.toml b/tests/test_data/codex/http_server.toml new file mode 100644 index 0000000..4a960da --- /dev/null +++ b/tests/test_data/codex/http_server.toml @@ -0,0 +1,7 @@ +[mcp_servers.figma] +url = "https://mcp.figma.com/mcp" +bearer_token_env_var = "FIGMA_OAUTH_TOKEN" + +[mcp_servers.figma.http_headers] +"X-Figma-Region" = "us-east-1" + diff --git a/tests/test_data/codex/stdio_server.toml b/tests/test_data/codex/stdio_server.toml new file mode 100644 index 0000000..cb6c985 --- /dev/null +++ b/tests/test_data/codex/stdio_server.toml @@ -0,0 +1,7 @@ +[mcp_servers.test-server] +command = "node" +args = ["server.js"] + +[mcp_servers.test-server.env] +API_KEY = "test-key" + diff --git a/tests/test_data/codex/valid_config.toml b/tests/test_data/codex/valid_config.toml new file mode 100644 index 0000000..f464ef5 --- /dev/null +++ b/tests/test_data/codex/valid_config.toml @@ -0,0 +1,13 @@ +[features] +rmcp_client = true + +[mcp_servers.context7] +command = "npx" +args = ["-y", "@upstash/context7-mcp"] +startup_timeout_sec = 10 +tool_timeout_sec = 60 +enabled = true + +[mcp_servers.context7.env] +MY_VAR = "value" + diff --git a/tests/test_data/configs/mcp_host_test_configs/kiro_mcp.json b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp.json new file mode 100644 index 0000000..7001130 --- /dev/null +++ b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} \ No newline at end of file diff --git a/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_complex.json b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_complex.json new file mode 100644 index 0000000..485523e --- /dev/null +++ b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_complex.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "local-server": { + "command": "auggie", + "args": ["--mcp"], + "disabled": false, + "autoApprove": ["codebase-retrieval"] + }, + "remote-server": { + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer token" + }, + "disabled": true, + "disabledTools": ["risky-tool"] + } + }, + "otherSettings": { + "theme": "dark", + "fontSize": 14 + } +} \ No newline at end of file diff --git a/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_empty.json b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_empty.json new file mode 100644 index 0000000..7001130 --- /dev/null +++ b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_empty.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} \ No newline at end of file diff --git a/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_with_server.json b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_with_server.json new file mode 100644 index 0000000..3fbd102 --- /dev/null +++ b/tests/test_data/configs/mcp_host_test_configs/kiro_mcp_with_server.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "existing-server": { + "command": "auggie", + "args": ["--mcp", "-m", "default", "-w", "."], + "env": { + "DEBUG": "true" + }, + "disabled": false, + "autoApprove": ["codebase-retrieval", "fetch"], + "disabledTools": ["dangerous-tool"] + } + } +} \ No newline at end of file diff --git a/tests/test_data/configs/mcp_host_test_configs/kiro_simple.json b/tests/test_data/configs/mcp_host_test_configs/kiro_simple.json new file mode 100644 index 0000000..8d8a263 --- /dev/null +++ b/tests/test_data/configs/mcp_host_test_configs/kiro_simple.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "test_server": { + "command": "auggie", + "args": [ + "--mcp" + ], + "disabled": false, + "autoApprove": [ + "codebase-retrieval" + ] + } + } +} \ No newline at end of file diff --git a/tests/test_data_utils.py b/tests/test_data_utils.py index f4f6251..59739a6 100644 --- a/tests/test_data_utils.py +++ b/tests/test_data_utils.py @@ -292,6 +292,25 @@ def load_mcp_server_config(self, server_type: str = "local") -> Dict[str, Any]: with open(config_path, 'r') as f: return json.load(f) + def load_kiro_mcp_config(self, config_type: str = "empty") -> Dict[str, Any]: + """Load Kiro-specific MCP configuration templates. + + Args: + config_type: Type of Kiro configuration to load + - "empty": Empty mcpServers configuration + - "with_server": Single server with all Kiro fields + - "complex": Multi-server with mixed configurations + + Returns: + Kiro MCP configuration dictionary + """ + config_path = self.mcp_host_configs_dir / f"kiro_mcp_{config_type}.json" + if not config_path.exists(): + self._create_kiro_mcp_config(config_type) + + with open(config_path, 'r') as f: + return json.load(f) + def _create_host_config_template(self, host_type: str, config_type: str): """Create host-specific configuration templates with inheritance patterns.""" templates = { @@ -364,6 +383,50 @@ def _create_host_config_template(self, host_type: str, config_type: str): "args": ["server.py"] } } + }, + + # Kiro family templates + "kiro_simple": { + "mcpServers": { + "test_server": { + "command": "auggie", + "args": ["--mcp"], + "disabled": False, + "autoApprove": ["codebase-retrieval"] + } + } + }, + "kiro_with_server": { + "mcpServers": { + "existing-server": { + "command": "auggie", + "args": ["--mcp", "-m", "default", "-w", "."], + "env": {"DEBUG": "true"}, + "disabled": False, + "autoApprove": ["codebase-retrieval", "fetch"], + "disabledTools": ["dangerous-tool"] + } + } + }, + "kiro_complex": { + "mcpServers": { + "local-server": { + "command": "auggie", + "args": ["--mcp"], + "disabled": False, + "autoApprove": ["codebase-retrieval"] + }, + "remote-server": { + "url": "https://api.example.com/mcp", + "headers": {"Authorization": "Bearer token"}, + "disabled": True, + "disabledTools": ["risky-tool"] + } + }, + "otherSettings": { + "theme": "dark", + "fontSize": 14 + } } } @@ -470,3 +533,48 @@ def _create_mcp_server_config(self, server_type: str): config_path = self.mcp_host_configs_dir / f"mcp_server_{server_type}.json" with open(config_path, 'w') as f: json.dump(config, f, indent=2) + + def _create_kiro_mcp_config(self, config_type: str): + """Create Kiro-specific MCP configuration templates.""" + templates = { + "empty": { + "mcpServers": {} + }, + "with_server": { + "mcpServers": { + "existing-server": { + "command": "auggie", + "args": ["--mcp", "-m", "default", "-w", "."], + "env": {"DEBUG": "true"}, + "disabled": False, + "autoApprove": ["codebase-retrieval", "fetch"], + "disabledTools": ["dangerous-tool"] + } + } + }, + "complex": { + "mcpServers": { + "local-server": { + "command": "auggie", + "args": ["--mcp"], + "disabled": False, + "autoApprove": ["codebase-retrieval"] + }, + "remote-server": { + "url": "https://api.example.com/mcp", + "headers": {"Authorization": "Bearer token"}, + "disabled": True, + "disabledTools": ["risky-tool"] + } + }, + "otherSettings": { + "theme": "dark", + "fontSize": 14 + } + } + } + + config = templates.get(config_type, {"mcpServers": {}}) + config_path = self.mcp_host_configs_dir / f"kiro_mcp_{config_type}.json" + with open(config_path, 'w') as f: + json.dump(config, f, indent=2) diff --git a/tests/test_mcp_cli_all_host_specific_args.py b/tests/test_mcp_cli_all_host_specific_args.py index 2026fc0..86f7092 100644 --- a/tests/test_mcp_cli_all_host_specific_args.py +++ b/tests/test_mcp_cli_all_host_specific_args.py @@ -15,7 +15,7 @@ from hatch.mcp_host_config import MCPHostType from hatch.mcp_host_config.models import ( MCPServerConfigGemini, MCPServerConfigCursor, MCPServerConfigVSCode, - MCPServerConfigClaude + MCPServerConfigClaude, MCPServerConfigCodex ) @@ -298,6 +298,199 @@ def test_exclude_tools_passed_to_gemini(self, mock_manager_class): self.assertEqual(server_config.excludeTools, ['dangerous_tool']) +class TestAllCodexArguments(unittest.TestCase): + """Test ALL Codex-specific CLI arguments.""" + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @patch('sys.stdout', new_callable=StringIO) + def test_all_codex_arguments_accepted(self, mock_stdout, mock_manager_class): + """Test that all Codex arguments are accepted and passed to model.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + # Test STDIO server with Codex-specific STDIO fields + result = handle_mcp_configure( + host='codex', + server_name='test-server', + command='npx', + args=['-y', '@upstash/context7-mcp'], + env_vars=['PATH', 'HOME'], + cwd='/workspace', + startup_timeout=15, + tool_timeout=120, + enabled=True, + include_tools=['read', 'write'], + exclude_tools=['delete'], + auto_approve=True + ) + + # Verify success + self.assertEqual(result, 0) + + # Verify configure_server was called + mock_manager.configure_server.assert_called_once() + + # Verify server_config is MCPServerConfigCodex + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertIsInstance(server_config, MCPServerConfigCodex) + + # Verify Codex-specific STDIO fields + self.assertEqual(server_config.env_vars, ['PATH', 'HOME']) + self.assertEqual(server_config.cwd, '/workspace') + self.assertEqual(server_config.startup_timeout_sec, 15) + self.assertEqual(server_config.tool_timeout_sec, 120) + self.assertTrue(server_config.enabled) + self.assertEqual(server_config.enabled_tools, ['read', 'write']) + self.assertEqual(server_config.disabled_tools, ['delete']) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @patch('sys.stdout', new_callable=StringIO) + def test_codex_env_vars_list(self, mock_stdout, mock_manager_class): + """Test that env_vars accepts multiple values as a list.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='codex', + server_name='test-server', + command='npx', + args=['-y', 'package'], + env_vars=['PATH', 'HOME', 'USER'], + auto_approve=True + ) + + self.assertEqual(result, 0) + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertEqual(server_config.env_vars, ['PATH', 'HOME', 'USER']) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @patch('sys.stdout', new_callable=StringIO) + def test_codex_env_header_parsing(self, mock_stdout, mock_manager_class): + """Test that env_header parses KEY=ENV_VAR format correctly.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='codex', + server_name='test-server', + command='npx', + args=['-y', 'package'], + env_header=['X-API-Key=API_KEY', 'Authorization=AUTH_TOKEN'], + auto_approve=True + ) + + self.assertEqual(result, 0) + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertEqual(server_config.env_http_headers, { + 'X-API-Key': 'API_KEY', + 'Authorization': 'AUTH_TOKEN' + }) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @patch('sys.stdout', new_callable=StringIO) + def test_codex_timeout_fields(self, mock_stdout, mock_manager_class): + """Test that timeout fields are passed as integers.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='codex', + server_name='test-server', + command='npx', + args=['-y', 'package'], + startup_timeout=30, + tool_timeout=180, + auto_approve=True + ) + + self.assertEqual(result, 0) + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertEqual(server_config.startup_timeout_sec, 30) + self.assertEqual(server_config.tool_timeout_sec, 180) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @patch('sys.stdout', new_callable=StringIO) + def test_codex_enabled_flag(self, mock_stdout, mock_manager_class): + """Test that enabled flag works as boolean.""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='codex', + server_name='test-server', + command='npx', + args=['-y', 'package'], + enabled=True, + auto_approve=True + ) + + self.assertEqual(result, 0) + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + self.assertTrue(server_config.enabled) + + @patch('hatch.cli_hatch.MCPHostConfigurationManager') + @patch('sys.stdout', new_callable=StringIO) + def test_codex_reuses_shared_arguments(self, mock_stdout, mock_manager_class): + """Test that Codex reuses shared arguments (cwd, include-tools, exclude-tools).""" + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + mock_result = MagicMock() + mock_result.success = True + mock_result.backup_path = None + mock_manager.configure_server.return_value = mock_result + + result = handle_mcp_configure( + host='codex', + server_name='test-server', + command='npx', + args=['-y', 'package'], + cwd='/workspace', + include_tools=['tool1', 'tool2'], + exclude_tools=['tool3'], + auto_approve=True + ) + + self.assertEqual(result, 0) + call_args = mock_manager.configure_server.call_args + server_config = call_args.kwargs['server_config'] + + # Verify shared arguments work for Codex STDIO servers + self.assertEqual(server_config.cwd, '/workspace') + self.assertEqual(server_config.enabled_tools, ['tool1', 'tool2']) + self.assertEqual(server_config.disabled_tools, ['tool3']) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_mcp_cli_direct_management.py b/tests/test_mcp_cli_direct_management.py index 44ddfc6..d22270f 100644 --- a/tests/test_mcp_cli_direct_management.py +++ b/tests/test_mcp_cli_direct_management.py @@ -40,17 +40,19 @@ def test_configure_argument_parsing_basic(self): try: result = main() # If main() returns without SystemExit, check the handler was called - # Updated to include ALL host-specific parameters + # Updated to include ALL host-specific parameters (27 total) mock_handler.assert_called_once_with( 'claude-desktop', 'weather-server', 'python', ['weather.py'], - None, None, None, None, False, None, None, None, None, None, None, False, False, False + None, None, None, None, False, None, None, None, None, None, None, + False, None, None, None, None, None, False, None, None, False, False, False ) except SystemExit as e: # If SystemExit is raised, it should be 0 (success) and handler should have been called if e.code == 0: mock_handler.assert_called_once_with( 'claude-desktop', 'weather-server', 'python', ['weather.py'], - None, None, None, None, False, None, None, None, None, None, None, False, False, False + None, None, None, None, False, None, None, None, None, None, None, + False, None, None, None, None, None, False, None, None, False, False, False ) else: self.fail(f"main() exited with code {e.code}, expected 0") @@ -70,11 +72,12 @@ def test_configure_argument_parsing_with_options(self): with patch('hatch.cli_hatch.handle_mcp_configure', return_value=0) as mock_handler: try: main() - # Updated to include ALL host-specific parameters + # Updated to include ALL host-specific parameters (27 total) mock_handler.assert_called_once_with( 'cursor', 'file-server', None, None, ['API_KEY=secret', 'DEBUG=true'], 'http://localhost:8080', - ['Authorization=Bearer token'], None, False, None, None, None, None, None, None, True, True, True + ['Authorization=Bearer token'], None, False, None, None, None, None, None, None, + False, None, None, None, None, None, False, None, None, True, True, True ) except SystemExit as e: self.assertEqual(e.code, 0)