diff --git a/.gitignore b/.gitignore index 142d43e..ce89625 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,9 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock +# uv +uv.lock + # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..094f9ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,240 @@ +# Vultr DNS MCP Package + +A Model Context Protocol (MCP) server for managing Vultr DNS domains and records through natural language interfaces. + +## Project Overview + +This package provides both an MCP server and a standalone Python client for Vultr DNS management. It supports all major DNS record types (A, AAAA, CNAME, MX, TXT, NS, SRV) with validation, analysis, and batch operations. + +## Quick Start + +### Installation +```bash +# Using uv (recommended) +uv add vultr-dns-mcp + +# Or using pip +pip install vultr-dns-mcp +``` + +### Basic Usage +```bash +# CLI - requires VULTR_API_KEY environment variable +vultr-dns-mcp domains list +vultr-dns-mcp records list example.com + +# As MCP server +uv run python -m vultr_dns_mcp.server --api-key YOUR_API_KEY +``` + +## Development Setup + +### Dependencies +```bash +# Using uv (recommended) +uv sync --extra dev + +# Or using pip +pip install -e .[dev] +``` + +### Running Tests +```bash +# All tests (using uv - recommended) +uv run pytest + +# Specific categories +uv run pytest -m unit +uv run pytest -m mcp +uv run pytest -m integration + +# With coverage +uv run pytest --cov=vultr_dns_mcp --cov-report=html + +# Comprehensive test runner +uv run python run_tests.py --all-checks + +# Traditional approach (fallback) +pytest +``` + +### Code Quality +```bash +# Using uv (recommended) +uv run black src tests +uv run isort src tests +uv run mypy src +uv run flake8 src tests + +# Traditional approach +black src tests +isort src tests +mypy src +flake8 src tests +``` + +### Build & Publishing +```bash +# Using uv (recommended) +uv build +uv run twine check dist/* +uv run twine upload --repository testpypi dist/* +uv run twine upload dist/* + +# Traditional approach +python -m build +python -m twine check dist/* +python -m twine upload --repository testpypi dist/* +python -m twine upload dist/* +``` + +## Test Structure + +Following FastMCP testing best practices: + +- `tests/conftest.py` - Test configuration and fixtures +- `tests/test_mcp_server.py` - MCP server functionality tests using in-memory pattern +- `tests/test_client.py` - VultrDNSClient tests +- `tests/test_cli.py` - CLI interface tests +- `tests/test_vultr_server.py` - Core VultrDNSServer tests +- `tests/test_package_validation.py` - Package integrity tests + +### Test Markers +- `@pytest.mark.unit` - Individual component testing +- `@pytest.mark.integration` - Component interaction testing +- `@pytest.mark.mcp` - MCP-specific functionality +- `@pytest.mark.slow` - Performance and timeout tests + +### Coverage Goals +- Overall: 80%+ (enforced) +- MCP Tools: 100% (critical functionality) +- API Client: 95%+ (core functionality) +- CLI Commands: 90%+ (user interface) + +## Project Structure + +``` +vultr-dns-mcp/ +├── src/vultr_dns_mcp/ +│ ├── client.py # High-level DNS client +│ ├── server.py # Core Vultr API client +│ ├── mcp_server.py # MCP server implementation +│ └── cli.py # Command-line interface +├── tests/ # Comprehensive test suite +├── docs/ # Documentation +└── pyproject.toml # Project configuration +``` + +## Key Features + +### MCP Tools (12 total) +- Domain management: list, create, delete, get details +- DNS record operations: CRUD for all record types +- Validation: Pre-creation validation with suggestions +- Analysis: Configuration analysis with security recommendations + +### MCP Resources +- Domain discovery endpoints +- DNS record resources +- Configuration capabilities + +### CLI Commands +- Domain operations: `domains list|create|delete|get` +- Record operations: `records list|create|update|delete` +- Setup utilities: `setup website|email` + +## Version History + +### Current: 1.0.1 +- Fixed FastMCP server initialization +- Corrected MCP server creation for current FastMCP version +- Simplified initialization to use only name parameter + +### 1.0.0 - Initial Release +- Complete MCP server implementation +- Python client library +- CLI interface +- Support for all DNS record types +- Validation and analysis features +- Comprehensive test suite + +## CI/CD Pipeline + +### GitHub Actions +- Multi-Python testing (3.8-3.12) +- Progressive test execution: validation → unit → integration → mcp +- Code quality gates: black, isort, flake8, mypy +- Security scanning: safety, bandit +- Package validation: build, install, test CLI + +### Quality Gates +1. All tests pass on all Python versions +2. Code coverage meets 80% threshold +3. Code quality checks pass +4. Security scans clean +5. Package builds and installs correctly + +## Publishing + +### Automated (Recommended) +1. Update version in `pyproject.toml` +2. Commit and push changes +3. Create and push version tag: `git tag v1.0.2 && git push origin v1.0.2` +4. Workflow automatically publishes to PyPI + +### Manual +Use the GitHub Actions "Publish to PyPI" workflow with manual trigger. + +## Development Guidelines + +### Testing +- Use FastMCP in-memory testing pattern for MCP functionality +- Mock external dependencies (Vultr API) +- Maintain high coverage on critical paths +- Follow pytest best practices + +### Code Style +- Black formatting +- isort import sorting +- Type hints with mypy +- Comprehensive docstrings +- Security best practices + +### Release Process +1. Update version in `pyproject.toml` +2. Update `CHANGELOG.md` +3. Run full test suite +4. Create git tag +5. Automated publishing via GitHub Actions + +## Troubleshooting + +### Common Issues +- **ImportError**: Run `pip install -e .` from repository root +- **AsyncioError**: Ensure `asyncio_mode = "auto"` in pyproject.toml +- **MockError**: Check that test fixtures are properly configured +- **API Errors**: Verify VULTR_API_KEY environment variable + +### Test Debugging +```bash +# Single test with verbose output (using uv) +uv run pytest tests/test_mcp_server.py::TestMCPTools::test_list_dns_domains_tool -vvv + +# Check pytest configuration +uv run pytest --collect-only tests/ + +# Validate imports +uv run python -c "from vultr_dns_mcp.server import create_mcp_server" + +# Traditional approach +pytest tests/test_mcp_server.py::TestMCPTools::test_list_dns_domains_tool -vvv +pytest --collect-only tests/ +python -c "from vultr_dns_mcp.server import create_mcp_server" +``` + +## Support & Documentation + +- **GitHub**: https://github.com/rsp2k/vultr-dns-mcp +- **PyPI**: https://pypi.org/project/vultr-dns-mcp/ +- **Documentation**: Complete API documentation and examples in package +- **Issues**: Use GitHub Issues for bug reports and feature requests \ No newline at end of file diff --git a/CLAUDE_DESKTOP_SETUP.md b/CLAUDE_DESKTOP_SETUP.md new file mode 100644 index 0000000..25e9f7e --- /dev/null +++ b/CLAUDE_DESKTOP_SETUP.md @@ -0,0 +1,248 @@ +# Testing Vultr DNS MCP in Claude Desktop + +This guide shows how to set up and test the Vultr DNS MCP package locally with Claude Desktop. + +## Prerequisites + +1. **Claude Desktop** installed on your machine +2. **Vultr API Key** - Get from [Vultr Account > API](https://my.vultr.com/settings/#settingsapi) +3. **Python 3.10+** with the package installed + +## Installation Options + +### Option 1: Install from PyPI (Recommended) +```bash +pip install vultr-dns-mcp +``` + +### Option 2: Install from Local Development +```bash +# From this project directory +pip install -e . +``` + +### Option 3: Using uv (Fastest) +```bash +uv add vultr-dns-mcp +``` + +## Claude Desktop Configuration + +### 1. Locate Claude Desktop Config + +The configuration file location depends on your OS: + +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json` +- **Linux**: `~/.config/Claude/claude_desktop_config.json` + +### 2. Add MCP Server Configuration + +Add this to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "vultr-dns": { + "command": "python", + "args": ["-m", "vultr_dns_mcp.server"], + "env": { + "VULTR_API_KEY": "YOUR_VULTR_API_KEY_HERE" + } + } + } +} +``` + +### 3. Alternative: Using uv (if you have it) + +```json +{ + "mcpServers": { + "vultr-dns": { + "command": "uv", + "args": ["run", "python", "-m", "vultr_dns_mcp.server"], + "env": { + "VULTR_API_KEY": "YOUR_VULTR_API_KEY_HERE" + } + } + } +} +``` + +### 4. Using Absolute Path (Most Reliable) + +If you have issues, use the absolute path to your Python installation: + +```json +{ + "mcpServers": { + "vultr-dns": { + "command": "/usr/bin/python3", + "args": ["-m", "vultr_dns_mcp.server"], + "env": { + "VULTR_API_KEY": "YOUR_VULTR_API_KEY_HERE" + } + } + } +} +``` + +## Testing the Setup + +### 1. Restart Claude Desktop +After saving the configuration file, completely restart Claude Desktop. + +### 2. Test MCP Connection +In Claude Desktop, you should see: +- An indicator that the MCP server is connected +- Access to Vultr DNS tools in the interface + +### 3. Example Prompts to Try + +Once connected, try these prompts in Claude Desktop: + +``` +"List all my DNS domains" +``` + +``` +"Show me the DNS records for example.com" +``` + +``` +"Create an A record for www.example.com pointing to 192.168.1.100" +``` + +``` +"Analyze the DNS configuration for my domain and suggest improvements" +``` + +``` +"Set up basic website DNS for newdomain.com with IP 203.0.113.10" +``` + +## Available MCP Tools + +The server provides these tools that Claude Desktop can use: + +1. **list_dns_domains** - List all your DNS domains +2. **get_dns_domain** - Get details for a specific domain +3. **create_dns_domain** - Create a new DNS domain +4. **delete_dns_domain** - Delete a domain and all its records +5. **list_dns_records** - List DNS records for a domain +6. **get_dns_record** - Get details for a specific record +7. **create_dns_record** - Create a new DNS record +8. **update_dns_record** - Update an existing record +9. **delete_dns_record** - Delete a DNS record +10. **validate_dns_record** - Validate a record before creation +11. **analyze_dns_records** - Analyze domain DNS configuration +12. **setup_website_dns** - Quick setup for website DNS + +## Troubleshooting + +### MCP Server Won't Start + +1. **Check Python installation**: + ```bash + python -c "import vultr_dns_mcp; print('✅ Package installed')" + ``` + +2. **Test server manually**: + ```bash + export VULTR_API_KEY="your-key" + python -m vultr_dns_mcp.server + ``` + +3. **Check API key**: + ```bash + export VULTR_API_KEY="your-key" + python -c " + from vultr_dns_mcp.client import VultrDNSClient + import asyncio + async def test(): + client = VultrDNSClient() + domains = await client.domains() + print(f'✅ Found {len(domains)} domains') + asyncio.run(test()) + " + ``` + +### Path Issues + +If Claude Desktop can't find Python: + +1. **Find your Python path**: + ```bash + which python3 + # or + which python + ``` + +2. **Update config with full path**: + ```json + { + "mcpServers": { + "vultr-dns": { + "command": "/full/path/to/python3", + "args": ["-m", "vultr_dns_mcp.server"], + "env": { + "VULTR_API_KEY": "YOUR_KEY" + } + } + } + } + ``` + +### Virtual Environment Issues + +If using a virtual environment: + +1. **Activate and find Python**: + ```bash + source your-venv/bin/activate # or your-venv\Scripts\activate on Windows + which python + ``` + +2. **Use that path in config**: + ```json + { + "mcpServers": { + "vultr-dns": { + "command": "/path/to/your-venv/bin/python", + "args": ["-m", "vultr_dns_mcp.server"], + "env": { + "VULTR_API_KEY": "YOUR_KEY" + } + } + } + } + ``` + +## Security Notes + +- Keep your Vultr API key secure +- Consider using environment variables instead of hardcoding in config +- The API key has access to modify your DNS records + +## Example Natural Language Interactions + +Once set up, you can use natural language with Claude Desktop: + +- "What domains do I have in Vultr?" +- "Add a CNAME record for blog.example.com pointing to example.com" +- "Delete the old MX record for example.com" +- "Set up email DNS for my domain with mail.example.com as the mail server" +- "Check if my domain has proper SPF and DMARC records" +- "Create an IPv6 AAAA record for www pointing to 2001:db8::1" + +Claude Desktop will use the MCP tools to perform these operations on your behalf! + +## Next Steps + +- Try the natural language prompts above +- Explore the comprehensive DNS management capabilities +- Use the analysis features to improve your DNS setup +- Set up automation for common DNS tasks + +Happy DNS managing! 🎉 \ No newline at end of file diff --git a/README.md b/README.md index 1f9fbbf..a7ec4b9 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,253 @@ -# Vultr DNS MCP Test Suite - Complete Fix Package +# Vultr DNS MCP -## 🎯 Quick Solution +A comprehensive Model Context Protocol (MCP) server for managing Vultr DNS records through natural language interfaces. -I've analyzed the broken tests in the vultr-dns-mcp repository and created a complete fix package. Here's how to apply it: +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-green.svg)](https://modelcontextprotocol.io/) + +## Features + +- **Complete MCP Server**: Full Model Context Protocol implementation with 12 tools and 3 resources +- **Comprehensive DNS Management**: Support for all major record types (A, AAAA, CNAME, MX, TXT, NS, SRV) +- **Intelligent Validation**: Pre-creation validation with helpful suggestions and warnings +- **Configuration Analysis**: DNS setup analysis with security recommendations +- **CLI Interface**: Complete command-line tool for direct DNS operations +- **High-Level Client**: Convenient Python API for common operations +- **Modern Development**: Fast development workflow with uv support + +## Quick Start + +### Installation -### One-Command Fix (if you have access to this directory): ```bash -# From your vultr-dns-mcp repository root: -bash /home/rpm/claude/vultr-dns-mcp-fix/fix_tests.sh +# Using uv (recommended - fast and modern) +uv add vultr-dns-mcp + +# Or using pip +pip install vultr-dns-mcp ``` -### Manual Fix (recommended): +### Basic Usage + ```bash -# 1. Navigate to your repository -cd /path/to/vultr-dns-mcp +# Set your Vultr API key +export VULTR_API_KEY="your-api-key" -# 2. Backup current files -cp tests/conftest.py tests/conftest.py.backup -cp tests/test_mcp_server.py tests/test_mcp_server.py.backup +# List domains +vultr-dns-mcp domains list -# 3. Copy fixed files -cp /home/rpm/claude/vultr-dns-mcp-fix/fixed_conftest.py tests/conftest.py -cp /home/rpm/claude/vultr-dns-mcp-fix/fixed_test_mcp_server.py tests/test_mcp_server.py +# List DNS records +vultr-dns-mcp records list example.com -# 4. Install dependencies +# Set up basic website DNS +vultr-dns-mcp setup-website example.com 192.168.1.100 + +# Run as MCP server +uv run python -m vultr_dns_mcp.server +``` + +### Python API + +```python +import asyncio +from vultr_dns_mcp import VultrDNSClient + +async def main(): + client = VultrDNSClient("your-api-key") + + # List domains + domains = await client.domains() + + # Add DNS records + await client.add_a_record("example.com", "www", "192.168.1.100") + await client.add_mx_record("example.com", "@", "mail.example.com", 10) + + # Get domain summary + summary = await client.get_domain_summary("example.com") + print(f"Domain has {summary['total_records']} records") + +asyncio.run(main()) +``` + +### MCP Integration + +This package provides a complete MCP server that can be integrated with MCP-compatible clients: + +```python +from vultr_dns_mcp import create_mcp_server, run_server + +# Create server +server = create_mcp_server("your-api-key") + +# Run server +await run_server("your-api-key") +``` + +## Development + +### Prerequisites + +- Python 3.10+ +- [uv](https://docs.astral.sh/uv/) (recommended) or pip +- Vultr API key + +### Setup with uv (Recommended) + +```bash +# Clone the repository +git clone https://github.com/rsp2k/vultr-dns-mcp.git +cd vultr-dns-mcp + +# Install dependencies +uv sync --extra dev + +# Run tests +uv run pytest + +# Run comprehensive test suite +uv run python run_tests.py --all-checks + +# Format code +uv run black src tests +uv run isort src tests + +# Type checking +uv run mypy src +``` + +### Setup with pip (Traditional) + +```bash +# Create virtual environment +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install in development mode pip install -e .[dev] -# 5. Run tests -pytest tests/ -v +# Run tests +pytest + +# Run comprehensive test suite +python run_tests.py --all-checks ``` -## 🔍 Problems Identified & Fixed +## MCP Tools Available -| Issue | Severity | Status | Fix Applied | -|-------|----------|--------|------------| -| Import path problems | 🔴 Critical | ✅ Fixed | Updated all import statements | -| Async/await patterns | 🔴 Critical | ✅ Fixed | Fixed FastMCP Client usage | -| Mock configuration | 🟡 Medium | ✅ Fixed | Complete API response mocks | -| Test data structure | 🟡 Medium | ✅ Fixed | Updated fixtures to match API | -| Error handling gaps | 🟢 Low | ✅ Fixed | Added comprehensive error tests | +| Tool | Description | +|------|-------------| +| `list_dns_domains` | List all DNS domains | +| `get_dns_domain` | Get domain details | +| `create_dns_domain` | Create new domain | +| `delete_dns_domain` | Delete domain and all records | +| `list_dns_records` | List records for a domain | +| `get_dns_record` | Get specific record details | +| `create_dns_record` | Create new DNS record | +| `update_dns_record` | Update existing record | +| `delete_dns_record` | Delete DNS record | +| `validate_dns_record` | Validate record before creation | +| `analyze_dns_records` | Analyze domain configuration | -## 📁 Files in This Fix Package - -### Core Fixes -- **`fixed_conftest.py`** - Updated test configuration with proper mocks -- **`fixed_test_mcp_server.py`** - All MCP server tests with correct async patterns -- **`fix_tests.sh`** - Automated installer script - -### Documentation -- **`FINAL_SOLUTION.md`** - Complete solution overview -- **`COMPLETE_FIX_GUIDE.md`** - Detailed fix documentation - -### Utilities -- **`analyze_test_issues.py`** - Issue analysis script -- **`comprehensive_test_fix.py`** - Complete fix generator -- **`create_fixes.py`** - Simple fix creator - -## 🚀 What Gets Fixed - -### Before (Broken): -```python -# Incorrect async pattern -async def test_tool(self, mcp_server): - result = await client.call_tool("tool_name", {}) - # ❌ Missing proper async context - # ❌ No mock configuration - # ❌ Incomplete error handling -``` - -### After (Fixed): -```python -@pytest.mark.asyncio -async def test_tool(self, mock_vultr_client): - with patch('vultr_dns_mcp.server.VultrDNSServer', return_value=mock_vultr_client): - server = create_mcp_server("test-api-key") - async with Client(server) as client: # ✅ Proper context manager - result = await client.call_tool("tool_name", {}) - assert result is not None # ✅ Proper assertions - mock_vultr_client.method.assert_called_once() # ✅ Mock verification -``` - -## 🧪 Expected Test Results - -After applying the fixes, you should see: +## CLI Commands ```bash -$ pytest tests/test_mcp_server.py -v +# Domain management +vultr-dns-mcp domains list +vultr-dns-mcp domains info example.com +vultr-dns-mcp domains create newdomain.com 192.168.1.100 -tests/test_mcp_server.py::TestMCPServerBasics::test_server_creation PASSED -tests/test_mcp_server.py::TestMCPTools::test_list_dns_domains_tool PASSED -tests/test_mcp_server.py::TestMCPTools::test_get_dns_domain_tool PASSED -tests/test_mcp_server.py::TestMCPTools::test_create_dns_domain_tool PASSED -tests/test_mcp_server.py::TestMCPResources::test_domains_resource PASSED -tests/test_mcp_server.py::TestMCPIntegration::test_complete_domain_workflow PASSED -tests/test_mcp_server.py::TestValidationLogic::test_a_record_validation PASSED +# Record management +vultr-dns-mcp records list example.com +vultr-dns-mcp records add example.com A www 192.168.1.100 +vultr-dns-mcp records delete example.com record-id -========================== 25 passed in 2.34s ========================== +# Setup utilities +vultr-dns-mcp setup-website example.com 192.168.1.100 +vultr-dns-mcp setup-email example.com mail.example.com + +# Start MCP server +vultr-dns-mcp server ``` -## 🔧 Key Technical Fixes +## Testing -### 1. Fixed Async Patterns -- Proper `@pytest.mark.asyncio` usage -- Correct `async with Client(server) as client:` context managers -- Fixed await patterns throughout +This project follows FastMCP testing best practices with comprehensive test coverage: -### 2. Improved Mock Configuration -- Complete `AsyncMock` setup with proper specs -- All Vultr API methods properly mocked -- Realistic API response structures +```bash +# Run all tests (uv) +uv run pytest -### 3. Better Error Handling -- Comprehensive error scenario testing -- Graceful handling of API failures -- Proper exception testing patterns +# Run specific test categories +uv run pytest -m unit # Unit tests +uv run pytest -m integration # Integration tests +uv run pytest -m mcp # MCP-specific tests -### 4. Updated Dependencies -- Fixed pytest-asyncio configuration -- Proper FastMCP version requirements -- Added missing test dependencies +# With coverage +uv run pytest --cov=vultr_dns_mcp --cov-report=html -## 🆘 Troubleshooting +# Full validation suite +uv run python run_tests.py --all-checks +``` -### If tests still fail: +## Contributing -1. **Check installation**: - ```bash - pip list | grep -E "(pytest|fastmcp|httpx)" - ``` +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run the test suite (`uv run python run_tests.py --all-checks`) +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request -2. **Verify imports**: - ```bash - python -c "from vultr_dns_mcp.server import create_mcp_server" - ``` +## Error Handling -3. **Run single test**: - ```bash - pytest tests/test_mcp_server.py::TestMCPTools::test_list_dns_domains_tool -vvv - ``` +The package provides specific exception types for better error handling: -4. **Check pytest config**: - ```bash - pytest --collect-only tests/ - ``` +```python +from vultr_dns_mcp import ( + VultrAPIError, + VultrAuthError, + VultrRateLimitError, + VultrResourceNotFoundError, + VultrValidationError +) -### Common Issues: -- **ImportError**: Run `pip install -e .` from repository root -- **AsyncioError**: Ensure `asyncio_mode = "auto"` in pyproject.toml -- **MockError**: Check that fixed_conftest.py was properly copied +try: + await client.get_domain("example.com") +except VultrAuthError: + print("Invalid API key or insufficient permissions") +except VultrResourceNotFoundError: + print("Domain not found") +except VultrRateLimitError: + print("Rate limit exceeded, please try again later") +except VultrAPIError as e: + print(f"API error {e.status_code}: {e.message}") +``` -## 📊 Success Metrics +## Configuration -You'll know the fix worked when: -- ✅ Zero test failures in MCP test suite -- ✅ All async tests run without warnings -- ✅ Mock verification passes -- ✅ Coverage >80% on core modules -- ✅ Integration tests complete end-to-end +Set your Vultr API key via environment variable: -## 🎉 Summary +```bash +export VULTR_API_KEY="your-vultr-api-key" +``` -This fix package addresses all the major issues in the vultr-dns-mcp test suite: +Or pass directly to the client: -1. **Fixes critical async/await patterns** that were causing test failures -2. **Provides comprehensive mock configuration** matching the Vultr API -3. **Adds proper error handling tests** for robustness -4. **Updates all import statements** to work correctly -5. **Includes complete documentation** for maintenance +```python +client = VultrDNSClient("your-api-key") +server = create_mcp_server("your-api-key") +``` -The fixed test suite follows FastMCP best practices and provides reliable, maintainable tests for the Vultr DNS MCP server functionality. +## License ---- +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -**Quick Start**: Copy `fixed_conftest.py` and `fixed_test_mcp_server.py` to your `tests/` directory, install dependencies with `pip install -e .[dev]`, and run `pytest tests/ -v`. +## Links -**Need Help?** Check `FINAL_SOLUTION.md` for detailed instructions or `COMPLETE_FIX_GUIDE.md` for comprehensive documentation. +- [GitHub Repository](https://github.com/rsp2k/vultr-dns-mcp) +- [PyPI Package](https://pypi.org/project/vultr-dns-mcp/) +- [Vultr API Documentation](https://www.vultr.com/api/) +- [Model Context Protocol](https://modelcontextprotocol.io/) +- [uv Package Manager](https://docs.astral.sh/uv/) \ No newline at end of file diff --git a/TESTING.md b/TESTING.md index 936fef5..392fe16 100644 --- a/TESTING.md +++ b/TESTING.md @@ -104,32 +104,41 @@ def mock_vultr_client(): ## 🚀 Running Tests -### Using pytest directly: +### Using uv (recommended): ```bash # All tests -pytest +uv run pytest # Specific categories -pytest -m unit -pytest -m integration -pytest -m mcp -pytest -m "not slow" +uv run pytest -m unit +uv run pytest -m integration +uv run pytest -m mcp +uv run pytest -m "not slow" # With coverage -pytest --cov=vultr_dns_mcp --cov-report=html +uv run pytest --cov=vultr_dns_mcp --cov-report=html ``` ### Using the test runner: ```bash -# Comprehensive test runner -python run_tests.py +# Comprehensive test runner (uv) +uv run python run_tests.py # Specific test types -python run_tests.py --type unit --verbose -python run_tests.py --type mcp --coverage -python run_tests.py --fast # Skip slow tests +uv run python run_tests.py --type unit --verbose +uv run python run_tests.py --type mcp --coverage +uv run python run_tests.py --fast # Skip slow tests # Full validation +uv run python run_tests.py --all-checks +``` + +### Traditional approach (fallback): +```bash +# All tests +pytest + +# Test runner python run_tests.py --all-checks ``` diff --git a/debug_server.py b/debug_server.py new file mode 100644 index 0000000..5e61de7 --- /dev/null +++ b/debug_server.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import asyncio +import sys +import os +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Resource, Tool, TextContent + +async def main(): + # Set up API key + api_key = os.getenv("VULTR_API_KEY") + if not api_key: + print("VULTR_API_KEY not set", file=sys.stderr) + sys.exit(1) + + print(f"Starting server with API key: {api_key[:8]}...", file=sys.stderr) + + # Create minimal server + server = Server("vultr-dns-debug") + + @server.list_tools() + async def list_tools() -> list[Tool]: + return [ + Tool( + name="test_tool", + description="A simple test tool", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name == "test_tool": + return [TextContent(type="text", text="Test tool working!")] + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + print("Server configured, starting stdio...", file=sys.stderr) + + try: + async with stdio_server() as (read_stream, write_stream): + print("Running server...", file=sys.stderr) + await server.run(read_stream, write_stream, None) + except Exception as e: + print(f"Server error: {e}", file=sys.stderr) + raise + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/install_dev.sh b/install_dev.sh index b16778e..629110f 100644 --- a/install_dev.sh +++ b/install_dev.sh @@ -10,29 +10,59 @@ echo "🔧 Installing vultr-dns-mcp in development mode..." # Change to package directory cd "$(dirname "$0")" -# Check if we're in a virtual environment -if [[ -z "$VIRTUAL_ENV" ]]; then - echo "⚠️ Warning: Not in a virtual environment" - echo " Consider running: python -m venv .venv && source .venv/bin/activate" +# Check for uv first, fall back to pip +if command -v uv &> /dev/null; then + echo "📦 Using uv for fast, modern dependency management..." + + # Sync dependencies with dev extras + echo "🔄 Syncing dependencies..." + uv sync --extra dev + + echo "✅ Installation complete!" + echo "" + echo "🚀 You can now run:" + echo " vultr-dns-mcp --help" + echo " vultr-dns-mcp server" + echo "" + echo "🧪 Run tests with:" + echo " uv run pytest" + echo " uv run python run_tests.py --all-checks" + echo "" + echo "🔧 Code quality tools:" + echo " uv run black src tests" + echo " uv run mypy src" + echo "" + +else + echo "📦 Using pip (consider installing uv for faster dependency management)..." + echo " Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh" + echo "" + + # Check if we're in a virtual environment + if [[ -z "$VIRTUAL_ENV" ]]; then + echo "⚠️ Warning: Not in a virtual environment" + echo " Consider running: python -m venv .venv && source .venv/bin/activate" + echo "" + fi + + # Install in development mode + echo "📦 Installing package dependencies..." + pip install -e . + + echo "🧪 Installing development dependencies..." + pip install -e .[dev] + + echo "✅ Installation complete!" + echo "" + echo "🚀 You can now run:" + echo " vultr-dns-mcp --help" + echo " vultr-dns-mcp server" + echo "" + echo "🧪 Run tests with:" + echo " pytest" + echo " python run_tests.py --all-checks" echo "" fi -# Install in development mode -echo "📦 Installing package dependencies..." -pip install -e . - -echo "🧪 Installing development dependencies..." -pip install -e .[dev] - -echo "✅ Installation complete!" -echo "" -echo "🚀 You can now run:" -echo " vultr-dns-mcp --help" -echo " vultr-dns-mcp server" -echo "" -echo "🧪 Run tests with:" -echo " python test_fix.py" -echo " pytest" -echo "" echo "📝 Set your API key:" echo " export VULTR_API_KEY='your-api-key-here'" diff --git a/pyproject.toml b/pyproject.toml index 4b0da24..daecbcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "mcp>=1.0.0", + "fastmcp>=0.1.0", "httpx>=0.24.0", "pydantic>=2.0.0", "click>=8.0.0" @@ -180,3 +180,16 @@ exclude_lines = [ "if 0:", "if __name__ == .__main__.:" ] + +[tool.uv] +dev-dependencies = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "isort>=5.12.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "pre-commit>=3.0.0", + "twine>=4.0.0" +] diff --git a/run_tests.py b/run_tests.py index 7253743..ca33504 100644 --- a/run_tests.py +++ b/run_tests.py @@ -18,8 +18,8 @@ def run_tests(test_type="all", verbose=False, coverage=False, fast=False): # Change to package directory package_dir = Path(__file__).parent - # Base pytest command - cmd = ["python", "-m", "pytest"] + # Base pytest command using uv run + cmd = ["uv", "run", "pytest"] # Add verbosity if verbose: @@ -73,7 +73,7 @@ def run_tests(test_type="all", verbose=False, coverage=False, fast=False): return result.returncode == 0 except FileNotFoundError: - print("❌ Error: pytest not found. Install with: pip install pytest") + print("❌ Error: pytest not found. Install with: uv add pytest") return False except Exception as e: print(f"❌ Error running tests: {e}") @@ -86,10 +86,10 @@ def run_linting(): print("=" * 50) checks = [ - (["python", "-m", "black", "--check", "src", "tests"], "Black formatting"), - (["python", "-m", "isort", "--check", "src", "tests"], "Import sorting"), - (["python", "-m", "flake8", "src", "tests"], "Flake8 linting"), - (["python", "-m", "mypy", "src"], "Type checking") + (["uv", "run", "black", "--check", "src", "tests"], "Black formatting"), + (["uv", "run", "isort", "--check", "src", "tests"], "Import sorting"), + (["uv", "run", "flake8", "src", "tests"], "Flake8 linting"), + (["uv", "run", "mypy", "src"], "Type checking") ] all_passed = True @@ -209,9 +209,9 @@ def main(): if success: print("🎉 All checks passed!") print("\n📚 Next steps:") - print(" • Run 'python -m build' to build the package") - print(" • Run 'python -m twine check dist/*' to validate") - print(" • Upload to PyPI with 'python -m twine upload dist/*'") + print(" • Run 'uv build' to build the package") + print(" • Run 'uv run twine check dist/*' to validate") + print(" • Upload to PyPI with 'uv run twine upload dist/*'") else: print("❌ Some checks failed. Please fix the issues above.") sys.exit(1) diff --git a/simple_fastmcp.py b/simple_fastmcp.py new file mode 100644 index 0000000..92ea143 --- /dev/null +++ b/simple_fastmcp.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import sys +import os +from fastmcp import FastMCP + +print("SIMPLE FASTMCP SERVER STARTING", file=sys.stderr) + +# Create FastMCP server +mcp = FastMCP(name="vultr-dns-simple") + +@mcp.tool +def test_tool() -> str: + """A simple test tool""" + return "Hello from Vultr DNS MCP!" + +@mcp.tool +def list_domains() -> str: + """List DNS domains""" + return "This would list your DNS domains" + +print("TOOLS REGISTERED, STARTING SERVER", file=sys.stderr) + +if __name__ == "__main__": + print("RUNNING MCP SERVER", file=sys.stderr) + mcp.run() \ No newline at end of file diff --git a/src/vultr_dns_mcp/__init__.py b/src/vultr_dns_mcp/__init__.py index 1e62157..82230e5 100644 --- a/src/vultr_dns_mcp/__init__.py +++ b/src/vultr_dns_mcp/__init__.py @@ -23,7 +23,16 @@ Main functions: run_server: Convenience function to run the MCP server """ -from .server import VultrDNSServer, create_mcp_server, run_server +from .server import ( + VultrDNSServer, + create_mcp_server, + run_server, + VultrAPIError, + VultrAuthError, + VultrRateLimitError, + VultrResourceNotFoundError, + VultrValidationError +) from .client import VultrDNSClient from ._version import __version__, __version_info__ @@ -32,6 +41,11 @@ __all__ = [ "VultrDNSClient", "create_mcp_server", "run_server", + "VultrAPIError", + "VultrAuthError", + "VultrRateLimitError", + "VultrResourceNotFoundError", + "VultrValidationError", "__version__", "__version_info__" ] diff --git a/src/vultr_dns_mcp/__main__.py b/src/vultr_dns_mcp/__main__.py new file mode 100644 index 0000000..7cfb8b6 --- /dev/null +++ b/src/vultr_dns_mcp/__main__.py @@ -0,0 +1,17 @@ +""" +Main entry point for running the Vultr DNS FastMCP server. +""" + +import sys +from .fastmcp_server import run_server + + +if __name__ == "__main__": + try: + run_server() + except KeyboardInterrupt: + print("Server stopped by user", file=sys.stderr) + sys.exit(0) + except Exception as e: + print(f"Server error: {e}", file=sys.stderr) + sys.exit(1) \ No newline at end of file diff --git a/src/vultr_dns_mcp/fastmcp_server.py b/src/vultr_dns_mcp/fastmcp_server.py new file mode 100644 index 0000000..16fbaf9 --- /dev/null +++ b/src/vultr_dns_mcp/fastmcp_server.py @@ -0,0 +1,208 @@ +""" +Vultr DNS FastMCP Server Implementation. + +This module contains the FastMCP server implementation for managing DNS records +through the Vultr API using the FastMCP framework. +""" + +import os +from typing import Optional, List, Dict, Any +from fastmcp import FastMCP +from .server import VultrDNSServer + + +def create_vultr_mcp_server(api_key: Optional[str] = None) -> FastMCP: + """ + Create a FastMCP server for Vultr DNS management. + + Args: + api_key: Vultr API key. If not provided, will read from VULTR_API_KEY env var. + + Returns: + Configured FastMCP server instance + """ + if not api_key: + api_key = os.getenv("VULTR_API_KEY") + + if not api_key: + raise ValueError( + "VULTR_API_KEY must be provided either as parameter or environment variable" + ) + + # Create FastMCP server + mcp = FastMCP(name="vultr-dns-mcp") + + # Initialize Vultr client + vultr_client = VultrDNSServer(api_key) + + @mcp.tool + async def list_dns_domains() -> List[Dict[str, Any]]: + """List all DNS domains in your Vultr account.""" + return await vultr_client.list_domains() + + @mcp.tool + async def get_dns_domain(domain: str) -> Dict[str, Any]: + """Get details for a specific DNS domain. + + Args: + domain: The domain name to get details for + """ + return await vultr_client.get_domain(domain) + + @mcp.tool + async def create_dns_domain(domain: str, ip: str, dns_sec: str = "disabled") -> Dict[str, Any]: + """Create a new DNS domain. + + Args: + domain: The domain name to create + ip: The default IP address for the domain + dns_sec: Enable DNSSEC (enabled/disabled, default: disabled) + """ + return await vultr_client.create_domain(domain, ip, dns_sec) + + @mcp.tool + async def delete_dns_domain(domain: str) -> Dict[str, str]: + """Delete a DNS domain and all its records. + + Args: + domain: The domain name to delete + """ + await vultr_client.delete_domain(domain) + return {"status": "success", "message": f"Domain {domain} deleted successfully"} + + @mcp.tool + async def list_dns_records(domain: str) -> List[Dict[str, Any]]: + """List all DNS records for a domain. + + Args: + domain: The domain name to list records for + """ + return await vultr_client.list_records(domain) + + @mcp.tool + async def get_dns_record(domain: str, record_id: str) -> Dict[str, Any]: + """Get details for a specific DNS record. + + Args: + domain: The domain name + record_id: The record ID to get details for + """ + return await vultr_client.get_record(domain, record_id) + + @mcp.tool + async def create_dns_record( + domain: str, + record_type: str, + name: str, + data: str, + ttl: int = 300, + priority: Optional[int] = None + ) -> Dict[str, Any]: + """Create a new DNS record. + + Args: + domain: The domain name + record_type: Record type (A, AAAA, CNAME, MX, TXT, NS, SRV) + name: Record name/subdomain + data: Record data/value + ttl: Time to live in seconds (default: 300) + priority: Priority for MX/SRV records + """ + return await vultr_client.create_record(domain, record_type, name, data, ttl, priority) + + @mcp.tool + async def update_dns_record( + domain: str, + record_id: str, + name: Optional[str] = None, + data: Optional[str] = None, + ttl: Optional[int] = None, + priority: Optional[int] = None + ) -> Dict[str, Any]: + """Update an existing DNS record. + + Args: + domain: The domain name + record_id: The record ID to update + name: New record name (optional) + data: New record data (optional) + ttl: New TTL value (optional) + priority: New priority for MX/SRV records (optional) + """ + return await vultr_client.update_record(domain, record_id, name, data, ttl, priority) + + @mcp.tool + async def delete_dns_record(domain: str, record_id: str) -> Dict[str, str]: + """Delete a DNS record. + + Args: + domain: The domain name + record_id: The record ID to delete + """ + await vultr_client.delete_record(domain, record_id) + return {"status": "success", "message": f"Record {record_id} deleted successfully"} + + @mcp.tool + async def validate_dns_record( + record_type: str, + name: str, + data: str, + ttl: int = 300, + priority: Optional[int] = None + ) -> Dict[str, Any]: + """Validate a DNS record before creation. + + Args: + record_type: Record type (A, AAAA, CNAME, MX, TXT, NS, SRV) + name: Record name/subdomain + data: Record data/value + ttl: Time to live in seconds + priority: Priority for MX/SRV records + """ + return await vultr_client.validate_record(record_type, name, data, ttl, priority) + + @mcp.tool + async def analyze_dns_records(domain: str) -> Dict[str, Any]: + """Analyze DNS records for a domain and provide recommendations. + + Args: + domain: The domain name to analyze + """ + return await vultr_client.analyze_records(domain) + + @mcp.tool + async def setup_website_dns(domain: str, ip: str, www_enabled: bool = True) -> List[Dict[str, Any]]: + """Set up basic DNS records for a website. + + Args: + domain: The domain name + ip: The website IP address + www_enabled: Whether to create www subdomain record (default: True) + """ + records = [] + + # Create A record for domain + records.append(await vultr_client.create_record(domain, "A", "@", ip)) + + # Create www CNAME if enabled + if www_enabled: + records.append(await vultr_client.create_record(domain, "CNAME", "www", domain)) + + return records + + return mcp + + +def run_server(api_key: Optional[str] = None) -> None: + """ + Create and run a Vultr DNS FastMCP server. + + Args: + api_key: Vultr API key. If not provided, will read from VULTR_API_KEY env var. + """ + mcp = create_vultr_mcp_server(api_key) + mcp.run() + + +if __name__ == "__main__": + run_server() \ No newline at end of file diff --git a/src/vultr_dns_mcp/server.py b/src/vultr_dns_mcp/server.py index e470144..51e9855 100644 --- a/src/vultr_dns_mcp/server.py +++ b/src/vultr_dns_mcp/server.py @@ -5,6 +5,7 @@ This module contains the main VultrDNSServer class and MCP server implementation for managing DNS records through the Vultr API. """ +import ipaddress import os import re from typing import Any, Dict, List, Optional @@ -16,6 +17,35 @@ from mcp.types import Resource, Tool, TextContent from pydantic import BaseModel +class VultrAPIError(Exception): + """Base exception for Vultr API errors.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + super().__init__(f"Vultr API error {status_code}: {message}") + + +class VultrAuthError(VultrAPIError): + """Raised when API authentication fails (401, 403).""" + pass + + +class VultrRateLimitError(VultrAPIError): + """Raised when API rate limit is exceeded (429).""" + pass + + +class VultrResourceNotFoundError(VultrAPIError): + """Raised when requested resource is not found (404).""" + pass + + +class VultrValidationError(VultrAPIError): + """Raised when request validation fails (400, 422).""" + pass + + class VultrDNSServer: """ Vultr DNS API client for managing domains and DNS records. @@ -48,7 +78,10 @@ class VultrDNSServer: """Make an HTTP request to the Vultr API.""" url = f"{self.API_BASE}{endpoint}" - async with httpx.AsyncClient() as client: + # Configure timeout: 30 seconds total, 10 seconds to connect + timeout = httpx.Timeout(30.0, connect=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: response = await client.request( method=method, url=url, @@ -57,7 +90,19 @@ class VultrDNSServer: ) if response.status_code not in [200, 201, 204]: - raise Exception(f"Vultr API error {response.status_code}: {response.text}") + # Raise specific exceptions based on status code + if response.status_code == 401: + raise VultrAuthError(response.status_code, "Invalid API key") + elif response.status_code == 403: + raise VultrAuthError(response.status_code, "Insufficient permissions") + elif response.status_code == 404: + raise VultrResourceNotFoundError(response.status_code, "Resource not found") + elif response.status_code == 429: + raise VultrRateLimitError(response.status_code, "Rate limit exceeded") + elif response.status_code in [400, 422]: + raise VultrValidationError(response.status_code, response.text) + else: + raise VultrAPIError(response.status_code, response.text) if response.status_code == 204: return {} @@ -592,15 +637,32 @@ def create_mcp_server(api_key: Optional[str] = None) -> Server: # Record-specific validation if record_type == 'A': - ipv4_pattern = r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$' - if not re.match(ipv4_pattern, data): + try: + ipaddress.IPv4Address(data) + except ipaddress.AddressValueError: validation_result["valid"] = False validation_result["errors"].append("Invalid IPv4 address format") elif record_type == 'AAAA': - if '::' in data and data.count('::') > 1: + try: + ipv6_addr = ipaddress.IPv6Address(data) + # Add helpful suggestions for IPv6 addresses + if ipv6_addr.ipv4_mapped: + validation_result["suggestions"].append("Consider using a native IPv6 address instead of IPv4-mapped format") + elif ipv6_addr.compressed != data: + validation_result["suggestions"].append(f"Consider using compressed format: {ipv6_addr.compressed}") + + # Check for common special addresses + if ipv6_addr.is_loopback: + validation_result["warnings"].append("This is the IPv6 loopback address (::1)") + elif ipv6_addr.is_link_local: + validation_result["warnings"].append("This is an IPv6 link-local address (fe80::/10)") + elif ipv6_addr.is_private: + validation_result["warnings"].append("This is an IPv6 private address") + + except ipaddress.AddressValueError as e: validation_result["valid"] = False - validation_result["errors"].append("Invalid IPv6 address: multiple :: sequences") + validation_result["errors"].append(f"Invalid IPv6 address: {str(e)}") elif record_type == 'CNAME': if name == '@' or name == '': diff --git a/test_async_fastmcp.py b/test_async_fastmcp.py new file mode 100644 index 0000000..b181196 --- /dev/null +++ b/test_async_fastmcp.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 + +import asyncio +import sys +from fastmcp import FastMCP + +print("TESTING ASYNC FASTMCP PATTERNS", file=sys.stderr) + +# Create FastMCP server +mcp = FastMCP(name="async-test") + +@mcp.tool +def sync_tool() -> str: + """A synchronous test tool""" + return "Sync tool working" + +@mcp.tool +async def async_tool() -> str: + """An asynchronous test tool""" + await asyncio.sleep(0.1) # Simulate async work + return "Async tool working" + +@mcp.tool +async def async_tool_with_params(name: str, count: int = 1) -> dict: + """An async tool with parameters""" + await asyncio.sleep(0.1) + return { + "message": f"Hello {name}", + "count": count, + "status": "async success" + } + +print("TOOLS REGISTERED, STARTING SERVER", file=sys.stderr) + +if __name__ == "__main__": + print("RUNNING ASYNC TEST SERVER", file=sys.stderr) + mcp.run() \ No newline at end of file diff --git a/test_improvements.py b/test_improvements.py new file mode 100644 index 0000000..7709c32 --- /dev/null +++ b/test_improvements.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Test our improvements to the Vultr DNS MCP package.""" + +import ipaddress +from vultr_dns_mcp.server import ( + VultrAPIError, + VultrAuthError, + VultrRateLimitError, + VultrResourceNotFoundError, + VultrValidationError +) + +def test_exceptions(): + """Test our enhanced exception classes.""" + print("🧪 Testing Enhanced Exception Classes:") + + # Test basic exception + error = VultrAPIError(500, 'Test error') + print(f"✅ VultrAPIError: {error}") + + # Test specific exceptions + auth_error = VultrAuthError(401, 'Auth failed') + print(f"✅ VultrAuthError: {auth_error}") + + rate_error = VultrRateLimitError(429, 'Too many requests') + print(f"✅ VultrRateLimitError: {rate_error}") + + not_found_error = VultrResourceNotFoundError(404, 'Not found') + print(f"✅ VultrResourceNotFoundError: {not_found_error}") + + validation_error = VultrValidationError(400, 'Bad request') + print(f"✅ VultrValidationError: {validation_error}") + + # Test inheritance + print(f"✅ VultrAuthError is VultrAPIError: {isinstance(auth_error, VultrAPIError)}") + print(f"✅ VultrAuthError status_code: {auth_error.status_code}") + print(f"✅ VultrAuthError message: {auth_error.message}") + print() + +def test_ipv6_validation(): + """Test enhanced IPv6 validation.""" + print("🧪 Testing Enhanced IPv6 Validation:") + + test_addresses = [ + ('2001:db8::1', 'Standard format'), + ('::1', 'Loopback'), + ('::ffff:192.0.2.1', 'IPv4-mapped'), + ('2001:0db8:0000:0000:0000:0000:0000:0001', 'Full format'), + ('fe80::1', 'Link-local'), + ('invalid::address::bad', 'Invalid (multiple ::)'), + ('gggg::1', 'Invalid hex'), + ('192.168.1.1', 'IPv4 (should fail for IPv6)'), + ] + + for addr, description in test_addresses: + try: + ipv6_addr = ipaddress.IPv6Address(addr) + print(f"✅ {addr:<35} -> {ipv6_addr.compressed} ({description})") + + # Test our enhanced features + if ipv6_addr.ipv4_mapped: + print(f" 📝 IPv4-mapped: {ipv6_addr.ipv4_mapped}") + if ipv6_addr.is_loopback: + print(f" 🔄 Loopback address") + if ipv6_addr.is_link_local: + print(f" 🔗 Link-local address") + if ipv6_addr.is_private: + print(f" 🔒 Private address") + if ipv6_addr.compressed != addr: + print(f" 💡 Could compress to: {ipv6_addr.compressed}") + + except ipaddress.AddressValueError as e: + print(f"❌ {addr:<35} -> Invalid: {e} ({description})") + + print() + +def test_mcp_server_creation(): + """Test that we can create an MCP server (without API key).""" + print("🧪 Testing MCP Server Creation:") + + try: + from vultr_dns_mcp.server import create_mcp_server + + # This should fail without API key (expected) + try: + server = create_mcp_server(None) + print("❌ Should have failed without API key") + except ValueError as e: + print(f"✅ Correctly failed without API key: {e}") + + # Test with mock API key + server = create_mcp_server("mock-api-key") + print(f"✅ MCP server created successfully: {type(server)}") + print(f"✅ Server has tools: {hasattr(server, '_tools')}") + print(f"✅ Server has resources: {hasattr(server, '_resources')}") + + except Exception as e: + print(f"❌ Failed to create MCP server: {e}") + + print() + +def simulate_domain_query(): + """Simulate what a domain query would look like.""" + print("🧪 Simulating Domain Query (Mock Response):") + + # This is what the response would look like with a real API key + mock_domains = [ + { + "domain": "example.com", + "date_created": "2024-01-01T00:00:00Z", + "dns_sec": "disabled" + }, + { + "domain": "test-site.net", + "date_created": "2024-06-15T12:30:00Z", + "dns_sec": "enabled" + } + ] + + print("📋 Available domains (mock data):") + for domain in mock_domains: + name = domain['domain'] + created = domain['date_created'] + dnssec = domain['dns_sec'] + print(f" • {name:<20} (created: {created}, DNSSEC: {dnssec})") + + print() + print("💡 To query real domains, set VULTR_API_KEY and run:") + print(" export VULTR_API_KEY='your-api-key'") + print(" uv run vultr-dns-mcp domains list") + print() + +if __name__ == "__main__": + print("🚀 Testing Vultr DNS MCP Improvements\n") + print("=" * 50) + + test_exceptions() + test_ipv6_validation() + test_mcp_server_creation() + simulate_domain_query() + + print("✅ All improvement tests completed successfully!") + print("\n🎉 Ready to use with: uv run vultr-dns-mcp --help") \ No newline at end of file diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 92b3f3f..9845632 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -361,6 +361,94 @@ class TestValidationLogic: "priority": 10 }) assert result is not None + + @pytest.mark.asyncio + async def test_aaaa_record_validation(self, mcp_server): + """Test comprehensive AAAA (IPv6) record validation logic.""" + async with ClientSession(mcp_server) as session: + # Valid IPv6 addresses + valid_ipv6_addresses = [ + "2001:db8::1", # Standard format + "2001:0db8:0000:0000:0000:0000:0000:0001", # Full format + "::", # All zeros + "::1", # Loopback + "fe80::1", # Link-local + "2001:db8:85a3::8a2e:370:7334", # Mixed compression + "::ffff:192.0.2.1", # IPv4-mapped + ] + + for ipv6_addr in valid_ipv6_addresses: + result = await session.call_tool("validate_dns_record", { + "record_type": "AAAA", + "name": "www", + "data": ipv6_addr + }) + assert result is not None + # Parse the result to check validation passed + import json + parsed = json.loads(result[0].text.replace("'", '"')) + assert parsed["validation"]["valid"] == True, f"Failed to validate {ipv6_addr}" + + # Invalid IPv6 addresses + invalid_ipv6_addresses = [ + "2001:db8::1::2", # Multiple :: + "2001:db8:85a3::8a2e::7334", # Multiple :: + "gggg::1", # Invalid hex + "2001:db8:85a3:0:0:8a2e:370g:7334", # Invalid character + "2001:db8:85a3:0:0:8a2e:370:7334:extra", # Too many groups + "", # Empty + "192.168.1.1", # IPv4 instead of IPv6 + ] + + for ipv6_addr in invalid_ipv6_addresses: + result = await session.call_tool("validate_dns_record", { + "record_type": "AAAA", + "name": "www", + "data": ipv6_addr + }) + assert result is not None + # Parse the result to check validation failed + import json + parsed = json.loads(result[0].text.replace("'", '"')) + assert parsed["validation"]["valid"] == False, f"Should have failed to validate {ipv6_addr}" + + @pytest.mark.asyncio + async def test_ipv6_suggestions_and_warnings(self, mcp_server): + """Test that IPv6 validation provides helpful suggestions and warnings.""" + async with ClientSession(mcp_server) as session: + # Test IPv4-mapped suggestion + result = await session.call_tool("validate_dns_record", { + "record_type": "AAAA", + "name": "www", + "data": "::ffff:192.0.2.1" + }) + assert result is not None + import json + parsed = json.loads(result[0].text.replace("'", '"')) + suggestions = parsed["validation"]["suggestions"] + assert any("IPv4-mapped" in s for s in suggestions) + + # Test compression suggestion + result = await session.call_tool("validate_dns_record", { + "record_type": "AAAA", + "name": "www", + "data": "2001:0db8:0000:0000:0000:0000:0000:0001" + }) + assert result is not None + parsed = json.loads(result[0].text.replace("'", '"')) + suggestions = parsed["validation"]["suggestions"] + assert any("compressed format" in s for s in suggestions) + + # Test loopback warning + result = await session.call_tool("validate_dns_record", { + "record_type": "AAAA", + "name": "www", + "data": "::1" + }) + assert result is not None + parsed = json.loads(result[0].text.replace("'", '"')) + warnings = parsed["validation"]["warnings"] + assert any("loopback" in w for w in warnings) if __name__ == "__main__": diff --git a/tests/test_vultr_server.py b/tests/test_vultr_server.py index 9ffcb57..650b0e4 100644 --- a/tests/test_vultr_server.py +++ b/tests/test_vultr_server.py @@ -3,7 +3,14 @@ import pytest import httpx from unittest.mock import AsyncMock, patch -from vultr_dns_mcp.server import VultrDNSServer +from vultr_dns_mcp.server import ( + VultrDNSServer, + VultrAPIError, + VultrAuthError, + VultrRateLimitError, + VultrResourceNotFoundError, + VultrValidationError +) @pytest.mark.unit @@ -74,10 +81,11 @@ class TestVultrDNSServer: with patch('httpx.AsyncClient') as mock_client: mock_client.return_value.__aenter__.return_value.request.return_value = mock_response - with pytest.raises(Exception) as exc_info: + with pytest.raises(VultrValidationError) as exc_info: await server._make_request("GET", "/test") - assert "Vultr API error 400: Bad Request" in str(exc_info.value) + assert exc_info.value.status_code == 400 + assert "Bad Request" in str(exc_info.value) @pytest.mark.asyncio async def test_make_request_error_401(self, mock_api_key): @@ -91,10 +99,11 @@ class TestVultrDNSServer: with patch('httpx.AsyncClient') as mock_client: mock_client.return_value.__aenter__.return_value.request.return_value = mock_response - with pytest.raises(Exception) as exc_info: + with pytest.raises(VultrAuthError) as exc_info: await server._make_request("GET", "/test") - assert "Vultr API error 401: Unauthorized" in str(exc_info.value) + assert exc_info.value.status_code == 401 + assert "Invalid API key" in str(exc_info.value) @pytest.mark.asyncio async def test_make_request_error_500(self, mock_api_key): @@ -108,10 +117,11 @@ class TestVultrDNSServer: with patch('httpx.AsyncClient') as mock_client: mock_client.return_value.__aenter__.return_value.request.return_value = mock_response - with pytest.raises(Exception) as exc_info: + with pytest.raises(VultrAPIError) as exc_info: await server._make_request("GET", "/test") - assert "Vultr API error 500: Internal Server Error" in str(exc_info.value) + assert exc_info.value.status_code == 500 + assert "Internal Server Error" in str(exc_info.value) @pytest.mark.unit @@ -448,10 +458,105 @@ class TestErrorScenarios: with patch('httpx.AsyncClient') as mock_client: mock_client.return_value.__aenter__.return_value.request.return_value = mock_response - with pytest.raises(Exception) as exc_info: + with pytest.raises(VultrRateLimitError) as exc_info: await server._make_request("GET", "/domains") + assert exc_info.value.status_code == 429 assert "Rate limit exceeded" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_not_found_error(self, mock_api_key): + """Test handling of 404 Not Found error.""" + server = VultrDNSServer(mock_api_key) + + mock_response = AsyncMock() + mock_response.status_code = 404 + mock_response.text = "Domain not found" + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__.return_value.request.return_value = mock_response + + with pytest.raises(VultrResourceNotFoundError) as exc_info: + await server._make_request("GET", "/domains/nonexistent.com") + + assert exc_info.value.status_code == 404 + assert "Resource not found" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_forbidden_error(self, mock_api_key): + """Test handling of 403 Forbidden error.""" + server = VultrDNSServer(mock_api_key) + + mock_response = AsyncMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden" + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__.return_value.request.return_value = mock_response + + with pytest.raises(VultrAuthError) as exc_info: + await server._make_request("GET", "/domains") + + assert exc_info.value.status_code == 403 + assert "Insufficient permissions" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_validation_error_422(self, mock_api_key): + """Test handling of 422 Unprocessable Entity error.""" + server = VultrDNSServer(mock_api_key) + + mock_response = AsyncMock() + mock_response.status_code = 422 + mock_response.text = "Invalid domain format" + + with patch('httpx.AsyncClient') as mock_client: + mock_client.return_value.__aenter__.return_value.request.return_value = mock_response + + with pytest.raises(VultrValidationError) as exc_info: + await server._make_request("POST", "/domains") + + assert exc_info.value.status_code == 422 + assert "Invalid domain format" in str(exc_info.value) + + +@pytest.mark.unit +class TestExceptionProperties: + """Test custom exception properties and behavior.""" + + def test_vultr_api_error_properties(self): + """Test VultrAPIError has correct properties.""" + error = VultrAPIError(500, "Server Error") + assert error.status_code == 500 + assert error.message == "Server Error" + assert str(error) == "Vultr API error 500: Server Error" + + def test_vultr_auth_error_inheritance(self): + """Test VultrAuthError inherits from VultrAPIError.""" + error = VultrAuthError(401, "Unauthorized") + assert isinstance(error, VultrAPIError) + assert error.status_code == 401 + assert error.message == "Unauthorized" + + def test_vultr_rate_limit_error_inheritance(self): + """Test VultrRateLimitError inherits from VultrAPIError.""" + error = VultrRateLimitError(429, "Too Many Requests") + assert isinstance(error, VultrAPIError) + assert error.status_code == 429 + assert error.message == "Too Many Requests" + + def test_vultr_not_found_error_inheritance(self): + """Test VultrResourceNotFoundError inherits from VultrAPIError.""" + error = VultrResourceNotFoundError(404, "Not Found") + assert isinstance(error, VultrAPIError) + assert error.status_code == 404 + assert error.message == "Not Found" + + def test_vultr_validation_error_inheritance(self): + """Test VultrValidationError inherits from VultrAPIError.""" + error = VultrValidationError(400, "Bad Request") + assert isinstance(error, VultrAPIError) + assert error.status_code == 400 + assert error.message == "Bad Request" if __name__ == "__main__":