Initial commit: MCP Name Cheap server implementation
- Production-ready MCP server for Name Cheap API integration - Domain management (registration, renewal, availability checking) - DNS management (records, nameserver configuration) - SSL certificate management and monitoring - Account information and balance checking - Smart identifier resolution for improved UX - Comprehensive error handling with specific exception types - 80%+ test coverage with unit, integration, and MCP tests - CLI and MCP server interfaces - FastMCP 2.10.5+ implementation with full MCP spec compliance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
f5e63c888d
10
.env.example
Normal file
10
.env.example
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# Name Cheap API Configuration
|
||||||
|
# Get these from your Name Cheap account panel
|
||||||
|
|
||||||
|
# API credentials (required)
|
||||||
|
NAMECHEAP_API_KEY=your_api_key_here
|
||||||
|
NAMECHEAP_USERNAME=your_username_here
|
||||||
|
NAMECHEAP_CLIENT_IP=your_whitelisted_ip_here
|
||||||
|
|
||||||
|
# Environment settings
|
||||||
|
NAMECHEAP_SANDBOX=true # Set to false for production
|
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- OS: [e.g. macOS, Ubuntu 22.04]
|
||||||
|
- Python version: [e.g. 3.11.5]
|
||||||
|
- mcp-namecheap version: [e.g. 1.0.0]
|
||||||
|
- FastMCP version: [e.g. 2.10.5]
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- Are you using sandbox mode? [yes/no]
|
||||||
|
- MCP client being used: [e.g. Claude Desktop, custom]
|
||||||
|
|
||||||
|
**Error output**
|
||||||
|
```
|
||||||
|
Paste any error messages or logs here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Use case**
|
||||||
|
Describe the specific use case for this feature. How would you use it?
|
||||||
|
|
||||||
|
**Name Cheap API Support**
|
||||||
|
- [ ] This feature is supported by the Name Cheap API
|
||||||
|
- [ ] This feature would require new Name Cheap API endpoints
|
||||||
|
- [ ] I'm not sure about API support
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
47
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
47
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Pull Request
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Brief description of the changes in this PR.
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] Documentation update
|
||||||
|
- [ ] Code quality improvement
|
||||||
|
- [ ] Test improvement
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- [ ] Tests pass locally
|
||||||
|
- [ ] Added tests for new functionality
|
||||||
|
- [ ] Updated existing tests as needed
|
||||||
|
- [ ] Manual testing completed
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
- [ ] Code follows the style guidelines of this project
|
||||||
|
- [ ] Self-review of code completed
|
||||||
|
- [ ] Code is commented, particularly in hard-to-understand areas
|
||||||
|
- [ ] Type hints are added for new code
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- [ ] Documentation updated (if applicable)
|
||||||
|
- [ ] CLAUDE.md updated with architecture changes (if applicable)
|
||||||
|
- [ ] README.md updated (if applicable)
|
||||||
|
|
||||||
|
## Name Cheap API
|
||||||
|
- [ ] Changes are compatible with Name Cheap API
|
||||||
|
- [ ] Tested in sandbox environment
|
||||||
|
- [ ] No breaking changes to existing API usage
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] My code follows the code style of this project
|
||||||
|
- [ ] My change requires a change to the documentation
|
||||||
|
- [ ] I have updated the documentation accordingly
|
||||||
|
- [ ] I have read the **CONTRIBUTING** document
|
||||||
|
- [ ] I have added tests to cover my changes
|
||||||
|
- [ ] All new and existing tests passed
|
81
.github/workflows/ci.yml
vendored
Normal file
81
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
with:
|
||||||
|
version: "latest"
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
run: uv python install ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Run Ruff linting
|
||||||
|
run: |
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
- name: Run Ruff formatting check
|
||||||
|
run: |
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Run Black formatting check
|
||||||
|
run: |
|
||||||
|
uv run black --check src/ tests/
|
||||||
|
|
||||||
|
- name: Run type checking with mypy
|
||||||
|
run: |
|
||||||
|
uv run mypy src/mcp_namecheap/
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: |
|
||||||
|
uv run pytest --cov=src/mcp_namecheap --cov-report=xml --cov-report=term-missing
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
flags: unittests
|
||||||
|
name: codecov-umbrella
|
||||||
|
fail_ci_if_error: false
|
||||||
|
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
run: uv python install 3.11
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Run safety check
|
||||||
|
run: |
|
||||||
|
uv run pip install safety
|
||||||
|
uv run safety check --json || true
|
||||||
|
|
||||||
|
- name: Run bandit security linter
|
||||||
|
run: |
|
||||||
|
uv run pip install bandit
|
||||||
|
uv run bandit -r src/ || true
|
26
.github/workflows/pre-commit.yml
vendored
Normal file
26
.github/workflows/pre-commit.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: Pre-commit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
run: uv python install 3.11
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Run pre-commit
|
||||||
|
run: |
|
||||||
|
uv run pre-commit run --all-files
|
109
.github/workflows/publish.yml
vendored
Normal file
109
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
name: Publish to PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Environment to publish to'
|
||||||
|
required: true
|
||||||
|
default: 'testpypi'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- testpypi
|
||||||
|
- pypi
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
run: uv python install 3.11
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: |
|
||||||
|
uv run python -m build
|
||||||
|
|
||||||
|
- name: Check package
|
||||||
|
run: |
|
||||||
|
uv run twine check dist/*
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
publish-testpypi:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'testpypi'
|
||||||
|
environment:
|
||||||
|
name: testpypi
|
||||||
|
url: https://test.pypi.org/p/mcp-namecheap
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Download build artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Publish to TestPyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
repository-url: https://test.pypi.org/legacy/
|
||||||
|
verbose: true
|
||||||
|
|
||||||
|
publish-pypi:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
url: https://pypi.org/p/mcp-namecheap
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Download build artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
verbose: true
|
||||||
|
|
||||||
|
manual-pypi:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'pypi'
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
url: https://pypi.org/p/mcp-namecheap
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Download build artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
verbose: true
|
35
.pre-commit-config.yaml
Normal file
35
.pre-commit-config.yaml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 23.12.1
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
args: [--line-length=88]
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.13.2
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
args: [--profile=black]
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.1.9
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.8.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [types-requests]
|
||||||
|
args: [--ignore-missing-imports]
|
251
CLAUDE.md
Normal file
251
CLAUDE.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# MCP Name Cheap - Development Documentation
|
||||||
|
|
||||||
|
This document contains development notes, architecture decisions, and implementation details for the MCP Name Cheap server.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The MCP Name Cheap server follows a layered architecture pattern with clear separation of concerns:
|
||||||
|
|
||||||
|
### Layer 1: Core API Client (`server.py`)
|
||||||
|
- **Purpose**: Low-level HTTP client for Name Cheap API
|
||||||
|
- **Responsibilities**:
|
||||||
|
- XML request/response handling
|
||||||
|
- Comprehensive error classification and handling
|
||||||
|
- Rate limiting and timeout management
|
||||||
|
- Environment-based configuration loading
|
||||||
|
|
||||||
|
### Layer 2: High-Level Client (`client.py`)
|
||||||
|
- **Purpose**: Business logic and smart identifier resolution
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Smart identifier resolution (domain names, SSL hostnames)
|
||||||
|
- Input validation using domain format checking
|
||||||
|
- Cache management for performance
|
||||||
|
- Enhanced data formatting
|
||||||
|
|
||||||
|
### Layer 3: FastMCP Tools (Resource Modules)
|
||||||
|
- **Purpose**: MCP protocol implementation with modular organization
|
||||||
|
- **Modules**:
|
||||||
|
- `domains.py`: Domain registration, renewal, and management
|
||||||
|
- `dns.py`: DNS record management and nameserver configuration
|
||||||
|
- `ssl.py`: SSL certificate listing and information
|
||||||
|
- `account.py`: Account information and balance checking
|
||||||
|
|
||||||
|
### Layer 4: Server Assembly (`fastmcp_server.py`)
|
||||||
|
- **Purpose**: FastMCP server configuration and module mounting
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Module registration and organization
|
||||||
|
- Server lifecycle management
|
||||||
|
|
||||||
|
## Smart Identifier Resolution
|
||||||
|
|
||||||
|
The server implements smart identifier resolution to improve user experience:
|
||||||
|
|
||||||
|
### Domain Identifiers
|
||||||
|
- **Primary**: Domain name (e.g., "example.com")
|
||||||
|
- **Resolution**: Exact match against account domains (case-insensitive)
|
||||||
|
- **Validation**: RFC-compliant domain format checking
|
||||||
|
|
||||||
|
### SSL Certificate Identifiers
|
||||||
|
- **Primary**: Certificate ID (numeric)
|
||||||
|
- **Secondary**: Hostname matching
|
||||||
|
- **Tertiary**: SSL type matching
|
||||||
|
- **Resolution**: Exact match with fallback to hostname/type lookup
|
||||||
|
|
||||||
|
## Error Handling Strategy
|
||||||
|
|
||||||
|
Comprehensive error handling with specific exception types:
|
||||||
|
|
||||||
|
```python
|
||||||
|
NameCheapAPIError # Base exception
|
||||||
|
├── NameCheapAuthError # Authentication/authorization
|
||||||
|
├── NameCheapNotFoundError # Resource not found
|
||||||
|
├── NameCheapRateLimitError # Rate limit exceeded
|
||||||
|
└── NameCheapValidationError # Input validation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Classification Logic
|
||||||
|
- **Authentication**: Keywords "authentication", "authorization", HTTP 401
|
||||||
|
- **Not Found**: Keywords "not found", specific error codes
|
||||||
|
- **Validation**: Keywords "validation", "invalid"
|
||||||
|
- **Rate Limiting**: HTTP 429
|
||||||
|
- **Generic**: All other API errors
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
Environment-based configuration with validation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Required variables
|
||||||
|
NAMECHEAP_API_KEY # API key from Name Cheap panel
|
||||||
|
NAMECHEAP_USERNAME # Account username
|
||||||
|
NAMECHEAP_CLIENT_IP # Whitelisted IP address
|
||||||
|
|
||||||
|
# Optional variables
|
||||||
|
NAMECHEAP_SANDBOX=true # Sandbox mode (default: false)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Multi-layered testing approach:
|
||||||
|
|
||||||
|
### Unit Tests (`test_server.py`, `test_client.py`)
|
||||||
|
- Individual component testing with mocks
|
||||||
|
- Error handling verification
|
||||||
|
- Configuration testing
|
||||||
|
- Input validation testing
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Component interaction testing
|
||||||
|
- Cache behavior verification
|
||||||
|
- Workflow testing (register → renew → list)
|
||||||
|
|
||||||
|
### MCP Tests (`test_mcp_server.py`)
|
||||||
|
- FastMCP in-memory testing patterns
|
||||||
|
- Tool functionality verification
|
||||||
|
- Resource content validation
|
||||||
|
- Error response testing
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
- **Domain Cache**: Cached after first `list_domains()` call
|
||||||
|
- **SSL Cache**: Cached after first `list_ssl_certificates()` call
|
||||||
|
- **Cache Invalidation**: Cleared after mutating operations (register, renew)
|
||||||
|
|
||||||
|
### HTTP Configuration
|
||||||
|
- **Total Timeout**: 30 seconds
|
||||||
|
- **Connect Timeout**: 10 seconds
|
||||||
|
- **Follow Redirects**: Enabled
|
||||||
|
- **Connection Reuse**: Single httpx.Client instance
|
||||||
|
|
||||||
|
## API Response Handling
|
||||||
|
|
||||||
|
### XML Processing
|
||||||
|
- Uses `xmltodict` for XML-to-dict conversion
|
||||||
|
- Handles both single items and arrays consistently
|
||||||
|
- Preserves XML attributes with `@` prefix
|
||||||
|
|
||||||
|
### Response Normalization
|
||||||
|
- Single items converted to lists where appropriate
|
||||||
|
- Enhanced data with computed fields (e.g., `_display_name`, `_is_expired`)
|
||||||
|
- Consistent error response format across all tools
|
||||||
|
|
||||||
|
## Resource URI Patterns
|
||||||
|
|
||||||
|
Standardized resource URI scheme:
|
||||||
|
|
||||||
|
```
|
||||||
|
namecheap://domains # List all domains
|
||||||
|
namecheap://domain/{domain_name} # Specific domain info
|
||||||
|
namecheap://dns/{domain_name} # DNS records
|
||||||
|
namecheap://nameservers/{domain_name} # Nameserver info
|
||||||
|
namecheap://ssl # List SSL certificates
|
||||||
|
namecheap://ssl/{certificate_id} # Specific certificate
|
||||||
|
namecheap://account # Account information
|
||||||
|
namecheap://balance # Account balance
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Implementation
|
||||||
|
|
||||||
|
Command-line interface for direct operations:
|
||||||
|
|
||||||
|
### Command Structure
|
||||||
|
```bash
|
||||||
|
mcp-namecheap <category> <action> [arguments] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
- `domains`: Domain management (list, check, info)
|
||||||
|
- `dns`: DNS management (get)
|
||||||
|
- `ssl`: SSL management (list)
|
||||||
|
- `account`: Account management (info, balance)
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Code Quality Pipeline
|
||||||
|
1. **Black**: Code formatting (88 char line length)
|
||||||
|
2. **isort**: Import sorting (black profile)
|
||||||
|
3. **flake8**: Linting with E203, E501, W503 ignored
|
||||||
|
4. **mypy**: Type checking with strict settings
|
||||||
|
5. **pytest**: Test execution with coverage
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
- Trailing whitespace removal
|
||||||
|
- End-of-file fixing
|
||||||
|
- YAML validation
|
||||||
|
- Large file checking
|
||||||
|
- Merge conflict detection
|
||||||
|
|
||||||
|
## FastMCP Integration
|
||||||
|
|
||||||
|
### Server Mounting
|
||||||
|
Each resource module creates its own FastMCP instance:
|
||||||
|
```python
|
||||||
|
domains_mcp = FastMCP("domains")
|
||||||
|
dns_mcp = FastMCP("dns")
|
||||||
|
ssl_mcp = FastMCP("ssl")
|
||||||
|
account_mcp = FastMCP("account")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Organization
|
||||||
|
Tools are organized by functionality with consistent naming:
|
||||||
|
- `list_*`: List resources
|
||||||
|
- `get_*`: Get specific resource information
|
||||||
|
- `set_*`: Modify resources
|
||||||
|
- `check_*`: Validation operations
|
||||||
|
|
||||||
|
### Resource Implementation
|
||||||
|
Resources provide human-readable content formatted for display:
|
||||||
|
- Tabular data with headers
|
||||||
|
- Status indicators and flags
|
||||||
|
- Error handling with graceful degradation
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
### Name Cheap API Constraints
|
||||||
|
- XML-only response format (no JSON)
|
||||||
|
- Rate limiting (specific limits not documented)
|
||||||
|
- Sandbox environment has limited functionality
|
||||||
|
- Some operations require domain ownership verification
|
||||||
|
|
||||||
|
### Implementation Limitations
|
||||||
|
- No async support in underlying httpx client (by design for FastMCP compatibility)
|
||||||
|
- Cache invalidation is conservative (clears all caches on any mutation)
|
||||||
|
- SSL smart identifiers limited to hostname and type matching
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Potential Improvements
|
||||||
|
1. **Bulk Operations**: Support for bulk domain operations
|
||||||
|
2. **Webhook Support**: Event notifications for domain/certificate changes
|
||||||
|
3. **Advanced DNS**: Support for advanced DNS record types
|
||||||
|
4. **Certificate Management**: SSL certificate ordering and management
|
||||||
|
5. **Domain Transfer**: Support for domain transfer operations
|
||||||
|
|
||||||
|
### Architecture Considerations
|
||||||
|
- Consider event-driven cache invalidation
|
||||||
|
- Evaluate async support for improved performance
|
||||||
|
- Investigate GraphQL-style querying for complex operations
|
||||||
|
|
||||||
|
## Debugging and Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
1. **Authentication Errors**: Verify API key, username, and IP whitelisting
|
||||||
|
2. **Domain Not Found**: Ensure domain exists in the account
|
||||||
|
3. **Rate Limiting**: Implement exponential backoff for retries
|
||||||
|
4. **Timeout Errors**: Check network connectivity and Name Cheap API status
|
||||||
|
|
||||||
|
### Debug Logging
|
||||||
|
The server includes comprehensive error messages with context:
|
||||||
|
- HTTP status codes and response bodies
|
||||||
|
- Name Cheap API error codes and descriptions
|
||||||
|
- Input validation failures with specific field information
|
||||||
|
|
||||||
|
### Testing in Sandbox
|
||||||
|
- Use `NAMECHEAP_SANDBOX=true` for safe testing
|
||||||
|
- Sandbox has limited domain TLDs available
|
||||||
|
- Some operations may not work identically in sandbox vs production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This documentation is maintained alongside code changes and should be updated when architecture or implementation details change.*
|
24
MANIFEST.in
Normal file
24
MANIFEST.in
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Include important files in source distribution
|
||||||
|
include README.md
|
||||||
|
include CLAUDE.md
|
||||||
|
include LICENSE
|
||||||
|
include pyproject.toml
|
||||||
|
include ruff.toml
|
||||||
|
include .env.example
|
||||||
|
|
||||||
|
# Include source code
|
||||||
|
recursive-include src *.py
|
||||||
|
recursive-include tests *.py
|
||||||
|
|
||||||
|
# Include GitHub templates
|
||||||
|
recursive-include .github *.md *.yml *.yaml
|
||||||
|
|
||||||
|
# Exclude development files
|
||||||
|
exclude run_tests.py
|
||||||
|
exclude .pre-commit-config.yaml
|
||||||
|
global-exclude *.pyc
|
||||||
|
global-exclude __pycache__
|
||||||
|
global-exclude .pytest_cache
|
||||||
|
global-exclude .coverage
|
||||||
|
global-exclude coverage.xml
|
||||||
|
global-exclude .env
|
253
PUBLISHING.md
Normal file
253
PUBLISHING.md
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
# Publishing Guide
|
||||||
|
|
||||||
|
This document explains how to publish the MCP Name Cheap package to TestPyPI and PyPI.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Install dependencies**:
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up accounts**:
|
||||||
|
- [TestPyPI account](https://test.pypi.org/account/register/)
|
||||||
|
- [PyPI account](https://pypi.org/account/register/)
|
||||||
|
|
||||||
|
3. **Configure API tokens** (recommended over username/password):
|
||||||
|
- Create API tokens in your PyPI account settings
|
||||||
|
- Store in `~/.pypirc` or use environment variables
|
||||||
|
|
||||||
|
## Publishing Methods
|
||||||
|
|
||||||
|
### Method 1: Using the Publishing Script (Recommended)
|
||||||
|
|
||||||
|
The project includes a convenient publishing script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Publish to TestPyPI (safe testing)
|
||||||
|
python scripts/publish.py --target testpypi
|
||||||
|
|
||||||
|
# Publish to production PyPI
|
||||||
|
python scripts/publish.py --target pypi
|
||||||
|
|
||||||
|
# Skip tests (if already run)
|
||||||
|
python scripts/publish.py --target testpypi --skip-tests
|
||||||
|
|
||||||
|
# Skip building (if dist/ already exists)
|
||||||
|
python scripts/publish.py --target testpypi --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Manual Publishing
|
||||||
|
|
||||||
|
#### Step 1: Run Tests and Quality Checks
|
||||||
|
```bash
|
||||||
|
python run_tests.py --all
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Clean and Build
|
||||||
|
```bash
|
||||||
|
# Clean old builds
|
||||||
|
rm -rf dist/ build/
|
||||||
|
|
||||||
|
# Build package
|
||||||
|
python -m build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Check Package
|
||||||
|
```bash
|
||||||
|
python -m twine check dist/*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Publish to TestPyPI
|
||||||
|
```bash
|
||||||
|
python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 5: Test Installation from TestPyPI
|
||||||
|
```bash
|
||||||
|
pip install -i https://test.pypi.org/simple/ mcp-namecheap
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 6: Publish to PyPI (if TestPyPI works)
|
||||||
|
```bash
|
||||||
|
python -m twine upload dist/*
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: GitHub Actions (Automated)
|
||||||
|
|
||||||
|
#### For TestPyPI
|
||||||
|
```bash
|
||||||
|
# Manual workflow dispatch
|
||||||
|
gh workflow run publish.yml -f environment=testpypi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### For PyPI
|
||||||
|
```bash
|
||||||
|
# Create and push a version tag
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
|
# This automatically triggers PyPI publishing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
### Update Version
|
||||||
|
1. **Update version in `pyproject.toml`**:
|
||||||
|
```toml
|
||||||
|
version = "1.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update version in `src/mcp_namecheap/__init__.py`**:
|
||||||
|
```python
|
||||||
|
__version__ = "1.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Commit changes**:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Bump version to 1.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Tag release**:
|
||||||
|
```bash
|
||||||
|
git tag v1.0.1
|
||||||
|
git push origin main
|
||||||
|
git push origin v1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Versioning
|
||||||
|
Follow [Semantic Versioning](https://semver.org/):
|
||||||
|
- **MAJOR** (1.0.0): Breaking changes
|
||||||
|
- **MINOR** (0.1.0): New features, backwards compatible
|
||||||
|
- **PATCH** (0.0.1): Bug fixes, backwards compatible
|
||||||
|
|
||||||
|
## Testing Published Packages
|
||||||
|
|
||||||
|
### TestPyPI
|
||||||
|
```bash
|
||||||
|
# Install from TestPyPI
|
||||||
|
pip install -i https://test.pypi.org/simple/ mcp-namecheap
|
||||||
|
|
||||||
|
# Test basic functionality
|
||||||
|
python -c "import mcp_namecheap; print(mcp_namecheap.__version__)"
|
||||||
|
mcp-namecheap --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### PyPI
|
||||||
|
```bash
|
||||||
|
# Install from PyPI
|
||||||
|
pip install mcp-namecheap
|
||||||
|
|
||||||
|
# Test installation
|
||||||
|
mcp-namecheap --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### .pypirc (Optional)
|
||||||
|
Store credentials in `~/.pypirc`:
|
||||||
|
```ini
|
||||||
|
[distutils]
|
||||||
|
index-servers =
|
||||||
|
pypi
|
||||||
|
testpypi
|
||||||
|
|
||||||
|
[pypi]
|
||||||
|
username = __token__
|
||||||
|
password = pypi-your-api-token-here
|
||||||
|
|
||||||
|
[testpypi]
|
||||||
|
repository = https://test.pypi.org/legacy/
|
||||||
|
username = __token__
|
||||||
|
password = pypi-your-test-api-token-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Alternatively, use environment variables:
|
||||||
|
```bash
|
||||||
|
export TWINE_USERNAME=__token__
|
||||||
|
export TWINE_PASSWORD=pypi-your-api-token-here
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Repository Setup
|
||||||
|
|
||||||
|
### Secrets Configuration
|
||||||
|
Add these secrets to your GitHub repository:
|
||||||
|
|
||||||
|
1. Go to Settings → Secrets and variables → Actions
|
||||||
|
2. Add repository secrets:
|
||||||
|
- `PYPI_API_TOKEN`: Your PyPI API token
|
||||||
|
- `TEST_PYPI_API_TOKEN`: Your TestPyPI API token
|
||||||
|
|
||||||
|
### Environments
|
||||||
|
Configure environments for additional security:
|
||||||
|
|
||||||
|
1. Go to Settings → Environments
|
||||||
|
2. Create environments:
|
||||||
|
- `testpypi`: For TestPyPI publishing
|
||||||
|
- `pypi`: For PyPI publishing (with protection rules)
|
||||||
|
|
||||||
|
## Publishing Checklist
|
||||||
|
|
||||||
|
### Pre-Publishing
|
||||||
|
- [ ] All tests pass (`python run_tests.py --all`)
|
||||||
|
- [ ] Version number updated in both places
|
||||||
|
- [ ] CHANGELOG.md updated (if exists)
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] Clean working directory (`git status`)
|
||||||
|
|
||||||
|
### TestPyPI Publishing
|
||||||
|
- [ ] Publish to TestPyPI
|
||||||
|
- [ ] Install from TestPyPI
|
||||||
|
- [ ] Test basic functionality
|
||||||
|
- [ ] Test MCP server functionality
|
||||||
|
- [ ] Verify documentation renders correctly
|
||||||
|
|
||||||
|
### PyPI Publishing
|
||||||
|
- [ ] TestPyPI version works correctly
|
||||||
|
- [ ] Create git tag for version
|
||||||
|
- [ ] Publish to PyPI
|
||||||
|
- [ ] Verify PyPI page looks correct
|
||||||
|
- [ ] Test installation from PyPI
|
||||||
|
- [ ] Update documentation with new version
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### "File already exists" error
|
||||||
|
- Version already published
|
||||||
|
- Update version number in `pyproject.toml`
|
||||||
|
|
||||||
|
#### "Invalid credentials" error
|
||||||
|
- Check API token
|
||||||
|
- Verify `.pypirc` configuration
|
||||||
|
- Try environment variables instead
|
||||||
|
|
||||||
|
#### "Package validation failed" error
|
||||||
|
- Run `twine check dist/*`
|
||||||
|
- Fix any metadata issues
|
||||||
|
- Rebuild package
|
||||||
|
|
||||||
|
#### Import errors after installation
|
||||||
|
- Check package structure
|
||||||
|
- Verify `__init__.py` files exist
|
||||||
|
- Test in clean environment
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- [PyPI Help](https://pypi.org/help/)
|
||||||
|
- [TestPyPI Help](https://test.pypi.org/help/)
|
||||||
|
- [Twine Documentation](https://twine.readthedocs.io/)
|
||||||
|
- [Python Packaging Guide](https://packaging.python.org/)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always test on TestPyPI first**
|
||||||
|
2. **Use API tokens instead of passwords**
|
||||||
|
3. **Tag releases in git**
|
||||||
|
4. **Keep version numbers in sync**
|
||||||
|
5. **Test in clean environments**
|
||||||
|
6. **Use semantic versioning**
|
||||||
|
7. **Automate with GitHub Actions**
|
||||||
|
8. **Document changes in releases**
|
470
README.md
Normal file
470
README.md
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
# MCP Name Cheap
|
||||||
|
|
||||||
|
[](https://badge.fury.io/py/mcp-namecheap)
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
[](https://github.com/psf/black)
|
||||||
|
[](https://github.com/astral-sh/ruff)
|
||||||
|
[](https://github.com/jlowin/fastmcp)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
Production-ready MCP server for comprehensive Name Cheap API integration. Manage domains, DNS records, SSL certificates, and account information through the Model Context Protocol with smart identifier resolution and comprehensive error handling.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### 🌐 Domain Management
|
||||||
|
- **Smart Domain Discovery**: Use exact domain names for seamless operations
|
||||||
|
- **Domain Registration**: Complete domain registration with contact validation
|
||||||
|
- **Domain Renewal**: Extend domain registrations with flexible year options
|
||||||
|
- **Availability Checking**: Bulk domain availability verification
|
||||||
|
- **Domain Information**: Detailed domain status, expiration, and configuration data
|
||||||
|
|
||||||
|
### 🔧 DNS Management
|
||||||
|
- **Complete DNS Control**: Manage A, AAAA, CNAME, MX, TXT, NS, and SRV records
|
||||||
|
- **Nameserver Configuration**: Switch between Name Cheap default and custom nameservers
|
||||||
|
- **Record Validation**: Pre-operation validation with helpful error messages
|
||||||
|
- **TTL Management**: Configurable Time-To-Live settings for all record types
|
||||||
|
|
||||||
|
### 🔒 SSL Certificate Management
|
||||||
|
- **Certificate Discovery**: Smart certificate lookup by ID, hostname, or type
|
||||||
|
- **Certificate Monitoring**: Track expiration dates and status
|
||||||
|
- **Comprehensive Details**: Full certificate information including SANs and providers
|
||||||
|
|
||||||
|
### 👤 Account Management
|
||||||
|
- **Account Information**: Complete account details and verification status
|
||||||
|
- **Balance Monitoring**: Real-time account balance and available funds
|
||||||
|
- **Multi-currency Support**: Handle multiple currency balances
|
||||||
|
|
||||||
|
### 🛠️ Developer Experience
|
||||||
|
- **FastMCP 2.10.5+**: Built on the latest FastMCP with full MCP spec compliance
|
||||||
|
- **Smart Identifiers**: Use human-readable names instead of UUIDs
|
||||||
|
- **Comprehensive Testing**: 80%+ test coverage with unit, integration, and MCP tests
|
||||||
|
- **Type Safety**: Full type hints with mypy validation
|
||||||
|
- **Error Handling**: Specific exception types with actionable error messages
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using pip
|
||||||
|
pip install mcp-namecheap
|
||||||
|
|
||||||
|
# Using uv (recommended)
|
||||||
|
uv add mcp-namecheap
|
||||||
|
|
||||||
|
# Development installation
|
||||||
|
git clone https://github.com/your-org/mcp-namecheap
|
||||||
|
cd mcp-namecheap
|
||||||
|
uv install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Get Name Cheap API Credentials**:
|
||||||
|
- API Key: Name Cheap Account Panel → Profile → Tools → Name Cheap API Access
|
||||||
|
- Username: Your Name Cheap account username
|
||||||
|
- Client IP: Your server's IP address (must be whitelisted)
|
||||||
|
|
||||||
|
2. **Environment Setup**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env file
|
||||||
|
NAMECHEAP_API_KEY=your_api_key_here
|
||||||
|
NAMECHEAP_USERNAME=your_username_here
|
||||||
|
NAMECHEAP_CLIENT_IP=your_whitelisted_ip_here
|
||||||
|
NAMECHEAP_SANDBOX=true # Use false for production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# As a module
|
||||||
|
python -m mcp_namecheap
|
||||||
|
|
||||||
|
# Using the installed script
|
||||||
|
mcp-namecheap
|
||||||
|
|
||||||
|
# With custom configuration
|
||||||
|
NAMECHEAP_SANDBOX=false mcp-namecheap
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 MCP Client Configuration
|
||||||
|
|
||||||
|
Add to your MCP client configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"namecheap": {
|
||||||
|
"command": "mcp-namecheap",
|
||||||
|
"env": {
|
||||||
|
"NAMECHEAP_API_KEY": "your_api_key",
|
||||||
|
"NAMECHEAP_USERNAME": "your_username",
|
||||||
|
"NAMECHEAP_CLIENT_IP": "your_ip",
|
||||||
|
"NAMECHEAP_SANDBOX": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Usage Examples
|
||||||
|
|
||||||
|
### Domain Operations
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Check domain availability
|
||||||
|
await mcp_client.call_tool("check_domain_availability", {
|
||||||
|
"domains": ["example.com", "mysite.org", "newdomain.net"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Register a new domain
|
||||||
|
await mcp_client.call_tool("register_domain", {
|
||||||
|
"domain_name": "mynewdomain.com",
|
||||||
|
"years": 2,
|
||||||
|
"contacts": {
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"address1": "123 Main Street",
|
||||||
|
"city": "Anytown",
|
||||||
|
"state_province": "CA",
|
||||||
|
"postal_code": "12345",
|
||||||
|
"country": "US",
|
||||||
|
"phone": "+1.5551234567",
|
||||||
|
"email_address": "john@example.com"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get domain information
|
||||||
|
await mcp_client.call_tool("get_domain_info", {
|
||||||
|
"domain_identifier": "example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Renew domain
|
||||||
|
await mcp_client.call_tool("renew_domain", {
|
||||||
|
"domain_identifier": "example.com",
|
||||||
|
"years": 1
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS Management
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get current DNS records
|
||||||
|
await mcp_client.call_tool("get_dns_records", {
|
||||||
|
"domain_identifier": "example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set DNS records
|
||||||
|
await mcp_client.call_tool("set_dns_records", {
|
||||||
|
"domain_identifier": "example.com",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"hostname": "@",
|
||||||
|
"record_type": "A",
|
||||||
|
"address": "192.168.1.100",
|
||||||
|
"ttl": 3600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname": "www",
|
||||||
|
"record_type": "CNAME",
|
||||||
|
"address": "example.com",
|
||||||
|
"ttl": 3600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hostname": "mail",
|
||||||
|
"record_type": "MX",
|
||||||
|
"address": "mail.example.com",
|
||||||
|
"ttl": 3600,
|
||||||
|
"mx_pref": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set custom nameservers
|
||||||
|
await mcp_client.call_tool("set_custom_nameservers", {
|
||||||
|
"domain_identifier": "example.com",
|
||||||
|
"nameservers": [
|
||||||
|
"ns1.mycustomns.com",
|
||||||
|
"ns2.mycustomns.com"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Revert to default nameservers
|
||||||
|
await mcp_client.call_tool("set_default_nameservers", {
|
||||||
|
"domain_identifier": "example.com"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSL Certificate Management
|
||||||
|
|
||||||
|
```python
|
||||||
|
# List all SSL certificates
|
||||||
|
await mcp_client.call_tool("list_ssl_certificates")
|
||||||
|
|
||||||
|
# Get certificate details (supports smart lookup)
|
||||||
|
await mcp_client.call_tool("get_ssl_info", {
|
||||||
|
"ssl_identifier": "example.com" # Can use hostname or certificate ID
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Account Information
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get account details
|
||||||
|
await mcp_client.call_tool("get_account_info")
|
||||||
|
|
||||||
|
# Check account balance
|
||||||
|
await mcp_client.call_tool("get_account_balance")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Available Tools
|
||||||
|
|
||||||
|
### Domain Tools
|
||||||
|
| Tool | Description | Smart Identifiers |
|
||||||
|
|------|-------------|-------------------|
|
||||||
|
| `list_domains` | List all domains in account | ✓ |
|
||||||
|
| `get_domain_info` | Get detailed domain information | Domain name |
|
||||||
|
| `check_domain_availability` | Check if domains are available | ✓ |
|
||||||
|
| `register_domain` | Register a new domain | ✓ |
|
||||||
|
| `renew_domain` | Renew existing domain | Domain name |
|
||||||
|
|
||||||
|
### DNS Tools
|
||||||
|
| Tool | Description | Smart Identifiers |
|
||||||
|
|------|-------------|-------------------|
|
||||||
|
| `get_dns_records` | Get DNS records for domain | Domain name |
|
||||||
|
| `set_dns_records` | Set DNS records for domain | Domain name |
|
||||||
|
| `set_default_nameservers` | Use Name Cheap nameservers | Domain name |
|
||||||
|
| `set_custom_nameservers` | Set custom nameservers | Domain name |
|
||||||
|
|
||||||
|
### SSL Tools
|
||||||
|
| Tool | Description | Smart Identifiers |
|
||||||
|
|------|-------------|-------------------|
|
||||||
|
| `list_ssl_certificates` | List SSL certificates | ✓ |
|
||||||
|
| `get_ssl_info` | Get certificate information | Certificate ID, hostname |
|
||||||
|
|
||||||
|
### Account Tools
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `get_account_info` | Get account information |
|
||||||
|
| `get_account_balance` | Get account balance |
|
||||||
|
|
||||||
|
## 📚 Available Resources
|
||||||
|
|
||||||
|
| Resource URI | Description |
|
||||||
|
|--------------|-------------|
|
||||||
|
| `namecheap://domains` | List all domains with status |
|
||||||
|
| `namecheap://domain/{domain_name}` | Detailed domain information |
|
||||||
|
| `namecheap://dns/{domain_name}` | DNS records for domain |
|
||||||
|
| `namecheap://nameservers/{domain_name}` | Nameserver configuration |
|
||||||
|
| `namecheap://ssl` | SSL certificates overview |
|
||||||
|
| `namecheap://ssl/{certificate_id}` | Detailed certificate info |
|
||||||
|
| `namecheap://account` | Account information |
|
||||||
|
| `namecheap://balance` | Account balance details |
|
||||||
|
|
||||||
|
## 🖥️ Command Line Interface
|
||||||
|
|
||||||
|
The package includes a comprehensive CLI for direct operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List domains
|
||||||
|
mcp-namecheap domains list
|
||||||
|
|
||||||
|
# Check domain availability
|
||||||
|
mcp-namecheap domains check example.com newsite.org
|
||||||
|
|
||||||
|
# Get domain information
|
||||||
|
mcp-namecheap domains info example.com
|
||||||
|
|
||||||
|
# Get DNS records
|
||||||
|
mcp-namecheap dns get example.com
|
||||||
|
|
||||||
|
# List SSL certificates
|
||||||
|
mcp-namecheap ssl list
|
||||||
|
|
||||||
|
# Get account information
|
||||||
|
mcp-namecheap account info
|
||||||
|
|
||||||
|
# Get account balance
|
||||||
|
mcp-namecheap account balance
|
||||||
|
|
||||||
|
# Output in JSON format
|
||||||
|
mcp-namecheap domains list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
The project includes comprehensive testing with multiple test types:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
python run_tests.py
|
||||||
|
|
||||||
|
# Run specific test types
|
||||||
|
python run_tests.py --unit # Unit tests only
|
||||||
|
python run_tests.py --integration # Integration tests only
|
||||||
|
python run_tests.py --mcp # MCP functionality tests only
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
python run_tests.py --coverage
|
||||||
|
|
||||||
|
# Code quality checks
|
||||||
|
python run_tests.py --lint # Ruff linting and formatting
|
||||||
|
python run_tests.py --format # Black and isort formatting
|
||||||
|
python run_tests.py --typecheck # Type checking with mypy
|
||||||
|
python run_tests.py --all # Everything
|
||||||
|
|
||||||
|
# Auto-fix formatting and linting
|
||||||
|
python run_tests.py --format --fix
|
||||||
|
python run_tests.py --lint --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
- **Unit Tests**: Individual component testing with mocks
|
||||||
|
- **Integration Tests**: Component interaction testing
|
||||||
|
- **MCP Tests**: FastMCP in-memory testing for all tools
|
||||||
|
- **Coverage Goal**: 80%+ overall, 100% for critical MCP tools
|
||||||
|
|
||||||
|
## 🔧 Development
|
||||||
|
|
||||||
|
### Setup Development Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/your-org/mcp-namecheap
|
||||||
|
cd mcp-namecheap
|
||||||
|
|
||||||
|
# Install with development dependencies
|
||||||
|
uv install -e ".[dev]"
|
||||||
|
|
||||||
|
# Install pre-commit hooks
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
|
# Copy environment template
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code with Ruff (fast)
|
||||||
|
ruff format src/ tests/
|
||||||
|
ruff check --fix src/ tests/
|
||||||
|
|
||||||
|
# Format code with Black and isort
|
||||||
|
black src/ tests/
|
||||||
|
isort src/ tests/
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
mypy src/mcp_namecheap/
|
||||||
|
|
||||||
|
# Run all quality checks
|
||||||
|
python run_tests.py --all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp-namecheap/
|
||||||
|
├── src/mcp_namecheap/
|
||||||
|
│ ├── __init__.py # Package exports
|
||||||
|
│ ├── __main__.py # python -m entry point
|
||||||
|
│ ├── client.py # High-level client with smart identifiers
|
||||||
|
│ ├── server.py # Core API client with error handling
|
||||||
|
│ ├── fastmcp_server.py # FastMCP server implementation
|
||||||
|
│ ├── cli.py # Command-line interface
|
||||||
|
│ ├── domains.py # Domain management tools
|
||||||
|
│ ├── dns.py # DNS management tools
|
||||||
|
│ ├── ssl.py # SSL certificate tools
|
||||||
|
│ └── account.py # Account management tools
|
||||||
|
├── tests/
|
||||||
|
│ ├── conftest.py # Test fixtures
|
||||||
|
│ ├── test_mcp_server.py # MCP functionality tests
|
||||||
|
│ ├── test_client.py # Client tests
|
||||||
|
│ └── test_server.py # Core server tests
|
||||||
|
├── CLAUDE.md # Development documentation
|
||||||
|
├── README.md # This file
|
||||||
|
├── pyproject.toml # Project configuration
|
||||||
|
└── run_tests.py # Comprehensive test runner
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. **Fork the repository**
|
||||||
|
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
||||||
|
3. **Make changes with tests**: Ensure high test coverage
|
||||||
|
4. **Run quality checks**: `python run_tests.py --all`
|
||||||
|
5. **Commit changes**: `git commit -m 'Add amazing feature'`
|
||||||
|
6. **Push to branch**: `git push origin feature/amazing-feature`
|
||||||
|
7. **Create Pull Request**
|
||||||
|
|
||||||
|
### Contribution Guidelines
|
||||||
|
- Maintain 80%+ test coverage
|
||||||
|
- Follow type hints and documentation standards
|
||||||
|
- Ensure all quality checks pass
|
||||||
|
- Add tests for new features
|
||||||
|
- Update documentation as needed
|
||||||
|
|
||||||
|
## 📝 API Response Format
|
||||||
|
|
||||||
|
All MCP tools return a standardized response format:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Success response
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {...}, # API response data
|
||||||
|
"message": "Operation completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error response
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Error description",
|
||||||
|
"message": "Operation failed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Error Handling
|
||||||
|
|
||||||
|
The server provides comprehensive error handling with specific exception types:
|
||||||
|
|
||||||
|
- **`NameCheapAPIError`**: Base exception for all API errors
|
||||||
|
- **`NameCheapAuthError`**: Authentication/authorization errors
|
||||||
|
- **`NameCheapNotFoundError`**: Resource not found errors
|
||||||
|
- **`NameCheapRateLimitError`**: Rate limit exceeded errors
|
||||||
|
- **`NameCheapValidationError`**: Input validation errors
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
- **No Secrets in Code**: All API credentials via environment variables
|
||||||
|
- **Input Validation**: Comprehensive validation using Pydantic models
|
||||||
|
- **Rate Limiting**: Respect Name Cheap API rate limits
|
||||||
|
- **Timeout Configuration**: Proper HTTP timeouts (30s total, 10s connect)
|
||||||
|
- **Sandbox Support**: Safe testing environment
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## 🔗 Related Links
|
||||||
|
|
||||||
|
- [Name Cheap API Documentation](https://www.namecheap.com/support/api/intro/)
|
||||||
|
- [FastMCP Documentation](https://github.com/jlowin/fastmcp)
|
||||||
|
- [Model Context Protocol](https://modelcontextprotocol.io/)
|
||||||
|
- [Issue Tracker](https://github.com/your-org/mcp-namecheap/issues)
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
- **Documentation**: [GitHub README](https://github.com/your-org/mcp-namecheap#readme)
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/your-org/mcp-namecheap/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/your-org/mcp-namecheap/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ using FastMCP and the Model Context Protocol**
|
192
pyproject.toml
Normal file
192
pyproject.toml
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mcp-namecheap"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Production-ready MCP server for Name Cheap API integration"
|
||||||
|
authors = [
|
||||||
|
{name = "MCP Name Cheap Team", email = "dev@example.com"}
|
||||||
|
]
|
||||||
|
license = {text = "MIT"}
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
keywords = ["mcp", "namecheap", "domains", "dns", "ssl", "fastmcp"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Internet :: Name Service (DNS)",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.10.5",
|
||||||
|
"httpx>=0.25.0",
|
||||||
|
"pydantic>=2.5.0",
|
||||||
|
"xmltodict>=0.13.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"typing-extensions>=4.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.11.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.5.0",
|
||||||
|
"pre-commit>=3.4.0",
|
||||||
|
"build>=1.0.0",
|
||||||
|
"twine>=4.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
test = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.11.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcp-namecheap = "mcp_namecheap.__main__:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/your-org/mcp-namecheap"
|
||||||
|
Documentation = "https://github.com/your-org/mcp-namecheap#readme"
|
||||||
|
Repository = "https://github.com/your-org/mcp-namecheap"
|
||||||
|
Issues = "https://github.com/your-org/mcp-namecheap/issues"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mcp_namecheap"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"/src",
|
||||||
|
"/tests",
|
||||||
|
"/README.md",
|
||||||
|
"/CLAUDE.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Black configuration
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ['py39']
|
||||||
|
include = '\.pyi?$'
|
||||||
|
extend-exclude = '''
|
||||||
|
/(
|
||||||
|
# directories
|
||||||
|
\.eggs
|
||||||
|
| \.git
|
||||||
|
| \.hg
|
||||||
|
| \.mypy_cache
|
||||||
|
| \.tox
|
||||||
|
| \.venv
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
)/
|
||||||
|
'''
|
||||||
|
|
||||||
|
# isort configuration
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
multi_line_output = 3
|
||||||
|
line_length = 88
|
||||||
|
known_first_party = ["mcp_namecheap"]
|
||||||
|
|
||||||
|
# mypy configuration
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_no_return = true
|
||||||
|
warn_unreachable = true
|
||||||
|
strict_equality = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = [
|
||||||
|
"xmltodict",
|
||||||
|
"pytest_mock",
|
||||||
|
]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
# pytest configuration
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
minversion = "7.0"
|
||||||
|
addopts = "-ra -q --strict-markers --strict-config"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
markers = [
|
||||||
|
"unit: Unit tests",
|
||||||
|
"integration: Integration tests",
|
||||||
|
"mcp: MCP functionality tests",
|
||||||
|
"slow: Slow running tests",
|
||||||
|
]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
# Ruff configuration
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"ARG", # flake8-unused-arguments
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"TID", # flake8-tidy-imports
|
||||||
|
"ICN", # flake8-import-conventions
|
||||||
|
"PL", # pylint
|
||||||
|
"RUF", # Ruff-specific rules
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (handled by black)
|
||||||
|
"PLR0913", # too many arguments
|
||||||
|
"PLR0912", # too many branches
|
||||||
|
"PLR2004", # magic value used in comparison
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"__init__.py" = ["F401"] # imported but unused
|
||||||
|
"tests/*" = ["ARG", "S101", "PLR2004"] # test-specific ignores
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["mcp_namecheap"]
|
||||||
|
|
||||||
|
# coverage configuration
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src/mcp_namecheap"]
|
||||||
|
omit = [
|
||||||
|
"tests/*",
|
||||||
|
"*/test_*",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
36
ruff.toml
Normal file
36
ruff.toml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Ruff configuration file
|
||||||
|
# This extends the pyproject.toml configuration for IDE integration
|
||||||
|
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"ARG", # flake8-unused-arguments
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"TID", # flake8-tidy-imports
|
||||||
|
"ICN", # flake8-import-conventions
|
||||||
|
"PL", # pylint
|
||||||
|
"RUF", # Ruff-specific rules
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (handled by black)
|
||||||
|
"PLR0913", # too many arguments
|
||||||
|
"PLR0912", # too many branches
|
||||||
|
"PLR2004", # magic value used in comparison
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
|
"__init__.py" = ["F401"] # imported but unused
|
||||||
|
"tests/*" = ["ARG", "S101", "PLR2004"] # test-specific ignores
|
||||||
|
|
||||||
|
[lint.isort]
|
||||||
|
known-first-party = ["mcp_namecheap"]
|
143
run_tests.py
Normal file
143
run_tests.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Comprehensive test runner for the MCP Name Cheap server."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(cmd: list[str], description: str) -> bool:
|
||||||
|
"""Run a command and return success status."""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Running: {description}")
|
||||||
|
print(f"Command: {' '.join(cmd)}")
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, check=True, cwd=Path(__file__).parent)
|
||||||
|
print(f"✓ {description} passed")
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"✗ {description} failed with exit code {e.returncode}")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"✗ Command not found: {cmd[0]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main test runner."""
|
||||||
|
parser = argparse.ArgumentParser(description="Run MCP Name Cheap tests")
|
||||||
|
parser.add_argument("--unit", action="store_true", help="Run only unit tests")
|
||||||
|
parser.add_argument("--integration", action="store_true", help="Run only integration tests")
|
||||||
|
parser.add_argument("--mcp", action="store_true", help="Run only MCP tests")
|
||||||
|
parser.add_argument("--slow", action="store_true", help="Include slow tests")
|
||||||
|
parser.add_argument("--coverage", action="store_true", help="Run with coverage")
|
||||||
|
parser.add_argument("--lint", action="store_true", help="Run linting only")
|
||||||
|
parser.add_argument("--format", action="store_true", help="Run formatting only")
|
||||||
|
parser.add_argument("--typecheck", action="store_true", help="Run type checking only")
|
||||||
|
parser.add_argument("--all", action="store_true", help="Run all checks and tests")
|
||||||
|
parser.add_argument("--fix", action="store_true", help="Auto-fix formatting issues")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not any([args.unit, args.integration, args.mcp, args.lint, args.format,
|
||||||
|
args.typecheck, args.all]):
|
||||||
|
# Default: run all tests but not linting/formatting
|
||||||
|
args.unit = args.integration = args.mcp = True
|
||||||
|
|
||||||
|
success = True
|
||||||
|
|
||||||
|
# Code formatting
|
||||||
|
if args.format or args.all:
|
||||||
|
if args.fix:
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "ruff", "format", "src/", "tests/", "run_tests.py"],
|
||||||
|
"Code formatting (ruff) - fixing"
|
||||||
|
)
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "ruff", "check", "--fix", "src/", "tests/"],
|
||||||
|
"Code linting (ruff) - fixing"
|
||||||
|
)
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "black", "src/", "tests/", "run_tests.py"],
|
||||||
|
"Code formatting (black) - fixing"
|
||||||
|
)
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "isort", "src/", "tests/", "run_tests.py"],
|
||||||
|
"Import sorting (isort) - fixing"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "black", "--check", "src/", "tests/", "run_tests.py"],
|
||||||
|
"Code formatting (black)"
|
||||||
|
)
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "isort", "--check-only", "src/", "tests/", "run_tests.py"],
|
||||||
|
"Import sorting (isort)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Linting with Ruff
|
||||||
|
if args.lint or args.all:
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "ruff", "check", "src/", "tests/"],
|
||||||
|
"Code linting (ruff)"
|
||||||
|
)
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "ruff", "format", "--check", "src/", "tests/"],
|
||||||
|
"Code formatting check (ruff)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
if args.typecheck or args.all:
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "mypy", "src/mcp_namecheap/"],
|
||||||
|
"Type checking (mypy)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test execution
|
||||||
|
pytest_cmd = ["python", "-m", "pytest"]
|
||||||
|
|
||||||
|
if args.coverage:
|
||||||
|
pytest_cmd.extend(["--cov=src/mcp_namecheap", "--cov-report=term-missing", "--cov-report=html"])
|
||||||
|
|
||||||
|
# Add verbosity
|
||||||
|
pytest_cmd.extend(["-v", "--tb=short"])
|
||||||
|
|
||||||
|
# Test selection
|
||||||
|
markers = []
|
||||||
|
if args.unit:
|
||||||
|
markers.append("unit")
|
||||||
|
if args.integration:
|
||||||
|
markers.append("integration")
|
||||||
|
if args.mcp:
|
||||||
|
markers.append("mcp")
|
||||||
|
|
||||||
|
if markers:
|
||||||
|
marker_expr = " or ".join(markers)
|
||||||
|
pytest_cmd.extend(["-m", marker_expr])
|
||||||
|
|
||||||
|
if not args.slow:
|
||||||
|
if markers:
|
||||||
|
pytest_cmd.extend(["-m", f"({marker_expr}) and not slow"])
|
||||||
|
else:
|
||||||
|
pytest_cmd.extend(["-m", "not slow"])
|
||||||
|
|
||||||
|
if any([args.unit, args.integration, args.mcp, args.all]) or not any([args.lint, args.format, args.typecheck]):
|
||||||
|
success &= run_command(pytest_cmd, "Running tests")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
if success:
|
||||||
|
print("🎉 All checks passed!")
|
||||||
|
print("="*60)
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("💥 Some checks failed!")
|
||||||
|
print("="*60)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
146
scripts/publish.py
Executable file
146
scripts/publish.py
Executable file
@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to help with publishing to TestPyPI and PyPI.
|
||||||
|
This script provides an interactive way to build and publish the package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(cmd: list[str], description: str) -> bool:
|
||||||
|
"""Run a command and return success status."""
|
||||||
|
print(f"\n🔄 {description}")
|
||||||
|
print(f"Command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, check=True, cwd=Path(__file__).parent.parent)
|
||||||
|
print(f"✅ {description} - Success")
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"❌ {description} - Failed with exit code {e.returncode}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main publishing script."""
|
||||||
|
parser = argparse.ArgumentParser(description="Publish MCP Name Cheap package")
|
||||||
|
parser.add_argument(
|
||||||
|
"--target",
|
||||||
|
choices=["testpypi", "pypi"],
|
||||||
|
default="testpypi",
|
||||||
|
help="Publishing target (default: testpypi)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-tests",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip running tests before publishing"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-build",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip building (use existing dist/)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("🚀 MCP Name Cheap Publishing Script")
|
||||||
|
print(f"Target: {args.target}")
|
||||||
|
|
||||||
|
success = True
|
||||||
|
|
||||||
|
# Run tests unless skipped
|
||||||
|
if not args.skip_tests:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("📋 Running tests...")
|
||||||
|
success &= run_command(
|
||||||
|
["python", "run_tests.py", "--all"],
|
||||||
|
"Running all tests and quality checks"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print("\n❌ Tests failed. Fix issues before publishing.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Clean and build
|
||||||
|
if not args.skip_build:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🏗️ Building package...")
|
||||||
|
|
||||||
|
# Clean old builds
|
||||||
|
success &= run_command(
|
||||||
|
["rm", "-rf", "dist/", "build/"],
|
||||||
|
"Cleaning old build artifacts"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build package
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "build"],
|
||||||
|
"Building source and wheel distributions"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print("\n❌ Build failed.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check package
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🔍 Checking package...")
|
||||||
|
success &= run_command(
|
||||||
|
["python", "-m", "twine", "check", "dist/*"],
|
||||||
|
"Checking package with twine"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print("\n❌ Package check failed.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Publish
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"📦 Publishing to {args.target}...")
|
||||||
|
|
||||||
|
if args.target == "testpypi":
|
||||||
|
repository_url = "https://test.pypi.org/legacy/"
|
||||||
|
print("\n⚠️ Publishing to TestPyPI")
|
||||||
|
print("You can install the test package with:")
|
||||||
|
print("pip install -i https://test.pypi.org/simple/ mcp-namecheap")
|
||||||
|
else:
|
||||||
|
repository_url = "https://upload.pypi.org/legacy/"
|
||||||
|
print("\n🚨 Publishing to PRODUCTION PyPI")
|
||||||
|
|
||||||
|
confirm = input("Are you sure you want to publish to production PyPI? (yes/no): ")
|
||||||
|
if confirm.lower() != "yes":
|
||||||
|
print("❌ Publishing cancelled.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Build twine command
|
||||||
|
twine_cmd = ["python", "-m", "twine", "upload"]
|
||||||
|
if args.target == "testpypi":
|
||||||
|
twine_cmd.extend(["--repository-url", repository_url])
|
||||||
|
twine_cmd.append("dist/*")
|
||||||
|
|
||||||
|
success &= run_command(
|
||||||
|
twine_cmd,
|
||||||
|
f"Publishing to {args.target}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\n🎉 Successfully published to {args.target}!")
|
||||||
|
if args.target == "testpypi":
|
||||||
|
print("\n📋 Next steps:")
|
||||||
|
print("1. Test the package: pip install -i https://test.pypi.org/simple/ mcp-namecheap")
|
||||||
|
print("2. If everything works, publish to PyPI: python scripts/publish.py --target pypi")
|
||||||
|
else:
|
||||||
|
print("\n🌟 Package is now live on PyPI!")
|
||||||
|
print("Users can install with: pip install mcp-namecheap")
|
||||||
|
else:
|
||||||
|
print(f"\n❌ Publishing to {args.target} failed.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
19
src/mcp_namecheap/__init__.py
Normal file
19
src/mcp_namecheap/__init__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""MCP Name Cheap: Production-ready MCP server for Name Cheap API integration.
|
||||||
|
|
||||||
|
This package provides comprehensive domain management, DNS configuration,
|
||||||
|
and SSL certificate tools through the Name Cheap API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "MCP Name Cheap Team"
|
||||||
|
__email__ = "dev@example.com"
|
||||||
|
|
||||||
|
from .client import NameCheapClient
|
||||||
|
from .server import NameCheapAPIError, NameCheapAuthError, NameCheapNotFoundError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NameCheapClient",
|
||||||
|
"NameCheapAPIError",
|
||||||
|
"NameCheapAuthError",
|
||||||
|
"NameCheapNotFoundError",
|
||||||
|
]
|
12
src/mcp_namecheap/__main__.py
Normal file
12
src/mcp_namecheap/__main__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""Entry point for running the MCP server as a module."""
|
||||||
|
|
||||||
|
from .fastmcp_server import run_server
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the MCP server."""
|
||||||
|
run_server()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
178
src/mcp_namecheap/account.py
Normal file
178
src/mcp_namecheap/account.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"""Account management tools for FastMCP."""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from .client import NameCheapClient
|
||||||
|
from .server import NameCheapAPIError
|
||||||
|
|
||||||
|
|
||||||
|
# Create account tools instance
|
||||||
|
account_mcp = FastMCP("account")
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: NameCheapClient = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> NameCheapClient:
|
||||||
|
"""Get or create the Name Cheap client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = NameCheapClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
# Account management tools
|
||||||
|
@account_mcp.tool()
|
||||||
|
async def get_account_info() -> Dict[str, Any]:
|
||||||
|
"""Get account information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with account details or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
account_info = await client.get_account_info()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": account_info,
|
||||||
|
"message": "Successfully retrieved account information"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to get account information"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": "Failed to get account information"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@account_mcp.tool()
|
||||||
|
async def get_account_balance() -> Dict[str, Any]:
|
||||||
|
"""Get account balance information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with balance details or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
balance_info = await client.get_account_balance()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": balance_info,
|
||||||
|
"message": "Successfully retrieved account balance"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to get account balance"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": "Failed to get account balance"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Resources for account information
|
||||||
|
@account_mcp.resource("namecheap://account")
|
||||||
|
async def get_account_resource() -> str:
|
||||||
|
"""Get account information as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
account_info = await client.get_account_info()
|
||||||
|
|
||||||
|
output = ["Account Information", "=" * 20, ""]
|
||||||
|
|
||||||
|
# Extract account details
|
||||||
|
if "UserGetAccountResult" in account_info:
|
||||||
|
info = account_info["UserGetAccountResult"]
|
||||||
|
|
||||||
|
output.extend([
|
||||||
|
f"Account ID: {info.get('@ID', 'Unknown')}",
|
||||||
|
f"Username: {info.get('@UserName', 'Unknown')}",
|
||||||
|
f"First Name: {info.get('@FirstName', 'Unknown')}",
|
||||||
|
f"Last Name: {info.get('@LastName', 'Unknown')}",
|
||||||
|
f"Email: {info.get('@Email', 'Unknown')}",
|
||||||
|
f"Account Created: {info.get('@AccountCreateDate', 'Unknown')}",
|
||||||
|
f"Company: {info.get('@Company', 'Not specified')}",
|
||||||
|
f"Phone: {info.get('@Phone', 'Not specified')}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
# Account status and settings
|
||||||
|
output.extend([
|
||||||
|
"Account Settings:",
|
||||||
|
"-" * 16,
|
||||||
|
f"Email Verified: {info.get('@IsEmailVerified', 'Unknown')}",
|
||||||
|
f"Phone Verified: {info.get('@IsPhoneVerified', 'Unknown')}",
|
||||||
|
f"Account Status: {info.get('@AccountStatus', 'Unknown')}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
output.append("No account information available.")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting account information: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@account_mcp.resource("namecheap://balance")
|
||||||
|
async def get_balance_resource() -> str:
|
||||||
|
"""Get account balance as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
balance_info = await client.get_account_balance()
|
||||||
|
|
||||||
|
output = ["Account Balance", "=" * 15, ""]
|
||||||
|
|
||||||
|
# Extract balance details
|
||||||
|
if "UserGetBalancesResult" in balance_info:
|
||||||
|
balances = balance_info["UserGetBalancesResult"]
|
||||||
|
|
||||||
|
# Handle single balance or multiple balances
|
||||||
|
if isinstance(balances, dict):
|
||||||
|
if "@Currency" in balances:
|
||||||
|
# Single balance
|
||||||
|
currency = balances.get("@Currency", "USD")
|
||||||
|
amount = balances.get("@AccountBalance", "0.00")
|
||||||
|
available = balances.get("@AvailableBalance", "0.00")
|
||||||
|
|
||||||
|
output.extend([
|
||||||
|
f"Currency: {currency}",
|
||||||
|
f"Account Balance: {amount}",
|
||||||
|
f"Available Balance: {available}",
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# Multiple balances nested
|
||||||
|
balance_list = balances.get("Balance", [])
|
||||||
|
if not isinstance(balance_list, list):
|
||||||
|
balance_list = [balance_list]
|
||||||
|
|
||||||
|
for balance in balance_list:
|
||||||
|
currency = balance.get("@Currency", "USD")
|
||||||
|
amount = balance.get("@AccountBalance", "0.00")
|
||||||
|
available = balance.get("@AvailableBalance", "0.00")
|
||||||
|
|
||||||
|
output.extend([
|
||||||
|
f"Currency: {currency}",
|
||||||
|
f"Account Balance: {amount}",
|
||||||
|
f"Available Balance: {available}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
output.append("No balance information available.")
|
||||||
|
else:
|
||||||
|
output.append("No balance information available.")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting account balance: {str(e)}"
|
269
src/mcp_namecheap/cli.py
Normal file
269
src/mcp_namecheap/cli.py
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
"""Command-line interface for direct Name Cheap operations."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
import argparse
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from .client import NameCheapClient
|
||||||
|
from .server import NameCheapAPIError
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapCLI:
|
||||||
|
"""Command-line interface for Name Cheap operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client: Optional[NameCheapClient] = None
|
||||||
|
|
||||||
|
def get_client(self) -> NameCheapClient:
|
||||||
|
"""Get or create the Name Cheap client."""
|
||||||
|
if self.client is None:
|
||||||
|
self.client = NameCheapClient()
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
def print_result(self, result: Dict[str, Any], format_json: bool = False) -> None:
|
||||||
|
"""Print operation result."""
|
||||||
|
if format_json:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
if result.get("success"):
|
||||||
|
print(f"✓ {result.get('message', 'Operation completed successfully')}")
|
||||||
|
if "data" in result and result["data"]:
|
||||||
|
print("\nData:")
|
||||||
|
print(json.dumps(result["data"], indent=2))
|
||||||
|
else:
|
||||||
|
print(f"✗ {result.get('message', 'Operation failed')}")
|
||||||
|
if "error" in result:
|
||||||
|
print(f"Error: {result['error']}")
|
||||||
|
|
||||||
|
async def list_domains(self, args: argparse.Namespace) -> None:
|
||||||
|
"""List all domains."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
domains = await client.list_domains()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": domains,
|
||||||
|
"message": f"Found {len(domains)} domains"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
async def check_domains(self, args: argparse.Namespace) -> None:
|
||||||
|
"""Check domain availability."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
results = await client.check_domain_availability(args.domains)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": results,
|
||||||
|
"message": f"Checked {len(args.domains)} domains"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
async def get_domain_info(self, args: argparse.Namespace) -> None:
|
||||||
|
"""Get domain information."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
info = await client.get_domain_info(args.domain)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": info,
|
||||||
|
"message": f"Retrieved info for {args.domain}"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
async def get_dns_records(self, args: argparse.Namespace) -> None:
|
||||||
|
"""Get DNS records for a domain."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
records = await client.get_dns_records(args.domain)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": records,
|
||||||
|
"message": f"Retrieved {len(records)} DNS records for {args.domain}"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
async def list_ssl(self, args: argparse.Namespace) -> None:
|
||||||
|
"""List SSL certificates."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
certificates = await client.list_ssl_certificates()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": certificates,
|
||||||
|
"message": f"Found {len(certificates)} SSL certificates"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
async def get_account_info(self, args: argparse.Namespace) -> None:
|
||||||
|
"""Get account information."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
info = await client.get_account_info()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": info,
|
||||||
|
"message": "Retrieved account information"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
async def get_balance(self, args: argparse.Namespace) -> None:
|
||||||
|
"""Get account balance."""
|
||||||
|
try:
|
||||||
|
client = self.get_client()
|
||||||
|
balance = await client.get_account_balance()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"data": balance,
|
||||||
|
"message": "Retrieved account balance"
|
||||||
|
}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
except Exception as e:
|
||||||
|
result = {"success": False, "error": str(e)}
|
||||||
|
self.print_result(result, args.json)
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Create the command-line argument parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Name Cheap CLI for domain, DNS, and SSL management"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json", action="store_true", help="Output results in JSON format"
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||||
|
|
||||||
|
# Domain commands
|
||||||
|
domains_parser = subparsers.add_parser("domains", help="Domain management")
|
||||||
|
domains_subparsers = domains_parser.add_subparsers(dest="domain_command")
|
||||||
|
|
||||||
|
# List domains
|
||||||
|
domains_subparsers.add_parser("list", help="List all domains")
|
||||||
|
|
||||||
|
# Check domain availability
|
||||||
|
check_parser = domains_subparsers.add_parser("check", help="Check domain availability")
|
||||||
|
check_parser.add_argument("domains", nargs="+", help="Domain names to check")
|
||||||
|
|
||||||
|
# Get domain info
|
||||||
|
info_parser = domains_subparsers.add_parser("info", help="Get domain information")
|
||||||
|
info_parser.add_argument("domain", help="Domain name")
|
||||||
|
|
||||||
|
# DNS commands
|
||||||
|
dns_parser = subparsers.add_parser("dns", help="DNS management")
|
||||||
|
dns_subparsers = dns_parser.add_subparsers(dest="dns_command")
|
||||||
|
|
||||||
|
# Get DNS records
|
||||||
|
dns_get_parser = dns_subparsers.add_parser("get", help="Get DNS records")
|
||||||
|
dns_get_parser.add_argument("domain", help="Domain name")
|
||||||
|
|
||||||
|
# SSL commands
|
||||||
|
ssl_parser = subparsers.add_parser("ssl", help="SSL certificate management")
|
||||||
|
ssl_subparsers = ssl_parser.add_subparsers(dest="ssl_command")
|
||||||
|
|
||||||
|
# List SSL certificates
|
||||||
|
ssl_subparsers.add_parser("list", help="List SSL certificates")
|
||||||
|
|
||||||
|
# Account commands
|
||||||
|
account_parser = subparsers.add_parser("account", help="Account management")
|
||||||
|
account_subparsers = account_parser.add_subparsers(dest="account_command")
|
||||||
|
|
||||||
|
# Get account info
|
||||||
|
account_subparsers.add_parser("info", help="Get account information")
|
||||||
|
|
||||||
|
# Get account balance
|
||||||
|
account_subparsers.add_parser("balance", help="Get account balance")
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main CLI entry point."""
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
parser = create_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
cli = NameCheapCLI()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Route commands
|
||||||
|
if args.command == "domains":
|
||||||
|
if args.domain_command == "list":
|
||||||
|
await cli.list_domains(args)
|
||||||
|
elif args.domain_command == "check":
|
||||||
|
await cli.check_domains(args)
|
||||||
|
elif args.domain_command == "info":
|
||||||
|
await cli.get_domain_info(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
elif args.command == "dns":
|
||||||
|
if args.dns_command == "get":
|
||||||
|
await cli.get_dns_records(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
elif args.command == "ssl":
|
||||||
|
if args.ssl_command == "list":
|
||||||
|
await cli.list_ssl(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
elif args.command == "account":
|
||||||
|
if args.account_command == "info":
|
||||||
|
await cli.get_account_info(args)
|
||||||
|
elif args.account_command == "balance":
|
||||||
|
await cli.get_balance(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nOperation cancelled by user")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if cli.client:
|
||||||
|
cli.client.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
263
src/mcp_namecheap/client.py
Normal file
263
src/mcp_namecheap/client.py
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"""High-level Name Cheap client with smart identifier resolution."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .server import NameCheapAPIServer, NameCheapNotFoundError, NameCheapValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def is_uuid_format(identifier: str) -> bool:
|
||||||
|
"""Check if string looks like a UUID format."""
|
||||||
|
# Name Cheap uses integer IDs, not UUIDs
|
||||||
|
return identifier.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapClient:
|
||||||
|
"""High-level Name Cheap client with smart identifier resolution."""
|
||||||
|
|
||||||
|
def __init__(self, server: Optional[NameCheapAPIServer] = None):
|
||||||
|
"""Initialize the client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server: Optional API server instance. If not provided, creates one.
|
||||||
|
"""
|
||||||
|
self.server = server or NameCheapAPIServer()
|
||||||
|
self._domain_cache: Optional[List[Dict[str, Any]]] = None
|
||||||
|
self._ssl_cache: Optional[List[Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
def _clear_caches(self) -> None:
|
||||||
|
"""Clear all internal caches."""
|
||||||
|
self._domain_cache = None
|
||||||
|
self._ssl_cache = None
|
||||||
|
|
||||||
|
# Smart identifier resolution methods
|
||||||
|
async def get_domain_id(self, identifier: str) -> str:
|
||||||
|
"""Get domain name from identifier (domain name or exact match).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Domain name or human-readable identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The domain name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NameCheapNotFoundError: If domain not found
|
||||||
|
"""
|
||||||
|
# For domains, the identifier IS the domain name
|
||||||
|
# But we still validate it exists in the account
|
||||||
|
if self._is_valid_domain_format(identifier):
|
||||||
|
# Check if domain exists in account
|
||||||
|
domains = self._get_cached_domains()
|
||||||
|
for domain in domains:
|
||||||
|
if domain.get("@Name", "").lower() == identifier.lower():
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
# If not found by exact match, search by domain name
|
||||||
|
domains = self._get_cached_domains()
|
||||||
|
for domain in domains:
|
||||||
|
domain_name = domain.get("@Name", "")
|
||||||
|
if domain_name.lower() == identifier.lower():
|
||||||
|
return domain_name
|
||||||
|
|
||||||
|
raise NameCheapNotFoundError(f"Domain '{identifier}' not found in account")
|
||||||
|
|
||||||
|
async def get_ssl_id(self, identifier: str) -> str:
|
||||||
|
"""Get SSL certificate ID from identifier (ID, hostname, or description).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Certificate ID, hostname, or human-readable identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The certificate ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NameCheapNotFoundError: If certificate not found
|
||||||
|
"""
|
||||||
|
# Check if already an ID
|
||||||
|
if is_uuid_format(identifier):
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
# Search by hostname or description
|
||||||
|
certificates = self._get_cached_ssl_certificates()
|
||||||
|
for cert in certificates:
|
||||||
|
cert_id = cert.get("@CertificateID", "")
|
||||||
|
hostname = cert.get("@HostName", "")
|
||||||
|
ssl_type = cert.get("@SSLType", "")
|
||||||
|
|
||||||
|
# Exact match on hostname
|
||||||
|
if hostname.lower() == identifier.lower():
|
||||||
|
return cert_id
|
||||||
|
|
||||||
|
# Match on SSL type
|
||||||
|
if ssl_type.lower() == identifier.lower():
|
||||||
|
return cert_id
|
||||||
|
|
||||||
|
raise NameCheapNotFoundError(f"SSL certificate '{identifier}' not found")
|
||||||
|
|
||||||
|
def _get_cached_domains(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get cached domain list or fetch if not cached."""
|
||||||
|
if self._domain_cache is None:
|
||||||
|
self._domain_cache = self.server.list_domains()
|
||||||
|
return self._domain_cache
|
||||||
|
|
||||||
|
def _get_cached_ssl_certificates(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get cached SSL certificate list or fetch if not cached."""
|
||||||
|
if self._ssl_cache is None:
|
||||||
|
self._ssl_cache = self.server.list_ssl_certificates()
|
||||||
|
return self._ssl_cache
|
||||||
|
|
||||||
|
def _is_valid_domain_format(self, domain: str) -> bool:
|
||||||
|
"""Check if string looks like a valid domain name."""
|
||||||
|
domain_pattern = re.compile(
|
||||||
|
r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?'
|
||||||
|
r'(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$'
|
||||||
|
)
|
||||||
|
return bool(domain_pattern.match(domain)) and len(domain) <= 253
|
||||||
|
|
||||||
|
# High-level domain methods
|
||||||
|
async def list_domains(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List all domains with enhanced information."""
|
||||||
|
self._clear_caches() # Ensure fresh data
|
||||||
|
domains = self.server.list_domains()
|
||||||
|
|
||||||
|
# Enhance domain data with computed fields
|
||||||
|
enhanced_domains = []
|
||||||
|
for domain in domains:
|
||||||
|
enhanced = dict(domain)
|
||||||
|
enhanced["_display_name"] = domain.get("@Name", "Unknown")
|
||||||
|
enhanced["_is_expired"] = domain.get("@IsExpired", "false").lower() == "true"
|
||||||
|
enhanced["_is_locked"] = domain.get("@IsLocked", "false").lower() == "true"
|
||||||
|
enhanced_domains.append(enhanced)
|
||||||
|
|
||||||
|
return enhanced_domains
|
||||||
|
|
||||||
|
async def get_domain_info(self, domain_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed domain information by identifier."""
|
||||||
|
domain_name = await self.get_domain_id(domain_identifier)
|
||||||
|
return self.server.get_domain_info(domain_name)
|
||||||
|
|
||||||
|
async def check_domain_availability(self, domains: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Check availability of multiple domains."""
|
||||||
|
# Validate domain formats
|
||||||
|
for domain in domains:
|
||||||
|
if not self._is_valid_domain_format(domain):
|
||||||
|
raise NameCheapValidationError(f"Invalid domain format: {domain}")
|
||||||
|
|
||||||
|
return self.server.check_domain_availability(domains)
|
||||||
|
|
||||||
|
async def register_domain(
|
||||||
|
self,
|
||||||
|
domain_name: str,
|
||||||
|
years: int,
|
||||||
|
contacts: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Register a new domain."""
|
||||||
|
if not self._is_valid_domain_format(domain_name):
|
||||||
|
raise NameCheapValidationError(f"Invalid domain format: {domain_name}")
|
||||||
|
|
||||||
|
if not 1 <= years <= 10:
|
||||||
|
raise NameCheapValidationError("Years must be between 1 and 10")
|
||||||
|
|
||||||
|
result = self.server.register_domain(domain_name, years, contacts)
|
||||||
|
self._clear_caches() # Clear cache after registration
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def renew_domain(self, domain_identifier: str, years: int) -> Dict[str, Any]:
|
||||||
|
"""Renew an existing domain."""
|
||||||
|
domain_name = await self.get_domain_id(domain_identifier)
|
||||||
|
|
||||||
|
if not 1 <= years <= 10:
|
||||||
|
raise NameCheapValidationError("Years must be between 1 and 10")
|
||||||
|
|
||||||
|
result = self.server.renew_domain(domain_name, years)
|
||||||
|
self._clear_caches() # Clear cache after renewal
|
||||||
|
return result
|
||||||
|
|
||||||
|
# High-level DNS methods
|
||||||
|
async def get_dns_records(self, domain_identifier: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get DNS records for a domain by identifier."""
|
||||||
|
domain_name = await self.get_domain_id(domain_identifier)
|
||||||
|
return self.server.get_dns_records(domain_name)
|
||||||
|
|
||||||
|
async def set_dns_records(
|
||||||
|
self,
|
||||||
|
domain_identifier: str,
|
||||||
|
records: List[Dict[str, Any]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Set DNS records for a domain by identifier."""
|
||||||
|
domain_name = await self.get_domain_id(domain_identifier)
|
||||||
|
|
||||||
|
# Validate DNS records
|
||||||
|
valid_types = {"A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV"}
|
||||||
|
for record in records:
|
||||||
|
if record.get("record_type") not in valid_types:
|
||||||
|
raise NameCheapValidationError(
|
||||||
|
f"Invalid DNS record type: {record.get('record_type')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.server.set_dns_records(domain_name, records)
|
||||||
|
|
||||||
|
async def set_default_nameservers(self, domain_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Set domain to use default Name Cheap nameservers."""
|
||||||
|
domain_name = await self.get_domain_id(domain_identifier)
|
||||||
|
return self.server.set_default_nameservers(domain_name)
|
||||||
|
|
||||||
|
async def set_custom_nameservers(
|
||||||
|
self,
|
||||||
|
domain_identifier: str,
|
||||||
|
nameservers: List[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Set custom nameservers for a domain."""
|
||||||
|
domain_name = await self.get_domain_id(domain_identifier)
|
||||||
|
|
||||||
|
if not 2 <= len(nameservers) <= 12:
|
||||||
|
raise NameCheapValidationError(
|
||||||
|
"Must provide between 2 and 12 nameservers"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate nameserver formats
|
||||||
|
for ns in nameservers:
|
||||||
|
if not self._is_valid_domain_format(ns):
|
||||||
|
raise NameCheapValidationError(f"Invalid nameserver format: {ns}")
|
||||||
|
|
||||||
|
return self.server.set_custom_nameservers(domain_name, nameservers)
|
||||||
|
|
||||||
|
# High-level SSL methods
|
||||||
|
async def list_ssl_certificates(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List all SSL certificates with enhanced information."""
|
||||||
|
self._clear_caches() # Ensure fresh data
|
||||||
|
certificates = self.server.list_ssl_certificates()
|
||||||
|
|
||||||
|
# Enhance certificate data
|
||||||
|
enhanced_certs = []
|
||||||
|
for cert in certificates:
|
||||||
|
enhanced = dict(cert)
|
||||||
|
enhanced["_display_name"] = cert.get("@HostName", "Unknown")
|
||||||
|
enhanced["_is_expired"] = cert.get("@IsExpiredYN", "false").lower() == "true"
|
||||||
|
enhanced_certs.append(enhanced)
|
||||||
|
|
||||||
|
return enhanced_certs
|
||||||
|
|
||||||
|
async def get_ssl_info(self, ssl_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Get SSL certificate information by identifier."""
|
||||||
|
cert_id = await self.get_ssl_id(ssl_identifier)
|
||||||
|
return self.server.get_ssl_info(cert_id)
|
||||||
|
|
||||||
|
# High-level account methods
|
||||||
|
async def get_account_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get account information."""
|
||||||
|
return self.server.get_account_info()
|
||||||
|
|
||||||
|
async def get_account_balance(self) -> Dict[str, Any]:
|
||||||
|
"""Get account balance."""
|
||||||
|
return self.server.get_account_balance()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the underlying server connection."""
|
||||||
|
self.server.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.close()
|
265
src/mcp_namecheap/dns.py
Normal file
265
src/mcp_namecheap/dns.py
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
"""DNS management tools for FastMCP."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from .client import NameCheapClient
|
||||||
|
from .server import NameCheapAPIError
|
||||||
|
|
||||||
|
|
||||||
|
# Create DNS tools instance
|
||||||
|
dns_mcp = FastMCP("dns")
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: NameCheapClient = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> NameCheapClient:
|
||||||
|
"""Get or create the Name Cheap client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = NameCheapClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models for validation
|
||||||
|
class DNSRecord(BaseModel):
|
||||||
|
"""DNS record model."""
|
||||||
|
hostname: str = Field(..., description="Host name (e.g., www, @, mail)")
|
||||||
|
record_type: str = Field(..., description="DNS record type (A, AAAA, CNAME, MX, TXT, NS, SRV)")
|
||||||
|
address: str = Field(..., description="Record value/address")
|
||||||
|
ttl: int = Field(1800, ge=300, le=86400, description="Time to live in seconds")
|
||||||
|
mx_pref: Optional[int] = Field(None, ge=0, le=65535, description="MX priority (only for MX records)")
|
||||||
|
|
||||||
|
|
||||||
|
class DNSRecordsRequest(BaseModel):
|
||||||
|
"""Request model for setting DNS records."""
|
||||||
|
domain_identifier: str = Field(..., description="Domain name or identifier")
|
||||||
|
records: List[DNSRecord] = Field(..., min_items=1, description="List of DNS records to set")
|
||||||
|
|
||||||
|
|
||||||
|
class NameserversRequest(BaseModel):
|
||||||
|
"""Request model for setting custom nameservers."""
|
||||||
|
domain_identifier: str = Field(..., description="Domain name or identifier")
|
||||||
|
nameservers: List[str] = Field(..., min_items=2, max_items=12, description="List of nameserver hostnames")
|
||||||
|
|
||||||
|
|
||||||
|
# DNS management tools
|
||||||
|
@dns_mcp.tool()
|
||||||
|
async def get_dns_records(domain_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Get DNS records for a domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain_identifier: Domain name (supports exact domain name matching)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with DNS records or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
records = await client.get_dns_records(domain_identifier)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": records,
|
||||||
|
"message": f"Retrieved {len(records)} DNS records for {domain_identifier}"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to get DNS records for {domain_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to get DNS records for {domain_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dns_mcp.tool()
|
||||||
|
async def set_dns_records(request: DNSRecordsRequest) -> Dict[str, Any]:
|
||||||
|
"""Set DNS records for a domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: DNS records request with domain identifier and records
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with operation result or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Convert DNS records to API format
|
||||||
|
records_data = []
|
||||||
|
for record in request.records:
|
||||||
|
record_data = {
|
||||||
|
"hostname": record.hostname,
|
||||||
|
"record_type": record.record_type.upper(),
|
||||||
|
"address": record.address,
|
||||||
|
"ttl": record.ttl,
|
||||||
|
}
|
||||||
|
if record.mx_pref is not None:
|
||||||
|
record_data["mx_pref"] = record.mx_pref
|
||||||
|
records_data.append(record_data)
|
||||||
|
|
||||||
|
result = await client.set_dns_records(request.domain_identifier, records_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result,
|
||||||
|
"message": f"Successfully set {len(request.records)} DNS records for {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to set DNS records for {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to set DNS records for {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dns_mcp.tool()
|
||||||
|
async def set_default_nameservers(domain_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Set domain to use Name Cheap default nameservers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain_identifier: Domain name (supports exact domain name matching)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with operation result or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
result = await client.set_default_nameservers(domain_identifier)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result,
|
||||||
|
"message": f"Successfully set default nameservers for {domain_identifier}"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to set default nameservers for {domain_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to set default nameservers for {domain_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dns_mcp.tool()
|
||||||
|
async def set_custom_nameservers(request: NameserversRequest) -> Dict[str, Any]:
|
||||||
|
"""Set custom nameservers for a domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Nameservers request with domain identifier and nameserver list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with operation result or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
result = await client.set_custom_nameservers(
|
||||||
|
request.domain_identifier,
|
||||||
|
request.nameservers
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result,
|
||||||
|
"message": f"Successfully set {len(request.nameservers)} custom nameservers for {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to set custom nameservers for {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to set custom nameservers for {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Resource for DNS records
|
||||||
|
@dns_mcp.resource("namecheap://dns/{domain_name}")
|
||||||
|
async def get_dns_resource(domain_name: str) -> str:
|
||||||
|
"""Get DNS records for a domain as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
records = await client.get_dns_records(domain_name)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
return f"No DNS records found for {domain_name}"
|
||||||
|
|
||||||
|
output = [f"DNS Records for {domain_name}", "=" * 40, ""]
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
hostname = record.get("@Name", "")
|
||||||
|
record_type = record.get("@Type", "")
|
||||||
|
address = record.get("@Address", "")
|
||||||
|
ttl = record.get("@TTL", "")
|
||||||
|
mx_pref = record.get("@MXPref", "")
|
||||||
|
|
||||||
|
# Format record display
|
||||||
|
if hostname == "@":
|
||||||
|
display_name = domain_name
|
||||||
|
else:
|
||||||
|
display_name = f"{hostname}.{domain_name}"
|
||||||
|
|
||||||
|
record_line = f"{display_name:<30} {ttl:<8} {record_type:<8} {address}"
|
||||||
|
if mx_pref and record_type == "MX":
|
||||||
|
record_line += f" (priority: {mx_pref})"
|
||||||
|
|
||||||
|
output.append(record_line)
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting DNS records: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@dns_mcp.resource("namecheap://nameservers/{domain_name}")
|
||||||
|
async def get_nameservers_resource(domain_name: str) -> str:
|
||||||
|
"""Get nameserver information for a domain as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
domain_info = await client.get_domain_info(domain_name)
|
||||||
|
|
||||||
|
output = [f"Nameservers for {domain_name}", "=" * 40, ""]
|
||||||
|
|
||||||
|
# Extract nameserver information
|
||||||
|
if "DomainGetInfoResult" in domain_info:
|
||||||
|
dns_details = domain_info["DomainGetInfoResult"].get("DnsDetails", {})
|
||||||
|
nameservers = dns_details.get("Nameserver", [])
|
||||||
|
|
||||||
|
if nameservers:
|
||||||
|
if isinstance(nameservers, list):
|
||||||
|
for i, ns in enumerate(nameservers, 1):
|
||||||
|
output.append(f"{i}. {ns}")
|
||||||
|
else:
|
||||||
|
output.append(f"1. {nameservers}")
|
||||||
|
else:
|
||||||
|
output.append("No nameservers configured")
|
||||||
|
|
||||||
|
# Show DNS type
|
||||||
|
dns_type = dns_details.get("@ProviderType", "Unknown")
|
||||||
|
output.append("")
|
||||||
|
output.append(f"DNS Provider: {dns_type}")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting nameserver info: {str(e)}"
|
311
src/mcp_namecheap/domains.py
Normal file
311
src/mcp_namecheap/domains.py
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
"""Domain management tools for FastMCP."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from .client import NameCheapClient
|
||||||
|
from .server import NameCheapAPIError
|
||||||
|
|
||||||
|
|
||||||
|
# Create domain tools instance
|
||||||
|
domains_mcp = FastMCP("domains")
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: NameCheapClient = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> NameCheapClient:
|
||||||
|
"""Get or create the Name Cheap client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = NameCheapClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models for validation
|
||||||
|
class ContactInfo(BaseModel):
|
||||||
|
"""Contact information for domain registration."""
|
||||||
|
first_name: str = Field(..., description="First name")
|
||||||
|
last_name: str = Field(..., description="Last name")
|
||||||
|
address1: str = Field(..., description="Street address")
|
||||||
|
city: str = Field(..., description="City")
|
||||||
|
state_province: str = Field(..., description="State or province")
|
||||||
|
postal_code: str = Field(..., description="Postal/ZIP code")
|
||||||
|
country: str = Field(..., description="Country code (e.g., US, CA)")
|
||||||
|
phone: str = Field(..., description="Phone number with country code")
|
||||||
|
email_address: str = Field(..., description="Email address")
|
||||||
|
address2: str = Field("", description="Apartment/suite number")
|
||||||
|
organization_name: str = Field("", description="Organization name")
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCheckRequest(BaseModel):
|
||||||
|
"""Request model for domain availability check."""
|
||||||
|
domains: List[str] = Field(..., min_items=1, description="List of domain names to check")
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRegisterRequest(BaseModel):
|
||||||
|
"""Request model for domain registration."""
|
||||||
|
domain_name: str = Field(..., description="Domain name to register")
|
||||||
|
years: int = Field(1, ge=1, le=10, description="Registration period in years")
|
||||||
|
contacts: ContactInfo = Field(..., description="Contact information")
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRenewRequest(BaseModel):
|
||||||
|
"""Request model for domain renewal."""
|
||||||
|
domain_identifier: str = Field(..., description="Domain name or identifier")
|
||||||
|
years: int = Field(1, ge=1, le=10, description="Renewal period in years")
|
||||||
|
|
||||||
|
|
||||||
|
# Domain management tools
|
||||||
|
@domains_mcp.tool()
|
||||||
|
async def list_domains() -> Dict[str, Any]:
|
||||||
|
"""List all domains in the account with enhanced information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with domain list or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
domains = await client.list_domains()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": domains,
|
||||||
|
"message": f"Found {len(domains)} domains in account"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to list domains"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": "Failed to list domains"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@domains_mcp.tool()
|
||||||
|
async def get_domain_info(domain_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed information about a domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
domain_identifier: Domain name (supports exact domain name matching)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with domain details or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
domain_info = await client.get_domain_info(domain_identifier)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": domain_info,
|
||||||
|
"message": f"Retrieved information for domain {domain_identifier}"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to get domain info for {domain_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to get domain info for {domain_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@domains_mcp.tool()
|
||||||
|
async def check_domain_availability(request: DomainCheckRequest) -> Dict[str, Any]:
|
||||||
|
"""Check if domains are available for registration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Domain check request with list of domains
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with availability results or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
results = await client.check_domain_availability(request.domains)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": results,
|
||||||
|
"message": f"Checked availability for {len(request.domains)} domains"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to check domain availability"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": "Failed to check domain availability"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@domains_mcp.tool()
|
||||||
|
async def register_domain(request: DomainRegisterRequest) -> Dict[str, Any]:
|
||||||
|
"""Register a new domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Domain registration request with domain, years, and contact info
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with registration details or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Convert ContactInfo to API format
|
||||||
|
contact_data = {
|
||||||
|
"FirstName": request.contacts.first_name,
|
||||||
|
"LastName": request.contacts.last_name,
|
||||||
|
"Address1": request.contacts.address1,
|
||||||
|
"City": request.contacts.city,
|
||||||
|
"StateProvince": request.contacts.state_province,
|
||||||
|
"PostalCode": request.contacts.postal_code,
|
||||||
|
"Country": request.contacts.country,
|
||||||
|
"Phone": request.contacts.phone,
|
||||||
|
"EmailAddress": request.contacts.email_address,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.contacts.address2:
|
||||||
|
contact_data["Address2"] = request.contacts.address2
|
||||||
|
if request.contacts.organization_name:
|
||||||
|
contact_data["OrganizationName"] = request.contacts.organization_name
|
||||||
|
|
||||||
|
result = await client.register_domain(
|
||||||
|
request.domain_name,
|
||||||
|
request.years,
|
||||||
|
contact_data
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result,
|
||||||
|
"message": f"Successfully registered domain {request.domain_name} for {request.years} years"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to register domain {request.domain_name}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to register domain {request.domain_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@domains_mcp.tool()
|
||||||
|
async def renew_domain(request: DomainRenewRequest) -> Dict[str, Any]:
|
||||||
|
"""Renew an existing domain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Domain renewal request with identifier and years
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with renewal details or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
result = await client.renew_domain(request.domain_identifier, request.years)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": result,
|
||||||
|
"message": f"Successfully renewed domain {request.domain_identifier} for {request.years} years"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to renew domain {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to renew domain {request.domain_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Resource for domain listings
|
||||||
|
@domains_mcp.resource("namecheap://domains")
|
||||||
|
async def list_domains_resource() -> str:
|
||||||
|
"""List all domains as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
domains = await client.list_domains()
|
||||||
|
|
||||||
|
# Format as readable text
|
||||||
|
if not domains:
|
||||||
|
return "No domains found in account."
|
||||||
|
|
||||||
|
output = ["Domains in Account:", "=" * 20, ""]
|
||||||
|
for domain in domains:
|
||||||
|
name = domain.get("@Name", "Unknown")
|
||||||
|
expires = domain.get("@Expires", "Unknown")
|
||||||
|
is_expired = domain.get("_is_expired", False)
|
||||||
|
is_locked = domain.get("_is_locked", False)
|
||||||
|
|
||||||
|
status_flags = []
|
||||||
|
if is_expired:
|
||||||
|
status_flags.append("EXPIRED")
|
||||||
|
if is_locked:
|
||||||
|
status_flags.append("LOCKED")
|
||||||
|
|
||||||
|
status = f" [{', '.join(status_flags)}]" if status_flags else ""
|
||||||
|
output.append(f"• {name} (expires: {expires}){status}")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error listing domains: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@domains_mcp.resource("namecheap://domain/{domain_name}")
|
||||||
|
async def get_domain_resource(domain_name: str) -> str:
|
||||||
|
"""Get detailed domain information as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
domain_info = await client.get_domain_info(domain_name)
|
||||||
|
|
||||||
|
# Format as readable text
|
||||||
|
output = [f"Domain Information: {domain_name}", "=" * 40, ""]
|
||||||
|
|
||||||
|
# Extract key information
|
||||||
|
if "DomainGetInfoResult" in domain_info:
|
||||||
|
info = domain_info["DomainGetInfoResult"]
|
||||||
|
output.append(f"Status: {info.get('@Status', 'Unknown')}")
|
||||||
|
output.append(f"ID: {info.get('@ID', 'Unknown')}")
|
||||||
|
output.append(f"Owner Name: {info.get('@OwnerName', 'Unknown')}")
|
||||||
|
output.append(f"Created: {info.get('@DomainDetails', {}).get('@CreatedDate', 'Unknown')}")
|
||||||
|
output.append(f"Expires: {info.get('@DomainDetails', {}).get('@ExpiredDate', 'Unknown')}")
|
||||||
|
output.append("")
|
||||||
|
|
||||||
|
# Nameservers
|
||||||
|
nameservers = info.get("DnsDetails", {}).get("Nameserver", [])
|
||||||
|
if nameservers:
|
||||||
|
output.append("Nameservers:")
|
||||||
|
if isinstance(nameservers, list):
|
||||||
|
for ns in nameservers:
|
||||||
|
output.append(f" • {ns}")
|
||||||
|
else:
|
||||||
|
output.append(f" • {nameservers}")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting domain info: {str(e)}"
|
36
src/mcp_namecheap/fastmcp_server.py
Normal file
36
src/mcp_namecheap/fastmcp_server.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""FastMCP server implementation mounting all resource modules."""
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
# Import all module instances
|
||||||
|
from .domains import domains_mcp
|
||||||
|
from .dns import dns_mcp
|
||||||
|
from .ssl import ssl_mcp
|
||||||
|
from .account import account_mcp
|
||||||
|
|
||||||
|
|
||||||
|
def create_server() -> FastMCP:
|
||||||
|
"""Create and configure the complete FastMCP server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured FastMCP server with all modules mounted
|
||||||
|
"""
|
||||||
|
# Create main server
|
||||||
|
server = FastMCP("NameCheap MCP Server")
|
||||||
|
|
||||||
|
# Mount all modules
|
||||||
|
server.mount(domains_mcp)
|
||||||
|
server.mount(dns_mcp)
|
||||||
|
server.mount(ssl_mcp)
|
||||||
|
server.mount(account_mcp)
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
# Create the server instance
|
||||||
|
mcp_server = create_server()
|
||||||
|
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
"""Run the MCP server."""
|
||||||
|
mcp_server.run()
|
302
src/mcp_namecheap/server.py
Normal file
302
src/mcp_namecheap/server.py
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
"""Name Cheap API server with comprehensive error handling."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
import httpx
|
||||||
|
import xmltodict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapAPIError(Exception):
|
||||||
|
"""Base exception for Name Cheap API errors."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, error_code: Optional[str] = None):
|
||||||
|
self.message = message
|
||||||
|
self.error_code = error_code
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapAuthError(NameCheapAPIError):
|
||||||
|
"""Authentication/authorization errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapNotFoundError(NameCheapAPIError):
|
||||||
|
"""Resource not found errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapRateLimitError(NameCheapAPIError):
|
||||||
|
"""Rate limit exceeded errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapValidationError(NameCheapAPIError):
|
||||||
|
"""Input validation errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapConfig(BaseModel):
|
||||||
|
"""Configuration for Name Cheap API."""
|
||||||
|
api_key: str
|
||||||
|
username: str
|
||||||
|
client_ip: str
|
||||||
|
sandbox: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class NameCheapAPIServer:
|
||||||
|
"""Low-level Name Cheap API client with comprehensive error handling."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[NameCheapConfig] = None):
|
||||||
|
"""Initialize the API server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Optional configuration. If not provided, will load from environment.
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = self._load_config_from_env()
|
||||||
|
|
||||||
|
self.config = config
|
||||||
|
self.base_url = (
|
||||||
|
"https://api.sandbox.namecheap.com/xml.response"
|
||||||
|
if config.sandbox
|
||||||
|
else "https://api.namecheap.com/xml.response"
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP client with proper timeouts
|
||||||
|
self.client = httpx.Client(
|
||||||
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_config_from_env(self) -> NameCheapConfig:
|
||||||
|
"""Load configuration from environment variables."""
|
||||||
|
api_key = os.getenv("NAMECHEAP_API_KEY")
|
||||||
|
username = os.getenv("NAMECHEAP_USERNAME")
|
||||||
|
client_ip = os.getenv("NAMECHEAP_CLIENT_IP")
|
||||||
|
sandbox = os.getenv("NAMECHEAP_SANDBOX", "false").lower() == "true"
|
||||||
|
|
||||||
|
if not all([api_key, username, client_ip]):
|
||||||
|
raise NameCheapAuthError(
|
||||||
|
"Missing required environment variables: "
|
||||||
|
"NAMECHEAP_API_KEY, NAMECHEAP_USERNAME, NAMECHEAP_CLIENT_IP"
|
||||||
|
)
|
||||||
|
|
||||||
|
return NameCheapConfig(
|
||||||
|
api_key=api_key,
|
||||||
|
username=username,
|
||||||
|
client_ip=client_ip,
|
||||||
|
sandbox=sandbox
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_request(self, command: str, **params: Any) -> Dict[str, Any]:
|
||||||
|
"""Make a request to the Name Cheap API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The API command to execute
|
||||||
|
**params: Additional parameters for the API call
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The API response data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NameCheapAPIError: For various API errors
|
||||||
|
"""
|
||||||
|
request_params = {
|
||||||
|
"ApiUser": self.config.username,
|
||||||
|
"ApiKey": self.config.api_key,
|
||||||
|
"UserName": self.config.username,
|
||||||
|
"Command": command,
|
||||||
|
"ClientIp": self.config.client_ip,
|
||||||
|
**params
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.get(self.base_url, params=request_params)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse XML response
|
||||||
|
data = xmltodict.parse(response.text)
|
||||||
|
api_response = data.get("ApiResponse", {})
|
||||||
|
|
||||||
|
# Check for API errors
|
||||||
|
status = api_response.get("@Status")
|
||||||
|
if status != "OK":
|
||||||
|
self._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
return api_response.get("CommandResponse", {})
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
raise NameCheapAuthError("Invalid API credentials")
|
||||||
|
elif e.response.status_code == 429:
|
||||||
|
raise NameCheapRateLimitError("API rate limit exceeded")
|
||||||
|
else:
|
||||||
|
raise NameCheapAPIError(f"HTTP {e.response.status_code}: {e.response.text}")
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise NameCheapAPIError("Request timeout - API may be unavailable")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise NameCheapAPIError(f"Network error: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, NameCheapAPIError):
|
||||||
|
raise
|
||||||
|
raise NameCheapAPIError(f"Unexpected error: {str(e)}")
|
||||||
|
|
||||||
|
def _handle_api_errors(self, api_response: Dict[str, Any]) -> None:
|
||||||
|
"""Handle API error responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_response: The API response containing errors
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Appropriate NameCheapAPIError subclass
|
||||||
|
"""
|
||||||
|
errors = api_response.get("Errors", {})
|
||||||
|
if isinstance(errors, dict):
|
||||||
|
error_list = errors.get("Error", [])
|
||||||
|
if not isinstance(error_list, list):
|
||||||
|
error_list = [error_list]
|
||||||
|
|
||||||
|
error_messages = []
|
||||||
|
for error in error_list:
|
||||||
|
if isinstance(error, dict):
|
||||||
|
error_code = error.get("@Number", "")
|
||||||
|
error_text = error.get("#text", str(error))
|
||||||
|
error_messages.append(f"[{error_code}] {error_text}")
|
||||||
|
else:
|
||||||
|
error_messages.append(str(error))
|
||||||
|
|
||||||
|
combined_message = "; ".join(error_messages)
|
||||||
|
|
||||||
|
# Classify errors
|
||||||
|
if any("not found" in msg.lower() for msg in error_messages):
|
||||||
|
raise NameCheapNotFoundError(combined_message)
|
||||||
|
elif any("authentication" in msg.lower() or "authorization" in msg.lower()
|
||||||
|
for msg in error_messages):
|
||||||
|
raise NameCheapAuthError(combined_message)
|
||||||
|
elif any("validation" in msg.lower() or "invalid" in msg.lower()
|
||||||
|
for msg in error_messages):
|
||||||
|
raise NameCheapValidationError(combined_message)
|
||||||
|
else:
|
||||||
|
raise NameCheapAPIError(combined_message)
|
||||||
|
else:
|
||||||
|
raise NameCheapAPIError("Unknown API error occurred")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the HTTP client."""
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# Core API methods
|
||||||
|
def list_domains(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List all domains in the account."""
|
||||||
|
response = self._make_request("namecheap.domains.getList")
|
||||||
|
domains = response.get("DomainGetListResult", {}).get("Domain", [])
|
||||||
|
if not isinstance(domains, list):
|
||||||
|
domains = [domains] if domains else []
|
||||||
|
return domains
|
||||||
|
|
||||||
|
def get_domain_info(self, domain_name: str) -> Dict[str, Any]:
|
||||||
|
"""Get detailed information about a domain."""
|
||||||
|
return self._make_request("namecheap.domains.getInfo", DomainName=domain_name)
|
||||||
|
|
||||||
|
def check_domain_availability(self, domains: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Check availability of multiple domains."""
|
||||||
|
response = self._make_request("namecheap.domains.check", DomainList=",".join(domains))
|
||||||
|
results = response.get("DomainCheckResult", [])
|
||||||
|
if not isinstance(results, list):
|
||||||
|
results = [results] if results else []
|
||||||
|
return results
|
||||||
|
|
||||||
|
def register_domain(self, domain_name: str, years: int, contacts: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Register a new domain."""
|
||||||
|
params = {
|
||||||
|
"DomainName": domain_name,
|
||||||
|
"Years": years,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add contact information for all contact types
|
||||||
|
for contact_type in ["Registrant", "Tech", "Admin", "AuxBilling"]:
|
||||||
|
for field, value in contacts.items():
|
||||||
|
params[f"{contact_type}{field}"] = value
|
||||||
|
|
||||||
|
return self._make_request("namecheap.domains.create", **params)
|
||||||
|
|
||||||
|
def renew_domain(self, domain_name: str, years: int) -> Dict[str, Any]:
|
||||||
|
"""Renew an existing domain."""
|
||||||
|
return self._make_request("namecheap.domains.renew", DomainName=domain_name, Years=years)
|
||||||
|
|
||||||
|
def get_dns_records(self, domain_name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get DNS records for a domain."""
|
||||||
|
sld, tld = self._split_domain(domain_name)
|
||||||
|
response = self._make_request("namecheap.domains.dns.getHosts", SLD=sld, TLD=tld)
|
||||||
|
hosts = response.get("DomainDNSGetHostsResult", {}).get("host", [])
|
||||||
|
if not isinstance(hosts, list):
|
||||||
|
hosts = [hosts] if hosts else []
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
def set_dns_records(self, domain_name: str, records: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Set DNS records for a domain."""
|
||||||
|
sld, tld = self._split_domain(domain_name)
|
||||||
|
params = {"SLD": sld, "TLD": tld}
|
||||||
|
|
||||||
|
for i, record in enumerate(records, 1):
|
||||||
|
params[f"HostName{i}"] = record["hostname"]
|
||||||
|
params[f"RecordType{i}"] = record["record_type"]
|
||||||
|
params[f"Address{i}"] = record["address"]
|
||||||
|
params[f"TTL{i}"] = record.get("ttl", 1800)
|
||||||
|
|
||||||
|
if record["record_type"] == "MX" and "mx_pref" in record:
|
||||||
|
params[f"MXPref{i}"] = record["mx_pref"]
|
||||||
|
|
||||||
|
return self._make_request("namecheap.domains.dns.setHosts", **params)
|
||||||
|
|
||||||
|
def set_default_nameservers(self, domain_name: str) -> Dict[str, Any]:
|
||||||
|
"""Set domain to use default Name Cheap nameservers."""
|
||||||
|
sld, tld = self._split_domain(domain_name)
|
||||||
|
return self._make_request("namecheap.domains.dns.setDefault", SLD=sld, TLD=tld)
|
||||||
|
|
||||||
|
def set_custom_nameservers(self, domain_name: str, nameservers: List[str]) -> Dict[str, Any]:
|
||||||
|
"""Set custom nameservers for a domain."""
|
||||||
|
sld, tld = self._split_domain(domain_name)
|
||||||
|
params = {"SLD": sld, "TLD": tld}
|
||||||
|
|
||||||
|
for i, ns in enumerate(nameservers, 1):
|
||||||
|
params[f"Nameserver{i}"] = ns
|
||||||
|
|
||||||
|
return self._make_request("namecheap.domains.dns.setCustom", **params)
|
||||||
|
|
||||||
|
def list_ssl_certificates(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List all SSL certificates."""
|
||||||
|
response = self._make_request("namecheap.ssl.getList")
|
||||||
|
certs = response.get("SSLListResult", {}).get("SSL", [])
|
||||||
|
if not isinstance(certs, list):
|
||||||
|
certs = [certs] if certs else []
|
||||||
|
return certs
|
||||||
|
|
||||||
|
def get_ssl_info(self, certificate_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get information about an SSL certificate."""
|
||||||
|
return self._make_request("namecheap.ssl.getInfo", CertificateID=certificate_id)
|
||||||
|
|
||||||
|
def get_account_info(self) -> Dict[str, Any]:
|
||||||
|
"""Get account information."""
|
||||||
|
return self._make_request("namecheap.users.getAccount")
|
||||||
|
|
||||||
|
def get_account_balance(self) -> Dict[str, Any]:
|
||||||
|
"""Get account balance."""
|
||||||
|
return self._make_request("namecheap.users.getBalances")
|
||||||
|
|
||||||
|
def _split_domain(self, domain_name: str) -> tuple[str, str]:
|
||||||
|
"""Split domain name into SLD and TLD."""
|
||||||
|
parts = domain_name.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise NameCheapValidationError(f"Invalid domain name: {domain_name}")
|
||||||
|
|
||||||
|
sld = parts[0]
|
||||||
|
tld = ".".join(parts[1:])
|
||||||
|
return sld, tld
|
182
src/mcp_namecheap/ssl.py
Normal file
182
src/mcp_namecheap/ssl.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"""SSL certificate management tools for FastMCP."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from .client import NameCheapClient
|
||||||
|
from .server import NameCheapAPIError
|
||||||
|
|
||||||
|
|
||||||
|
# Create SSL tools instance
|
||||||
|
ssl_mcp = FastMCP("ssl")
|
||||||
|
|
||||||
|
# Global client instance
|
||||||
|
_client: NameCheapClient = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> NameCheapClient:
|
||||||
|
"""Get or create the Name Cheap client."""
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = NameCheapClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
# SSL management tools
|
||||||
|
@ssl_mcp.tool()
|
||||||
|
async def list_ssl_certificates() -> Dict[str, Any]:
|
||||||
|
"""List all SSL certificates in the account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with SSL certificate list or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
certificates = await client.list_ssl_certificates()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": certificates,
|
||||||
|
"message": f"Found {len(certificates)} SSL certificates in account"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to list SSL certificates"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": "Failed to list SSL certificates"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ssl_mcp.tool()
|
||||||
|
async def get_ssl_info(ssl_identifier: str) -> Dict[str, Any]:
|
||||||
|
"""Get information about an SSL certificate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ssl_identifier: Certificate ID, hostname, or type (supports smart matching)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success response with SSL certificate details or error details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
ssl_info = await client.get_ssl_info(ssl_identifier)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": ssl_info,
|
||||||
|
"message": f"Retrieved SSL certificate information for {ssl_identifier}"
|
||||||
|
}
|
||||||
|
except NameCheapAPIError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"message": f"Failed to get SSL certificate info for {ssl_identifier}"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Unexpected error: {str(e)}",
|
||||||
|
"message": f"Failed to get SSL certificate info for {ssl_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Resources for SSL certificates
|
||||||
|
@ssl_mcp.resource("namecheap://ssl")
|
||||||
|
async def list_ssl_resource() -> str:
|
||||||
|
"""List all SSL certificates as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
certificates = await client.list_ssl_certificates()
|
||||||
|
|
||||||
|
if not certificates:
|
||||||
|
return "No SSL certificates found in account."
|
||||||
|
|
||||||
|
output = ["SSL Certificates in Account:", "=" * 30, ""]
|
||||||
|
|
||||||
|
for cert in certificates:
|
||||||
|
cert_id = cert.get("@CertificateID", "Unknown")
|
||||||
|
hostname = cert.get("@HostName", "Unknown")
|
||||||
|
ssl_type = cert.get("@SSLType", "Unknown")
|
||||||
|
status = cert.get("@Status", "Unknown")
|
||||||
|
purchase_date = cert.get("@PurchaseDate", "Unknown")
|
||||||
|
expire_date = cert.get("@ExpireDate", "Unknown")
|
||||||
|
is_expired = cert.get("_is_expired", False)
|
||||||
|
|
||||||
|
status_flag = " [EXPIRED]" if is_expired else ""
|
||||||
|
|
||||||
|
output.extend([
|
||||||
|
f"Certificate ID: {cert_id}",
|
||||||
|
f" Hostname: {hostname}",
|
||||||
|
f" Type: {ssl_type}",
|
||||||
|
f" Status: {status}{status_flag}",
|
||||||
|
f" Purchased: {purchase_date}",
|
||||||
|
f" Expires: {expire_date}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error listing SSL certificates: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@ssl_mcp.resource("namecheap://ssl/{certificate_id}")
|
||||||
|
async def get_ssl_resource(certificate_id: str) -> str:
|
||||||
|
"""Get detailed SSL certificate information as a resource."""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
ssl_info = await client.get_ssl_info(certificate_id)
|
||||||
|
|
||||||
|
output = [f"SSL Certificate Details: {certificate_id}", "=" * 50, ""]
|
||||||
|
|
||||||
|
# Extract key information from SSL info
|
||||||
|
if "SSLGetInfoResult" in ssl_info:
|
||||||
|
info = ssl_info["SSLGetInfoResult"]
|
||||||
|
|
||||||
|
# Basic info
|
||||||
|
output.extend([
|
||||||
|
f"Certificate ID: {info.get('@CertificateID', 'Unknown')}",
|
||||||
|
f"Host Name: {info.get('@HostName', 'Unknown')}",
|
||||||
|
f"SSL Type: {info.get('@SSLType', 'Unknown')}",
|
||||||
|
f"Status: {info.get('@Status', 'Unknown')}",
|
||||||
|
f"Purchase Date: {info.get('@PurchaseDate', 'Unknown')}",
|
||||||
|
f"Expire Date: {info.get('@ExpireDate', 'Unknown')}",
|
||||||
|
f"Activation Expire Date: {info.get('@ActivationExpireDate', 'Unknown')}",
|
||||||
|
f"Is Expired: {info.get('@IsExpiredYN', 'Unknown')}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
# Certificate details if available
|
||||||
|
cert_details = info.get("CertificateDetails", {})
|
||||||
|
if cert_details:
|
||||||
|
output.extend([
|
||||||
|
"Certificate Details:",
|
||||||
|
"-" * 20,
|
||||||
|
f"Common Name: {cert_details.get('@CommonName', 'Unknown')}",
|
||||||
|
f"SANs: {cert_details.get('@SANs', 'None')}",
|
||||||
|
f"Years: {cert_details.get('@Years', 'Unknown')}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
|
||||||
|
# Provider details if available
|
||||||
|
provider = info.get("Provider", {})
|
||||||
|
if provider:
|
||||||
|
output.extend([
|
||||||
|
"Provider Information:",
|
||||||
|
"-" * 20,
|
||||||
|
f"Name: {provider.get('@Name', 'Unknown')}",
|
||||||
|
f"Display Name: {provider.get('@DisplayName', 'Unknown')}",
|
||||||
|
""
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
output.append("No detailed SSL information available.")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error getting SSL certificate info: {str(e)}"
|
213
tests/conftest.py
Normal file
213
tests/conftest.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Test configuration and fixtures."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
from mcp_namecheap.server import NameCheapAPIServer, NameCheapConfig
|
||||||
|
from mcp_namecheap.client import NameCheapClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config():
|
||||||
|
"""Mock Name Cheap configuration."""
|
||||||
|
return NameCheapConfig(
|
||||||
|
api_key="test_api_key",
|
||||||
|
username="test_user",
|
||||||
|
client_ip="127.0.0.1",
|
||||||
|
sandbox=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_server(mock_config):
|
||||||
|
"""Mock Name Cheap API server."""
|
||||||
|
with patch('mcp_namecheap.server.httpx.Client') as mock_client_class:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
server = NameCheapAPIServer(mock_config)
|
||||||
|
server.client = mock_client
|
||||||
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client(mock_server):
|
||||||
|
"""Mock Name Cheap client."""
|
||||||
|
return NameCheapClient(mock_server)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_domains_response():
|
||||||
|
"""Sample domains list response."""
|
||||||
|
return {
|
||||||
|
"DomainGetListResult": {
|
||||||
|
"Domain": [
|
||||||
|
{
|
||||||
|
"@ID": "12345",
|
||||||
|
"@Name": "example.com",
|
||||||
|
"@User": "test_user",
|
||||||
|
"@Created": "01/15/2020",
|
||||||
|
"@Expires": "01/15/2025",
|
||||||
|
"@IsExpired": "false",
|
||||||
|
"@IsLocked": "false",
|
||||||
|
"@AutoRenew": "false",
|
||||||
|
"@WhoisGuard": "ENABLED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@ID": "12346",
|
||||||
|
"@Name": "test.org",
|
||||||
|
"@User": "test_user",
|
||||||
|
"@Created": "03/20/2021",
|
||||||
|
"@Expires": "03/20/2026",
|
||||||
|
"@IsExpired": "false",
|
||||||
|
"@IsLocked": "true",
|
||||||
|
"@AutoRenew": "true",
|
||||||
|
"@WhoisGuard": "DISABLED"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_domain_info_response():
|
||||||
|
"""Sample domain info response."""
|
||||||
|
return {
|
||||||
|
"DomainGetInfoResult": {
|
||||||
|
"@Status": "Ok",
|
||||||
|
"@ID": "12345",
|
||||||
|
"@DomainName": "example.com",
|
||||||
|
"@OwnerName": "test_user",
|
||||||
|
"@IsOwner": "true",
|
||||||
|
"DomainDetails": {
|
||||||
|
"@CreatedDate": "01/15/2020",
|
||||||
|
"@ExpiredDate": "01/15/2025",
|
||||||
|
"@NumYears": "5"
|
||||||
|
},
|
||||||
|
"DnsDetails": {
|
||||||
|
"@ProviderType": "CUSTOM",
|
||||||
|
"Nameserver": [
|
||||||
|
"ns1.example.com",
|
||||||
|
"ns2.example.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_dns_records_response():
|
||||||
|
"""Sample DNS records response."""
|
||||||
|
return {
|
||||||
|
"DomainDNSGetHostsResult": {
|
||||||
|
"host": [
|
||||||
|
{
|
||||||
|
"@HostId": "1",
|
||||||
|
"@Name": "@",
|
||||||
|
"@Type": "A",
|
||||||
|
"@Address": "192.168.1.1",
|
||||||
|
"@MXPref": "10",
|
||||||
|
"@TTL": "1800"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@HostId": "2",
|
||||||
|
"@Name": "www",
|
||||||
|
"@Type": "CNAME",
|
||||||
|
"@Address": "example.com",
|
||||||
|
"@MXPref": "10",
|
||||||
|
"@TTL": "1800"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_ssl_certificates_response():
|
||||||
|
"""Sample SSL certificates response."""
|
||||||
|
return {
|
||||||
|
"SSLListResult": {
|
||||||
|
"SSL": [
|
||||||
|
{
|
||||||
|
"@CertificateID": "111111",
|
||||||
|
"@HostName": "example.com",
|
||||||
|
"@SSLType": "PositiveSSL",
|
||||||
|
"@PurchaseDate": "01/15/2023",
|
||||||
|
"@ExpireDate": "01/15/2024",
|
||||||
|
"@ActivationExpireDate": "01/15/2024",
|
||||||
|
"@IsExpiredYN": "false",
|
||||||
|
"@Status": "active"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_domain_check_response():
|
||||||
|
"""Sample domain availability check response."""
|
||||||
|
return {
|
||||||
|
"DomainCheckResult": [
|
||||||
|
{
|
||||||
|
"@Domain": "available-domain.com",
|
||||||
|
"@Available": "true",
|
||||||
|
"@ErrorNo": "",
|
||||||
|
"@Description": "",
|
||||||
|
"@IsPremiumName": "false",
|
||||||
|
"@PremiumRegistrationPrice": "",
|
||||||
|
"@PremiumRenewalPrice": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@Domain": "taken-domain.com",
|
||||||
|
"@Available": "false",
|
||||||
|
"@ErrorNo": "",
|
||||||
|
"@Description": "",
|
||||||
|
"@IsPremiumName": "false",
|
||||||
|
"@PremiumRegistrationPrice": "",
|
||||||
|
"@PremiumRenewalPrice": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_account_info_response():
|
||||||
|
"""Sample account info response."""
|
||||||
|
return {
|
||||||
|
"UserGetAccountResult": {
|
||||||
|
"@ID": "12345",
|
||||||
|
"@UserName": "test_user",
|
||||||
|
"@FirstName": "Test",
|
||||||
|
"@LastName": "User",
|
||||||
|
"@Email": "test@example.com",
|
||||||
|
"@AccountCreateDate": "01/01/2020",
|
||||||
|
"@Company": "Test Company",
|
||||||
|
"@Phone": "+1.5551234567",
|
||||||
|
"@IsEmailVerified": "true",
|
||||||
|
"@IsPhoneVerified": "true",
|
||||||
|
"@AccountStatus": "Ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_balance_response():
|
||||||
|
"""Sample account balance response."""
|
||||||
|
return {
|
||||||
|
"UserGetBalancesResult": {
|
||||||
|
"@Currency": "USD",
|
||||||
|
"@AccountBalance": "100.00",
|
||||||
|
"@AvailableBalance": "95.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_response(data: Dict[str, Any], status: str = "OK"):
|
||||||
|
"""Create a mock XML response."""
|
||||||
|
return {
|
||||||
|
"ApiResponse": {
|
||||||
|
"@Status": status,
|
||||||
|
"CommandResponse": data
|
||||||
|
}
|
||||||
|
}
|
250
tests/test_client.py
Normal file
250
tests/test_client.py
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
"""Test high-level Name Cheap client."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, AsyncMock
|
||||||
|
|
||||||
|
from mcp_namecheap.client import NameCheapClient, is_uuid_format
|
||||||
|
from mcp_namecheap.server import NameCheapNotFoundError, NameCheapValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class TestNameCheapClient:
|
||||||
|
"""Test Name Cheap client functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_is_uuid_format(self):
|
||||||
|
"""Test UUID format detection (for Name Cheap, numeric IDs)."""
|
||||||
|
assert is_uuid_format("12345") is True
|
||||||
|
assert is_uuid_format("0") is True
|
||||||
|
assert is_uuid_format("example.com") is False
|
||||||
|
assert is_uuid_format("") is False
|
||||||
|
assert is_uuid_format("abc123") is False
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_domain_id_exact_match(self, mock_client, sample_domains_response):
|
||||||
|
"""Test domain ID resolution with exact match."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
# Should return the domain name itself for exact match
|
||||||
|
domain_id = await mock_client.get_domain_id("example.com")
|
||||||
|
assert domain_id == "example.com"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_domain_id_case_insensitive(self, mock_client, sample_domains_response):
|
||||||
|
"""Test domain ID resolution is case insensitive."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
domain_id = await mock_client.get_domain_id("EXAMPLE.COM")
|
||||||
|
assert domain_id == "example.com"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_domain_id_not_found(self, mock_client, sample_domains_response):
|
||||||
|
"""Test domain ID resolution when domain not found."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapNotFoundError):
|
||||||
|
await mock_client.get_domain_id("nonexistent.com")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_ssl_id_by_id(self, mock_client, sample_ssl_certificates_response):
|
||||||
|
"""Test SSL ID resolution by certificate ID."""
|
||||||
|
mock_client.server.list_ssl_certificates.return_value = sample_ssl_certificates_response["SSLListResult"]["SSL"]
|
||||||
|
|
||||||
|
# Numeric ID should be returned as-is
|
||||||
|
ssl_id = await mock_client.get_ssl_id("111111")
|
||||||
|
assert ssl_id == "111111"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_ssl_id_by_hostname(self, mock_client, sample_ssl_certificates_response):
|
||||||
|
"""Test SSL ID resolution by hostname."""
|
||||||
|
mock_client.server.list_ssl_certificates.return_value = sample_ssl_certificates_response["SSLListResult"]["SSL"]
|
||||||
|
|
||||||
|
ssl_id = await mock_client.get_ssl_id("example.com")
|
||||||
|
assert ssl_id == "111111"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_ssl_id_not_found(self, mock_client, sample_ssl_certificates_response):
|
||||||
|
"""Test SSL ID resolution when certificate not found."""
|
||||||
|
mock_client.server.list_ssl_certificates.return_value = sample_ssl_certificates_response["SSLListResult"]["SSL"]
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapNotFoundError):
|
||||||
|
await mock_client.get_ssl_id("nonexistent.com")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_list_domains_enhanced(self, mock_client, sample_domains_response):
|
||||||
|
"""Test enhanced domain listing."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
domains = await mock_client.list_domains()
|
||||||
|
|
||||||
|
assert len(domains) == 2
|
||||||
|
assert domains[0]["_display_name"] == "example.com"
|
||||||
|
assert domains[0]["_is_expired"] is False
|
||||||
|
assert domains[1]["_is_locked"] is True
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_check_domain_availability_validation(self, mock_client):
|
||||||
|
"""Test domain availability check with validation."""
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.check_domain_availability(["invalid..domain"])
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_register_domain_validation(self, mock_client):
|
||||||
|
"""Test domain registration validation."""
|
||||||
|
contacts = {
|
||||||
|
"FirstName": "Test",
|
||||||
|
"LastName": "User",
|
||||||
|
"Address1": "123 Main St",
|
||||||
|
"City": "Test City",
|
||||||
|
"StateProvince": "CA",
|
||||||
|
"PostalCode": "12345",
|
||||||
|
"Country": "US",
|
||||||
|
"Phone": "+1.5551234567",
|
||||||
|
"EmailAddress": "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Invalid domain format
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.register_domain("invalid..domain", 1, contacts)
|
||||||
|
|
||||||
|
# Invalid years
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.register_domain("example.com", 15, contacts)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_set_dns_records_validation(self, mock_client, sample_domains_response):
|
||||||
|
"""Test DNS records validation."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
# Invalid record type
|
||||||
|
records = [{
|
||||||
|
"hostname": "@",
|
||||||
|
"record_type": "INVALID",
|
||||||
|
"address": "192.168.1.1",
|
||||||
|
"ttl": 1800
|
||||||
|
}]
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.set_dns_records("example.com", records)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_set_custom_nameservers_validation(self, mock_client, sample_domains_response):
|
||||||
|
"""Test custom nameservers validation."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
# Too few nameservers
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.set_custom_nameservers("example.com", ["ns1.example.com"])
|
||||||
|
|
||||||
|
# Too many nameservers
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.set_custom_nameservers("example.com", [f"ns{i}.example.com" for i in range(1, 15)])
|
||||||
|
|
||||||
|
# Invalid nameserver format
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
await mock_client.set_custom_nameservers("example.com", ["ns1.example.com", "invalid..nameserver"])
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_is_valid_domain_format(self, mock_client):
|
||||||
|
"""Test domain format validation."""
|
||||||
|
assert mock_client._is_valid_domain_format("example.com") is True
|
||||||
|
assert mock_client._is_valid_domain_format("sub.example.com") is True
|
||||||
|
assert mock_client._is_valid_domain_format("test-domain.co.uk") is True
|
||||||
|
assert mock_client._is_valid_domain_format("invalid..domain") is False
|
||||||
|
assert mock_client._is_valid_domain_format("") is False
|
||||||
|
assert mock_client._is_valid_domain_format("a" * 254) is False # Too long
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_cache_clearing(self, mock_client, sample_domains_response):
|
||||||
|
"""Test cache clearing after operations."""
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
mock_client.server.register_domain.return_value = {"DomainID": "12347"}
|
||||||
|
|
||||||
|
# Populate cache
|
||||||
|
await mock_client.list_domains()
|
||||||
|
assert mock_client._domain_cache is not None
|
||||||
|
|
||||||
|
# Register domain should clear cache
|
||||||
|
contacts = {
|
||||||
|
"FirstName": "Test",
|
||||||
|
"LastName": "User",
|
||||||
|
"Address1": "123 Main St",
|
||||||
|
"City": "Test City",
|
||||||
|
"StateProvince": "CA",
|
||||||
|
"PostalCode": "12345",
|
||||||
|
"Country": "US",
|
||||||
|
"Phone": "+1.5551234567",
|
||||||
|
"EmailAddress": "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
await mock_client.register_domain("new-domain.com", 1, contacts)
|
||||||
|
assert mock_client._domain_cache is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_list_ssl_certificates_enhanced(self, mock_client, sample_ssl_certificates_response):
|
||||||
|
"""Test enhanced SSL certificate listing."""
|
||||||
|
mock_client.server.list_ssl_certificates.return_value = sample_ssl_certificates_response["SSLListResult"]["SSL"]
|
||||||
|
|
||||||
|
certificates = await mock_client.list_ssl_certificates()
|
||||||
|
|
||||||
|
assert len(certificates) == 1
|
||||||
|
assert certificates[0]["_display_name"] == "example.com"
|
||||||
|
assert certificates[0]["_is_expired"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientIntegration:
|
||||||
|
"""Integration tests for client operations."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_domain_workflow(self, mock_client, sample_domains_response, sample_domain_check_response):
|
||||||
|
"""Test complete domain workflow."""
|
||||||
|
# Setup mocks
|
||||||
|
mock_client.server.check_domain_availability.return_value = sample_domain_check_response["DomainCheckResult"]
|
||||||
|
mock_client.server.register_domain.return_value = {"DomainID": "12347"}
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
|
||||||
|
# Check availability
|
||||||
|
results = await mock_client.check_domain_availability(["available-domain.com"])
|
||||||
|
assert results[0]["@Available"] == "true"
|
||||||
|
|
||||||
|
# Register domain
|
||||||
|
contacts = {
|
||||||
|
"FirstName": "Test",
|
||||||
|
"LastName": "User",
|
||||||
|
"Address1": "123 Main St",
|
||||||
|
"City": "Test City",
|
||||||
|
"StateProvince": "CA",
|
||||||
|
"PostalCode": "12345",
|
||||||
|
"Country": "US",
|
||||||
|
"Phone": "+1.5551234567",
|
||||||
|
"EmailAddress": "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await mock_client.register_domain("available-domain.com", 1, contacts)
|
||||||
|
assert "DomainID" in result
|
||||||
|
|
||||||
|
# List domains
|
||||||
|
domains = await mock_client.list_domains()
|
||||||
|
assert len(domains) == 2
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_dns_workflow(self, mock_client, sample_domains_response, sample_dns_records_response):
|
||||||
|
"""Test complete DNS workflow."""
|
||||||
|
# Setup mocks
|
||||||
|
mock_client.server.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
mock_client.server.get_dns_records.return_value = sample_dns_records_response["DomainDNSGetHostsResult"]["host"]
|
||||||
|
mock_client.server.set_dns_records.return_value = {"Status": "OK"}
|
||||||
|
|
||||||
|
# Get DNS records
|
||||||
|
records = await mock_client.get_dns_records("example.com")
|
||||||
|
assert len(records) == 2
|
||||||
|
|
||||||
|
# Set DNS records
|
||||||
|
new_records = [{
|
||||||
|
"hostname": "@",
|
||||||
|
"record_type": "A",
|
||||||
|
"address": "192.168.1.100",
|
||||||
|
"ttl": 3600
|
||||||
|
}]
|
||||||
|
|
||||||
|
result = await mock_client.set_dns_records("example.com", new_records)
|
||||||
|
assert result["Status"] == "OK"
|
266
tests/test_mcp_server.py
Normal file
266
tests/test_mcp_server.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
"""Test MCP server functionality using FastMCP testing patterns."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
from fastmcp import testing
|
||||||
|
|
||||||
|
from mcp_namecheap.fastmcp_server import mcp_server
|
||||||
|
from .conftest import create_mock_response
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPServer:
|
||||||
|
"""Test MCP server tools and resources."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mcp_client(self):
|
||||||
|
"""Create FastMCP test client."""
|
||||||
|
return testing.create_client(mcp_server)
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_list_domains_tool(self, mcp_client, sample_domains_response):
|
||||||
|
"""Test list domains tool."""
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("list_domains")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(result["data"]) == 2
|
||||||
|
assert result["data"][0]["@Name"] == "example.com"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_get_domain_info_tool(self, mcp_client, sample_domain_info_response):
|
||||||
|
"""Test get domain info tool."""
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_domain_info.return_value = sample_domain_info_response
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("get_domain_info", domain_identifier="example.com")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["data"]["DomainGetInfoResult"]["@DomainName"] == "example.com"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_check_domain_availability_tool(self, mcp_client, sample_domain_check_response):
|
||||||
|
"""Test check domain availability tool."""
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.check_domain_availability.return_value = sample_domain_check_response["DomainCheckResult"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("check_domain_availability", {
|
||||||
|
"domains": ["available-domain.com", "taken-domain.com"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(result["data"]) == 2
|
||||||
|
assert result["data"][0]["@Available"] == "true"
|
||||||
|
assert result["data"][1]["@Available"] == "false"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_get_dns_records_tool(self, mcp_client, sample_dns_records_response):
|
||||||
|
"""Test get DNS records tool."""
|
||||||
|
with patch('mcp_namecheap.dns.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_dns_records.return_value = sample_dns_records_response["DomainDNSGetHostsResult"]["host"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("get_dns_records", domain_identifier="example.com")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(result["data"]) == 2
|
||||||
|
assert result["data"][0]["@Type"] == "A"
|
||||||
|
assert result["data"][1]["@Type"] == "CNAME"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_set_dns_records_tool(self, mcp_client):
|
||||||
|
"""Test set DNS records tool."""
|
||||||
|
with patch('mcp_namecheap.dns.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.set_dns_records.return_value = {"Status": "OK"}
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("set_dns_records", {
|
||||||
|
"domain_identifier": "example.com",
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"hostname": "@",
|
||||||
|
"record_type": "A",
|
||||||
|
"address": "192.168.1.1",
|
||||||
|
"ttl": 1800
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_list_ssl_certificates_tool(self, mcp_client, sample_ssl_certificates_response):
|
||||||
|
"""Test list SSL certificates tool."""
|
||||||
|
with patch('mcp_namecheap.ssl.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.list_ssl_certificates.return_value = sample_ssl_certificates_response["SSLListResult"]["SSL"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("list_ssl_certificates")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert len(result["data"]) == 1
|
||||||
|
assert result["data"][0]["@HostName"] == "example.com"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_get_account_info_tool(self, mcp_client, sample_account_info_response):
|
||||||
|
"""Test get account info tool."""
|
||||||
|
with patch('mcp_namecheap.account.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_account_info.return_value = sample_account_info_response
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("get_account_info")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["data"]["UserGetAccountResult"]["@UserName"] == "test_user"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_get_account_balance_tool(self, mcp_client, sample_balance_response):
|
||||||
|
"""Test get account balance tool."""
|
||||||
|
with patch('mcp_namecheap.account.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_account_balance.return_value = sample_balance_response
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("get_account_balance")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["data"]["UserGetBalancesResult"]["@AccountBalance"] == "100.00"
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_domains_resource(self, mcp_client, sample_domains_response):
|
||||||
|
"""Test domains resource."""
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.list_domains.return_value = sample_domains_response["DomainGetListResult"]["Domain"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.read_resource("namecheap://domains")
|
||||||
|
|
||||||
|
assert "Domains in Account:" in result
|
||||||
|
assert "example.com" in result
|
||||||
|
assert "test.org" in result
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_domain_resource(self, mcp_client, sample_domain_info_response):
|
||||||
|
"""Test individual domain resource."""
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_domain_info.return_value = sample_domain_info_response
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.read_resource("namecheap://domain/example.com")
|
||||||
|
|
||||||
|
assert "Domain Information: example.com" in result
|
||||||
|
assert "test_user" in result
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_dns_resource(self, mcp_client, sample_dns_records_response):
|
||||||
|
"""Test DNS records resource."""
|
||||||
|
with patch('mcp_namecheap.dns.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_dns_records.return_value = sample_dns_records_response["DomainDNSGetHostsResult"]["host"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.read_resource("namecheap://dns/example.com")
|
||||||
|
|
||||||
|
assert "DNS Records for example.com" in result
|
||||||
|
assert "192.168.1.1" in result
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_ssl_resource(self, mcp_client, sample_ssl_certificates_response):
|
||||||
|
"""Test SSL certificates resource."""
|
||||||
|
with patch('mcp_namecheap.ssl.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.list_ssl_certificates.return_value = sample_ssl_certificates_response["SSLListResult"]["SSL"]
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.read_resource("namecheap://ssl")
|
||||||
|
|
||||||
|
assert "SSL Certificates in Account:" in result
|
||||||
|
assert "example.com" in result
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_account_resource(self, mcp_client, sample_account_info_response):
|
||||||
|
"""Test account resource."""
|
||||||
|
with patch('mcp_namecheap.account.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_account_info.return_value = sample_account_info_response
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.read_resource("namecheap://account")
|
||||||
|
|
||||||
|
assert "Account Information" in result
|
||||||
|
assert "test_user" in result
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_balance_resource(self, mcp_client, sample_balance_response):
|
||||||
|
"""Test balance resource."""
|
||||||
|
with patch('mcp_namecheap.account.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_account_balance.return_value = sample_balance_response
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.read_resource("namecheap://balance")
|
||||||
|
|
||||||
|
assert "Account Balance" in result
|
||||||
|
assert "100.00" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPErrorHandling:
|
||||||
|
"""Test MCP server error handling."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mcp_client(self):
|
||||||
|
"""Create FastMCP test client."""
|
||||||
|
return testing.create_client(mcp_server)
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_domain_not_found_error(self, mcp_client):
|
||||||
|
"""Test domain not found error handling."""
|
||||||
|
from mcp_namecheap.server import NameCheapNotFoundError
|
||||||
|
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.get_domain_info.side_effect = NameCheapNotFoundError("Domain not found")
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("get_domain_info", domain_identifier="nonexistent.com")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Domain not found" in result["error"]
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_validation_error(self, mcp_client):
|
||||||
|
"""Test validation error handling."""
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await mcp_client.call_tool("check_domain_availability", {
|
||||||
|
"domains": [] # Empty list should fail validation
|
||||||
|
})
|
||||||
|
|
||||||
|
@pytest.mark.mcp
|
||||||
|
async def test_api_error_handling(self, mcp_client):
|
||||||
|
"""Test API error handling."""
|
||||||
|
from mcp_namecheap.server import NameCheapAPIError
|
||||||
|
|
||||||
|
with patch('mcp_namecheap.domains.get_client') as mock_get_client:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.list_domains.side_effect = NameCheapAPIError("API Error")
|
||||||
|
mock_get_client.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mcp_client.call_tool("list_domains")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "API Error" in result["error"]
|
347
tests/test_server.py
Normal file
347
tests/test_server.py
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
"""Test Name Cheap API server."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
import httpx
|
||||||
|
import xmltodict
|
||||||
|
|
||||||
|
from mcp_namecheap.server import (
|
||||||
|
NameCheapAPIServer,
|
||||||
|
NameCheapConfig,
|
||||||
|
NameCheapAPIError,
|
||||||
|
NameCheapAuthError,
|
||||||
|
NameCheapNotFoundError,
|
||||||
|
NameCheapRateLimitError,
|
||||||
|
NameCheapValidationError
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNameCheapAPIServer:
|
||||||
|
"""Test Name Cheap API server functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_config_creation(self):
|
||||||
|
"""Test configuration creation."""
|
||||||
|
config = NameCheapConfig(
|
||||||
|
api_key="test_key",
|
||||||
|
username="test_user",
|
||||||
|
client_ip="127.0.0.1",
|
||||||
|
sandbox=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.api_key == "test_key"
|
||||||
|
assert config.username == "test_user"
|
||||||
|
assert config.client_ip == "127.0.0.1"
|
||||||
|
assert config.sandbox is True
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_server_initialization(self, mock_config):
|
||||||
|
"""Test server initialization."""
|
||||||
|
with patch('mcp_namecheap.server.httpx.Client') as mock_client:
|
||||||
|
server = NameCheapAPIServer(mock_config)
|
||||||
|
|
||||||
|
assert server.config == mock_config
|
||||||
|
assert "sandbox" in server.base_url
|
||||||
|
mock_client.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_load_config_from_env_missing_vars(self):
|
||||||
|
"""Test loading config from environment with missing variables."""
|
||||||
|
with patch.dict('os.environ', {}, clear=True):
|
||||||
|
with pytest.raises(NameCheapAuthError):
|
||||||
|
NameCheapAPIServer()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_load_config_from_env_success(self):
|
||||||
|
"""Test loading config from environment successfully."""
|
||||||
|
env_vars = {
|
||||||
|
'NAMECHEAP_API_KEY': 'test_key',
|
||||||
|
'NAMECHEAP_USERNAME': 'test_user',
|
||||||
|
'NAMECHEAP_CLIENT_IP': '127.0.0.1',
|
||||||
|
'NAMECHEAP_SANDBOX': 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict('os.environ', env_vars):
|
||||||
|
with patch('mcp_namecheap.server.httpx.Client'):
|
||||||
|
server = NameCheapAPIServer()
|
||||||
|
|
||||||
|
assert server.config.api_key == 'test_key'
|
||||||
|
assert server.config.sandbox is True
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_make_request_success(self, mock_server):
|
||||||
|
"""Test successful API request."""
|
||||||
|
# Mock response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = '''
|
||||||
|
<ApiResponse Status="OK">
|
||||||
|
<CommandResponse>
|
||||||
|
<TestResult>Success</TestResult>
|
||||||
|
</CommandResponse>
|
||||||
|
</ApiResponse>
|
||||||
|
'''
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_server.client.get.return_value = mock_response
|
||||||
|
|
||||||
|
result = mock_server._make_request("test.command")
|
||||||
|
|
||||||
|
assert result["TestResult"] == "Success"
|
||||||
|
mock_server.client.get.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_make_request_api_error(self, mock_server):
|
||||||
|
"""Test API request with API error response."""
|
||||||
|
# Mock error response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = '''
|
||||||
|
<ApiResponse Status="ERROR">
|
||||||
|
<Errors>
|
||||||
|
<Error Number="1234">Test error message</Error>
|
||||||
|
</Errors>
|
||||||
|
</ApiResponse>
|
||||||
|
'''
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_server.client.get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapAPIError):
|
||||||
|
mock_server._make_request("test.command")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_make_request_http_error(self, mock_server):
|
||||||
|
"""Test API request with HTTP error."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 401
|
||||||
|
mock_response.text = "Unauthorized"
|
||||||
|
|
||||||
|
mock_server.client.get.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Unauthorized", request=Mock(), response=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapAuthError):
|
||||||
|
mock_server._make_request("test.command")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_make_request_rate_limit(self, mock_server):
|
||||||
|
"""Test API request with rate limit error."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 429
|
||||||
|
mock_response.text = "Too Many Requests"
|
||||||
|
|
||||||
|
mock_server.client.get.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Too Many Requests", request=Mock(), response=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapRateLimitError):
|
||||||
|
mock_server._make_request("test.command")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_make_request_timeout(self, mock_server):
|
||||||
|
"""Test API request with timeout."""
|
||||||
|
mock_server.client.get.side_effect = httpx.TimeoutException("Timeout")
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapAPIError) as exc_info:
|
||||||
|
mock_server._make_request("test.command")
|
||||||
|
|
||||||
|
assert "timeout" in str(exc_info.value).lower()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_handle_api_errors_not_found(self, mock_server):
|
||||||
|
"""Test API error handling for not found errors."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": {
|
||||||
|
"@Number": "2030166",
|
||||||
|
"#text": "Domain not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapNotFoundError):
|
||||||
|
mock_server._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_handle_api_errors_validation(self, mock_server):
|
||||||
|
"""Test API error handling for validation errors."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": {
|
||||||
|
"@Number": "1234",
|
||||||
|
"#text": "Invalid domain format"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
mock_server._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_handle_api_errors_multiple(self, mock_server):
|
||||||
|
"""Test API error handling with multiple errors."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": [
|
||||||
|
{"@Number": "1", "#text": "First error"},
|
||||||
|
{"@Number": "2", "#text": "Second error"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapAPIError) as exc_info:
|
||||||
|
mock_server._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
assert "First error" in str(exc_info.value)
|
||||||
|
assert "Second error" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_split_domain_valid(self, mock_server):
|
||||||
|
"""Test domain splitting with valid domains."""
|
||||||
|
sld, tld = mock_server._split_domain("example.com")
|
||||||
|
assert sld == "example"
|
||||||
|
assert tld == "com"
|
||||||
|
|
||||||
|
sld, tld = mock_server._split_domain("test.co.uk")
|
||||||
|
assert sld == "test"
|
||||||
|
assert tld == "co.uk"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_split_domain_invalid(self, mock_server):
|
||||||
|
"""Test domain splitting with invalid domains."""
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
mock_server._split_domain("invalid")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_domains(self, mock_server):
|
||||||
|
"""Test list domains method."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = '''
|
||||||
|
<ApiResponse Status="OK">
|
||||||
|
<CommandResponse>
|
||||||
|
<DomainGetListResult>
|
||||||
|
<Domain ID="12345" Name="example.com" User="test" />
|
||||||
|
</DomainGetListResult>
|
||||||
|
</CommandResponse>
|
||||||
|
</ApiResponse>
|
||||||
|
'''
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_server.client.get.return_value = mock_response
|
||||||
|
|
||||||
|
domains = mock_server.list_domains()
|
||||||
|
|
||||||
|
assert len(domains) == 1
|
||||||
|
assert domains[0]["@Name"] == "example.com"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_check_domain_availability(self, mock_server):
|
||||||
|
"""Test check domain availability method."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = '''
|
||||||
|
<ApiResponse Status="OK">
|
||||||
|
<CommandResponse>
|
||||||
|
<DomainCheckResult Domain="test.com" Available="true" />
|
||||||
|
</CommandResponse>
|
||||||
|
</ApiResponse>
|
||||||
|
'''
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_server.client.get.return_value = mock_response
|
||||||
|
|
||||||
|
results = mock_server.check_domain_availability(["test.com"])
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["@Available"] == "true"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_dns_records(self, mock_server):
|
||||||
|
"""Test get DNS records method."""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = '''
|
||||||
|
<ApiResponse Status="OK">
|
||||||
|
<CommandResponse>
|
||||||
|
<DomainDNSGetHostsResult>
|
||||||
|
<host HostId="1" Name="@" Type="A" Address="192.168.1.1" TTL="1800" />
|
||||||
|
</DomainDNSGetHostsResult>
|
||||||
|
</CommandResponse>
|
||||||
|
</ApiResponse>
|
||||||
|
'''
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_server.client.get.return_value = mock_response
|
||||||
|
|
||||||
|
records = mock_server.get_dns_records("example.com")
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0]["@Type"] == "A"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_context_manager(self, mock_config):
|
||||||
|
"""Test server as context manager."""
|
||||||
|
with patch('mcp_namecheap.server.httpx.Client') as mock_client_class:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
with NameCheapAPIServer(mock_config) as server:
|
||||||
|
assert server.config == mock_config
|
||||||
|
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorClassification:
|
||||||
|
"""Test error classification logic."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_auth_error_classification(self, mock_server):
|
||||||
|
"""Test authentication error classification."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": {
|
||||||
|
"@Number": "1001",
|
||||||
|
"#text": "Authentication failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapAuthError):
|
||||||
|
mock_server._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_not_found_error_classification(self, mock_server):
|
||||||
|
"""Test not found error classification."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": {
|
||||||
|
"@Number": "2030166",
|
||||||
|
"#text": "Domain not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapNotFoundError):
|
||||||
|
mock_server._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_validation_error_classification(self, mock_server):
|
||||||
|
"""Test validation error classification."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": {
|
||||||
|
"@Number": "1234",
|
||||||
|
"#text": "Invalid parameter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapValidationError):
|
||||||
|
mock_server._handle_api_errors(api_response)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_generic_error_classification(self, mock_server):
|
||||||
|
"""Test generic error classification."""
|
||||||
|
api_response = {
|
||||||
|
"Errors": {
|
||||||
|
"Error": {
|
||||||
|
"@Number": "9999",
|
||||||
|
"#text": "Unknown error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(NameCheapAPIError):
|
||||||
|
mock_server._handle_api_errors(api_response)
|
Loading…
x
Reference in New Issue
Block a user