generated from personal-projects/leo-claude-mktplace
Compare commits
54 Commits
694406941c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 30e28dd09e | |||
| 16ca5cd644 | |||
| 2dbb66deae | |||
| 88c16c840b | |||
| 9fea0683f7 | |||
| a5390a3086 | |||
| c3caf4e169 | |||
| cd8718c114 | |||
| 53a0006a3f | |||
| fb8cc08112 | |||
| 809eef132a | |||
| 1c55eed7c0 | |||
| 5075139841 | |||
| 16436c847a | |||
| 608b488763 | |||
| 49f2d0bdbb | |||
| f2cba079eb | |||
| 4e81b9bb96 | |||
| d21f85545b | |||
| 3d1fd2e2a6 | |||
| 2fc43ff5c3 | |||
| d11649071e | |||
| 42d625c27f | |||
| 6beb8026df | |||
| acacefeaed | |||
| c9961293d9 | |||
| 4f43109797 | |||
| f237c5de01 | |||
| f2ca2a65a2 | |||
| efedce2059 | |||
| 1c63210f1d | |||
| 8105879d71 | |||
| 1733600876 | |||
| eb7e97c967 | |||
| 52f1a9d7e7 | |||
| bcd1cf8841 | |||
| 5a1f708e86 | |||
| ee3ec0e0e4 | |||
| e21f1226c6 | |||
| 4eac323977 | |||
| 6c8e6b4b0a | |||
| b041d1568a | |||
| 0e0c34f735 | |||
| c378840492 | |||
| cd55d53f1b | |||
| 604661f096 | |||
| b94dcebfc7 | |||
| 201cc680ca | |||
| 0653a4f70e | |||
| 13ffd8a543 | |||
| 2230bceb51 | |||
| ab8c9069da | |||
| 7ffc0f9ce4 | |||
| 38dd315dd5 |
67
.dockerignore
Normal file
67
.dockerignore
Normal file
@@ -0,0 +1,67 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
*.coverage
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
docker-compose.override.yml
|
||||
|
||||
# Test files
|
||||
tests/
|
||||
19
.env.docker.example
Normal file
19
.env.docker.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Docker Compose Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Gitea Configuration (REQUIRED)
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
GITEA_OWNER=your_username_or_org
|
||||
GITEA_REPO=your_repo_name
|
||||
|
||||
# Authentication Configuration (OPTIONAL)
|
||||
# Uncomment to enable Bearer token authentication
|
||||
# AUTH_TOKEN=your_bearer_token_here
|
||||
|
||||
# Tool Filtering Configuration (OPTIONAL)
|
||||
# Uncomment to enable specific tools only (whitelist mode)
|
||||
# ENABLED_TOOLS=list_issues,create_issue,update_issue,list_labels
|
||||
|
||||
# Uncomment to disable specific tools (blacklist mode)
|
||||
# DISABLED_TOOLS=delete_issue,close_milestone
|
||||
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# --- Gitea MCP Server env vars (used by marketplace package) ---
|
||||
GITEA_API_URL=https://gitea.hotserv.cloud/api/v1
|
||||
GITEA_API_TOKEN=your_gitea_personal_access_token
|
||||
|
||||
# Gitea Configuration
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
GITEA_OWNER=your_username_or_org
|
||||
GITEA_REPO=your_repo_name
|
||||
|
||||
# HTTP Server Configuration
|
||||
HTTP_HOST=127.0.0.1
|
||||
HTTP_PORT=8000
|
||||
|
||||
# --- Caddy / TLS ---
|
||||
MCP_DOMAIN=mcp-gitea.hotserv.cloud
|
||||
|
||||
# Authentication Configuration (Optional)
|
||||
# AUTH_TOKEN=your_bearer_token_here
|
||||
|
||||
# Tool Filtering Configuration (Optional)
|
||||
# ENABLED_TOOLS=list_issues,create_issue,update_issue
|
||||
# DISABLED_TOOLS=delete_issue,close_milestone
|
||||
68
CHANGELOG.md
Normal file
68
CHANGELOG.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.0.0] - 2026-02-03
|
||||
|
||||
### Added
|
||||
- MCP Streamable HTTP protocol support (`/mcp` endpoint with POST/HEAD)
|
||||
- Two-service Docker infrastructure with Caddy reverse proxy (`docker/`)
|
||||
- Production startup script (`scripts/start.sh`) with environment validation
|
||||
- Health check script (`scripts/healthcheck.sh`) for Docker
|
||||
- CLAUDE.md with comprehensive project guidance for AI assistants
|
||||
- Health endpoint tests with authentication bypass verification
|
||||
|
||||
### Changed
|
||||
- **BREAKING**: Package renamed from `gitea_http_wrapper` to `gitea_mcp_remote`
|
||||
- **BREAKING**: Default HTTP port changed from 8000 to 8080
|
||||
- **BREAKING**: Default HTTP host changed from 127.0.0.1 to 0.0.0.0
|
||||
- Architecture changed from subprocess wrapper to direct marketplace import
|
||||
- Server implementation uses `StreamableHTTPServerTransport` from MCP SDK
|
||||
- Tests moved from `src/gitea_mcp_remote/tests/` to repository root `tests/`
|
||||
- Middleware order fixed: HealthCheckBypass now wraps BearerAuth (runs first)
|
||||
- Docker files moved to `docker/` directory with Caddy service added
|
||||
- Updated README.md and DEPLOYMENT.md with correct architecture documentation
|
||||
|
||||
### Fixed
|
||||
- Health endpoint returning 401 when AUTH_TOKEN was set (middleware order bug)
|
||||
- pyproject.toml testpaths pointing to wrong directory
|
||||
|
||||
## [1.0.0] - 2025-02-03
|
||||
|
||||
### Changed
|
||||
- **BREAKING**: Complete architectural rebuild from standalone MCP server to HTTP wrapper pattern
|
||||
- **BREAKING**: Now wraps official `gitea-mcp-server` package instead of implementing Gitea operations directly
|
||||
- Project renamed from standalone implementation to HTTP transport wrapper
|
||||
|
||||
### Added
|
||||
- HTTP transport layer via Starlette/uvicorn for Claude Desktop compatibility
|
||||
- Configuration management module (`config/`) with environment variable support
|
||||
- Tool filtering module (`filtering/`) for Claude Desktop compatibility controls
|
||||
- Bearer token authentication middleware (`middleware/auth.py`)
|
||||
- Comprehensive test suite (30 tests covering all modules)
|
||||
- Docker deployment infrastructure with docker-compose.yml
|
||||
- Health check endpoints (`/health`, `/healthz`, `/ping`)
|
||||
- Deployment documentation and Docker guides
|
||||
- Environment variable configuration with `.env` support
|
||||
|
||||
### Removed
|
||||
- Standalone MCP tool implementations (now delegated to wrapped `gitea-mcp-server`)
|
||||
- Direct Gitea API integration code (handled by wrapped server)
|
||||
|
||||
## [0.1.0] - 2025-01-XX (Initial Standalone Implementation)
|
||||
|
||||
### Added
|
||||
- Initial Python project structure
|
||||
- MCP server core with stdio transport
|
||||
- Issue operations (create, update, list, get)
|
||||
- Label operations (add, remove, list)
|
||||
- Milestone operations (create, update, list)
|
||||
- Authentication with Gitea API tokens
|
||||
- Comprehensive README and documentation
|
||||
|
||||
### Notes
|
||||
- This version was a standalone MCP server implementation
|
||||
- Superseded by HTTP wrapper architecture in Sprint 02
|
||||
239
CLAUDE.md
Normal file
239
CLAUDE.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# CLAUDE.md - Gitea MCP Remote
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Name:** gitea-mcp-remote
|
||||
**Type:** HTTP transport server for MCP (Model Context Protocol)
|
||||
**Purpose:** Expose Gitea operations via MCP Streamable HTTP protocol for AI assistants
|
||||
|
||||
This is NOT a standalone MCP server. It imports tools from the `gitea-mcp-server` marketplace package and serves them over HTTP with authentication and tool filtering.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client (Claude Desktop) ──HTTP──▶ gitea-mcp-remote ──imports──▶ gitea-mcp-server ──API──▶ Gitea
|
||||
│
|
||||
├── Authentication (Bearer token)
|
||||
├── Tool Filtering (enable/disable)
|
||||
└── MCP Streamable HTTP protocol
|
||||
```
|
||||
|
||||
**Key Design Decision:** This server uses direct imports from the marketplace `gitea-mcp-server` package, NOT subprocess spawning. Tool definitions and dispatchers are imported and used directly.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gitea-mcp-remote/
|
||||
├── src/gitea_mcp_remote/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── server_http.py # MCP HTTP server (main module)
|
||||
│ ├── config/ # Configuration module
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── settings.py # Pydantic settings loader
|
||||
│ ├── middleware/ # HTTP middleware
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── auth.py # Bearer auth + health check bypass
|
||||
│ └── filtering/ # Tool filtering
|
||||
│ ├── __init__.py
|
||||
│ └── filter.py # Whitelist/blacklist filtering
|
||||
├── tests/ # Test suite (at repo root)
|
||||
│ ├── conftest.py
|
||||
│ ├── test_config.py
|
||||
│ ├── test_middleware.py
|
||||
│ ├── test_filtering.py
|
||||
│ └── test_mcp_endpoints.py
|
||||
├── docker/ # Docker infrastructure
|
||||
│ ├── Dockerfile # Multi-stage build
|
||||
│ ├── docker-compose.yml # App + Caddy services
|
||||
│ ├── Caddyfile # Reverse proxy config
|
||||
│ └── .env.example # Environment template
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── start.sh # Production startup
|
||||
│ └── healthcheck.sh # Docker health check
|
||||
├── docs/ # Documentation
|
||||
│ └── sprint-proposals/ # Sprint planning docs
|
||||
├── pyproject.toml # Project configuration
|
||||
├── requirements.txt # Dependencies
|
||||
├── README.md # User documentation
|
||||
├── DEPLOYMENT.md # Deployment guide
|
||||
└── CLAUDE.md # This file
|
||||
```
|
||||
|
||||
## Development Workflows
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install with development dependencies
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest tests/ -v
|
||||
|
||||
# With coverage
|
||||
pytest tests/ --cov=gitea_mcp_remote
|
||||
|
||||
# Specific test file
|
||||
pytest tests/test_mcp_endpoints.py -v
|
||||
```
|
||||
|
||||
### Running the Server Locally
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export GITEA_URL=https://gitea.example.com
|
||||
export GITEA_TOKEN=your_token
|
||||
export GITEA_OWNER=your_org
|
||||
|
||||
# Run server
|
||||
gitea-mcp-remote
|
||||
```
|
||||
|
||||
### Docker Development
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker-compose -f docker/docker-compose.yml up --build
|
||||
|
||||
# Validate configuration
|
||||
docker-compose -f docker/docker-compose.yml config
|
||||
|
||||
# Check logs
|
||||
docker-compose -f docker/docker-compose.yml logs -f
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
### server_http.py
|
||||
|
||||
The main MCP server implementation:
|
||||
- Imports tools from `mcp_server` (marketplace package)
|
||||
- Creates `Server` instance with tool handlers
|
||||
- Uses `StreamableHTTPServerTransport` for MCP protocol
|
||||
- Health endpoint at `/health` (bypasses auth)
|
||||
- MCP endpoint at `/mcp` (POST for requests, HEAD for protocol version)
|
||||
|
||||
### config/settings.py
|
||||
|
||||
Pydantic settings with:
|
||||
- `gitea_url`, `gitea_token`, `gitea_owner` (required)
|
||||
- `gitea_repo` (optional)
|
||||
- `http_host` (default: 0.0.0.0), `http_port` (default: 8080)
|
||||
- `auth_token` (optional Bearer token)
|
||||
- `mcp_auth_mode` (optional/required/none)
|
||||
- `enabled_tools`, `disabled_tools` (comma-separated)
|
||||
|
||||
### middleware/auth.py
|
||||
|
||||
Two middleware classes:
|
||||
- `BearerAuthMiddleware`: Validates Authorization header
|
||||
- `HealthCheckBypassMiddleware`: Sets `skip_auth` flag for health endpoints
|
||||
|
||||
**Important:** Middleware order matters. HealthCheckBypass must wrap BearerAuth (outermost) so it runs first.
|
||||
|
||||
## MCP Protocol Notes
|
||||
|
||||
This server implements the MCP Streamable HTTP protocol:
|
||||
|
||||
1. **HEAD /mcp** - Returns protocol version header (`x-mcp-protocol-version: 2024-11-05`)
|
||||
2. **POST /mcp** - Accepts JSON-RPC 2.0 requests, returns SSE responses
|
||||
|
||||
Supported methods:
|
||||
- `initialize` - Protocol handshake
|
||||
- `tools/list` - List available tools
|
||||
- `tools/call` - Execute a tool
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_issues",
|
||||
"arguments": {"owner": "org", "repo": "repo", "state": "open"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Format (SSE)
|
||||
|
||||
```
|
||||
event: message
|
||||
data: {"jsonrpc":"2.0","id":1,"result":[...]}
|
||||
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| GITEA_URL | Yes | - | Gitea instance URL |
|
||||
| GITEA_TOKEN | Yes | - | Gitea API token |
|
||||
| GITEA_OWNER | Yes | - | Default owner/org |
|
||||
| GITEA_REPO | No | None | Default repository |
|
||||
| HTTP_HOST | No | 0.0.0.0 | Server bind address |
|
||||
| HTTP_PORT | No | 8080 | Server port |
|
||||
| AUTH_TOKEN | No | None | Bearer token for auth |
|
||||
| MCP_AUTH_MODE | No | optional | Auth mode |
|
||||
| ENABLED_TOOLS | No | None | Whitelist (comma-sep) |
|
||||
| DISABLED_TOOLS | No | None | Blacklist (comma-sep) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Import Errors from mcp_server
|
||||
|
||||
If `from mcp_server import ...` fails:
|
||||
1. Verify `gitea-mcp-server` is installed: `pip list | grep gitea`
|
||||
2. The package is installed from Git via pyproject.toml dependency
|
||||
3. Reinstall: `pip install -e .`
|
||||
|
||||
### Health Endpoint Returns 401
|
||||
|
||||
Middleware order is wrong. HealthCheckBypassMiddleware must be outermost:
|
||||
```python
|
||||
if settings.auth_token:
|
||||
app = BearerAuthMiddleware(app, auth_token=settings.auth_token)
|
||||
app = HealthCheckBypassMiddleware(app) # Must be last (outermost)
|
||||
```
|
||||
|
||||
### Tests Not Found
|
||||
|
||||
Ensure pyproject.toml has correct testpaths:
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
```
|
||||
|
||||
### MCP Requests Fail with 406
|
||||
|
||||
Missing Accept header. Requests must include:
|
||||
```
|
||||
Accept: application/json, text/event-stream
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Runtime:**
|
||||
- `gitea-mcp-server` - Marketplace package (Git dependency)
|
||||
- `mcp` - MCP SDK
|
||||
- `uvicorn` - ASGI server
|
||||
- `starlette` - Web framework
|
||||
- `pydantic-settings` - Configuration
|
||||
|
||||
**Development:**
|
||||
- `pytest` - Testing
|
||||
- `pytest-asyncio` - Async test support
|
||||
- `pytest-cov` - Coverage
|
||||
- `httpx` - HTTP client for tests
|
||||
734
DEPLOYMENT.md
Normal file
734
DEPLOYMENT.md
Normal file
@@ -0,0 +1,734 @@
|
||||
# Deployment Guide
|
||||
|
||||
This guide covers production deployment of Gitea MCP Remote in various environments.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Docker Deployment](#docker-deployment)
|
||||
3. [Security Best Practices](#security-best-practices)
|
||||
4. [Monitoring and Health Checks](#monitoring-and-health-checks)
|
||||
5. [Reverse Proxy Configuration](#reverse-proxy-configuration)
|
||||
6. [Cloud Deployment](#cloud-deployment)
|
||||
7. [Kubernetes Deployment](#kubernetes-deployment)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required
|
||||
|
||||
- Docker and Docker Compose (for Docker deployment)
|
||||
- Gitea instance with API access
|
||||
- Gitea API token with appropriate permissions
|
||||
- Network connectivity between wrapper and Gitea instance
|
||||
|
||||
### Recommended
|
||||
|
||||
- HTTPS-capable reverse proxy (Nginx, Caddy, Traefik)
|
||||
- Secrets management solution (not `.env` files in production)
|
||||
- Monitoring and logging infrastructure
|
||||
- Firewall or VPN for network security
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote.git
|
||||
cd gitea-mcp-remote
|
||||
```
|
||||
|
||||
2. **Create configuration:**
|
||||
|
||||
```bash
|
||||
cp docker/.env.example docker/.env
|
||||
nano docker/.env # Edit with your values
|
||||
```
|
||||
|
||||
Required configuration:
|
||||
```bash
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_TOKEN=your_gitea_api_token
|
||||
GITEA_OWNER=your_username_or_org
|
||||
GITEA_REPO=your_default_repo # Optional
|
||||
AUTH_TOKEN=your_bearer_token # Recommended
|
||||
```
|
||||
|
||||
3. **Start the services (app + Caddy):**
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
4. **Verify deployment:**
|
||||
|
||||
```bash
|
||||
curl http://localhost/health # Via Caddy
|
||||
curl http://localhost:8080/health # Direct to app
|
||||
```
|
||||
|
||||
### Production Configuration
|
||||
|
||||
The default `docker/docker-compose.yml` includes both app and Caddy reverse proxy services. For customization:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# Python MCP Server
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: gitea-mcp-remote:latest
|
||||
container_name: gitea-mcp-remote-app
|
||||
restart: always
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
- GITEA_URL=${GITEA_URL}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_OWNER=${GITEA_OWNER}
|
||||
- GITEA_REPO=${GITEA_REPO:-}
|
||||
- HTTP_HOST=0.0.0.0
|
||||
- HTTP_PORT=8080
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- gitea-mcp-network
|
||||
|
||||
# Caddy Reverse Proxy (HTTPS termination)
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: gitea-mcp-remote-caddy
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- gitea-mcp-network
|
||||
|
||||
networks:
|
||||
gitea-mcp-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
```
|
||||
|
||||
### Docker Build Options
|
||||
|
||||
**Build the image:**
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile -t gitea-mcp-remote:latest .
|
||||
```
|
||||
|
||||
**Build with specific Python version:**
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile --build-arg PYTHON_VERSION=3.11 -t gitea-mcp-remote:latest .
|
||||
```
|
||||
|
||||
**Tag for registry:**
|
||||
|
||||
```bash
|
||||
docker tag gitea-mcp-remote:latest registry.example.com/gitea-mcp-remote:latest
|
||||
docker push registry.example.com/gitea-mcp-remote:latest
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. Use Authentication
|
||||
|
||||
Always set `AUTH_TOKEN` in production:
|
||||
|
||||
```bash
|
||||
# Generate a secure token
|
||||
openssl rand -base64 32
|
||||
|
||||
# Add to .env
|
||||
AUTH_TOKEN=<generated_token>
|
||||
```
|
||||
|
||||
### 2. Use HTTPS
|
||||
|
||||
Never expose the wrapper directly to the internet without HTTPS. Use a reverse proxy (see below).
|
||||
|
||||
### 3. Network Isolation
|
||||
|
||||
- Bind to localhost only (`127.0.0.1`) if using a reverse proxy
|
||||
- Use Docker networks to isolate services
|
||||
- Consider VPN or private networking for access
|
||||
|
||||
### 4. Secrets Management
|
||||
|
||||
Don't use `.env` files in production. Use Docker secrets, Kubernetes secrets, or a secrets manager:
|
||||
|
||||
**Docker Secrets Example:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea-mcp-wrapper:
|
||||
secrets:
|
||||
- gitea_token
|
||||
- auth_token
|
||||
environment:
|
||||
- GITEA_TOKEN_FILE=/run/secrets/gitea_token
|
||||
- AUTH_TOKEN_FILE=/run/secrets/auth_token
|
||||
|
||||
secrets:
|
||||
gitea_token:
|
||||
external: true
|
||||
auth_token:
|
||||
external: true
|
||||
```
|
||||
|
||||
### 5. Regular Updates
|
||||
|
||||
- Rotate Gitea API token regularly
|
||||
- Rotate AUTH_TOKEN regularly
|
||||
- Keep Docker base image updated
|
||||
- Update dependencies: `pip install --upgrade -r requirements.txt`
|
||||
|
||||
### 6. Minimal Permissions
|
||||
|
||||
Grant the Gitea API token only the minimum required permissions:
|
||||
|
||||
- Repository read/write
|
||||
- Issue management
|
||||
- Label management
|
||||
- Milestone management
|
||||
|
||||
Avoid granting admin or organization-level permissions.
|
||||
|
||||
## Monitoring and Health Checks
|
||||
|
||||
### Health Check Endpoints
|
||||
|
||||
The wrapper provides three health check endpoints:
|
||||
|
||||
```bash
|
||||
GET /health
|
||||
GET /healthz
|
||||
GET /ping
|
||||
```
|
||||
|
||||
All return `{"status": "healthy"}` with HTTP 200 when the server is operational.
|
||||
|
||||
### Docker Health Checks
|
||||
|
||||
Docker automatically monitors the health check and can restart if unhealthy:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
```
|
||||
|
||||
### Monitoring Integration
|
||||
|
||||
**Prometheus metrics:** (Not yet implemented, but can be added)
|
||||
|
||||
**Log monitoring:**
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker-compose logs -f gitea-mcp-wrapper
|
||||
|
||||
# JSON structured logs
|
||||
docker logs gitea-mcp-wrapper --tail 100
|
||||
```
|
||||
|
||||
**Uptime monitoring:**
|
||||
|
||||
Use tools like UptimeRobot, Pingdom, or Datadog to monitor `/health` endpoint.
|
||||
|
||||
## Reverse Proxy Configuration
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name mcp.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Pass through Authorization header
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_pass_header Authorization;
|
||||
|
||||
# WebSocket support (if needed in future)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Health check endpoint (optional, can bypass auth)
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:8000/health;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
```caddyfile
|
||||
mcp.example.com {
|
||||
reverse_proxy localhost:8000 {
|
||||
# Pass through Authorization header
|
||||
header_up Authorization {>Authorization}
|
||||
}
|
||||
|
||||
# Optional: Rate limiting
|
||||
rate_limit {
|
||||
zone mcp_zone
|
||||
rate 100r/m
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
gitea-mcp-wrapper:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mcp.rule=Host(`mcp.example.com`)"
|
||||
- "traefik.http.routers.mcp.entrypoints=websecure"
|
||||
- "traefik.http.routers.mcp.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mcp.loadbalancer.server.port=8000"
|
||||
```
|
||||
|
||||
## Cloud Deployment
|
||||
|
||||
### AWS EC2
|
||||
|
||||
1. **Launch EC2 instance:**
|
||||
- Amazon Linux 2 or Ubuntu 22.04
|
||||
- t3.micro or larger
|
||||
- Security group: Allow port 443 (HTTPS)
|
||||
|
||||
2. **Install Docker:**
|
||||
|
||||
```bash
|
||||
sudo yum update -y
|
||||
sudo yum install -y docker
|
||||
sudo service docker start
|
||||
sudo usermod -aG docker ec2-user
|
||||
```
|
||||
|
||||
3. **Deploy wrapper:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lmiranda/gitea-mcp-remote.git
|
||||
cd gitea-mcp-remote
|
||||
cp .env.docker.example .env
|
||||
nano .env # Configure
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Configure Nginx or ALB for HTTPS**
|
||||
|
||||
### AWS ECS (Fargate)
|
||||
|
||||
1. **Create task definition:**
|
||||
|
||||
```json
|
||||
{
|
||||
"family": "gitea-mcp-wrapper",
|
||||
"networkMode": "awsvpc",
|
||||
"requiresCompatibilities": ["FARGATE"],
|
||||
"cpu": "256",
|
||||
"memory": "512",
|
||||
"containerDefinitions": [
|
||||
{
|
||||
"name": "gitea-mcp-wrapper",
|
||||
"image": "your-ecr-repo/gitea-mcp-wrapper:latest",
|
||||
"portMappings": [
|
||||
{
|
||||
"containerPort": 8000,
|
||||
"protocol": "tcp"
|
||||
}
|
||||
],
|
||||
"environment": [
|
||||
{"name": "GITEA_URL", "value": "https://gitea.example.com"},
|
||||
{"name": "HTTP_HOST", "value": "0.0.0.0"},
|
||||
{"name": "HTTP_PORT", "value": "8000"}
|
||||
],
|
||||
"secrets": [
|
||||
{
|
||||
"name": "GITEA_TOKEN",
|
||||
"valueFrom": "arn:aws:secretsmanager:region:account:secret:gitea-token"
|
||||
},
|
||||
{
|
||||
"name": "AUTH_TOKEN",
|
||||
"valueFrom": "arn:aws:secretsmanager:region:account:secret:auth-token"
|
||||
}
|
||||
],
|
||||
"healthCheck": {
|
||||
"command": ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"],
|
||||
"interval": 30,
|
||||
"timeout": 5,
|
||||
"retries": 3,
|
||||
"startPeriod": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create ECS service with ALB**
|
||||
|
||||
### Google Cloud Run
|
||||
|
||||
1. **Build and push image:**
|
||||
|
||||
```bash
|
||||
gcloud builds submit --tag gcr.io/PROJECT_ID/gitea-mcp-wrapper
|
||||
```
|
||||
|
||||
2. **Deploy:**
|
||||
|
||||
```bash
|
||||
gcloud run deploy gitea-mcp-wrapper \
|
||||
--image gcr.io/PROJECT_ID/gitea-mcp-wrapper \
|
||||
--platform managed \
|
||||
--region us-central1 \
|
||||
--allow-unauthenticated \
|
||||
--set-env-vars GITEA_URL=https://gitea.example.com \
|
||||
--set-secrets GITEA_TOKEN=gitea-token:latest,AUTH_TOKEN=auth-token:latest \
|
||||
--port 8000
|
||||
```
|
||||
|
||||
### Azure Container Instances
|
||||
|
||||
```bash
|
||||
az container create \
|
||||
--resource-group myResourceGroup \
|
||||
--name gitea-mcp-wrapper \
|
||||
--image your-registry/gitea-mcp-wrapper:latest \
|
||||
--ports 8000 \
|
||||
--dns-name-label gitea-mcp \
|
||||
--environment-variables \
|
||||
GITEA_URL=https://gitea.example.com \
|
||||
HTTP_HOST=0.0.0.0 \
|
||||
HTTP_PORT=8000 \
|
||||
--secure-environment-variables \
|
||||
GITEA_TOKEN=your_token \
|
||||
AUTH_TOKEN=your_auth_token
|
||||
```
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
### Deployment Manifest
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gitea-mcp-wrapper
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gitea-mcp-wrapper
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: gitea-mcp-wrapper
|
||||
spec:
|
||||
containers:
|
||||
- name: gitea-mcp-wrapper
|
||||
image: your-registry/gitea-mcp-wrapper:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: GITEA_URL
|
||||
value: "https://gitea.example.com"
|
||||
- name: HTTP_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: HTTP_PORT
|
||||
value: "8000"
|
||||
- name: GITEA_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-secrets
|
||||
key: token
|
||||
- name: AUTH_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gitea-secrets
|
||||
key: auth-token
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea-mcp-wrapper
|
||||
namespace: default
|
||||
spec:
|
||||
selector:
|
||||
app: gitea-mcp-wrapper
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 8000
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gitea-mcp-wrapper
|
||||
namespace: default
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- mcp.example.com
|
||||
secretName: mcp-tls
|
||||
rules:
|
||||
- host: mcp.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gitea-mcp-wrapper
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
### Secrets Management
|
||||
|
||||
```bash
|
||||
# Create secret
|
||||
kubectl create secret generic gitea-secrets \
|
||||
--from-literal=token=your_gitea_token \
|
||||
--from-literal=auth-token=your_auth_token \
|
||||
--namespace=default
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs gitea-mcp-wrapper
|
||||
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# Rebuild image
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Health Check Failing
|
||||
|
||||
```bash
|
||||
# Test health endpoint directly
|
||||
docker exec gitea-mcp-wrapper curl http://localhost:8000/health
|
||||
|
||||
# Check if server is listening
|
||||
docker exec gitea-mcp-wrapper netstat -tlnp
|
||||
```
|
||||
|
||||
### Cannot Reach Gitea from Container
|
||||
|
||||
```bash
|
||||
# Test connectivity
|
||||
docker exec gitea-mcp-wrapper curl -v https://gitea.example.com
|
||||
|
||||
# Check DNS resolution
|
||||
docker exec gitea-mcp-wrapper nslookup gitea.example.com
|
||||
|
||||
# For docker-compose, ensure network allows egress
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
```bash
|
||||
# Check container stats
|
||||
docker stats gitea-mcp-wrapper
|
||||
|
||||
# Adjust resource limits in docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
```
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
```bash
|
||||
# Verify AUTH_TOKEN is set
|
||||
docker exec gitea-mcp-wrapper printenv AUTH_TOKEN
|
||||
|
||||
# Test with curl
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/tools/list
|
||||
|
||||
# Check logs for auth failures
|
||||
docker-compose logs gitea-mcp-wrapper | grep -i auth
|
||||
```
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
The wrapper is stateless and can be scaled horizontally:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
gitea-mcp-wrapper:
|
||||
deploy:
|
||||
replicas: 3
|
||||
```
|
||||
|
||||
Or in Kubernetes:
|
||||
|
||||
```bash
|
||||
kubectl scale deployment gitea-mcp-wrapper --replicas=5
|
||||
```
|
||||
|
||||
### Load Balancing
|
||||
|
||||
Use a load balancer to distribute traffic:
|
||||
- Docker Swarm: Built-in load balancing
|
||||
- Kubernetes: Service with multiple pods
|
||||
- Cloud: AWS ALB, GCP Load Balancer, Azure Load Balancer
|
||||
|
||||
### Caching
|
||||
|
||||
Consider caching responses to reduce Gitea API load:
|
||||
- Add Redis or Memcached
|
||||
- Cache tool list responses
|
||||
- Cache frequently accessed issues/labels
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Implement rate limiting at reverse proxy level to prevent API abuse:
|
||||
|
||||
**Nginx:**
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=mcp:10m rate=10r/s;
|
||||
limit_req zone=mcp burst=20 nodelay;
|
||||
```
|
||||
|
||||
**Caddy:**
|
||||
```caddyfile
|
||||
rate_limit {
|
||||
rate 100r/m
|
||||
}
|
||||
```
|
||||
|
||||
## Backup and Disaster Recovery
|
||||
|
||||
### Configuration Backup
|
||||
|
||||
```bash
|
||||
# Backup .env file
|
||||
cp .env .env.backup.$(date +%Y%m%d)
|
||||
|
||||
# Backup docker-compose.yml
|
||||
cp docker-compose.yml docker-compose.yml.backup.$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Image Backup
|
||||
|
||||
```bash
|
||||
# Save Docker image
|
||||
docker save gitea-mcp-wrapper:latest | gzip > gitea-mcp-wrapper-backup.tar.gz
|
||||
|
||||
# Load Docker image
|
||||
docker load < gitea-mcp-wrapper-backup.tar.gz
|
||||
```
|
||||
|
||||
### Recovery Plan
|
||||
|
||||
1. Restore configuration files
|
||||
2. Rebuild or load Docker image
|
||||
3. Start services: `docker-compose up -d`
|
||||
4. Verify health: `curl http://localhost:8000/health`
|
||||
5. Test authentication and tool access
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] HTTPS configured via reverse proxy
|
||||
- [ ] `AUTH_TOKEN` set and secure
|
||||
- [ ] Secrets stored in secrets manager (not `.env`)
|
||||
- [ ] Health checks configured
|
||||
- [ ] Monitoring and alerting set up
|
||||
- [ ] Logs aggregated and retained
|
||||
- [ ] Firewall rules configured
|
||||
- [ ] Rate limiting enabled
|
||||
- [ ] Resource limits set
|
||||
- [ ] Backup strategy in place
|
||||
- [ ] Disaster recovery plan documented
|
||||
- [ ] Security updates scheduled
|
||||
- [ ] Token rotation process defined
|
||||
|
||||
---
|
||||
|
||||
**For questions or issues, please open an issue on the [GitHub repository](https://github.com/lmiranda/gitea-mcp-remote/issues).**
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Gitea HTTP MCP Wrapper Dockerfile
|
||||
# Multi-stage build for optimized image size
|
||||
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source code
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
|
||||
# Install package
|
||||
RUN pip install --user --no-cache-dir -e .
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# Copy source code
|
||||
COPY src/ src/
|
||||
COPY pyproject.toml .
|
||||
|
||||
# Make sure scripts in .local are usable
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
# Set Python environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Expose default port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"
|
||||
|
||||
# Run the HTTP MCP server
|
||||
CMD ["gitea-http-wrapper"]
|
||||
380
README.md
380
README.md
@@ -1,27 +1,87 @@
|
||||
# Gitea MCP Remote
|
||||
|
||||
MCP server for Gitea API integration.
|
||||
An HTTP transport server that exposes Gitea operations via the MCP Streamable HTTP protocol for AI assistants like Claude Desktop. This server imports tools from the marketplace `gitea-mcp-server` package and serves them over HTTP with authentication and tool filtering.
|
||||
|
||||
## Overview
|
||||
## Architecture
|
||||
|
||||
This project provides a Model Context Protocol (MCP) server that enables AI assistants to interact with Gitea through its API.
|
||||
This is NOT a standalone MCP server. It imports tool definitions from the marketplace package and serves them:
|
||||
1. Imports tools from `gitea-mcp-server` marketplace package
|
||||
2. Serves via MCP Streamable HTTP protocol (`/mcp` endpoint)
|
||||
3. Adds Bearer token authentication (optional)
|
||||
4. Provides tool filtering (whitelist/blacklist)
|
||||
5. Health check endpoints for monitoring
|
||||
|
||||
## Project Status
|
||||
```
|
||||
Claude Desktop (HTTP) ──▶ gitea-mcp-remote ──imports──▶ gitea-mcp-server ──API──▶ Gitea
|
||||
│
|
||||
├── Authentication (Bearer token)
|
||||
├── Tool Filtering
|
||||
└── MCP Streamable HTTP protocol
|
||||
```
|
||||
|
||||
Currently in initial development. Project structure has been initialized.
|
||||
## Features
|
||||
|
||||
- **HTTP Transport**: Exposes MCP server via HTTP for Claude Desktop
|
||||
- **Authentication**: Optional Bearer token authentication
|
||||
- **Tool Filtering**: Enable/disable specific tools for compatibility
|
||||
- **Docker Deployment**: Production-ready containerization
|
||||
- **Health Checks**: Monitoring endpoints (`/health`, `/healthz`, `/ping`)
|
||||
- **Async Architecture**: Built on Starlette and uvicorn
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- Official `gitea-mcp-server` package (auto-installed as dependency)
|
||||
- Gitea instance with API access
|
||||
- Gitea API token with appropriate permissions
|
||||
|
||||
## Quick Start with Docker
|
||||
|
||||
The easiest way to deploy is using Docker Compose with Caddy reverse proxy:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote.git
|
||||
cd gitea-mcp-remote
|
||||
|
||||
# 2. Create .env file from template
|
||||
cp docker/.env.example docker/.env
|
||||
|
||||
# 3. Edit .env with your Gitea credentials
|
||||
nano docker/.env
|
||||
|
||||
# 4. Start the services (app + Caddy)
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# 5. Check health
|
||||
curl http://localhost/health
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost` (Caddy) or `http://localhost:8080` (direct).
|
||||
|
||||
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed deployment instructions.
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
See [Quick Start](#quick-start-with-docker) above or [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
### Option 2: From Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/lmiranda/gitea-mcp-remote.git
|
||||
cd gitea-mcp-remote
|
||||
|
||||
# Install the wrapper and its dependencies (including gitea-mcp-server)
|
||||
pip install -e .
|
||||
|
||||
# Or use requirements.txt
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Development
|
||||
### For Development
|
||||
|
||||
Install with development dependencies:
|
||||
|
||||
@@ -29,12 +89,314 @@ Install with development dependencies:
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Run tests:
|
||||
## Configuration
|
||||
|
||||
The wrapper uses environment variables or a `.env` file for configuration.
|
||||
|
||||
### Required Configuration
|
||||
|
||||
```bash
|
||||
pytest
|
||||
# Gitea Instance
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
GITEA_OWNER=your_username_or_org
|
||||
|
||||
# Optional
|
||||
GITEA_REPO=your_repo_name # Can be omitted, specified per-request
|
||||
|
||||
# HTTP Server (defaults)
|
||||
HTTP_HOST=0.0.0.0
|
||||
HTTP_PORT=8080
|
||||
```
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
```bash
|
||||
# Bearer Authentication (optional but recommended)
|
||||
AUTH_TOKEN=your_secret_bearer_token
|
||||
|
||||
# Tool Filtering (optional)
|
||||
ENABLED_TOOLS=list_issues,create_issue,update_issue # Whitelist mode
|
||||
# OR
|
||||
DISABLED_TOOLS=delete_issue,close_milestone # Blacklist mode
|
||||
```
|
||||
|
||||
### Getting a Gitea API Token
|
||||
|
||||
1. Log into your Gitea instance
|
||||
2. Navigate to Settings > Applications
|
||||
3. Under "Generate New Token", enter a name (e.g., "MCP Wrapper")
|
||||
4. Select appropriate permissions (minimum: read/write for repositories)
|
||||
5. Click "Generate Token" and copy the token
|
||||
6. Add the token to your `.env` file
|
||||
|
||||
## Usage
|
||||
|
||||
### Running the Server
|
||||
|
||||
#### With Docker
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### From Source
|
||||
|
||||
```bash
|
||||
# Create .env file from template
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
nano .env
|
||||
|
||||
# Run the server
|
||||
gitea-mcp-remote
|
||||
# Or use the startup script
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
The server will start on the configured host/port (default: `http://0.0.0.0:8080`).
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
#### Health Check
|
||||
```bash
|
||||
GET /health
|
||||
|
||||
Response: {"status": "ok"}
|
||||
```
|
||||
|
||||
#### MCP Protocol Endpoint
|
||||
|
||||
The server implements the MCP Streamable HTTP protocol:
|
||||
|
||||
```bash
|
||||
# Check protocol version
|
||||
HEAD /mcp
|
||||
|
||||
# Send MCP JSON-RPC requests
|
||||
POST /mcp
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
Authorization: Bearer YOUR_TOKEN # If auth enabled
|
||||
|
||||
# Example: Initialize
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "my-client",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Example: List tools
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
# Example: Call tool
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "list_issues",
|
||||
"arguments": {
|
||||
"owner": "myorg",
|
||||
"repo": "myrepo",
|
||||
"state": "open"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Claude Desktop
|
||||
|
||||
Configure Claude Desktop to use the HTTP wrapper:
|
||||
|
||||
**Location:**
|
||||
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
- Linux: `~/.config/Claude/claude_desktop_config.json`
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
The wrapper exposes all tools from the official `gitea-mcp-server`. See the [official Gitea MCP documentation](https://github.com/modelcontextprotocol/servers/tree/main/src/gitea) for the complete list of available tools:
|
||||
|
||||
- **Issues**: List, get, create, update issues
|
||||
- **Labels**: List, create labels
|
||||
- **Milestones**: List, create milestones
|
||||
|
||||
Tool availability can be controlled via the `ENABLED_TOOLS` or `DISABLED_TOOLS` configuration.
|
||||
|
||||
## Development
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
# Install with development dependencies
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/ --cov=gitea_mcp_remote
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_mcp_endpoints.py -v
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
gitea-mcp-remote/
|
||||
├── src/gitea_mcp_remote/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── server_http.py # MCP HTTP server
|
||||
│ ├── config/ # Configuration module
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── settings.py
|
||||
│ ├── middleware/ # HTTP middleware
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── auth.py
|
||||
│ └── filtering/ # Tool filtering
|
||||
│ ├── __init__.py
|
||||
│ └── filter.py
|
||||
├── tests/ # Test suite (at repo root)
|
||||
│ ├── conftest.py
|
||||
│ ├── test_config.py
|
||||
│ ├── test_filtering.py
|
||||
│ ├── test_middleware.py
|
||||
│ └── test_mcp_endpoints.py
|
||||
├── docker/ # Docker infrastructure
|
||||
│ ├── Dockerfile
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── Caddyfile
|
||||
│ └── .env.example
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── start.sh
|
||||
│ └── healthcheck.sh
|
||||
├── pyproject.toml # Project config
|
||||
├── requirements.txt # Dependencies
|
||||
├── README.md # This file
|
||||
├── CLAUDE.md # Claude Code guidance
|
||||
└── DEPLOYMENT.md # Deployment guide
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
For production deployment instructions, see [DEPLOYMENT.md](DEPLOYMENT.md), which covers:
|
||||
|
||||
- Docker deployment
|
||||
- Docker Compose orchestration
|
||||
- Security best practices
|
||||
- Monitoring and health checks
|
||||
- Scaling considerations
|
||||
- Cloud deployment (AWS, GCP, Azure)
|
||||
- Kubernetes deployment
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
If you receive authentication errors:
|
||||
|
||||
1. Verify your `GITEA_TOKEN` is correct
|
||||
2. Check that the token has appropriate permissions
|
||||
3. Ensure your `GITEA_URL` does NOT include `/api/v1` (wrapper adds it)
|
||||
4. Verify the Gitea instance is accessible from the wrapper's network
|
||||
|
||||
### HTTP 401/403 Errors
|
||||
|
||||
If Claude Desktop receives 401 or 403 errors:
|
||||
|
||||
1. Check that `AUTH_TOKEN` is configured (if authentication is enabled)
|
||||
2. Verify Claude Desktop config includes the correct `Authorization` header
|
||||
3. Check server logs for authentication failures
|
||||
|
||||
### Connection Errors
|
||||
|
||||
If the wrapper cannot connect to Gitea:
|
||||
|
||||
1. Check that `GITEA_URL` is correct and accessible
|
||||
2. Verify network connectivity to the Gitea instance
|
||||
3. Check for firewalls or proxies blocking the connection
|
||||
4. In Docker: Ensure the container can reach the Gitea host
|
||||
|
||||
### gitea-mcp-server Not Found
|
||||
|
||||
If the wrapper fails to start with "gitea-mcp-server not found":
|
||||
|
||||
1. Verify `gitea-mcp-server` is installed: `pip list | grep gitea-mcp`
|
||||
2. Install it manually: `pip install gitea-mcp-server`
|
||||
3. In Docker: Rebuild the image
|
||||
|
||||
### Tool Filtering Not Working
|
||||
|
||||
If tool filtering is not applied:
|
||||
|
||||
1. Check `.env` file syntax (no spaces around `=`)
|
||||
2. Verify comma-separated list format
|
||||
3. Check server logs for filter configuration
|
||||
4. Send `tools/list` MCP request to see filtered tools
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Always use HTTPS** in production (configure reverse proxy)
|
||||
- **Set AUTH_TOKEN** to secure the HTTP endpoint
|
||||
- **Rotate tokens regularly** (both Gitea token and auth token)
|
||||
- **Use secrets management** (not .env files) in production
|
||||
- **Limit network exposure** (firewall, VPN, or private network)
|
||||
- **Monitor access logs** for suspicious activity
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues or pull requests.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT License - see LICENSE file for details
|
||||
|
||||
## Version
|
||||
|
||||
Current version: 2.0.0
|
||||
|
||||
## Author
|
||||
|
||||
Leo Miranda
|
||||
|
||||
## Links
|
||||
|
||||
- Repository: https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote
|
||||
- Issues: https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues
|
||||
- Marketplace Package: https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/mcp-servers/gitea
|
||||
- MCP Documentation: https://modelcontextprotocol.io
|
||||
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
gitea-mcp-wrapper:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: gitea-mcp-wrapper:latest
|
||||
container_name: gitea-mcp-wrapper
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
# Gitea Configuration
|
||||
- GITEA_URL=${GITEA_URL}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_OWNER=${GITEA_OWNER}
|
||||
- GITEA_REPO=${GITEA_REPO}
|
||||
|
||||
# HTTP Server Configuration
|
||||
- HTTP_HOST=0.0.0.0
|
||||
- HTTP_PORT=8000
|
||||
|
||||
# Authentication (Optional)
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-}
|
||||
|
||||
# Tool Filtering (Optional)
|
||||
- ENABLED_TOOLS=${ENABLED_TOOLS:-}
|
||||
- DISABLED_TOOLS=${DISABLED_TOOLS:-}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
- gitea-mcp-network
|
||||
|
||||
networks:
|
||||
gitea-mcp-network:
|
||||
driver: bridge
|
||||
38
docker/.env.example
Normal file
38
docker/.env.example
Normal file
@@ -0,0 +1,38 @@
|
||||
# Gitea MCP Remote - Docker Environment Configuration
|
||||
#
|
||||
# Copy this file to .env and fill in your values:
|
||||
# cp .env.example .env
|
||||
|
||||
# =============================================================================
|
||||
# Required Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Gitea instance URL (e.g., https://gitea.example.com)
|
||||
GITEA_URL=https://gitea.example.com
|
||||
|
||||
# Gitea API token for authentication
|
||||
# Generate at: Settings -> Applications -> Generate New Token
|
||||
GITEA_TOKEN=your_gitea_api_token_here
|
||||
|
||||
# Default repository owner/organization
|
||||
GITEA_OWNER=your_username_or_org
|
||||
|
||||
# =============================================================================
|
||||
# Optional Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Default repository name (optional - can be specified per-request)
|
||||
GITEA_REPO=
|
||||
|
||||
# Bearer token for MCP endpoint authentication (optional)
|
||||
# If set, clients must include "Authorization: Bearer <token>" header
|
||||
AUTH_TOKEN=
|
||||
|
||||
# MCP authentication mode: 'required', 'optional', or 'none'
|
||||
MCP_AUTH_MODE=optional
|
||||
|
||||
# Tool filtering (optional, comma-separated)
|
||||
# ENABLED_TOOLS=list_issues,create_issue,list_labels
|
||||
# DISABLED_TOOLS=delete_issue,delete_label
|
||||
ENABLED_TOOLS=
|
||||
DISABLED_TOOLS=
|
||||
46
docker/Caddyfile
Normal file
46
docker/Caddyfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# Caddy reverse proxy configuration for Gitea MCP Remote
|
||||
#
|
||||
# This configuration provides:
|
||||
# - HTTPS termination with automatic certificates
|
||||
# - Reverse proxy to the Python MCP server
|
||||
# - Health check endpoint passthrough
|
||||
# - MCP protocol endpoint routing
|
||||
{
|
||||
# Global options
|
||||
email admin@example.com
|
||||
|
||||
# For local development, disable HTTPS redirect
|
||||
# auto_https off
|
||||
}
|
||||
|
||||
# Default site - adjust domain as needed
|
||||
:443, :80 {
|
||||
# Health check endpoint - no authentication
|
||||
handle /health {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
# MCP protocol endpoint
|
||||
handle /mcp {
|
||||
reverse_proxy app:8080 {
|
||||
# Pass through headers for MCP protocol
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Real-IP {remote_host}
|
||||
|
||||
# Ensure proper content type handling
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Fallback - proxy all other requests
|
||||
handle {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
# Logging
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
}
|
||||
}
|
||||
}
|
||||
65
docker/Dockerfile
Normal file
65
docker/Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# Gitea MCP Remote — Dockerfile
|
||||
# Multi-stage build for optimized image size
|
||||
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies including git for marketplace dependency
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source code
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
|
||||
# Install package (includes marketplace dependency from git)
|
||||
RUN pip install --user --no-cache-dir .
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# Copy source code
|
||||
COPY src/ src/
|
||||
COPY pyproject.toml .
|
||||
|
||||
# Make sure scripts in .local are usable
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
# Set Python environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Default to port 8080 (Caddy proxies to this)
|
||||
ENV HTTP_PORT=8080
|
||||
ENV HTTP_HOST=0.0.0.0
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check using curl
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
# Run the MCP server
|
||||
CMD ["gitea-mcp-remote"]
|
||||
66
docker/docker-compose.yml
Normal file
66
docker/docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
services:
|
||||
# Python MCP Server
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: gitea-mcp-remote:latest
|
||||
container_name: gitea-mcp-remote-app
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
# Gitea Configuration (required)
|
||||
- GITEA_URL=${GITEA_URL}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- GITEA_OWNER=${GITEA_OWNER}
|
||||
# Optional Gitea config
|
||||
- GITEA_REPO=${GITEA_REPO:-}
|
||||
|
||||
# HTTP Server Configuration
|
||||
- HTTP_HOST=0.0.0.0
|
||||
- HTTP_PORT=8080
|
||||
|
||||
# Authentication (optional - for MCP endpoint)
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-}
|
||||
- MCP_AUTH_MODE=${MCP_AUTH_MODE:-optional}
|
||||
|
||||
# Tool Filtering (optional)
|
||||
- ENABLED_TOOLS=${ENABLED_TOOLS:-}
|
||||
- DISABLED_TOOLS=${DISABLED_TOOLS:-}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
- gitea-mcp-network
|
||||
|
||||
# Caddy Reverse Proxy
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: gitea-mcp-remote-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- gitea-mcp-network
|
||||
|
||||
networks:
|
||||
gitea-mcp-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
244
docs/sprint-proposals/SPRINT-01-SUMMARY.md
Normal file
244
docs/sprint-proposals/SPRINT-01-SUMMARY.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Sprint 01: Core Architecture Correction - SUMMARY
|
||||
|
||||
**Status:** 🟡 AWAITING APPROVAL
|
||||
**Milestone:** [Sprint 01: Core Architecture Correction](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/milestone/29)
|
||||
**Sprint Duration:** 1 week (Feb 3-10, 2026)
|
||||
**Total Estimated Effort:** 19-28 hours
|
||||
|
||||
---
|
||||
|
||||
## Sprint Overview
|
||||
|
||||
This sprint addresses **three fatal architectural problems** introduced in the v1.0.0 release. This is **surgical correction work**, not a rewrite - supporting modules (config, middleware, filtering, tests) are solid and only need import path updates.
|
||||
|
||||
### The Three Fatal Problems
|
||||
|
||||
1. **Subprocess Architecture → Direct Python Import**
|
||||
- Current: Spawns gitea-mcp-server as subprocess
|
||||
- Required: Direct Python import from marketplace package
|
||||
|
||||
2. **Custom REST API → MCP Streamable HTTP Protocol**
|
||||
- Current: Custom endpoints `/tools/list` and `/tools/call`
|
||||
- Required: MCP protocol `POST /mcp` with JSON-RPC 2.0
|
||||
|
||||
3. **Missing Marketplace Dependency**
|
||||
- Current: Comment about installing separately
|
||||
- Required: Actual pip dependency from marketplace Git repo
|
||||
|
||||
---
|
||||
|
||||
## Issues Created
|
||||
|
||||
All issues are in Gitea milestone: **Sprint 01: Core Architecture Correction**
|
||||
|
||||
| Issue | Title | Type | Size | Est. Time | Dependencies |
|
||||
|-------|-------|------|------|-----------|--------------|
|
||||
| [#19](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/19) | Rename package to gitea_mcp_remote and update configuration | Refactor | M | 2-3h | None |
|
||||
| [#20](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/20) | Update middleware and filtering with new import paths | Refactor | S | 1h | #19 |
|
||||
| [#21](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/21) | Move tests to repository root and update imports | Refactor | M | 1-2h | #19, #20 |
|
||||
| [#22](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/22) | Add marketplace dependency and update project config | Build | S | 1h | #19 |
|
||||
| [#23](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/23) | Remove old server and create MCP base server structure | Feature | M | 2-3h | #19, #20, #22 |
|
||||
| [#24](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/24) | Implement MCP Streamable HTTP protocol endpoints | Feature | M | 2-3h | #23 |
|
||||
| [#25](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/25) | Create Docker multi-service infrastructure with Caddy | Build | M | 3-4h | #22, #24 |
|
||||
| [#26](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/26) | Create startup scripts and MCP server tests | Test | M | 2-3h | #24 |
|
||||
| [#27](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/27) | Create CLAUDE.md and update deployment documentation | Docs | M | 2-3h | All |
|
||||
| [#28](https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/issues/28) | Final validation and integration testing | Test | M | 2-3h | All |
|
||||
|
||||
**Total Issues:** 10 (was 9, split large task into 2 medium tasks)
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
The dependency graph ensures proper execution order:
|
||||
|
||||
```
|
||||
#19 (Rename + Config) ← FOUNDATION
|
||||
├─→ #20 (Middleware + Filtering)
|
||||
│ └─→ #21 (Tests)
|
||||
│
|
||||
├─→ #22 (pyproject.toml)
|
||||
│ ├─→ #23 (MCP Base Server)
|
||||
│ │ ├─→ #24 (MCP Protocol)
|
||||
│ │ │ ├─→ #25 (Docker)
|
||||
│ │ │ └─→ #26 (Scripts + Tests)
|
||||
│ │ │
|
||||
│ └─→ #21 (Tests - can run parallel)
|
||||
│
|
||||
└─→ All above
|
||||
└─→ #27 (Documentation)
|
||||
└─→ #28 (Final Validation)
|
||||
```
|
||||
|
||||
**Recommended sequence:**
|
||||
1. #19 → #20 → #22 → #21 (Foundation - Day 1-2)
|
||||
2. #23 → #24 (Core server - Day 2-3)
|
||||
3. #25 → #26 (Infrastructure - Day 3-4)
|
||||
4. #27 → #28 (Documentation and validation - Day 4-5)
|
||||
|
||||
---
|
||||
|
||||
## What to KEEP (Rename Imports Only)
|
||||
|
||||
These modules are **well-tested and solid**:
|
||||
|
||||
- ✅ `config/settings.py` - Minor field changes only
|
||||
- ✅ `middleware/auth.py` - Import paths only
|
||||
- ✅ `filtering/filter.py` - Change ValueError to warning
|
||||
- ✅ All tests - Move to root, update imports
|
||||
- ✅ `DEPLOYMENT.md` - Update references
|
||||
|
||||
---
|
||||
|
||||
## What to REPLACE
|
||||
|
||||
- ❌ `server.py` → ✅ `server_http.py` (new MCP implementation)
|
||||
- ❌ `pyproject.toml` → ✅ Updated with marketplace dependency
|
||||
- ❌ `docker-compose.yml` → ✅ `docker/docker-compose.yml` (two services)
|
||||
- ❌ `Dockerfile` → ✅ `docker/Dockerfile` (git + port 8080)
|
||||
|
||||
---
|
||||
|
||||
## New Files to CREATE
|
||||
|
||||
- 📄 `docker/Caddyfile` - Reverse proxy config
|
||||
- 📄 `CLAUDE.md` - Project guidance for Claude Code
|
||||
- 📄 `tests/test_server_http.py` - MCP server tests
|
||||
- 📄 `scripts/start.sh` - Production startup
|
||||
- 📄 `scripts/healthcheck.sh` - Docker healthcheck
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria (16 Validations)
|
||||
|
||||
### Package Structure (3)
|
||||
- [ ] `src/gitea_mcp_remote/` exists (not `gitea_http_wrapper`)
|
||||
- [ ] No imports reference `gitea_http_wrapper`
|
||||
- [ ] `tests/` is at repository root (not in `src/`)
|
||||
|
||||
### Configuration (3)
|
||||
- [ ] `config/settings.py` has `mcp_auth_mode` field
|
||||
- [ ] `config/settings.py` has `gitea_repo: str | None`
|
||||
- [ ] HTTP defaults are `0.0.0.0:8080`
|
||||
|
||||
### Server Implementation (4)
|
||||
- [ ] `server_http.py` imports from `mcp_server` package
|
||||
- [ ] MCP endpoints exist: `POST /mcp`, `HEAD /mcp`
|
||||
- [ ] Health endpoints exist: `/health`, `/healthz`, `/ping`
|
||||
- [ ] No subprocess spawning code
|
||||
|
||||
### Dependencies (3)
|
||||
- [ ] `pyproject.toml` has marketplace Git dependency
|
||||
- [ ] Entry point is `gitea-mcp-remote` (not `gitea-http-wrapper`)
|
||||
- [ ] Can run: `pip install -e .` successfully
|
||||
|
||||
### Docker (3)
|
||||
- [ ] `docker/docker-compose.yml` has two services (app + caddy)
|
||||
- [ ] `docker/Dockerfile` installs git and uses port 8080
|
||||
- [ ] `docker/Caddyfile` exists and proxies to app:8080
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
### Effort Distribution
|
||||
- **Small (1-2h):** 2 issues (#20, #22) = 2-4 hours
|
||||
- **Medium (2-4h):** 8 issues (#19, #21, #23-28) = 17-24 hours
|
||||
- **Total:** 19-28 hours ≈ 23.5 hours average
|
||||
|
||||
### Sprint Schedule (1 week)
|
||||
- **Day 1-2:** Foundation (Issues #19-22) - 5-7 hours
|
||||
- **Day 2-3:** Core Server (Issues #23-24) - 4-6 hours
|
||||
- **Day 3-4:** Infrastructure (Issues #25-26) - 5-7 hours
|
||||
- **Day 4-5:** Docs & Validation (Issues #27-28) - 4-6 hours
|
||||
- **Buffer:** 1-2 hours for unexpected issues
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk ✅
|
||||
- Config, middleware, filtering: Well-tested, only import changes
|
||||
- Test relocation: No logic changes
|
||||
|
||||
### Medium Risk ⚠️
|
||||
- `server_http.py`: New file, but following MCP HTTP spec
|
||||
- MCP protocol integration: Well-documented standard
|
||||
|
||||
### High Risk 🔴
|
||||
- Docker multi-service: Requires Caddy configuration
|
||||
- Marketplace Git dependency: Must be accessible during build
|
||||
|
||||
### Mitigation
|
||||
1. Execute in exact dependency order
|
||||
2. Test at each major milestone
|
||||
3. Validate Docker build before deployment
|
||||
4. Keep development branch for rollback
|
||||
|
||||
---
|
||||
|
||||
## Documentation Created
|
||||
|
||||
1. **[sprint-01-core-architecture-correction.md](./sprint-01-core-architecture-correction.md)**
|
||||
- Executive summary
|
||||
- Three fatal problems explained
|
||||
- What to keep vs replace
|
||||
- Architecture diagram
|
||||
- Risk assessment
|
||||
|
||||
2. **[sprint-01-implementation-guide.md](./sprint-01-implementation-guide.md)**
|
||||
- Step-by-step technical implementation
|
||||
- Code snippets for each change
|
||||
- Validation commands
|
||||
- Complete file replacements
|
||||
|
||||
3. **[sprint-01-issue-breakdown.md](./sprint-01-issue-breakdown.md)**
|
||||
- Detailed issue descriptions
|
||||
- Dependency graph
|
||||
- Execution order
|
||||
- Size distribution
|
||||
|
||||
4. **[SPRINT-01-SUMMARY.md](./SPRINT-01-SUMMARY.md)** (this file)
|
||||
- Sprint overview
|
||||
- Issue table with links
|
||||
- Success criteria
|
||||
- Approval checklist
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- **Milestone:** https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote/milestone/29
|
||||
- **Repository:** https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote
|
||||
- **Branch:** development
|
||||
- **Marketplace:** https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace
|
||||
|
||||
---
|
||||
|
||||
## Approval Checklist
|
||||
|
||||
Before execution begins, verify:
|
||||
|
||||
- [ ] All 10 issues created and assigned to milestone
|
||||
- [ ] Dependencies correctly set between issues
|
||||
- [ ] Labels applied correctly (Type, Priority, Component, Size)
|
||||
- [ ] Implementation guide reviewed and accurate
|
||||
- [ ] Timeline is realistic (1 week)
|
||||
- [ ] Success criteria are clear and testable
|
||||
- [ ] Rollback plan understood (development branch)
|
||||
- [ ] User has reviewed and approved the plan
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
**AWAITING USER APPROVAL** to begin execution.
|
||||
|
||||
Once approved:
|
||||
1. Start with Issue #19 (Foundation)
|
||||
2. Follow dependency order strictly
|
||||
3. Update issue status as work progresses
|
||||
4. Run validation after each major milestone
|
||||
5. Complete sprint with Issue #28 (Final Validation)
|
||||
|
||||
**Note:** This is attempt #3. User emphasized paying close attention to details. All requirements from the architectural correction prompt have been captured in the issue breakdown.
|
||||
329
docs/sprint-proposals/sprint-01-core-architecture-correction.md
Normal file
329
docs/sprint-proposals/sprint-01-core-architecture-correction.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Sprint 01: Core Architecture Correction
|
||||
|
||||
**Status:** Planning
|
||||
**Sprint Duration:** 1 week (estimated 20-24 hours of work)
|
||||
**Priority:** CRITICAL - Architectural Foundation
|
||||
**Attempt:** #3 (Pay close attention to details)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This sprint addresses three fatal architectural problems introduced in the v1.0.0 release that prevent the HTTP wrapper from functioning correctly with the MCP protocol. This is **surgical correction work**, not a rewrite. Supporting modules (config, middleware, filtering, tests) are solid and only need import path updates.
|
||||
|
||||
## The Three Fatal Problems
|
||||
|
||||
### 1. Subprocess Architecture → Direct Python Import
|
||||
**Current (Wrong):** `server.py` spawns `gitea-mcp-server` as a subprocess
|
||||
**Required (Correct):** Direct Python import from marketplace package
|
||||
|
||||
```python
|
||||
# WRONG (current)
|
||||
self.process = await asyncio.create_subprocess_exec("gitea-mcp-server", ...)
|
||||
|
||||
# CORRECT (target)
|
||||
from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient, GiteaConfig
|
||||
```
|
||||
|
||||
**Why this is fatal:** Cannot access marketplace code as subprocess, breaks MCP protocol contract.
|
||||
|
||||
### 2. Custom REST API → MCP Streamable HTTP Protocol
|
||||
**Current (Wrong):** Custom endpoints `/tools/list` and `/tools/call`
|
||||
**Required (Correct):** MCP Streamable HTTP protocol
|
||||
|
||||
```python
|
||||
# WRONG (current)
|
||||
POST /tools/list
|
||||
POST /tools/call
|
||||
|
||||
# CORRECT (target)
|
||||
POST /mcp # JSON-RPC 2.0 messages
|
||||
HEAD /mcp # Protocol version header
|
||||
```
|
||||
|
||||
**Why this is fatal:** Not compatible with Claude Desktop's MCP client implementation.
|
||||
|
||||
### 3. Missing Marketplace Dependency
|
||||
**Current (Wrong):** Comment in pyproject.toml about installing separately
|
||||
**Required (Correct):** Actual pip dependency from marketplace Git repository
|
||||
|
||||
```toml
|
||||
# WRONG (current)
|
||||
# gitea-mcp-server - installed separately (not on PyPI yet)
|
||||
|
||||
# CORRECT (target)
|
||||
dependencies = [
|
||||
"gitea-mcp-server @ git+https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git#subdirectory=mcp-servers/gitea",
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**Why this is fatal:** Dependency not installable, breaks Docker builds and deployment.
|
||||
|
||||
## What to KEEP (Rename Imports Only)
|
||||
|
||||
These modules are **solid and well-tested**. Only update import paths from `gitea_http_wrapper` to `gitea_mcp_remote`:
|
||||
|
||||
### config/settings.py
|
||||
- **Keep:** Overall structure, Pydantic settings, validation logic
|
||||
- **Minor changes:**
|
||||
- Make `gitea_repo` optional (allow None)
|
||||
- Add `mcp_auth_mode: str = "optional"` field
|
||||
- Change HTTP defaults: `http_host="0.0.0.0"`, `http_port=8080`
|
||||
- Remove `get_gitea_mcp_env()` method (no longer needed for subprocess)
|
||||
|
||||
### middleware/auth.py
|
||||
- **Keep:** Entire file logic unchanged
|
||||
- **Change:** Import paths only (`gitea_http_wrapper` → `gitea_mcp_remote`)
|
||||
|
||||
### filtering/filter.py
|
||||
- **Keep:** Entire filtering logic
|
||||
- **Changes:**
|
||||
- Line 30: Change `raise ValueError(...)` to `logger.warning(...)` (non-fatal)
|
||||
- Import paths: `gitea_http_wrapper` → `gitea_mcp_remote`
|
||||
|
||||
### Tests (all files)
|
||||
- **Keep:** All test logic and fixtures
|
||||
- **Move:** `src/gitea_http_wrapper/tests/` → `tests/` (top-level)
|
||||
- **Change:** Import paths to reflect new structure
|
||||
|
||||
### DEPLOYMENT.md
|
||||
- **Keep:** Overall deployment guide structure
|
||||
- **Update:** References to new MCP endpoints, Docker structure, marketplace dependency
|
||||
|
||||
## What to REPLACE
|
||||
|
||||
### server.py → server_http.py
|
||||
**Complete replacement** with:
|
||||
- Direct Python imports from marketplace `mcp_server`
|
||||
- MCP Streamable HTTP transport (`POST /mcp`, `HEAD /mcp`)
|
||||
- JSON-RPC 2.0 message handling
|
||||
- GiteaClient instantiation with GiteaConfig
|
||||
- Tool dispatcher integration
|
||||
- Keep health endpoints: `/health`, `/healthz`, `/ping`
|
||||
|
||||
### pyproject.toml
|
||||
**Full replacement** with:
|
||||
- Marketplace Git dependency
|
||||
- Updated package name: `gitea-mcp-remote`
|
||||
- New entry point: `gitea-mcp-remote = "gitea_mcp_remote.server_http:main"`
|
||||
- Updated test paths: `testpaths = ["tests"]`
|
||||
|
||||
### docker-compose.yml → docker/docker-compose.yml
|
||||
**Move and restructure** with:
|
||||
- Two services: `app` (Python server) and `caddy` (reverse proxy)
|
||||
- App listens on port 8080 (internal)
|
||||
- Caddy exposes port 443 (external HTTPS)
|
||||
- Volume for Caddy certs persistence
|
||||
|
||||
### Dockerfile → docker/Dockerfile
|
||||
**Replace** with:
|
||||
- Install `git` package (for Git dependency install)
|
||||
- Expose port 8080 (not 8000)
|
||||
- Use `curl` for healthcheck (not wget)
|
||||
- Install from `requirements.txt` first, then marketplace dependency
|
||||
|
||||
## New Files to CREATE
|
||||
|
||||
### docker/Caddyfile
|
||||
Reverse proxy configuration:
|
||||
- HTTPS termination
|
||||
- Proxy to app:8080
|
||||
- MCP endpoint routing
|
||||
|
||||
### CLAUDE.md
|
||||
Project guidance for Claude Code:
|
||||
- Architecture explanation
|
||||
- Development workflows
|
||||
- Deployment procedures
|
||||
- MCP protocol notes
|
||||
|
||||
### scripts/start.sh
|
||||
Production startup script:
|
||||
- Environment validation
|
||||
- Graceful startup
|
||||
- Logging configuration
|
||||
|
||||
### scripts/healthcheck.sh
|
||||
Docker healthcheck script:
|
||||
- Check `/health` endpoint
|
||||
- Validate MCP endpoint
|
||||
- Exit codes for Docker
|
||||
|
||||
### tests/test_server_http.py
|
||||
New test file for HTTP server:
|
||||
- MCP endpoint tests
|
||||
- JSON-RPC 2.0 validation
|
||||
- Protocol version tests
|
||||
|
||||
## Package Rename
|
||||
|
||||
**From:** `src/gitea_http_wrapper/`
|
||||
**To:** `src/gitea_mcp_remote/`
|
||||
|
||||
All imports throughout codebase must be updated:
|
||||
```python
|
||||
# OLD
|
||||
from gitea_http_wrapper.config import GiteaSettings
|
||||
|
||||
# NEW
|
||||
from gitea_mcp_remote.config import GiteaSettings
|
||||
```
|
||||
|
||||
## Execution Order (18 Steps)
|
||||
|
||||
This is the **exact sequence** that must be followed:
|
||||
|
||||
1. Rename package directory: `gitea_http_wrapper` → `gitea_mcp_remote`
|
||||
2. Update `config/settings.py` (fields + imports)
|
||||
3. Update `middleware/auth.py` (imports only)
|
||||
4. Update `filtering/filter.py` (warning + imports)
|
||||
5. Move tests: `src/gitea_mcp_remote/tests/` → `tests/`
|
||||
6. Update all test imports
|
||||
7. Delete old `server.py`
|
||||
8. Create new `server_http.py` with MCP protocol
|
||||
9. Replace `pyproject.toml` with marketplace dependency
|
||||
10. Update `pytest.ini` test paths
|
||||
11. Create `docker/` directory
|
||||
12. Move and update `docker-compose.yml` → `docker/docker-compose.yml`
|
||||
13. Replace `Dockerfile` → `docker/Dockerfile`
|
||||
14. Create `docker/Caddyfile`
|
||||
15. Create `scripts/start.sh` and `scripts/healthcheck.sh`
|
||||
16. Create `tests/test_server_http.py`
|
||||
17. Create `CLAUDE.md`
|
||||
18. Update `DEPLOYMENT.md` references
|
||||
|
||||
## Validation Checklist (16 Items)
|
||||
|
||||
After implementation, ALL must pass:
|
||||
|
||||
### Package Structure
|
||||
- [ ] `src/gitea_mcp_remote/` exists (not `gitea_http_wrapper`)
|
||||
- [ ] No imports reference `gitea_http_wrapper`
|
||||
- [ ] `tests/` is at repository root (not in `src/`)
|
||||
|
||||
### Configuration
|
||||
- [ ] `config/settings.py` has `mcp_auth_mode` field
|
||||
- [ ] `config/settings.py` has `gitea_repo: str | None`
|
||||
- [ ] HTTP defaults are `0.0.0.0:8080`
|
||||
|
||||
### Server Implementation
|
||||
- [ ] `server_http.py` imports from `mcp_server` package
|
||||
- [ ] MCP endpoints exist: `POST /mcp`, `HEAD /mcp`
|
||||
- [ ] Health endpoints exist: `/health`, `/healthz`, `/ping`
|
||||
- [ ] No subprocess spawning code
|
||||
|
||||
### Dependencies
|
||||
- [ ] `pyproject.toml` has marketplace Git dependency
|
||||
- [ ] Entry point is `gitea-mcp-remote` (not `gitea-http-wrapper`)
|
||||
- [ ] Can run: `pip install -e .` successfully
|
||||
|
||||
### Docker
|
||||
- [ ] `docker/docker-compose.yml` has two services (app + caddy)
|
||||
- [ ] `docker/Dockerfile` installs git and uses port 8080
|
||||
- [ ] `docker/Caddyfile` exists and proxies to app:8080
|
||||
|
||||
### Tests
|
||||
- [ ] All tests pass: `pytest tests/`
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Claude Desktop (MCP Client) │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│ JSON-RPC 2.0 over HTTP
|
||||
│ POST /mcp
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Caddy (HTTPS Termination) │
|
||||
│ - TLS/SSL │
|
||||
│ - Reverse proxy to :8080 │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ server_http.py (MCP HTTP Transport) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Starlette App │ │
|
||||
│ │ - POST /mcp (JSON-RPC handler) │ │
|
||||
│ │ - HEAD /mcp (protocol version) │ │
|
||||
│ │ - /health endpoints │ │
|
||||
│ └────────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Middleware Stack │ │
|
||||
│ │ - BearerAuthMiddleware (auth.py) ✓ Keep │ │
|
||||
│ │ - HealthCheckBypassMiddleware ✓ Keep │ │
|
||||
│ └────────────────────┬────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Tool Dispatcher │ │
|
||||
│ │ - create_tool_dispatcher() from mcp_server │ │
|
||||
│ │ - Tool filtering (filter.py) ✓ Keep │ │
|
||||
│ └────────────────────┬────────────────────────────────────┘ │
|
||||
└──────────────────────┼──────────────────────────────────────┘
|
||||
│ Direct Python calls
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Marketplace: mcp_server (gitea-mcp-server) │
|
||||
│ - GiteaClient │
|
||||
│ - GiteaConfig │
|
||||
│ - get_tool_definitions() │
|
||||
│ - create_tool_dispatcher() │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│ HTTPS API calls
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Gitea Instance (gitea.hotserv.cloud) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk (Supporting Modules)
|
||||
- Config, middleware, filtering: Well-tested, only import changes
|
||||
- Tests: Moving location, no logic changes
|
||||
|
||||
### Medium Risk (New Server Implementation)
|
||||
- `server_http.py`: New file, but following MCP HTTP spec closely
|
||||
- MCP protocol integration: Well-documented standard
|
||||
|
||||
### High Risk (Deployment Changes)
|
||||
- Docker multi-service setup: Requires Caddy configuration
|
||||
- Marketplace Git dependency: Must be accessible during build
|
||||
|
||||
### Mitigation Strategy
|
||||
1. Execute in exact order (dependencies first, server last)
|
||||
2. Test at each major milestone (config → middleware → server)
|
||||
3. Validate Docker build before final deployment
|
||||
4. Keep development branch for rollback if needed
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ All 16 validation items pass
|
||||
2. ✅ Can install via `pip install -e .`
|
||||
3. ✅ Can build Docker image successfully
|
||||
4. ✅ Can start via `docker-compose up`
|
||||
5. ✅ MCP endpoint responds to `POST /mcp` with protocol version
|
||||
6. ✅ Claude Desktop can connect and list tools
|
||||
7. ✅ Can create Gitea issue via MCP protocol
|
||||
8. ✅ All tests pass
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Setup & Config Changes:** 2-3 hours
|
||||
- **Server Rewrite:** 4-6 hours
|
||||
- **Docker Restructure:** 3-4 hours
|
||||
- **Testing & Validation:** 4-5 hours
|
||||
- **Documentation:** 2-3 hours
|
||||
- **Buffer for Issues:** 4-5 hours
|
||||
|
||||
**Total:** 19-26 hours → 1 week sprint
|
||||
|
||||
## References
|
||||
|
||||
- MCP Streamable HTTP Spec: https://spec.modelcontextprotocol.io/specification/basic/transports/
|
||||
- JSON-RPC 2.0 Spec: https://www.jsonrpc.org/specification
|
||||
- Marketplace Repository: https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace
|
||||
- Original Issue: (To be created in this sprint)
|
||||
1454
docs/sprint-proposals/sprint-01-implementation-guide.md
Normal file
1454
docs/sprint-proposals/sprint-01-implementation-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
489
docs/sprint-proposals/sprint-01-issue-breakdown.md
Normal file
489
docs/sprint-proposals/sprint-01-issue-breakdown.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# Sprint 01: Issue Breakdown
|
||||
|
||||
## Issue Structure
|
||||
|
||||
Each issue is sized for 1-4 hours of work and includes:
|
||||
- Clear acceptance criteria
|
||||
- Dependencies on other issues
|
||||
- Reference to implementation guide
|
||||
- Appropriate labels
|
||||
|
||||
---
|
||||
|
||||
## Issue #1: Rename Package Directory and Update Config
|
||||
|
||||
**Title:** `[Sprint 01] refactor: Rename package to gitea_mcp_remote and update configuration`
|
||||
|
||||
**Estimated Time:** 2-3 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Refactor`
|
||||
- `Priority/High`
|
||||
- `Component/Core`
|
||||
- `Size/M`
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Description:**
|
||||
|
||||
Rename the package directory from `gitea_http_wrapper` to `gitea_mcp_remote` and update the configuration module with new fields required for MCP protocol.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Rename `src/gitea_http_wrapper/` to `src/gitea_mcp_remote/`
|
||||
- [ ] Update `config/settings.py`:
|
||||
- Make `gitea_repo` optional (allow None)
|
||||
- Add `mcp_auth_mode: str = "optional"` field
|
||||
- Change HTTP defaults: `http_host="0.0.0.0"`, `http_port=8080`
|
||||
- Remove `get_gitea_mcp_env()` method
|
||||
- [ ] Update `config/__init__.py` imports
|
||||
- [ ] Verify imports work: `from gitea_mcp_remote.config import GiteaSettings`
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Package directory is `src/gitea_mcp_remote/`
|
||||
- Config has `mcp_auth_mode` field
|
||||
- Config has optional `gitea_repo` field
|
||||
- HTTP defaults are `0.0.0.0:8080`
|
||||
- Can import: `from gitea_mcp_remote.config import GiteaSettings`
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 1, Issues #1-2
|
||||
|
||||
---
|
||||
|
||||
## Issue #2: Update Middleware and Filtering Modules
|
||||
|
||||
**Title:** `[Sprint 01] refactor: Update middleware and filtering with new import paths`
|
||||
|
||||
**Estimated Time:** 1 hour
|
||||
|
||||
**Labels:**
|
||||
- `Type/Refactor`
|
||||
- `Priority/High`
|
||||
- `Component/Core`
|
||||
- `Size/S`
|
||||
|
||||
**Dependencies:** Issue #1
|
||||
|
||||
**Description:**
|
||||
|
||||
Update middleware and filtering modules to use new package name. Middleware requires only import changes, filtering changes ValueError to warning.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Update `middleware/__init__.py` imports
|
||||
- [ ] Update `middleware/auth.py` - imports only
|
||||
- [ ] Update `filtering/__init__.py` imports
|
||||
- [ ] Update `filtering/filter.py`:
|
||||
- Add logging import
|
||||
- Change line 29-32 ValueError to logger.warning
|
||||
- [ ] Verify imports work
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Middleware imports from `gitea_mcp_remote.middleware`
|
||||
- Filtering imports from `gitea_mcp_remote.filtering`
|
||||
- ToolFilter logs warning instead of raising ValueError when both filter types specified
|
||||
- Can import both modules successfully
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 2, Issues #3-4
|
||||
|
||||
---
|
||||
|
||||
## Issue #3: Relocate Tests and Update Imports
|
||||
|
||||
**Title:** `[Sprint 01] refactor: Move tests to repository root and update imports`
|
||||
|
||||
**Estimated Time:** 1-2 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Refactor`
|
||||
- `Priority/High`
|
||||
- `Component/Tests`
|
||||
- `Size/M`
|
||||
|
||||
**Dependencies:** Issue #1, Issue #2
|
||||
|
||||
**Description:**
|
||||
|
||||
Move test suite from `src/gitea_mcp_remote/tests/` to repository root `tests/` directory and update all test imports to use new package name.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Move `src/gitea_mcp_remote/tests/` to `tests/`
|
||||
- [ ] Update imports in `tests/conftest.py`
|
||||
- [ ] Update imports in `tests/test_config.py`
|
||||
- [ ] Update imports in `tests/test_middleware.py`
|
||||
- [ ] Update imports in `tests/test_filtering.py`
|
||||
- [ ] Update `pytest.ini` to use `testpaths = tests`
|
||||
- [ ] Run pytest and verify all tests pass
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Tests located at repository root: `tests/`
|
||||
- No tests in `src/gitea_mcp_remote/tests/`
|
||||
- All test imports use `gitea_mcp_remote` package name
|
||||
- All existing tests pass: `pytest tests/ -v`
|
||||
- pytest.ini references `testpaths = tests`
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 3, Issues #5-6
|
||||
|
||||
---
|
||||
|
||||
## Issue #4: Replace pyproject.toml with Marketplace Dependency
|
||||
|
||||
**Title:** `[Sprint 01] build: Add marketplace dependency and update project configuration`
|
||||
|
||||
**Estimated Time:** 1 hour
|
||||
|
||||
**Labels:**
|
||||
- `Type/Build`
|
||||
- `Priority/Critical`
|
||||
- `Component/Dependencies`
|
||||
- `Size/S`
|
||||
|
||||
**Dependencies:** Issue #1
|
||||
|
||||
**Description:**
|
||||
|
||||
Replace pyproject.toml with new configuration including the marketplace Git dependency for gitea-mcp-server.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Update `pyproject.toml`:
|
||||
- Add marketplace Git dependency
|
||||
- Update package name to `gitea-mcp-remote`
|
||||
- Change entry point to `gitea-mcp-remote`
|
||||
- Update version to 1.1.0
|
||||
- Update test paths to `testpaths = ["tests"]`
|
||||
- [ ] Test installation: `pip install -e .`
|
||||
- [ ] Verify marketplace dependency installs
|
||||
- [ ] Verify entry point exists: `which gitea-mcp-remote`
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- pyproject.toml includes marketplace Git dependency
|
||||
- Entry point is `gitea-mcp-remote` (not `gitea-http-wrapper`)
|
||||
- Can run: `pip install -e .` successfully
|
||||
- Marketplace dependency installs from Git repository
|
||||
- Command `gitea-mcp-remote` is available
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 5, Issue #9
|
||||
|
||||
---
|
||||
|
||||
## Issue #5: Implement MCP HTTP Server
|
||||
|
||||
**Title:** `[Sprint 01] feat: Implement MCP Streamable HTTP protocol server`
|
||||
|
||||
**Estimated Time:** 4-6 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Feature`
|
||||
- `Priority/Critical`
|
||||
- `Component/Core`
|
||||
- `Size/L` → **BREAKDOWN REQUIRED**
|
||||
|
||||
**Dependencies:** Issue #1, Issue #2, Issue #4
|
||||
|
||||
**Description:**
|
||||
|
||||
**NOTE:** This is a Large (L) task that should be broken down into Medium (M) subtasks:
|
||||
|
||||
### Subtask 5.1: Remove Old Server and Create MCP Base Server (2-3 hours)
|
||||
- Delete `src/gitea_mcp_remote/server.py`
|
||||
- Create `src/gitea_mcp_remote/server_http.py` with:
|
||||
- Imports from marketplace `mcp_server`
|
||||
- GiteaMCPServer class with GiteaClient initialization
|
||||
- Startup/shutdown handlers
|
||||
- Basic route structure
|
||||
|
||||
### Subtask 5.2: Implement MCP Protocol Endpoints (2-3 hours)
|
||||
- Add HEAD /mcp endpoint (protocol version)
|
||||
- Add POST /mcp endpoint (JSON-RPC 2.0 handler)
|
||||
- Implement MCP methods:
|
||||
- `initialize`
|
||||
- `tools/list`
|
||||
- `tools/call`
|
||||
- Add error handling for JSON-RPC
|
||||
|
||||
**Combined Tasks:**
|
||||
- [ ] Delete old `server.py`
|
||||
- [ ] Create new `server_http.py`
|
||||
- [ ] Import from marketplace: `from mcp_server import ...`
|
||||
- [ ] Implement GiteaMCPServer class
|
||||
- [ ] Implement HEAD /mcp (protocol version)
|
||||
- [ ] Implement POST /mcp (JSON-RPC handler)
|
||||
- [ ] Implement initialize method
|
||||
- [ ] Implement tools/list method with filtering
|
||||
- [ ] Implement tools/call method with dispatcher
|
||||
- [ ] Keep health endpoints: /health, /healthz, /ping
|
||||
- [ ] Add JSON-RPC error handling
|
||||
- [ ] Verify imports: `from gitea_mcp_remote.server_http import GiteaMCPServer`
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Old `server.py` deleted
|
||||
- New `server_http.py` exists
|
||||
- Imports from marketplace `mcp_server` package
|
||||
- MCP endpoints exist: `POST /mcp`, `HEAD /mcp`
|
||||
- Health endpoints exist: `/health`, `/healthz`, `/ping`
|
||||
- No subprocess spawning code
|
||||
- Can import server module successfully
|
||||
- JSON-RPC 2.0 request/response handling works
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 4, Issues #7-8
|
||||
|
||||
**Recommendation:** Create two separate issues (5.1 and 5.2) to keep within M size.
|
||||
|
||||
---
|
||||
|
||||
## Issue #6: Create Docker Infrastructure
|
||||
|
||||
**Title:** `[Sprint 01] build: Create Docker multi-service infrastructure with Caddy`
|
||||
|
||||
**Estimated Time:** 3-4 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Build`
|
||||
- `Priority/High`
|
||||
- `Component/Docker`
|
||||
- `Size/M`
|
||||
|
||||
**Dependencies:** Issue #4, Issue #5
|
||||
|
||||
**Description:**
|
||||
|
||||
Create Docker infrastructure with two-service architecture: Python app and Caddy reverse proxy.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `docker/` directory
|
||||
- [ ] Create `docker/docker-compose.yml` with two services (app + caddy)
|
||||
- [ ] Create `docker/Dockerfile`:
|
||||
- Install git package
|
||||
- Expose port 8080
|
||||
- Use curl for healthcheck
|
||||
- Install marketplace dependency
|
||||
- [ ] Create `docker/Caddyfile`:
|
||||
- HTTPS termination
|
||||
- Proxy to app:8080
|
||||
- MCP endpoint routing
|
||||
- [ ] Validate Dockerfile builds
|
||||
- [ ] Validate docker-compose configuration
|
||||
- [ ] Validate Caddyfile syntax
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- `docker/docker-compose.yml` has two services (app + caddy)
|
||||
- `docker/Dockerfile` installs git and uses port 8080
|
||||
- `docker/Caddyfile` exists and proxies to app:8080
|
||||
- Can build: `docker build -f docker/Dockerfile -t test .`
|
||||
- Can validate: `docker-compose -f docker/docker-compose.yml config`
|
||||
- Caddy config validates successfully
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 6, Issues #11-14
|
||||
|
||||
---
|
||||
|
||||
## Issue #7: Create Utility Scripts and Server Tests
|
||||
|
||||
**Title:** `[Sprint 01] test: Create startup scripts and MCP server tests`
|
||||
|
||||
**Estimated Time:** 2-3 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Test`
|
||||
- `Priority/Medium`
|
||||
- `Component/Tests`
|
||||
- `Size/M`
|
||||
|
||||
**Dependencies:** Issue #5
|
||||
|
||||
**Description:**
|
||||
|
||||
Create production utility scripts and comprehensive tests for the new MCP HTTP server.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `scripts/start.sh` (production startup)
|
||||
- [ ] Create `scripts/healthcheck.sh` (Docker healthcheck)
|
||||
- [ ] Make scripts executable
|
||||
- [ ] Create `tests/test_server_http.py`:
|
||||
- Health endpoint tests
|
||||
- MCP HEAD endpoint test (protocol version)
|
||||
- MCP POST endpoint tests (initialize, tools/list, tools/call)
|
||||
- JSON-RPC error handling tests
|
||||
- Tool filtering integration test
|
||||
- [ ] Run new tests and verify they pass
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- `scripts/start.sh` validates environment and starts server
|
||||
- `scripts/healthcheck.sh` checks health endpoint
|
||||
- Both scripts are executable
|
||||
- `tests/test_server_http.py` exists with comprehensive coverage
|
||||
- All new tests pass: `pytest tests/test_server_http.py -v`
|
||||
- All existing tests still pass: `pytest tests/ -v`
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 7-8, Issues #15-16
|
||||
|
||||
---
|
||||
|
||||
## Issue #8: Create Documentation
|
||||
|
||||
**Title:** `[Sprint 01] docs: Create CLAUDE.md and update deployment documentation`
|
||||
|
||||
**Estimated Time:** 2-3 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Documentation`
|
||||
- `Priority/Medium`
|
||||
- `Component/Documentation`
|
||||
- `Size/M`
|
||||
|
||||
**Dependencies:** All previous issues
|
||||
|
||||
**Description:**
|
||||
|
||||
Create comprehensive project documentation for Claude Code and update deployment guide with new MCP protocol and Docker structure.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Create `CLAUDE.md`:
|
||||
- Project overview
|
||||
- Architecture diagram
|
||||
- Development workflows
|
||||
- MCP protocol notes
|
||||
- Configuration reference
|
||||
- Deployment instructions
|
||||
- Troubleshooting guide
|
||||
- [ ] Update `DEPLOYMENT.md`:
|
||||
- Replace custom REST API refs with MCP protocol
|
||||
- Update Docker structure (docker/ directory, two services)
|
||||
- Update marketplace dependency installation
|
||||
- Update Claude Desktop config example
|
||||
- Add MCP protocol debugging section
|
||||
- [ ] Verify documentation accuracy
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- `CLAUDE.md` exists with complete project guidance
|
||||
- `DEPLOYMENT.md` updated with MCP protocol references
|
||||
- No references to old `/tools/list` or `/tools/call` endpoints
|
||||
- Docker paths reference `docker/docker-compose.yml`
|
||||
- Claude Desktop config shows `/mcp` endpoint
|
||||
- All code examples are accurate
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Phase 9, Issues #17-18
|
||||
|
||||
---
|
||||
|
||||
## Issue #9: Final Validation and Integration Testing
|
||||
|
||||
**Title:** `[Sprint 01] test: Final validation and integration testing`
|
||||
|
||||
**Estimated Time:** 2-3 hours
|
||||
|
||||
**Labels:**
|
||||
- `Type/Test`
|
||||
- `Priority/Critical`
|
||||
- `Component/Integration`
|
||||
- `Size/M`
|
||||
|
||||
**Dependencies:** All previous issues
|
||||
|
||||
**Description:**
|
||||
|
||||
Run complete validation checklist to ensure all architectural corrections are in place and working correctly.
|
||||
|
||||
**Tasks:**
|
||||
- [ ] Verify package structure (no gitea_http_wrapper)
|
||||
- [ ] Verify no old imports remain
|
||||
- [ ] Verify config has all new fields
|
||||
- [ ] Verify server has MCP endpoints
|
||||
- [ ] Run: `pip install -e .` successfully
|
||||
- [ ] Run: `pytest tests/ -v` - all tests pass
|
||||
- [ ] Build Docker image successfully
|
||||
- [ ] Validate docker-compose configuration
|
||||
- [ ] Validate Caddyfile syntax
|
||||
- [ ] Test MCP endpoint responds to protocol version request
|
||||
- [ ] Test MCP endpoint handles JSON-RPC messages
|
||||
- [ ] Document any issues found
|
||||
- [ ] Create follow-up issues if needed
|
||||
|
||||
**Acceptance Criteria:**
|
||||
All 16 validation items pass:
|
||||
|
||||
**Package Structure:**
|
||||
- [ ] `src/gitea_mcp_remote/` exists (not `gitea_http_wrapper`)
|
||||
- [ ] No imports reference `gitea_http_wrapper`
|
||||
- [ ] `tests/` is at repository root (not in `src/`)
|
||||
|
||||
**Configuration:**
|
||||
- [ ] `config/settings.py` has `mcp_auth_mode` field
|
||||
- [ ] `config/settings.py` has `gitea_repo: str | None`
|
||||
- [ ] HTTP defaults are `0.0.0.0:8080`
|
||||
|
||||
**Server Implementation:**
|
||||
- [ ] `server_http.py` imports from `mcp_server` package
|
||||
- [ ] MCP endpoints exist: `POST /mcp`, `HEAD /mcp`
|
||||
- [ ] Health endpoints exist: `/health`, `/healthz`, `/ping`
|
||||
- [ ] No subprocess spawning code
|
||||
|
||||
**Dependencies:**
|
||||
- [ ] `pyproject.toml` has marketplace Git dependency
|
||||
- [ ] Entry point is `gitea-mcp-remote` (not `gitea-http-wrapper`)
|
||||
- [ ] Can run: `pip install -e .` successfully
|
||||
|
||||
**Docker:**
|
||||
- [ ] `docker/docker-compose.yml` has two services (app + caddy)
|
||||
- [ ] `docker/Dockerfile` installs git and uses port 8080
|
||||
- [ ] `docker/Caddyfile` exists and proxies to app:8080
|
||||
|
||||
**Tests:**
|
||||
- [ ] All tests pass: `pytest tests/`
|
||||
|
||||
**Implementation Reference:**
|
||||
See `docs/sprint-proposals/sprint-01-implementation-guide.md` - Final Validation section
|
||||
|
||||
---
|
||||
|
||||
## Issue Dependencies Graph
|
||||
|
||||
```
|
||||
Issue #1 (Rename + Config)
|
||||
├─→ Issue #2 (Middleware + Filtering)
|
||||
│ └─→ Issue #3 (Tests)
|
||||
│
|
||||
├─→ Issue #4 (pyproject.toml)
|
||||
│ ├─→ Issue #5 (MCP Server)
|
||||
│ │ ├─→ Issue #6 (Docker)
|
||||
│ │ └─→ Issue #7 (Scripts + Tests)
|
||||
│ │
|
||||
│ └─→ Issue #3 (Tests)
|
||||
│
|
||||
└─→ All above
|
||||
└─→ Issue #8 (Documentation)
|
||||
└─→ Issue #9 (Final Validation)
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Issue #1 - Rename + Config (Foundation)
|
||||
2. Issue #2 - Middleware + Filtering (Supporting modules)
|
||||
3. Issue #4 - pyproject.toml (Dependencies before server)
|
||||
4. Issue #3 - Tests (Can run in parallel with #4)
|
||||
5. Issue #5 - MCP Server (Core implementation) **Consider splitting into 5.1 and 5.2**
|
||||
6. Issue #6 - Docker (Deployment infrastructure)
|
||||
7. Issue #7 - Scripts + Tests (Validation tools)
|
||||
8. Issue #8 - Documentation (After implementation complete)
|
||||
9. Issue #9 - Final Validation (Sprint completion)
|
||||
|
||||
## Size Distribution
|
||||
|
||||
- **Small (1-2h):** Issues #2, #4 (2 issues)
|
||||
- **Medium (2-4h):** Issues #1, #3, #6, #7, #8, #9 (6 issues)
|
||||
- **Large (4-6h):** Issue #5 (1 issue - SHOULD BE SPLIT)
|
||||
|
||||
**Recommendation:** Split Issue #5 into two Medium issues for better tracking and clearer completion criteria.
|
||||
|
||||
## Total Estimated Time
|
||||
|
||||
- Minimum: 19 hours
|
||||
- Maximum: 28 hours
|
||||
- Average: 23.5 hours
|
||||
- **Sprint Duration:** 1 week (5 working days)
|
||||
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gitea-mcp-remote"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for Gitea API integration"
|
||||
version = "2.0.0"
|
||||
description = "HTTP transport wrapper for Gitea MCP server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Leo Miranda", email = "lmiranda@example.com" }
|
||||
]
|
||||
keywords = ["mcp", "gitea", "api", "server"]
|
||||
keywords = ["mcp", "gitea", "api", "server", "http", "wrapper"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
@@ -24,19 +24,32 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"mcp>=0.1.0",
|
||||
"httpx>=0.24.0",
|
||||
# THE MARKETPLACE PACKAGE — this is the whole point of this repo
|
||||
"gitea-mcp-server @ git+https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git#subdirectory=mcp-servers/gitea",
|
||||
# MCP SDK
|
||||
"mcp>=0.9.0",
|
||||
# HTTP server
|
||||
"uvicorn>=0.30.0",
|
||||
"starlette>=0.38.0",
|
||||
# Config (already used by existing modules)
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
# Auth
|
||||
"pyjwt>=2.8.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"httpx>=0.24.0",
|
||||
"starlette>=0.36.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gitea-mcp = "gitea_mcp.server:main"
|
||||
gitea-mcp-remote = "gitea_mcp_remote.server_http:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/lmiranda/gitea-mcp-remote"
|
||||
|
||||
18
pytest.ini
Normal file
18
pytest.ini
Normal file
@@ -0,0 +1,18 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
|
||||
# Coverage options
|
||||
addopts =
|
||||
--verbose
|
||||
--strict-markers
|
||||
--tb=short
|
||||
|
||||
# Markers for test categorization
|
||||
markers =
|
||||
unit: Unit tests (fast, no external dependencies)
|
||||
integration: Integration tests (may require external services)
|
||||
slow: Slow-running tests
|
||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
# THE MARKETPLACE PACKAGE — this is the whole point of this repo
|
||||
gitea-mcp-server @ git+https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git#subdirectory=mcp-servers/gitea
|
||||
|
||||
# MCP SDK
|
||||
mcp>=0.9.0
|
||||
|
||||
# HTTP server
|
||||
uvicorn>=0.30.0
|
||||
starlette>=0.38.0
|
||||
|
||||
# Config (already used by existing modules)
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Auth
|
||||
pyjwt>=2.8.0
|
||||
21
run_tests.sh
Executable file
21
run_tests.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Test runner script for gitea-mcp-remote
|
||||
|
||||
set -e
|
||||
|
||||
echo "Running gitea-mcp-remote test suite..."
|
||||
echo
|
||||
|
||||
# Install dev dependencies if not already installed
|
||||
if ! command -v pytest &> /dev/null; then
|
||||
echo "Installing dev dependencies..."
|
||||
pip install -e ".[dev]"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Run tests with coverage
|
||||
echo "Running tests with coverage..."
|
||||
python -m pytest tests/ -v --cov=src/gitea_mcp --cov-report=term-missing --cov-report=html
|
||||
|
||||
echo
|
||||
echo "Coverage report saved to htmlcov/index.html"
|
||||
21
scripts/healthcheck.sh
Executable file
21
scripts/healthcheck.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Health check script for Gitea MCP Remote
|
||||
#
|
||||
# Used by Docker healthcheck and monitoring systems.
|
||||
# Returns exit code 0 if healthy, 1 if unhealthy.
|
||||
|
||||
set -e
|
||||
|
||||
HOST="${HTTP_HOST:-localhost}"
|
||||
PORT="${HTTP_PORT:-8080}"
|
||||
ENDPOINT="http://${HOST}:${PORT}/health"
|
||||
|
||||
# Make request and check response
|
||||
response=$(curl -sf "$ENDPOINT" 2>/dev/null) || exit 1
|
||||
|
||||
# Verify JSON response contains status: ok
|
||||
if echo "$response" | grep -q '"status".*"ok"'; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
60
scripts/start.sh
Executable file
60
scripts/start.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# Production startup script for Gitea MCP Remote
|
||||
#
|
||||
# This script validates the environment and starts the server.
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Starting Gitea MCP Remote...${NC}"
|
||||
|
||||
# Check required environment variables
|
||||
check_required_env() {
|
||||
local var_name=$1
|
||||
if [ -z "${!var_name}" ]; then
|
||||
echo -e "${RED}ERROR: Required environment variable $var_name is not set${NC}"
|
||||
return 1
|
||||
fi
|
||||
echo -e "${GREEN} $var_name is set${NC}"
|
||||
}
|
||||
|
||||
echo "Checking required environment variables..."
|
||||
MISSING=0
|
||||
|
||||
check_required_env "GITEA_URL" || MISSING=1
|
||||
check_required_env "GITEA_TOKEN" || MISSING=1
|
||||
check_required_env "GITEA_OWNER" || MISSING=1
|
||||
|
||||
if [ $MISSING -eq 1 ]; then
|
||||
echo -e "${RED}Missing required environment variables. Exiting.${NC}"
|
||||
echo ""
|
||||
echo "Required variables:"
|
||||
echo " GITEA_URL - Gitea server URL (e.g., https://gitea.example.com)"
|
||||
echo " GITEA_TOKEN - Gitea API token"
|
||||
echo " GITEA_OWNER - Default repository owner"
|
||||
echo ""
|
||||
echo "Optional variables:"
|
||||
echo " GITEA_REPO - Default repository name"
|
||||
echo " AUTH_TOKEN - Bearer token for MCP endpoint authentication"
|
||||
echo " HTTP_HOST - Server host (default: 0.0.0.0)"
|
||||
echo " HTTP_PORT - Server port (default: 8080)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show optional configuration
|
||||
echo ""
|
||||
echo "Optional configuration:"
|
||||
[ -n "$GITEA_REPO" ] && echo -e " ${GREEN}GITEA_REPO: $GITEA_REPO${NC}" || echo -e " ${YELLOW}GITEA_REPO: not set (will use per-request)${NC}"
|
||||
[ -n "$AUTH_TOKEN" ] && echo -e " ${GREEN}AUTH_TOKEN: (set)${NC}" || echo -e " ${YELLOW}AUTH_TOKEN: not set (no auth required)${NC}"
|
||||
echo " HTTP_HOST: ${HTTP_HOST:-0.0.0.0}"
|
||||
echo " HTTP_PORT: ${HTTP_PORT:-8080}"
|
||||
|
||||
# Start the server
|
||||
echo ""
|
||||
echo -e "${GREEN}Starting server...${NC}"
|
||||
exec gitea-mcp-remote
|
||||
17
src/gitea_http_wrapper/__init__.py
Normal file
17
src/gitea_http_wrapper/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Gitea HTTP MCP Wrapper
|
||||
|
||||
This package provides an HTTP transport wrapper around the official Gitea MCP server.
|
||||
It handles configuration loading, tool filtering, and HTTP authentication middleware.
|
||||
|
||||
Architecture:
|
||||
- config/: Configuration loader module
|
||||
- middleware/: HTTP authentication middleware
|
||||
- filtering/: Tool filtering for Claude Desktop compatibility
|
||||
- server.py: Main HTTP MCP server implementation
|
||||
"""
|
||||
|
||||
from .server import GiteaMCPWrapper, create_app, main
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["__version__", "GiteaMCPWrapper", "create_app", "main"]
|
||||
5
src/gitea_http_wrapper/config/__init__.py
Normal file
5
src/gitea_http_wrapper/config/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Configuration loader module."""
|
||||
|
||||
from .settings import GiteaSettings, load_settings
|
||||
|
||||
__all__ = ["GiteaSettings", "load_settings"]
|
||||
113
src/gitea_http_wrapper/config/settings.py
Normal file
113
src/gitea_http_wrapper/config/settings.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Configuration settings for Gitea HTTP MCP wrapper."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class GiteaSettings(BaseSettings):
|
||||
"""Configuration settings loaded from environment or .env file."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Gitea Configuration
|
||||
gitea_url: str = Field(
|
||||
...,
|
||||
description="Gitea instance URL (e.g., https://git.example.com)",
|
||||
)
|
||||
gitea_token: str = Field(
|
||||
...,
|
||||
description="Gitea API token for authentication",
|
||||
)
|
||||
gitea_owner: str = Field(
|
||||
...,
|
||||
description="Default repository owner/organization",
|
||||
)
|
||||
gitea_repo: str = Field(
|
||||
...,
|
||||
description="Default repository name",
|
||||
)
|
||||
|
||||
# HTTP Server Configuration
|
||||
http_host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description="HTTP server bind address",
|
||||
)
|
||||
http_port: int = Field(
|
||||
default=8000,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="HTTP server port",
|
||||
)
|
||||
|
||||
# Authentication Configuration
|
||||
auth_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Bearer token for HTTP authentication (optional)",
|
||||
)
|
||||
|
||||
# Tool Filtering Configuration
|
||||
enabled_tools: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of enabled tools (optional, enables all if not set)",
|
||||
)
|
||||
disabled_tools: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of disabled tools (optional)",
|
||||
)
|
||||
|
||||
@field_validator("gitea_url")
|
||||
@classmethod
|
||||
def validate_gitea_url(cls, v: str) -> str:
|
||||
"""Ensure Gitea URL is properly formatted."""
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("gitea_url must start with http:// or https://")
|
||||
return v.rstrip("/")
|
||||
|
||||
@property
|
||||
def enabled_tools_list(self) -> Optional[list[str]]:
|
||||
"""Parse enabled_tools into a list."""
|
||||
if not self.enabled_tools:
|
||||
return None
|
||||
return [tool.strip() for tool in self.enabled_tools.split(",") if tool.strip()]
|
||||
|
||||
@property
|
||||
def disabled_tools_list(self) -> Optional[list[str]]:
|
||||
"""Parse disabled_tools into a list."""
|
||||
if not self.disabled_tools:
|
||||
return None
|
||||
return [tool.strip() for tool in self.disabled_tools.split(",") if tool.strip()]
|
||||
|
||||
def get_gitea_mcp_env(self) -> dict[str, str]:
|
||||
"""Get environment variables for the wrapped Gitea MCP server."""
|
||||
return {
|
||||
"GITEA_BASE_URL": self.gitea_url,
|
||||
"GITEA_API_TOKEN": self.gitea_token,
|
||||
"GITEA_DEFAULT_OWNER": self.gitea_owner,
|
||||
"GITEA_DEFAULT_REPO": self.gitea_repo,
|
||||
}
|
||||
|
||||
|
||||
def load_settings(env_file: Optional[Path] = None) -> GiteaSettings:
|
||||
"""
|
||||
Load settings from environment or .env file.
|
||||
|
||||
Args:
|
||||
env_file: Optional path to .env file. If not provided, searches for .env in current directory.
|
||||
|
||||
Returns:
|
||||
GiteaSettings instance with loaded configuration.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required settings are missing or invalid.
|
||||
"""
|
||||
if env_file:
|
||||
return GiteaSettings(_env_file=env_file)
|
||||
return GiteaSettings()
|
||||
5
src/gitea_http_wrapper/filtering/__init__.py
Normal file
5
src/gitea_http_wrapper/filtering/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Tool filtering module for Claude Desktop compatibility."""
|
||||
|
||||
from .filter import ToolFilter
|
||||
|
||||
__all__ = ["ToolFilter"]
|
||||
108
src/gitea_http_wrapper/filtering/filter.py
Normal file
108
src/gitea_http_wrapper/filtering/filter.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tool filtering for Claude Desktop compatibility."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ToolFilter:
|
||||
"""
|
||||
Filter MCP tools based on enabled/disabled lists.
|
||||
|
||||
This class handles tool filtering to ensure only compatible tools are exposed
|
||||
to Claude Desktop, preventing crashes from unsupported tool schemas.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled_tools: list[str] | None = None,
|
||||
disabled_tools: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize tool filter.
|
||||
|
||||
Args:
|
||||
enabled_tools: List of tool names to enable. If None, all tools are enabled.
|
||||
disabled_tools: List of tool names to disable. Takes precedence over enabled_tools.
|
||||
|
||||
Raises:
|
||||
ValueError: If both enabled_tools and disabled_tools are specified.
|
||||
"""
|
||||
if enabled_tools is not None and disabled_tools is not None:
|
||||
raise ValueError(
|
||||
"Cannot specify both enabled_tools and disabled_tools. Choose one filtering mode."
|
||||
)
|
||||
|
||||
self.enabled_tools = set(enabled_tools) if enabled_tools else None
|
||||
self.disabled_tools = set(disabled_tools) if disabled_tools else None
|
||||
|
||||
def should_include_tool(self, tool_name: str) -> bool:
|
||||
"""
|
||||
Determine if a tool should be included based on filter rules.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to check.
|
||||
|
||||
Returns:
|
||||
True if tool should be included, False otherwise.
|
||||
"""
|
||||
# If disabled list is specified, exclude disabled tools
|
||||
if self.disabled_tools is not None:
|
||||
return tool_name not in self.disabled_tools
|
||||
|
||||
# If enabled list is specified, only include enabled tools
|
||||
if self.enabled_tools is not None:
|
||||
return tool_name in self.enabled_tools
|
||||
|
||||
# If no filters specified, include all tools
|
||||
return True
|
||||
|
||||
def filter_tools_list(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Filter a list of tool definitions.
|
||||
|
||||
Args:
|
||||
tools: List of tool definitions (dicts with at least a 'name' field).
|
||||
|
||||
Returns:
|
||||
Filtered list of tool definitions.
|
||||
"""
|
||||
return [tool for tool in tools if self.should_include_tool(tool.get("name", ""))]
|
||||
|
||||
def filter_tools_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Filter tools from an MCP list_tools response.
|
||||
|
||||
Args:
|
||||
response: MCP response dict containing 'tools' list.
|
||||
|
||||
Returns:
|
||||
Filtered response with tools list updated.
|
||||
"""
|
||||
if "tools" in response and isinstance(response["tools"], list):
|
||||
response = response.copy()
|
||||
response["tools"] = self.filter_tools_list(response["tools"])
|
||||
return response
|
||||
|
||||
def get_filter_stats(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the filter configuration.
|
||||
|
||||
Returns:
|
||||
Dict containing filter mode and tool counts.
|
||||
"""
|
||||
if self.disabled_tools is not None:
|
||||
return {
|
||||
"mode": "blacklist",
|
||||
"disabled_count": len(self.disabled_tools),
|
||||
"disabled_tools": sorted(self.disabled_tools),
|
||||
}
|
||||
elif self.enabled_tools is not None:
|
||||
return {
|
||||
"mode": "whitelist",
|
||||
"enabled_count": len(self.enabled_tools),
|
||||
"enabled_tools": sorted(self.enabled_tools),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mode": "passthrough",
|
||||
"message": "All tools enabled",
|
||||
}
|
||||
5
src/gitea_http_wrapper/middleware/__init__.py
Normal file
5
src/gitea_http_wrapper/middleware/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""HTTP authentication middleware module."""
|
||||
|
||||
from .auth import BearerAuthMiddleware, HealthCheckBypassMiddleware
|
||||
|
||||
__all__ = ["BearerAuthMiddleware", "HealthCheckBypassMiddleware"]
|
||||
144
src/gitea_http_wrapper/middleware/auth.py
Normal file
144
src/gitea_http_wrapper/middleware/auth.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""HTTP authentication middleware for MCP server."""
|
||||
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to enforce Bearer token authentication on HTTP requests.
|
||||
|
||||
This middleware validates the Authorization header for all requests.
|
||||
If a token is configured, requests must include "Authorization: Bearer <token>".
|
||||
If no token is configured, all requests are allowed (open access).
|
||||
"""
|
||||
|
||||
def __init__(self, app, auth_token: str | None = None):
|
||||
"""
|
||||
Initialize authentication middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application to wrap.
|
||||
auth_token: Optional Bearer token for authentication.
|
||||
If None, authentication is disabled.
|
||||
"""
|
||||
super().__init__(app)
|
||||
self.auth_token = auth_token
|
||||
self.auth_enabled = auth_token is not None
|
||||
|
||||
if self.auth_enabled:
|
||||
logger.info("Bearer authentication enabled")
|
||||
else:
|
||||
logger.warning("Bearer authentication disabled - server is open access")
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""
|
||||
Process request and enforce authentication if enabled.
|
||||
|
||||
Args:
|
||||
request: Incoming HTTP request.
|
||||
call_next: Next middleware or route handler.
|
||||
|
||||
Returns:
|
||||
Response from downstream handler or 401/403 error.
|
||||
"""
|
||||
# Skip authentication if disabled
|
||||
if not self.auth_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Skip authentication if marked by HealthCheckBypassMiddleware
|
||||
if getattr(request.state, "skip_auth", False):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract Authorization header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
# Check if header is present
|
||||
if not auth_header:
|
||||
logger.warning(f"Missing Authorization header from {request.client.host}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Unauthorized",
|
||||
"message": "Missing Authorization header",
|
||||
},
|
||||
)
|
||||
|
||||
# Check if header format is correct
|
||||
if not auth_header.startswith("Bearer "):
|
||||
logger.warning(f"Invalid Authorization format from {request.client.host}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Unauthorized",
|
||||
"message": "Authorization header must use Bearer scheme",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract token
|
||||
provided_token = auth_header[7:] # Remove "Bearer " prefix
|
||||
|
||||
# Validate token
|
||||
if provided_token != self.auth_token:
|
||||
logger.warning(f"Invalid token from {request.client.host}")
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "Forbidden",
|
||||
"message": "Invalid authentication token",
|
||||
},
|
||||
)
|
||||
|
||||
# Token is valid, proceed to next handler
|
||||
logger.debug(f"Authenticated request from {request.client.host}")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class HealthCheckBypassMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to bypass authentication for health check endpoints.
|
||||
|
||||
This allows monitoring systems to check server health without authentication.
|
||||
"""
|
||||
|
||||
def __init__(self, app, health_check_paths: list[str] | None = None):
|
||||
"""
|
||||
Initialize health check bypass middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application to wrap.
|
||||
health_check_paths: List of paths to bypass authentication.
|
||||
Defaults to ["/health", "/healthz", "/ping"].
|
||||
"""
|
||||
super().__init__(app)
|
||||
self.health_check_paths = health_check_paths or ["/health", "/healthz", "/ping"]
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""
|
||||
Process request and bypass authentication for health checks.
|
||||
|
||||
Args:
|
||||
request: Incoming HTTP request.
|
||||
call_next: Next middleware or route handler.
|
||||
|
||||
Returns:
|
||||
Response from downstream handler.
|
||||
"""
|
||||
# Check if request is for a health check endpoint
|
||||
if request.url.path in self.health_check_paths:
|
||||
logger.debug(f"Bypassing auth for health check: {request.url.path}")
|
||||
# Mark request to skip authentication in BearerAuthMiddleware
|
||||
request.state.skip_auth = True
|
||||
|
||||
# Continue to next middleware
|
||||
return await call_next(request)
|
||||
309
src/gitea_http_wrapper/server.py
Normal file
309
src/gitea_http_wrapper/server.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""HTTP MCP server implementation wrapping Gitea MCP."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from gitea_http_wrapper.config import GiteaSettings, load_settings
|
||||
from gitea_http_wrapper.filtering import ToolFilter
|
||||
from gitea_http_wrapper.middleware import (
|
||||
BearerAuthMiddleware,
|
||||
HealthCheckBypassMiddleware,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaMCPWrapper:
|
||||
"""
|
||||
HTTP wrapper around the official Gitea MCP server.
|
||||
|
||||
This class manages:
|
||||
1. Starting the Gitea MCP server as a subprocess with stdio transport
|
||||
2. Proxying HTTP requests to the MCP server
|
||||
3. Filtering tools based on configuration
|
||||
4. Handling responses and errors
|
||||
"""
|
||||
|
||||
def __init__(self, settings: GiteaSettings):
|
||||
"""
|
||||
Initialize the MCP wrapper.
|
||||
|
||||
Args:
|
||||
settings: Configuration settings for Gitea and HTTP server.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.tool_filter = ToolFilter(
|
||||
enabled_tools=settings.enabled_tools_list,
|
||||
disabled_tools=settings.disabled_tools_list,
|
||||
)
|
||||
self.process = None
|
||||
self.reader = None
|
||||
self.writer = None
|
||||
|
||||
async def start_gitea_mcp(self) -> None:
|
||||
"""
|
||||
Start the Gitea MCP server as a subprocess.
|
||||
|
||||
The server runs with stdio transport, and we communicate via stdin/stdout.
|
||||
"""
|
||||
logger.info("Starting Gitea MCP server subprocess")
|
||||
|
||||
# Set environment variables for Gitea MCP
|
||||
env = os.environ.copy()
|
||||
env.update(self.settings.get_gitea_mcp_env())
|
||||
|
||||
# Start the process
|
||||
# Note: This assumes gitea-mcp-server is installed and on PATH
|
||||
# In production Docker, this should be guaranteed
|
||||
try:
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
"gitea-mcp-server",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
self.reader = self.process.stdout
|
||||
self.writer = self.process.stdin
|
||||
logger.info("Gitea MCP server started successfully")
|
||||
except FileNotFoundError:
|
||||
logger.error("gitea-mcp-server not found in PATH")
|
||||
raise RuntimeError(
|
||||
"gitea-mcp-server not found. Ensure it's installed: pip install gitea-mcp-server"
|
||||
)
|
||||
|
||||
async def stop_gitea_mcp(self) -> None:
|
||||
"""Stop the Gitea MCP server subprocess."""
|
||||
if self.process:
|
||||
logger.info("Stopping Gitea MCP server subprocess")
|
||||
self.process.terminate()
|
||||
await self.process.wait()
|
||||
logger.info("Gitea MCP server stopped")
|
||||
|
||||
async def send_mcp_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Send a JSON-RPC request to the MCP server.
|
||||
|
||||
Args:
|
||||
method: MCP method name (e.g., "tools/list", "tools/call").
|
||||
params: Method parameters.
|
||||
|
||||
Returns:
|
||||
JSON-RPC response from MCP server.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If MCP server is not running or communication fails.
|
||||
"""
|
||||
if not self.writer or not self.reader:
|
||||
raise RuntimeError("MCP server not started")
|
||||
|
||||
# Build JSON-RPC request
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
# Send request
|
||||
request_json = json.dumps(request) + "\n"
|
||||
self.writer.write(request_json.encode())
|
||||
await self.writer.drain()
|
||||
|
||||
# Read response
|
||||
response_line = await self.reader.readline()
|
||||
response = json.loads(response_line.decode())
|
||||
|
||||
# Check for JSON-RPC error
|
||||
if "error" in response:
|
||||
logger.error(f"MCP error: {response['error']}")
|
||||
raise RuntimeError(f"MCP error: {response['error']}")
|
||||
|
||||
return response.get("result", {})
|
||||
|
||||
async def list_tools(self) -> dict[str, Any]:
|
||||
"""
|
||||
List available tools from MCP server with filtering applied.
|
||||
|
||||
Returns:
|
||||
Filtered tools list response.
|
||||
"""
|
||||
response = await self.send_mcp_request("tools/list", {})
|
||||
filtered_response = self.tool_filter.filter_tools_response(response)
|
||||
|
||||
logger.info(
|
||||
f"Listed {len(filtered_response.get('tools', []))} tools "
|
||||
f"(filter: {self.tool_filter.get_filter_stats()['mode']})"
|
||||
)
|
||||
return filtered_response
|
||||
|
||||
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Call a tool on the MCP server.
|
||||
|
||||
Args:
|
||||
tool_name: Name of tool to call.
|
||||
arguments: Tool arguments.
|
||||
|
||||
Returns:
|
||||
Tool execution result.
|
||||
|
||||
Raises:
|
||||
ValueError: If tool is filtered out.
|
||||
"""
|
||||
# Check if tool is allowed
|
||||
if not self.tool_filter.should_include_tool(tool_name):
|
||||
raise ValueError(f"Tool '{tool_name}' is not available (filtered)")
|
||||
|
||||
logger.info(f"Calling tool: {tool_name}")
|
||||
result = await self.send_mcp_request(
|
||||
"tools/call",
|
||||
{"name": tool_name, "arguments": arguments},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# Global wrapper instance
|
||||
wrapper: GiteaMCPWrapper | None = None
|
||||
|
||||
|
||||
async def health_check(request: Request) -> JSONResponse:
|
||||
"""Health check endpoint."""
|
||||
return JSONResponse({"status": "healthy"})
|
||||
|
||||
|
||||
async def list_tools_endpoint(request: Request) -> JSONResponse:
|
||||
"""List available tools."""
|
||||
try:
|
||||
tools = await wrapper.list_tools()
|
||||
return JSONResponse(tools)
|
||||
except Exception as e:
|
||||
logger.exception("Error listing tools")
|
||||
return JSONResponse(
|
||||
{"error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def call_tool_endpoint(request: Request) -> JSONResponse:
|
||||
"""Call a tool."""
|
||||
try:
|
||||
body = await request.json()
|
||||
tool_name = body.get("name")
|
||||
arguments = body.get("arguments", {})
|
||||
|
||||
if not tool_name:
|
||||
return JSONResponse(
|
||||
{"error": "Missing 'name' field"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
result = await wrapper.call_tool(tool_name, arguments)
|
||||
return JSONResponse(result)
|
||||
except ValueError as e:
|
||||
# Tool filtered
|
||||
return JSONResponse(
|
||||
{"error": str(e)},
|
||||
status_code=403,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error calling tool")
|
||||
return JSONResponse(
|
||||
{"error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def startup() -> None:
|
||||
"""Application startup handler."""
|
||||
global wrapper
|
||||
settings = load_settings()
|
||||
wrapper = GiteaMCPWrapper(settings)
|
||||
await wrapper.start_gitea_mcp()
|
||||
logger.info(f"HTTP MCP server starting on {settings.http_host}:{settings.http_port}")
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
"""Application shutdown handler."""
|
||||
global wrapper
|
||||
if wrapper:
|
||||
await wrapper.stop_gitea_mcp()
|
||||
|
||||
|
||||
# Define routes
|
||||
routes = [
|
||||
Route("/health", health_check, methods=["GET"]),
|
||||
Route("/healthz", health_check, methods=["GET"]),
|
||||
Route("/ping", health_check, methods=["GET"]),
|
||||
Route("/tools/list", list_tools_endpoint, methods=["POST"]),
|
||||
Route("/tools/call", call_tool_endpoint, methods=["POST"]),
|
||||
]
|
||||
|
||||
# Create Starlette app
|
||||
app = Starlette(
|
||||
routes=routes,
|
||||
on_startup=[startup],
|
||||
on_shutdown=[shutdown],
|
||||
)
|
||||
|
||||
|
||||
def create_app(settings: GiteaSettings | None = None) -> Starlette:
|
||||
"""
|
||||
Create and configure the Starlette application.
|
||||
|
||||
Args:
|
||||
settings: Optional settings override for testing.
|
||||
|
||||
Returns:
|
||||
Configured Starlette application.
|
||||
"""
|
||||
if settings is None:
|
||||
settings = load_settings()
|
||||
|
||||
# Add middleware
|
||||
app.add_middleware(HealthCheckBypassMiddleware)
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token=settings.auth_token)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the HTTP MCP server."""
|
||||
settings = load_settings()
|
||||
|
||||
# Log filter configuration
|
||||
filter_stats = ToolFilter(
|
||||
enabled_tools=settings.enabled_tools_list,
|
||||
disabled_tools=settings.disabled_tools_list,
|
||||
).get_filter_stats()
|
||||
logger.info(f"Tool filtering: {filter_stats}")
|
||||
|
||||
# Run server
|
||||
uvicorn.run(
|
||||
"gitea_http_wrapper.server:app",
|
||||
host=settings.http_host,
|
||||
port=settings.http_port,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
src/gitea_http_wrapper/tests/__init__.py
Normal file
9
src/gitea_http_wrapper/tests/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Test suite for HTTP wrapper functionality."""
|
||||
|
||||
# This package contains tests for:
|
||||
# - config: Configuration loader and validation
|
||||
# - filtering: Tool filtering for Claude Desktop compatibility
|
||||
# - middleware: HTTP authentication middleware
|
||||
# - server: Core HTTP MCP server (integration tests would go here)
|
||||
|
||||
__all__ = []
|
||||
59
src/gitea_http_wrapper/tests/conftest.py
Normal file
59
src/gitea_http_wrapper/tests/conftest.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Pytest configuration and shared fixtures for test suite."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gitea_config():
|
||||
"""Provide sample Gitea configuration for tests."""
|
||||
return {
|
||||
"gitea_url": "https://gitea.test.com",
|
||||
"gitea_token": "test_token_123",
|
||||
"gitea_owner": "test_owner",
|
||||
"gitea_repo": "test_repo",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tools_list():
|
||||
"""Provide sample MCP tools list for testing."""
|
||||
return [
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "List issues in repository",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a new issue",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_labels",
|
||||
"description": "List labels in repository",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_mcp_response(sample_tools_list):
|
||||
"""Provide sample MCP list_tools response."""
|
||||
return {
|
||||
"tools": sample_tools_list,
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
},
|
||||
}
|
||||
211
src/gitea_http_wrapper/tests/test_config.py
Normal file
211
src/gitea_http_wrapper/tests/test_config.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for configuration loader module."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from gitea_http_wrapper.config import GiteaSettings, load_settings
|
||||
|
||||
|
||||
class TestGiteaSettings:
|
||||
"""Test GiteaSettings configuration class."""
|
||||
|
||||
def test_required_fields(self):
|
||||
"""Test that required fields are enforced."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GiteaSettings()
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
# Note: gitea_repo is optional (for PMO mode)
|
||||
required_fields = {"gitea_url", "gitea_token", "gitea_owner"}
|
||||
error_fields = {error["loc"][0] for error in errors}
|
||||
assert required_fields.issubset(error_fields)
|
||||
|
||||
def test_valid_configuration(self):
|
||||
"""Test valid configuration creation."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="test_token",
|
||||
gitea_owner="test_owner",
|
||||
gitea_repo="test_repo",
|
||||
)
|
||||
|
||||
assert settings.gitea_url == "https://gitea.example.com"
|
||||
assert settings.gitea_token == "test_token"
|
||||
assert settings.gitea_owner == "test_owner"
|
||||
assert settings.gitea_repo == "test_repo"
|
||||
assert settings.http_host == "127.0.0.1"
|
||||
assert settings.http_port == 8000
|
||||
assert settings.auth_token is None
|
||||
|
||||
def test_gitea_url_validation(self):
|
||||
"""Test Gitea URL validation."""
|
||||
# Valid URLs
|
||||
valid_urls = [
|
||||
"http://gitea.local",
|
||||
"https://gitea.example.com",
|
||||
"http://192.168.1.1:3000",
|
||||
]
|
||||
|
||||
for url in valid_urls:
|
||||
settings = GiteaSettings(
|
||||
gitea_url=url,
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
)
|
||||
assert settings.gitea_url == url.rstrip("/")
|
||||
|
||||
# Invalid URL (no protocol)
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GiteaSettings(
|
||||
gitea_url="gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
)
|
||||
assert "must start with http://" in str(exc_info.value)
|
||||
|
||||
def test_gitea_url_trailing_slash_removed(self):
|
||||
"""Test that trailing slashes are removed from Gitea URL."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com/",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
)
|
||||
assert settings.gitea_url == "https://gitea.example.com"
|
||||
|
||||
def test_http_port_validation(self):
|
||||
"""Test HTTP port validation."""
|
||||
# Valid port
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
http_port=9000,
|
||||
)
|
||||
assert settings.http_port == 9000
|
||||
|
||||
# Invalid port (too high)
|
||||
with pytest.raises(ValidationError):
|
||||
GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
http_port=70000,
|
||||
)
|
||||
|
||||
# Invalid port (too low)
|
||||
with pytest.raises(ValidationError):
|
||||
GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
http_port=0,
|
||||
)
|
||||
|
||||
def test_enabled_tools_list_parsing(self):
|
||||
"""Test enabled_tools string parsing to list."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
enabled_tools="tool1,tool2,tool3",
|
||||
)
|
||||
assert settings.enabled_tools_list == ["tool1", "tool2", "tool3"]
|
||||
|
||||
# Test with spaces
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
enabled_tools="tool1, tool2 , tool3",
|
||||
)
|
||||
assert settings.enabled_tools_list == ["tool1", "tool2", "tool3"]
|
||||
|
||||
# Test empty string
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
enabled_tools="",
|
||||
)
|
||||
assert settings.enabled_tools_list is None
|
||||
|
||||
def test_disabled_tools_list_parsing(self):
|
||||
"""Test disabled_tools string parsing to list."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
disabled_tools="tool1,tool2",
|
||||
)
|
||||
assert settings.disabled_tools_list == ["tool1", "tool2"]
|
||||
|
||||
def test_get_gitea_mcp_env(self):
|
||||
"""Test environment variable generation for wrapped MCP server."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="test_token",
|
||||
gitea_owner="test_owner",
|
||||
gitea_repo="test_repo",
|
||||
)
|
||||
|
||||
env = settings.get_gitea_mcp_env()
|
||||
|
||||
assert env["GITEA_BASE_URL"] == "https://gitea.example.com"
|
||||
assert env["GITEA_API_TOKEN"] == "test_token"
|
||||
assert env["GITEA_DEFAULT_OWNER"] == "test_owner"
|
||||
assert env["GITEA_DEFAULT_REPO"] == "test_repo"
|
||||
|
||||
|
||||
class TestLoadSettings:
|
||||
"""Test load_settings factory function."""
|
||||
|
||||
def test_load_from_env_file(self, tmp_path):
|
||||
"""Test loading settings from a .env file."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"""
|
||||
GITEA_URL=https://gitea.test.com
|
||||
GITEA_TOKEN=test_token_123
|
||||
GITEA_OWNER=test_owner
|
||||
GITEA_REPO=test_repo
|
||||
HTTP_PORT=9000
|
||||
"""
|
||||
)
|
||||
|
||||
settings = load_settings(env_file)
|
||||
|
||||
assert settings.gitea_url == "https://gitea.test.com"
|
||||
assert settings.gitea_token == "test_token_123"
|
||||
assert settings.gitea_owner == "test_owner"
|
||||
assert settings.gitea_repo == "test_repo"
|
||||
assert settings.http_port == 9000
|
||||
|
||||
def test_load_from_environment(self, monkeypatch):
|
||||
"""Test loading settings from environment variables."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://env.gitea.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "env_token")
|
||||
monkeypatch.setenv("GITEA_OWNER", "env_owner")
|
||||
monkeypatch.setenv("GITEA_REPO", "env_repo")
|
||||
monkeypatch.setenv("HTTP_PORT", "8080")
|
||||
|
||||
# Mock _env_file to prevent loading actual .env
|
||||
settings = GiteaSettings()
|
||||
|
||||
assert settings.gitea_url == "https://env.gitea.com"
|
||||
assert settings.gitea_token == "env_token"
|
||||
assert settings.gitea_owner == "env_owner"
|
||||
assert settings.gitea_repo == "env_repo"
|
||||
assert settings.http_port == 8080
|
||||
143
src/gitea_http_wrapper/tests/test_filtering.py
Normal file
143
src/gitea_http_wrapper/tests/test_filtering.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Tests for tool filtering module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from gitea_http_wrapper.filtering import ToolFilter
|
||||
|
||||
|
||||
class TestToolFilter:
|
||||
"""Test ToolFilter class."""
|
||||
|
||||
def test_init_with_both_lists_raises(self):
|
||||
"""Test that specifying both enabled and disabled lists raises error."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ToolFilter(enabled_tools=["tool1"], disabled_tools=["tool2"])
|
||||
|
||||
assert "Cannot specify both" in str(exc_info.value)
|
||||
|
||||
def test_passthrough_mode(self):
|
||||
"""Test passthrough mode (no filtering)."""
|
||||
filter = ToolFilter()
|
||||
|
||||
assert filter.should_include_tool("any_tool")
|
||||
assert filter.should_include_tool("another_tool")
|
||||
|
||||
stats = filter.get_filter_stats()
|
||||
assert stats["mode"] == "passthrough"
|
||||
|
||||
def test_whitelist_mode(self):
|
||||
"""Test whitelist mode (enabled_tools)."""
|
||||
filter = ToolFilter(enabled_tools=["tool1", "tool2"])
|
||||
|
||||
assert filter.should_include_tool("tool1")
|
||||
assert filter.should_include_tool("tool2")
|
||||
assert not filter.should_include_tool("tool3")
|
||||
assert not filter.should_include_tool("tool4")
|
||||
|
||||
stats = filter.get_filter_stats()
|
||||
assert stats["mode"] == "whitelist"
|
||||
assert stats["enabled_count"] == 2
|
||||
assert "tool1" in stats["enabled_tools"]
|
||||
assert "tool2" in stats["enabled_tools"]
|
||||
|
||||
def test_blacklist_mode(self):
|
||||
"""Test blacklist mode (disabled_tools)."""
|
||||
filter = ToolFilter(disabled_tools=["tool1", "tool2"])
|
||||
|
||||
assert not filter.should_include_tool("tool1")
|
||||
assert not filter.should_include_tool("tool2")
|
||||
assert filter.should_include_tool("tool3")
|
||||
assert filter.should_include_tool("tool4")
|
||||
|
||||
stats = filter.get_filter_stats()
|
||||
assert stats["mode"] == "blacklist"
|
||||
assert stats["disabled_count"] == 2
|
||||
assert "tool1" in stats["disabled_tools"]
|
||||
assert "tool2" in stats["disabled_tools"]
|
||||
|
||||
def test_filter_tools_list(self):
|
||||
"""Test filtering a list of tool definitions."""
|
||||
filter = ToolFilter(enabled_tools=["tool1", "tool3"])
|
||||
|
||||
tools = [
|
||||
{"name": "tool1", "description": "First tool"},
|
||||
{"name": "tool2", "description": "Second tool"},
|
||||
{"name": "tool3", "description": "Third tool"},
|
||||
{"name": "tool4", "description": "Fourth tool"},
|
||||
]
|
||||
|
||||
filtered = filter.filter_tools_list(tools)
|
||||
|
||||
assert len(filtered) == 2
|
||||
assert filtered[0]["name"] == "tool1"
|
||||
assert filtered[1]["name"] == "tool3"
|
||||
|
||||
def test_filter_tools_response(self):
|
||||
"""Test filtering an MCP list_tools response."""
|
||||
filter = ToolFilter(disabled_tools=["tool2"])
|
||||
|
||||
response = {
|
||||
"tools": [
|
||||
{"name": "tool1", "description": "First tool"},
|
||||
{"name": "tool2", "description": "Second tool"},
|
||||
{"name": "tool3", "description": "Third tool"},
|
||||
],
|
||||
"other_data": "preserved",
|
||||
}
|
||||
|
||||
filtered = filter.filter_tools_response(response)
|
||||
|
||||
assert len(filtered["tools"]) == 2
|
||||
assert filtered["tools"][0]["name"] == "tool1"
|
||||
assert filtered["tools"][1]["name"] == "tool3"
|
||||
assert filtered["other_data"] == "preserved"
|
||||
|
||||
def test_filter_tools_response_no_tools_key(self):
|
||||
"""Test filtering response without 'tools' key."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
response = {"other_data": "value"}
|
||||
filtered = filter.filter_tools_response(response)
|
||||
|
||||
assert filtered == response
|
||||
|
||||
def test_filter_tools_response_immutable(self):
|
||||
"""Test that original response is not mutated."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
original = {
|
||||
"tools": [
|
||||
{"name": "tool1"},
|
||||
{"name": "tool2"},
|
||||
]
|
||||
}
|
||||
|
||||
filtered = filter.filter_tools_response(original)
|
||||
|
||||
# Original should still have 2 tools
|
||||
assert len(original["tools"]) == 2
|
||||
# Filtered should have 1 tool
|
||||
assert len(filtered["tools"]) == 1
|
||||
|
||||
def test_empty_tool_list(self):
|
||||
"""Test filtering empty tool list."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
result = filter.filter_tools_list([])
|
||||
assert result == []
|
||||
|
||||
def test_tool_with_no_name(self):
|
||||
"""Test handling tool without name field."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
tools = [
|
||||
{"name": "tool1"},
|
||||
{"description": "No name"},
|
||||
{"name": "tool2"},
|
||||
]
|
||||
|
||||
filtered = filter.filter_tools_list(tools)
|
||||
|
||||
# Only tool1 should match, tool without name is excluded
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["name"] == "tool1"
|
||||
162
src/gitea_http_wrapper/tests/test_middleware.py
Normal file
162
src/gitea_http_wrapper/tests/test_middleware.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Tests for HTTP authentication middleware."""
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from gitea_http_wrapper.middleware import (
|
||||
BearerAuthMiddleware,
|
||||
HealthCheckBypassMiddleware,
|
||||
)
|
||||
|
||||
|
||||
# Test application endpoint
|
||||
async def test_endpoint(request):
|
||||
return JSONResponse({"message": "success"})
|
||||
|
||||
|
||||
class TestBearerAuthMiddleware:
|
||||
"""Test BearerAuthMiddleware."""
|
||||
|
||||
def test_no_auth_configured(self):
|
||||
"""Test that requests pass through when no auth token is configured."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token=None)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "success"
|
||||
|
||||
def test_auth_configured_valid_token(self):
|
||||
"""Test successful authentication with valid token."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test", headers={"Authorization": "Bearer secret_token"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "success"
|
||||
|
||||
def test_auth_configured_missing_header(self):
|
||||
"""Test rejection when Authorization header is missing."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing Authorization header" in response.json()["message"]
|
||||
|
||||
def test_auth_configured_invalid_format(self):
|
||||
"""Test rejection when Authorization header has wrong format."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Test with wrong scheme
|
||||
response = client.get("/test", headers={"Authorization": "Basic secret_token"})
|
||||
assert response.status_code == 401
|
||||
assert "Bearer scheme" in response.json()["message"]
|
||||
|
||||
# Test with no scheme
|
||||
response = client.get("/test", headers={"Authorization": "secret_token"})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_auth_configured_invalid_token(self):
|
||||
"""Test rejection when token is invalid."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test", headers={"Authorization": "Bearer wrong_token"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Invalid authentication token" in response.json()["message"]
|
||||
|
||||
def test_auth_case_sensitive_token(self):
|
||||
"""Test that token comparison is case-sensitive."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="Secret_Token")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Correct case
|
||||
response = client.get("/test", headers={"Authorization": "Bearer Secret_Token"})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Wrong case
|
||||
response = client.get("/test", headers={"Authorization": "Bearer secret_token"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestHealthCheckBypassMiddleware:
|
||||
"""Test HealthCheckBypassMiddleware."""
|
||||
|
||||
def test_default_health_check_paths(self):
|
||||
"""Test that default health check paths bypass auth."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/health", test_endpoint),
|
||||
Route("/healthz", test_endpoint),
|
||||
Route("/ping", test_endpoint),
|
||||
Route("/test", test_endpoint),
|
||||
]
|
||||
)
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
app.add_middleware(HealthCheckBypassMiddleware)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Health checks should work without auth
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/healthz").status_code == 200
|
||||
assert client.get("/ping").status_code == 200
|
||||
|
||||
# Regular endpoint should require auth
|
||||
assert client.get("/test").status_code == 401
|
||||
|
||||
def test_custom_health_check_paths(self):
|
||||
"""Test custom health check paths."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/custom-health", test_endpoint),
|
||||
Route("/test", test_endpoint),
|
||||
]
|
||||
)
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
app.add_middleware(
|
||||
HealthCheckBypassMiddleware,
|
||||
health_check_paths=["/custom-health"],
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Custom health check should work without auth
|
||||
assert client.get("/custom-health").status_code == 200
|
||||
|
||||
# Regular endpoint should require auth
|
||||
assert client.get("/test").status_code == 401
|
||||
|
||||
def test_middleware_order(self):
|
||||
"""Test that middleware order is correct."""
|
||||
# HealthCheckBypass should be added BEFORE BearerAuth
|
||||
# so it can bypass the auth check
|
||||
|
||||
app = Starlette(routes=[Route("/health", test_endpoint)])
|
||||
|
||||
# Correct order: HealthCheck bypass first, then Auth
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
app.add_middleware(HealthCheckBypassMiddleware)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/health")
|
||||
|
||||
# Should succeed without auth
|
||||
assert response.status_code == 200
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Gitea MCP Server - MCP server for Gitea API integration."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Authentication and configuration management for Gitea MCP server."""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
class AuthConfig:
|
||||
"""Manages authentication configuration for Gitea API."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication configuration from environment variables."""
|
||||
load_dotenv()
|
||||
|
||||
self.api_url: Optional[str] = os.getenv("GITEA_API_URL")
|
||||
self.api_token: Optional[str] = os.getenv("GITEA_API_TOKEN")
|
||||
|
||||
self._validate()
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Validate that required configuration is present.
|
||||
|
||||
Raises:
|
||||
ValueError: If required environment variables are missing.
|
||||
"""
|
||||
if not self.api_url:
|
||||
raise ValueError(
|
||||
"GITEA_API_URL environment variable is required. "
|
||||
"Please set it in your .env file or environment."
|
||||
)
|
||||
|
||||
if not self.api_token:
|
||||
raise ValueError(
|
||||
"GITEA_API_TOKEN environment variable is required. "
|
||||
"Please set it in your .env file or environment."
|
||||
)
|
||||
|
||||
# Remove trailing slash from URL if present
|
||||
if self.api_url.endswith("/"):
|
||||
self.api_url = self.api_url[:-1]
|
||||
|
||||
def get_auth_headers(self) -> dict[str, str]:
|
||||
"""Get authentication headers for API requests.
|
||||
|
||||
Returns:
|
||||
dict: HTTP headers with authorization token.
|
||||
"""
|
||||
return {
|
||||
"Authorization": f"token {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
"""HTTP client for Gitea API."""
|
||||
|
||||
import httpx
|
||||
from typing import Any, Optional
|
||||
from .auth import AuthConfig
|
||||
|
||||
|
||||
class GiteaClientError(Exception):
|
||||
"""Base exception for Gitea client errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaAuthError(GiteaClientError):
|
||||
"""Authentication error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaNotFoundError(GiteaClientError):
|
||||
"""Resource not found error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaServerError(GiteaClientError):
|
||||
"""Server error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Async HTTP client for Gitea API."""
|
||||
|
||||
def __init__(self, config: AuthConfig, timeout: float = 30.0):
|
||||
"""Initialize Gitea API client.
|
||||
|
||||
Args:
|
||||
config: Authentication configuration.
|
||||
timeout: Request timeout in seconds (default: 30.0).
|
||||
"""
|
||||
self.config = config
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.config.api_url,
|
||||
headers=self.config.get_auth_headers(),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
def _handle_error(self, response: httpx.Response) -> None:
|
||||
"""Handle HTTP error responses.
|
||||
|
||||
Args:
|
||||
response: HTTP response object.
|
||||
|
||||
Raises:
|
||||
GiteaAuthError: For 401/403 errors.
|
||||
GiteaNotFoundError: For 404 errors.
|
||||
GiteaServerError: For 500+ errors.
|
||||
GiteaClientError: For other errors.
|
||||
"""
|
||||
status = response.status_code
|
||||
|
||||
if status == 401:
|
||||
raise GiteaAuthError(
|
||||
"Authentication failed. Please check your GITEA_API_TOKEN."
|
||||
)
|
||||
elif status == 403:
|
||||
raise GiteaAuthError(
|
||||
"Access forbidden. Your API token may not have required permissions."
|
||||
)
|
||||
elif status == 404:
|
||||
raise GiteaNotFoundError(
|
||||
f"Resource not found: {response.request.url}"
|
||||
)
|
||||
elif status >= 500:
|
||||
raise GiteaServerError(
|
||||
f"Gitea server error (HTTP {status}): {response.text}"
|
||||
)
|
||||
else:
|
||||
raise GiteaClientError(
|
||||
f"API request failed (HTTP {status}): {response.text}"
|
||||
)
|
||||
|
||||
async def get(self, path: str, **kwargs) -> dict[str, Any]:
|
||||
"""Make GET request to Gitea API.
|
||||
|
||||
Args:
|
||||
path: API endpoint path (e.g., "/api/v1/repos/owner/repo").
|
||||
**kwargs: Additional arguments for httpx request.
|
||||
|
||||
Returns:
|
||||
dict: JSON response data.
|
||||
|
||||
Raises:
|
||||
GiteaClientError: If request fails.
|
||||
"""
|
||||
if not self._client:
|
||||
raise GiteaClientError(
|
||||
"Client not initialized. Use 'async with' context manager."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.get(path, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError:
|
||||
self._handle_error(response)
|
||||
except httpx.RequestError as e:
|
||||
raise GiteaClientError(
|
||||
f"Request failed: {e}"
|
||||
) from e
|
||||
|
||||
async def post(self, path: str, json: Optional[dict[str, Any]] = None, **kwargs) -> dict[str, Any]:
|
||||
"""Make POST request to Gitea API.
|
||||
|
||||
Args:
|
||||
path: API endpoint path.
|
||||
json: JSON data to send in request body.
|
||||
**kwargs: Additional arguments for httpx request.
|
||||
|
||||
Returns:
|
||||
dict: JSON response data.
|
||||
|
||||
Raises:
|
||||
GiteaClientError: If request fails.
|
||||
"""
|
||||
if not self._client:
|
||||
raise GiteaClientError(
|
||||
"Client not initialized. Use 'async with' context manager."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.post(path, json=json, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError:
|
||||
self._handle_error(response)
|
||||
except httpx.RequestError as e:
|
||||
raise GiteaClientError(
|
||||
f"Request failed: {e}"
|
||||
) from e
|
||||
|
||||
async def patch(self, path: str, json: Optional[dict[str, Any]] = None, **kwargs) -> dict[str, Any]:
|
||||
"""Make PATCH request to Gitea API.
|
||||
|
||||
Args:
|
||||
path: API endpoint path.
|
||||
json: JSON data to send in request body.
|
||||
**kwargs: Additional arguments for httpx request.
|
||||
|
||||
Returns:
|
||||
dict: JSON response data.
|
||||
|
||||
Raises:
|
||||
GiteaClientError: If request fails.
|
||||
"""
|
||||
if not self._client:
|
||||
raise GiteaClientError(
|
||||
"Client not initialized. Use 'async with' context manager."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.patch(path, json=json, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError:
|
||||
self._handle_error(response)
|
||||
except httpx.RequestError as e:
|
||||
raise GiteaClientError(
|
||||
f"Request failed: {e}"
|
||||
) from e
|
||||
|
||||
async def delete(self, path: str, **kwargs) -> Optional[dict[str, Any]]:
|
||||
"""Make DELETE request to Gitea API.
|
||||
|
||||
Args:
|
||||
path: API endpoint path.
|
||||
**kwargs: Additional arguments for httpx request.
|
||||
|
||||
Returns:
|
||||
dict or None: JSON response data if available, None for 204 responses.
|
||||
|
||||
Raises:
|
||||
GiteaClientError: If request fails.
|
||||
"""
|
||||
if not self._client:
|
||||
raise GiteaClientError(
|
||||
"Client not initialized. Use 'async with' context manager."
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.delete(path, **kwargs)
|
||||
response.raise_for_status()
|
||||
|
||||
# DELETE requests may return 204 No Content
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError:
|
||||
self._handle_error(response)
|
||||
except httpx.RequestError as e:
|
||||
raise GiteaClientError(
|
||||
f"Request failed: {e}"
|
||||
) from e
|
||||
@@ -1,152 +0,0 @@
|
||||
"""MCP server implementation for Gitea API integration."""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from . import __version__
|
||||
from .auth import AuthConfig
|
||||
from .client import GiteaClient, GiteaClientError
|
||||
from .tools import get_issue_tools, handle_issue_tool
|
||||
|
||||
|
||||
# Global client instance
|
||||
gitea_client: GiteaClient | None = None
|
||||
|
||||
|
||||
async def serve() -> None:
|
||||
"""Run the MCP server."""
|
||||
server = Server("gitea-mcp")
|
||||
|
||||
# Initialize authentication config
|
||||
try:
|
||||
config = AuthConfig()
|
||||
except ValueError as e:
|
||||
print(f"Configuration error: {e}")
|
||||
raise
|
||||
|
||||
# Initialize Gitea client
|
||||
global gitea_client
|
||||
gitea_client = GiteaClient(config)
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""List available MCP tools.
|
||||
|
||||
Returns:
|
||||
list: Available tools including issue operations.
|
||||
"""
|
||||
# Get issue tools
|
||||
tools = get_issue_tools()
|
||||
|
||||
# Placeholder for future tools (PR tools, etc.)
|
||||
tools.extend([
|
||||
Tool(
|
||||
name="list_repositories",
|
||||
description="List repositories in an organization (coming soon)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {
|
||||
"type": "string",
|
||||
"description": "Organization name",
|
||||
}
|
||||
},
|
||||
"required": ["org"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="create_pull_request",
|
||||
description="Create a new pull request (coming soon)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Pull request title",
|
||||
},
|
||||
"head": {
|
||||
"type": "string",
|
||||
"description": "Source branch",
|
||||
},
|
||||
"base": {
|
||||
"type": "string",
|
||||
"description": "Target branch",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "title", "head", "base"],
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
return tools
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Handle tool calls.
|
||||
|
||||
Args:
|
||||
name: Tool name.
|
||||
arguments: Tool arguments.
|
||||
|
||||
Returns:
|
||||
list: Tool response.
|
||||
"""
|
||||
# Handle issue tools
|
||||
if name.startswith("gitea_") and any(
|
||||
name.endswith(suffix) for suffix in ["_issues", "_issue"]
|
||||
):
|
||||
return await handle_issue_tool(name, arguments, gitea_client)
|
||||
|
||||
# Placeholder for other tools
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Tool '{name}' is not yet implemented.",
|
||||
)
|
||||
]
|
||||
|
||||
# Run the server using stdio transport
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point with CLI argument parsing."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Gitea MCP Server - MCP server for Gitea API integration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"gitea-mcp {__version__}",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Run the server
|
||||
try:
|
||||
asyncio.run(serve())
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped by user")
|
||||
except Exception as e:
|
||||
print(f"Server error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Gitea MCP tools package."""
|
||||
|
||||
from .issues import get_issue_tools, handle_issue_tool
|
||||
|
||||
__all__ = [
|
||||
"get_issue_tools",
|
||||
"handle_issue_tool",
|
||||
]
|
||||
@@ -1,392 +0,0 @@
|
||||
"""Gitea issue operations tools for MCP server."""
|
||||
|
||||
from typing import Any, Optional
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from ..client import GiteaClient, GiteaClientError
|
||||
|
||||
|
||||
def get_issue_tools() -> list[Tool]:
|
||||
"""Get list of issue operation tools.
|
||||
|
||||
Returns:
|
||||
list[Tool]: List of MCP tools for issue operations.
|
||||
"""
|
||||
return [
|
||||
Tool(
|
||||
name="gitea_list_issues",
|
||||
description="List issues in a Gitea repository with optional filters",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner (username or organization)",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Filter by state: open, closed, or all",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
},
|
||||
"labels": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of label names to filter by",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "string",
|
||||
"description": "Milestone name to filter by",
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number for pagination (default: 1)",
|
||||
"default": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of issues per page (default: 30)",
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="gitea_get_issue",
|
||||
description="Get details of a specific issue by number",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner (username or organization)",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Issue number/index",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "index"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="gitea_create_issue",
|
||||
description="Create a new issue in a Gitea repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner (username or organization)",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Issue body/description",
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "Array of label IDs to assign",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "integer",
|
||||
"description": "Milestone ID to assign",
|
||||
},
|
||||
"assignees": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of usernames to assign",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "title"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="gitea_update_issue",
|
||||
description="Update an existing issue in a Gitea repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner (username or organization)",
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Issue number/index",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New issue title",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "New issue body/description",
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Issue state: open or closed",
|
||||
"enum": ["open", "closed"],
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "Array of label IDs to assign (replaces existing)",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "integer",
|
||||
"description": "Milestone ID to assign",
|
||||
},
|
||||
"assignees": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of usernames to assign (replaces existing)",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "index"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def handle_issue_tool(
|
||||
name: str, arguments: dict[str, Any], client: GiteaClient
|
||||
) -> list[TextContent]:
|
||||
"""Handle issue tool calls.
|
||||
|
||||
Args:
|
||||
name: Tool name.
|
||||
arguments: Tool arguments.
|
||||
client: Gitea API client instance.
|
||||
|
||||
Returns:
|
||||
list[TextContent]: Tool response.
|
||||
"""
|
||||
try:
|
||||
if name == "gitea_list_issues":
|
||||
return await _list_issues(arguments, client)
|
||||
elif name == "gitea_get_issue":
|
||||
return await _get_issue(arguments, client)
|
||||
elif name == "gitea_create_issue":
|
||||
return await _create_issue(arguments, client)
|
||||
elif name == "gitea_update_issue":
|
||||
return await _update_issue(arguments, client)
|
||||
else:
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Unknown issue tool: {name}",
|
||||
)
|
||||
]
|
||||
except GiteaClientError as e:
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Error: {str(e)}",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def _list_issues(
|
||||
arguments: dict[str, Any], client: GiteaClient
|
||||
) -> list[TextContent]:
|
||||
"""List issues in a repository.
|
||||
|
||||
Args:
|
||||
arguments: Tool arguments containing owner, repo, and optional filters.
|
||||
client: Gitea API client instance.
|
||||
|
||||
Returns:
|
||||
list[TextContent]: List of issues.
|
||||
"""
|
||||
owner = arguments["owner"]
|
||||
repo = arguments["repo"]
|
||||
state = arguments.get("state", "open")
|
||||
labels = arguments.get("labels")
|
||||
milestone = arguments.get("milestone")
|
||||
page = arguments.get("page", 1)
|
||||
limit = arguments.get("limit", 30)
|
||||
|
||||
params = {
|
||||
"state": state,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
if labels:
|
||||
params["labels"] = labels
|
||||
|
||||
if milestone:
|
||||
params["milestone"] = milestone
|
||||
|
||||
async with client:
|
||||
issues = await client.get(f"/repos/{owner}/{repo}/issues", params=params)
|
||||
|
||||
# Format response
|
||||
if not issues:
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"No {state} issues found in {owner}/{repo}",
|
||||
)
|
||||
]
|
||||
|
||||
result = f"Found {len(issues)} {state} issue(s) in {owner}/{repo}:\n\n"
|
||||
for issue in issues:
|
||||
result += f"#{issue['number']} - {issue['title']}\n"
|
||||
result += f" State: {issue['state']}\n"
|
||||
if issue.get('labels'):
|
||||
labels_str = ", ".join([label['name'] for label in issue['labels']])
|
||||
result += f" Labels: {labels_str}\n"
|
||||
if issue.get('milestone'):
|
||||
result += f" Milestone: {issue['milestone']['title']}\n"
|
||||
result += f" Created: {issue['created_at']}\n"
|
||||
result += f" Updated: {issue['updated_at']}\n\n"
|
||||
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
|
||||
async def _get_issue(
|
||||
arguments: dict[str, Any], client: GiteaClient
|
||||
) -> list[TextContent]:
|
||||
"""Get a specific issue by number.
|
||||
|
||||
Args:
|
||||
arguments: Tool arguments containing owner, repo, and index.
|
||||
client: Gitea API client instance.
|
||||
|
||||
Returns:
|
||||
list[TextContent]: Issue details.
|
||||
"""
|
||||
owner = arguments["owner"]
|
||||
repo = arguments["repo"]
|
||||
index = arguments["index"]
|
||||
|
||||
async with client:
|
||||
issue = await client.get(f"/repos/{owner}/{repo}/issues/{index}")
|
||||
|
||||
# Format response
|
||||
result = f"Issue #{issue['number']}: {issue['title']}\n\n"
|
||||
result += f"State: {issue['state']}\n"
|
||||
result += f"Created: {issue['created_at']}\n"
|
||||
result += f"Updated: {issue['updated_at']}\n"
|
||||
|
||||
if issue.get('labels'):
|
||||
labels_str = ", ".join([label['name'] for label in issue['labels']])
|
||||
result += f"Labels: {labels_str}\n"
|
||||
|
||||
if issue.get('milestone'):
|
||||
result += f"Milestone: {issue['milestone']['title']}\n"
|
||||
|
||||
if issue.get('assignees'):
|
||||
assignees_str = ", ".join([user['login'] for user in issue['assignees']])
|
||||
result += f"Assignees: {assignees_str}\n"
|
||||
|
||||
result += f"\nBody:\n{issue.get('body', '(no description)')}\n"
|
||||
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
|
||||
async def _create_issue(
|
||||
arguments: dict[str, Any], client: GiteaClient
|
||||
) -> list[TextContent]:
|
||||
"""Create a new issue.
|
||||
|
||||
Args:
|
||||
arguments: Tool arguments containing owner, repo, title, and optional fields.
|
||||
client: Gitea API client instance.
|
||||
|
||||
Returns:
|
||||
list[TextContent]: Created issue details.
|
||||
"""
|
||||
owner = arguments["owner"]
|
||||
repo = arguments["repo"]
|
||||
|
||||
data = {
|
||||
"title": arguments["title"],
|
||||
}
|
||||
|
||||
if "body" in arguments:
|
||||
data["body"] = arguments["body"]
|
||||
|
||||
if "labels" in arguments:
|
||||
data["labels"] = arguments["labels"]
|
||||
|
||||
if "milestone" in arguments:
|
||||
data["milestone"] = arguments["milestone"]
|
||||
|
||||
if "assignees" in arguments:
|
||||
data["assignees"] = arguments["assignees"]
|
||||
|
||||
async with client:
|
||||
issue = await client.post(f"/repos/{owner}/{repo}/issues", json=data)
|
||||
|
||||
result = f"Created issue #{issue['number']}: {issue['title']}\n"
|
||||
result += f"URL: {issue.get('html_url', 'N/A')}\n"
|
||||
|
||||
return [TextContent(type="text", text=result)]
|
||||
|
||||
|
||||
async def _update_issue(
|
||||
arguments: dict[str, Any], client: GiteaClient
|
||||
) -> list[TextContent]:
|
||||
"""Update an existing issue.
|
||||
|
||||
Args:
|
||||
arguments: Tool arguments containing owner, repo, index, and fields to update.
|
||||
client: Gitea API client instance.
|
||||
|
||||
Returns:
|
||||
list[TextContent]: Updated issue details.
|
||||
"""
|
||||
owner = arguments["owner"]
|
||||
repo = arguments["repo"]
|
||||
index = arguments["index"]
|
||||
|
||||
data = {}
|
||||
|
||||
if "title" in arguments:
|
||||
data["title"] = arguments["title"]
|
||||
|
||||
if "body" in arguments:
|
||||
data["body"] = arguments["body"]
|
||||
|
||||
if "state" in arguments:
|
||||
data["state"] = arguments["state"]
|
||||
|
||||
if "labels" in arguments:
|
||||
data["labels"] = arguments["labels"]
|
||||
|
||||
if "milestone" in arguments:
|
||||
data["milestone"] = arguments["milestone"]
|
||||
|
||||
if "assignees" in arguments:
|
||||
data["assignees"] = arguments["assignees"]
|
||||
|
||||
async with client:
|
||||
issue = await client.patch(f"/repos/{owner}/{repo}/issues/{index}", json=data)
|
||||
|
||||
result = f"Updated issue #{issue['number']}: {issue['title']}\n"
|
||||
result += f"State: {issue['state']}\n"
|
||||
|
||||
return [TextContent(type="text", text=result)]
|
||||
11
src/gitea_mcp_remote/__init__.py
Normal file
11
src/gitea_mcp_remote/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Gitea MCP Remote — HTTP deployment wrapper for marketplace Gitea MCP server.
|
||||
|
||||
Imports tool definitions from gitea-mcp-server (marketplace) and serves them
|
||||
over Streamable HTTP transport with authentication and TLS via Caddy.
|
||||
"""
|
||||
|
||||
from .server_http import create_app, main
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__all__ = ["__version__", "create_app", "main"]
|
||||
5
src/gitea_mcp_remote/config/__init__.py
Normal file
5
src/gitea_mcp_remote/config/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Configuration module for Gitea MCP HTTP transport."""
|
||||
|
||||
from gitea_mcp_remote.config.settings import GiteaSettings, load_settings
|
||||
|
||||
__all__ = ["GiteaSettings", "load_settings"]
|
||||
108
src/gitea_mcp_remote/config/settings.py
Normal file
108
src/gitea_mcp_remote/config/settings.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Configuration settings for Gitea MCP HTTP transport."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class GiteaSettings(BaseSettings):
|
||||
"""Configuration settings loaded from environment or .env file."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Gitea Configuration
|
||||
gitea_url: str = Field(
|
||||
...,
|
||||
description="Gitea instance URL (e.g., https://git.example.com)",
|
||||
)
|
||||
gitea_token: str = Field(
|
||||
...,
|
||||
description="Gitea API token for authentication",
|
||||
)
|
||||
gitea_owner: str = Field(
|
||||
...,
|
||||
description="Default repository owner/organization",
|
||||
)
|
||||
gitea_repo: str | None = Field(
|
||||
default=None,
|
||||
description="Default repository name (optional)",
|
||||
)
|
||||
|
||||
# HTTP Server Configuration
|
||||
http_host: str = Field(
|
||||
default="0.0.0.0",
|
||||
description="HTTP server bind address",
|
||||
)
|
||||
http_port: int = Field(
|
||||
default=8080,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="HTTP server port",
|
||||
)
|
||||
|
||||
# Authentication Configuration
|
||||
auth_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Bearer token for HTTP authentication (optional)",
|
||||
)
|
||||
mcp_auth_mode: str = Field(
|
||||
default="optional",
|
||||
description="MCP authentication mode: 'required', 'optional', or 'none'",
|
||||
)
|
||||
|
||||
# Tool Filtering Configuration
|
||||
enabled_tools: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of enabled tools (optional, enables all if not set)",
|
||||
)
|
||||
disabled_tools: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of disabled tools (optional)",
|
||||
)
|
||||
|
||||
@field_validator("gitea_url")
|
||||
@classmethod
|
||||
def validate_gitea_url(cls, v: str) -> str:
|
||||
"""Ensure Gitea URL is properly formatted."""
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("gitea_url must start with http:// or https://")
|
||||
return v.rstrip("/")
|
||||
|
||||
@property
|
||||
def enabled_tools_list(self) -> Optional[list[str]]:
|
||||
"""Parse enabled_tools into a list."""
|
||||
if not self.enabled_tools:
|
||||
return None
|
||||
return [tool.strip() for tool in self.enabled_tools.split(",") if tool.strip()]
|
||||
|
||||
@property
|
||||
def disabled_tools_list(self) -> Optional[list[str]]:
|
||||
"""Parse disabled_tools into a list."""
|
||||
if not self.disabled_tools:
|
||||
return None
|
||||
return [tool.strip() for tool in self.disabled_tools.split(",") if tool.strip()]
|
||||
|
||||
|
||||
def load_settings(env_file: Optional[Path] = None) -> GiteaSettings:
|
||||
"""
|
||||
Load settings from environment or .env file.
|
||||
|
||||
Args:
|
||||
env_file: Optional path to .env file. If not provided, searches for .env in current directory.
|
||||
|
||||
Returns:
|
||||
GiteaSettings instance with loaded configuration.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required settings are missing or invalid.
|
||||
"""
|
||||
if env_file:
|
||||
return GiteaSettings(_env_file=env_file)
|
||||
return GiteaSettings()
|
||||
5
src/gitea_mcp_remote/filtering/__init__.py
Normal file
5
src/gitea_mcp_remote/filtering/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Tool filtering module for Claude Desktop compatibility."""
|
||||
|
||||
from .filter import ToolFilter
|
||||
|
||||
__all__ = ["ToolFilter"]
|
||||
109
src/gitea_mcp_remote/filtering/filter.py
Normal file
109
src/gitea_mcp_remote/filtering/filter.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tool filtering for Claude Desktop compatibility."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolFilter:
|
||||
"""
|
||||
Filter MCP tools based on enabled/disabled lists.
|
||||
|
||||
This class handles tool filtering to ensure only compatible tools are exposed
|
||||
to Claude Desktop, preventing crashes from unsupported tool schemas.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled_tools: list[str] | None = None,
|
||||
disabled_tools: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize tool filter.
|
||||
|
||||
Args:
|
||||
enabled_tools: List of tool names to enable. If None, all tools are enabled.
|
||||
disabled_tools: List of tool names to disable. Takes precedence over enabled_tools.
|
||||
"""
|
||||
if enabled_tools is not None and disabled_tools is not None:
|
||||
logger.warning(
|
||||
"Both enabled_tools and disabled_tools specified. "
|
||||
"Disabled list takes precedence over enabled list."
|
||||
)
|
||||
|
||||
self.enabled_tools = set(enabled_tools) if enabled_tools else None
|
||||
self.disabled_tools = set(disabled_tools) if disabled_tools else None
|
||||
|
||||
def should_include_tool(self, tool_name: str) -> bool:
|
||||
"""
|
||||
Determine if a tool should be included based on filter rules.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to check.
|
||||
|
||||
Returns:
|
||||
True if tool should be included, False otherwise.
|
||||
"""
|
||||
# If disabled list is specified, exclude disabled tools
|
||||
if self.disabled_tools is not None:
|
||||
return tool_name not in self.disabled_tools
|
||||
|
||||
# If enabled list is specified, only include enabled tools
|
||||
if self.enabled_tools is not None:
|
||||
return tool_name in self.enabled_tools
|
||||
|
||||
# If no filters specified, include all tools
|
||||
return True
|
||||
|
||||
def filter_tools_list(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Filter a list of tool definitions.
|
||||
|
||||
Args:
|
||||
tools: List of tool definitions (dicts with at least a 'name' field).
|
||||
|
||||
Returns:
|
||||
Filtered list of tool definitions.
|
||||
"""
|
||||
return [tool for tool in tools if self.should_include_tool(tool.get("name", ""))]
|
||||
|
||||
def filter_tools_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Filter tools from an MCP list_tools response.
|
||||
|
||||
Args:
|
||||
response: MCP response dict containing 'tools' list.
|
||||
|
||||
Returns:
|
||||
Filtered response with tools list updated.
|
||||
"""
|
||||
if "tools" in response and isinstance(response["tools"], list):
|
||||
response = response.copy()
|
||||
response["tools"] = self.filter_tools_list(response["tools"])
|
||||
return response
|
||||
|
||||
def get_filter_stats(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the filter configuration.
|
||||
|
||||
Returns:
|
||||
Dict containing filter mode and tool counts.
|
||||
"""
|
||||
if self.disabled_tools is not None:
|
||||
return {
|
||||
"mode": "blacklist",
|
||||
"disabled_count": len(self.disabled_tools),
|
||||
"disabled_tools": sorted(self.disabled_tools),
|
||||
}
|
||||
elif self.enabled_tools is not None:
|
||||
return {
|
||||
"mode": "whitelist",
|
||||
"enabled_count": len(self.enabled_tools),
|
||||
"enabled_tools": sorted(self.enabled_tools),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"mode": "passthrough",
|
||||
"message": "All tools enabled",
|
||||
}
|
||||
5
src/gitea_mcp_remote/middleware/__init__.py
Normal file
5
src/gitea_mcp_remote/middleware/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""HTTP authentication middleware module."""
|
||||
|
||||
from .auth import BearerAuthMiddleware, HealthCheckBypassMiddleware
|
||||
|
||||
__all__ = ["BearerAuthMiddleware", "HealthCheckBypassMiddleware"]
|
||||
144
src/gitea_mcp_remote/middleware/auth.py
Normal file
144
src/gitea_mcp_remote/middleware/auth.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""HTTP authentication middleware for MCP server."""
|
||||
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to enforce Bearer token authentication on HTTP requests.
|
||||
|
||||
This middleware validates the Authorization header for all requests.
|
||||
If a token is configured, requests must include "Authorization: Bearer <token>".
|
||||
If no token is configured, all requests are allowed (open access).
|
||||
"""
|
||||
|
||||
def __init__(self, app, auth_token: str | None = None):
|
||||
"""
|
||||
Initialize authentication middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application to wrap.
|
||||
auth_token: Optional Bearer token for authentication.
|
||||
If None, authentication is disabled.
|
||||
"""
|
||||
super().__init__(app)
|
||||
self.auth_token = auth_token
|
||||
self.auth_enabled = auth_token is not None
|
||||
|
||||
if self.auth_enabled:
|
||||
logger.info("Bearer authentication enabled")
|
||||
else:
|
||||
logger.warning("Bearer authentication disabled - server is open access")
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""
|
||||
Process request and enforce authentication if enabled.
|
||||
|
||||
Args:
|
||||
request: Incoming HTTP request.
|
||||
call_next: Next middleware or route handler.
|
||||
|
||||
Returns:
|
||||
Response from downstream handler or 401/403 error.
|
||||
"""
|
||||
# Skip authentication if disabled
|
||||
if not self.auth_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Skip authentication if marked by HealthCheckBypassMiddleware
|
||||
if getattr(request.state, "skip_auth", False):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract Authorization header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
# Check if header is present
|
||||
if not auth_header:
|
||||
logger.warning(f"Missing Authorization header from {request.client.host}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Unauthorized",
|
||||
"message": "Missing Authorization header",
|
||||
},
|
||||
)
|
||||
|
||||
# Check if header format is correct
|
||||
if not auth_header.startswith("Bearer "):
|
||||
logger.warning(f"Invalid Authorization format from {request.client.host}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "Unauthorized",
|
||||
"message": "Authorization header must use Bearer scheme",
|
||||
},
|
||||
)
|
||||
|
||||
# Extract token
|
||||
provided_token = auth_header[7:] # Remove "Bearer " prefix
|
||||
|
||||
# Validate token
|
||||
if provided_token != self.auth_token:
|
||||
logger.warning(f"Invalid token from {request.client.host}")
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "Forbidden",
|
||||
"message": "Invalid authentication token",
|
||||
},
|
||||
)
|
||||
|
||||
# Token is valid, proceed to next handler
|
||||
logger.debug(f"Authenticated request from {request.client.host}")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class HealthCheckBypassMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to bypass authentication for health check endpoints.
|
||||
|
||||
This allows monitoring systems to check server health without authentication.
|
||||
"""
|
||||
|
||||
def __init__(self, app, health_check_paths: list[str] | None = None):
|
||||
"""
|
||||
Initialize health check bypass middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application to wrap.
|
||||
health_check_paths: List of paths to bypass authentication.
|
||||
Defaults to ["/health", "/healthz", "/ping"].
|
||||
"""
|
||||
super().__init__(app)
|
||||
self.health_check_paths = health_check_paths or ["/health", "/healthz", "/ping"]
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
"""
|
||||
Process request and bypass authentication for health checks.
|
||||
|
||||
Args:
|
||||
request: Incoming HTTP request.
|
||||
call_next: Next middleware or route handler.
|
||||
|
||||
Returns:
|
||||
Response from downstream handler.
|
||||
"""
|
||||
# Check if request is for a health check endpoint
|
||||
if request.url.path in self.health_check_paths:
|
||||
logger.debug(f"Bypassing auth for health check: {request.url.path}")
|
||||
# Mark request to skip authentication in BearerAuthMiddleware
|
||||
request.state.skip_auth = True
|
||||
|
||||
# Continue to next middleware
|
||||
return await call_next(request)
|
||||
157
src/gitea_mcp_remote/server_http.py
Normal file
157
src/gitea_mcp_remote/server_http.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Gitea MCP Remote — HTTP server with MCP Streamable HTTP protocol.
|
||||
|
||||
This module imports tool definitions from the marketplace gitea-mcp-server
|
||||
package and serves them over HTTP with authentication.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route, Mount
|
||||
from mcp.server import Server
|
||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from gitea_mcp_remote.config import load_settings
|
||||
from gitea_mcp_remote.middleware import BearerAuthMiddleware, HealthCheckBypassMiddleware
|
||||
from gitea_mcp_remote.filtering import ToolFilter
|
||||
|
||||
# Import marketplace package
|
||||
from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
"""Health check endpoint - bypasses authentication."""
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
def create_mcp_server(tool_names: list[str] | None = None) -> Server:
|
||||
"""Create and configure the MCP server with tools from the marketplace."""
|
||||
mcp_server = Server("gitea-mcp-remote")
|
||||
|
||||
# Get tool definitions from marketplace
|
||||
tools = get_tool_definitions(tool_filter=tool_names)
|
||||
|
||||
# Create Gitea client and dispatcher
|
||||
gitea_client = GiteaClient()
|
||||
dispatcher = create_tool_dispatcher(gitea_client, tool_filter=tool_names)
|
||||
|
||||
@mcp_server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return available Gitea tools."""
|
||||
return tools
|
||||
|
||||
@mcp_server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Execute a tool with the given arguments."""
|
||||
return await dispatcher(name, arguments)
|
||||
|
||||
return mcp_server
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create the Starlette application with middleware."""
|
||||
settings = load_settings()
|
||||
|
||||
# Set up tool filtering
|
||||
tool_filter = ToolFilter(
|
||||
enabled_tools=settings.enabled_tools_list,
|
||||
disabled_tools=settings.disabled_tools_list,
|
||||
)
|
||||
|
||||
# Convert to list for marketplace API
|
||||
tool_names = None # means "all"
|
||||
if tool_filter.enabled_tools:
|
||||
tool_names = list(tool_filter.enabled_tools)
|
||||
|
||||
# Create MCP server with filtered tools
|
||||
mcp_server = create_mcp_server(tool_names)
|
||||
|
||||
# Store server for endpoint access
|
||||
app_state = {"mcp_server": mcp_server}
|
||||
|
||||
class MCPEndpoint:
|
||||
"""ASGI app wrapper for MCP protocol endpoint."""
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
"""Handle MCP requests - both POST and HEAD."""
|
||||
method = scope.get("method", "")
|
||||
|
||||
if method == "HEAD":
|
||||
# Return protocol version header
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [
|
||||
[b"x-mcp-protocol-version", b"2024-11-05"],
|
||||
],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b"",
|
||||
})
|
||||
return
|
||||
|
||||
# For POST requests, use the StreamableHTTPServerTransport
|
||||
# Create transport for this request
|
||||
transport = StreamableHTTPServerTransport(mcp_session_id=None)
|
||||
|
||||
# Run the MCP server with this transport
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
# Start server task
|
||||
server_task = asyncio.create_task(
|
||||
app_state["mcp_server"].run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
app_state["mcp_server"].create_initialization_options()
|
||||
)
|
||||
)
|
||||
|
||||
# Handle the HTTP request
|
||||
try:
|
||||
await transport.handle_request(scope, receive, send)
|
||||
finally:
|
||||
# Cancel server task if still running
|
||||
if not server_task.done():
|
||||
server_task.cancel()
|
||||
try:
|
||||
await server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Create MCP endpoint instance
|
||||
mcp_endpoint = MCPEndpoint()
|
||||
|
||||
routes = [
|
||||
Route("/health", health_check, methods=["GET"]),
|
||||
Route("/mcp", mcp_endpoint, methods=["GET", "POST", "HEAD"]),
|
||||
]
|
||||
|
||||
app = Starlette(routes=routes)
|
||||
|
||||
# Apply middleware (order matters - outermost runs first)
|
||||
# HealthCheckBypass must wrap BearerAuth so it sets skip_auth flag first
|
||||
if settings.auth_token:
|
||||
app = BearerAuthMiddleware(app, auth_token=settings.auth_token)
|
||||
app = HealthCheckBypassMiddleware(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the gitea-mcp-remote command."""
|
||||
settings = load_settings()
|
||||
app = create_app()
|
||||
|
||||
logger.info(f"Starting Gitea MCP Remote on {settings.http_host}:{settings.http_port}")
|
||||
uvicorn.run(app, host=settings.http_host, port=settings.http_port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +1,9 @@
|
||||
"""Tests for Gitea MCP server."""
|
||||
"""Test suite for HTTP wrapper functionality."""
|
||||
|
||||
# This package contains tests for:
|
||||
# - config: Configuration loader and validation
|
||||
# - filtering: Tool filtering for Claude Desktop compatibility
|
||||
# - middleware: HTTP authentication middleware
|
||||
# - server: Core HTTP MCP server (integration tests would go here)
|
||||
|
||||
__all__ = []
|
||||
|
||||
59
tests/conftest.py
Normal file
59
tests/conftest.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Pytest configuration and shared fixtures for test suite."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gitea_config():
|
||||
"""Provide sample Gitea configuration for tests."""
|
||||
return {
|
||||
"gitea_url": "https://gitea.test.com",
|
||||
"gitea_token": "test_token_123",
|
||||
"gitea_owner": "test_owner",
|
||||
"gitea_repo": "test_repo",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tools_list():
|
||||
"""Provide sample MCP tools list for testing."""
|
||||
return [
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "List issues in repository",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a new issue",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_labels",
|
||||
"description": "List labels in repository",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_mcp_response(sample_tools_list):
|
||||
"""Provide sample MCP list_tools response."""
|
||||
return {
|
||||
"tools": sample_tools_list,
|
||||
"meta": {
|
||||
"version": "1.0",
|
||||
},
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Tests for authentication module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from gitea_mcp.auth import AuthConfig
|
||||
|
||||
|
||||
def test_auth_config_success(monkeypatch):
|
||||
"""Test successful authentication configuration."""
|
||||
monkeypatch.setenv("GITEA_API_URL", "http://gitea.example.com/api/v1")
|
||||
monkeypatch.setenv("GITEA_API_TOKEN", "test_token_123")
|
||||
|
||||
config = AuthConfig()
|
||||
|
||||
assert config.api_url == "http://gitea.example.com/api/v1"
|
||||
assert config.api_token == "test_token_123"
|
||||
|
||||
|
||||
def test_auth_config_removes_trailing_slash(monkeypatch):
|
||||
"""Test that trailing slash is removed from URL."""
|
||||
monkeypatch.setenv("GITEA_API_URL", "http://gitea.example.com/api/v1/")
|
||||
monkeypatch.setenv("GITEA_API_TOKEN", "test_token_123")
|
||||
|
||||
config = AuthConfig()
|
||||
|
||||
assert config.api_url == "http://gitea.example.com/api/v1"
|
||||
|
||||
|
||||
def test_auth_config_missing_url(monkeypatch):
|
||||
"""Test error when GITEA_API_URL is missing."""
|
||||
monkeypatch.delenv("GITEA_API_URL", raising=False)
|
||||
monkeypatch.setenv("GITEA_API_TOKEN", "test_token_123")
|
||||
|
||||
with pytest.raises(ValueError, match="GITEA_API_URL"):
|
||||
AuthConfig()
|
||||
|
||||
|
||||
def test_auth_config_missing_token(monkeypatch):
|
||||
"""Test error when GITEA_API_TOKEN is missing."""
|
||||
monkeypatch.setenv("GITEA_API_URL", "http://gitea.example.com/api/v1")
|
||||
monkeypatch.delenv("GITEA_API_TOKEN", raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match="GITEA_API_TOKEN"):
|
||||
AuthConfig()
|
||||
|
||||
|
||||
def test_get_auth_headers(monkeypatch):
|
||||
"""Test authentication headers generation."""
|
||||
monkeypatch.setenv("GITEA_API_URL", "http://gitea.example.com/api/v1")
|
||||
monkeypatch.setenv("GITEA_API_TOKEN", "test_token_123")
|
||||
|
||||
config = AuthConfig()
|
||||
headers = config.get_auth_headers()
|
||||
|
||||
assert headers["Authorization"] == "token test_token_123"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Tests for Gitea API client."""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from gitea_mcp.auth import AuthConfig
|
||||
from gitea_mcp.client import (
|
||||
GiteaClient,
|
||||
GiteaClientError,
|
||||
GiteaAuthError,
|
||||
GiteaNotFoundError,
|
||||
GiteaServerError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(monkeypatch):
|
||||
"""Create mock authentication config."""
|
||||
monkeypatch.setenv("GITEA_API_URL", "http://gitea.example.com/api/v1")
|
||||
monkeypatch.setenv("GITEA_API_TOKEN", "test_token_123")
|
||||
return AuthConfig()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_initialization(mock_config):
|
||||
"""Test client initialization."""
|
||||
client = GiteaClient(mock_config, timeout=10.0)
|
||||
assert client.config == mock_config
|
||||
assert client.timeout == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_context_manager(mock_config):
|
||||
"""Test client as async context manager."""
|
||||
async with GiteaClient(mock_config) as client:
|
||||
assert client._client is not None
|
||||
assert isinstance(client._client, httpx.AsyncClient)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_without_context_manager_raises_error(mock_config):
|
||||
"""Test that using client without context manager raises error."""
|
||||
client = GiteaClient(mock_config)
|
||||
|
||||
with pytest.raises(GiteaClientError, match="not initialized"):
|
||||
await client.get("/test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_error_401(mock_config):
|
||||
"""Test handling 401 authentication error."""
|
||||
response = MagicMock()
|
||||
response.status_code = 401
|
||||
|
||||
async with GiteaClient(mock_config) as client:
|
||||
with pytest.raises(GiteaAuthError, match="Authentication failed"):
|
||||
client._handle_error(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_error_403(mock_config):
|
||||
"""Test handling 403 forbidden error."""
|
||||
response = MagicMock()
|
||||
response.status_code = 403
|
||||
|
||||
async with GiteaClient(mock_config) as client:
|
||||
with pytest.raises(GiteaAuthError, match="Access forbidden"):
|
||||
client._handle_error(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_error_404(mock_config):
|
||||
"""Test handling 404 not found error."""
|
||||
response = MagicMock()
|
||||
response.status_code = 404
|
||||
response.request = MagicMock()
|
||||
response.request.url = "http://gitea.example.com/api/v1/test"
|
||||
|
||||
async with GiteaClient(mock_config) as client:
|
||||
with pytest.raises(GiteaNotFoundError, match="not found"):
|
||||
client._handle_error(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_error_500(mock_config):
|
||||
"""Test handling 500 server error."""
|
||||
response = MagicMock()
|
||||
response.status_code = 500
|
||||
response.text = "Internal Server Error"
|
||||
|
||||
async with GiteaClient(mock_config) as client:
|
||||
with pytest.raises(GiteaServerError, match="server error"):
|
||||
client._handle_error(response)
|
||||
215
tests/test_config.py
Normal file
215
tests/test_config.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Tests for configuration loader module."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from gitea_mcp_remote.config import GiteaSettings, load_settings
|
||||
|
||||
|
||||
class TestGiteaSettings:
|
||||
"""Test GiteaSettings configuration class."""
|
||||
|
||||
def test_required_fields(self):
|
||||
"""Test that required fields are enforced."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GiteaSettings()
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
# Note: gitea_repo is optional (for PMO mode)
|
||||
required_fields = {"gitea_url", "gitea_token", "gitea_owner"}
|
||||
error_fields = {error["loc"][0] for error in errors}
|
||||
assert required_fields.issubset(error_fields)
|
||||
|
||||
def test_valid_configuration(self):
|
||||
"""Test valid configuration creation."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="test_token",
|
||||
gitea_owner="test_owner",
|
||||
gitea_repo="test_repo",
|
||||
)
|
||||
|
||||
assert settings.gitea_url == "https://gitea.example.com"
|
||||
assert settings.gitea_token == "test_token"
|
||||
assert settings.gitea_owner == "test_owner"
|
||||
assert settings.gitea_repo == "test_repo"
|
||||
assert settings.http_host == "0.0.0.0"
|
||||
assert settings.http_port == 8080
|
||||
assert settings.auth_token is None
|
||||
assert settings.mcp_auth_mode == "optional"
|
||||
|
||||
def test_gitea_url_validation(self):
|
||||
"""Test Gitea URL validation."""
|
||||
# Valid URLs
|
||||
valid_urls = [
|
||||
"http://gitea.local",
|
||||
"https://gitea.example.com",
|
||||
"http://192.168.1.1:3000",
|
||||
]
|
||||
|
||||
for url in valid_urls:
|
||||
settings = GiteaSettings(
|
||||
gitea_url=url,
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
)
|
||||
assert settings.gitea_url == url.rstrip("/")
|
||||
|
||||
# Invalid URL (no protocol)
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
GiteaSettings(
|
||||
gitea_url="gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
)
|
||||
assert "must start with http://" in str(exc_info.value)
|
||||
|
||||
def test_gitea_url_trailing_slash_removed(self):
|
||||
"""Test that trailing slashes are removed from Gitea URL."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com/",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
)
|
||||
assert settings.gitea_url == "https://gitea.example.com"
|
||||
|
||||
def test_http_port_validation(self):
|
||||
"""Test HTTP port validation."""
|
||||
# Valid port
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
http_port=9000,
|
||||
)
|
||||
assert settings.http_port == 9000
|
||||
|
||||
# Invalid port (too high)
|
||||
with pytest.raises(ValidationError):
|
||||
GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
http_port=70000,
|
||||
)
|
||||
|
||||
# Invalid port (too low)
|
||||
with pytest.raises(ValidationError):
|
||||
GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
http_port=0,
|
||||
)
|
||||
|
||||
def test_enabled_tools_list_parsing(self):
|
||||
"""Test enabled_tools string parsing to list."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
enabled_tools="tool1,tool2,tool3",
|
||||
)
|
||||
assert settings.enabled_tools_list == ["tool1", "tool2", "tool3"]
|
||||
|
||||
# Test with spaces
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
enabled_tools="tool1, tool2 , tool3",
|
||||
)
|
||||
assert settings.enabled_tools_list == ["tool1", "tool2", "tool3"]
|
||||
|
||||
# Test empty string
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
enabled_tools="",
|
||||
)
|
||||
assert settings.enabled_tools_list is None
|
||||
|
||||
def test_disabled_tools_list_parsing(self):
|
||||
"""Test disabled_tools string parsing to list."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="token",
|
||||
gitea_owner="owner",
|
||||
gitea_repo="repo",
|
||||
disabled_tools="tool1,tool2",
|
||||
)
|
||||
assert settings.disabled_tools_list == ["tool1", "tool2"]
|
||||
|
||||
def test_mcp_auth_mode_field(self):
|
||||
"""Test mcp_auth_mode field with different values."""
|
||||
settings = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="test_token",
|
||||
gitea_owner="test_owner",
|
||||
mcp_auth_mode="required",
|
||||
)
|
||||
|
||||
assert settings.mcp_auth_mode == "required"
|
||||
|
||||
# Default value
|
||||
settings_default = GiteaSettings(
|
||||
gitea_url="https://gitea.example.com",
|
||||
gitea_token="test_token",
|
||||
gitea_owner="test_owner",
|
||||
)
|
||||
assert settings_default.mcp_auth_mode == "optional"
|
||||
|
||||
|
||||
class TestLoadSettings:
|
||||
"""Test load_settings factory function."""
|
||||
|
||||
def test_load_from_env_file(self, tmp_path):
|
||||
"""Test loading settings from a .env file."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"""
|
||||
GITEA_URL=https://gitea.test.com
|
||||
GITEA_TOKEN=test_token_123
|
||||
GITEA_OWNER=test_owner
|
||||
GITEA_REPO=test_repo
|
||||
HTTP_PORT=9000
|
||||
"""
|
||||
)
|
||||
|
||||
settings = load_settings(env_file)
|
||||
|
||||
assert settings.gitea_url == "https://gitea.test.com"
|
||||
assert settings.gitea_token == "test_token_123"
|
||||
assert settings.gitea_owner == "test_owner"
|
||||
assert settings.gitea_repo == "test_repo"
|
||||
assert settings.http_port == 9000
|
||||
|
||||
def test_load_from_environment(self, monkeypatch):
|
||||
"""Test loading settings from environment variables."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://env.gitea.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "env_token")
|
||||
monkeypatch.setenv("GITEA_OWNER", "env_owner")
|
||||
monkeypatch.setenv("GITEA_REPO", "env_repo")
|
||||
monkeypatch.setenv("HTTP_PORT", "8080")
|
||||
|
||||
# Mock _env_file to prevent loading actual .env
|
||||
settings = GiteaSettings()
|
||||
|
||||
assert settings.gitea_url == "https://env.gitea.com"
|
||||
assert settings.gitea_token == "env_token"
|
||||
assert settings.gitea_owner == "env_owner"
|
||||
assert settings.gitea_repo == "env_repo"
|
||||
assert settings.http_port == 8080
|
||||
149
tests/test_filtering.py
Normal file
149
tests/test_filtering.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for tool filtering module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from gitea_mcp_remote.filtering import ToolFilter
|
||||
|
||||
|
||||
class TestToolFilter:
|
||||
"""Test ToolFilter class."""
|
||||
|
||||
def test_init_with_both_lists_logs_warning(self, caplog):
|
||||
"""Test that specifying both enabled and disabled lists logs warning."""
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
filter = ToolFilter(enabled_tools=["tool1"], disabled_tools=["tool2"])
|
||||
|
||||
assert "Both enabled_tools and disabled_tools specified" in caplog.text
|
||||
assert "Disabled list takes precedence" in caplog.text
|
||||
|
||||
# Verify disabled list takes precedence
|
||||
assert not filter.should_include_tool("tool2")
|
||||
|
||||
def test_passthrough_mode(self):
|
||||
"""Test passthrough mode (no filtering)."""
|
||||
filter = ToolFilter()
|
||||
|
||||
assert filter.should_include_tool("any_tool")
|
||||
assert filter.should_include_tool("another_tool")
|
||||
|
||||
stats = filter.get_filter_stats()
|
||||
assert stats["mode"] == "passthrough"
|
||||
|
||||
def test_whitelist_mode(self):
|
||||
"""Test whitelist mode (enabled_tools)."""
|
||||
filter = ToolFilter(enabled_tools=["tool1", "tool2"])
|
||||
|
||||
assert filter.should_include_tool("tool1")
|
||||
assert filter.should_include_tool("tool2")
|
||||
assert not filter.should_include_tool("tool3")
|
||||
assert not filter.should_include_tool("tool4")
|
||||
|
||||
stats = filter.get_filter_stats()
|
||||
assert stats["mode"] == "whitelist"
|
||||
assert stats["enabled_count"] == 2
|
||||
assert "tool1" in stats["enabled_tools"]
|
||||
assert "tool2" in stats["enabled_tools"]
|
||||
|
||||
def test_blacklist_mode(self):
|
||||
"""Test blacklist mode (disabled_tools)."""
|
||||
filter = ToolFilter(disabled_tools=["tool1", "tool2"])
|
||||
|
||||
assert not filter.should_include_tool("tool1")
|
||||
assert not filter.should_include_tool("tool2")
|
||||
assert filter.should_include_tool("tool3")
|
||||
assert filter.should_include_tool("tool4")
|
||||
|
||||
stats = filter.get_filter_stats()
|
||||
assert stats["mode"] == "blacklist"
|
||||
assert stats["disabled_count"] == 2
|
||||
assert "tool1" in stats["disabled_tools"]
|
||||
assert "tool2" in stats["disabled_tools"]
|
||||
|
||||
def test_filter_tools_list(self):
|
||||
"""Test filtering a list of tool definitions."""
|
||||
filter = ToolFilter(enabled_tools=["tool1", "tool3"])
|
||||
|
||||
tools = [
|
||||
{"name": "tool1", "description": "First tool"},
|
||||
{"name": "tool2", "description": "Second tool"},
|
||||
{"name": "tool3", "description": "Third tool"},
|
||||
{"name": "tool4", "description": "Fourth tool"},
|
||||
]
|
||||
|
||||
filtered = filter.filter_tools_list(tools)
|
||||
|
||||
assert len(filtered) == 2
|
||||
assert filtered[0]["name"] == "tool1"
|
||||
assert filtered[1]["name"] == "tool3"
|
||||
|
||||
def test_filter_tools_response(self):
|
||||
"""Test filtering an MCP list_tools response."""
|
||||
filter = ToolFilter(disabled_tools=["tool2"])
|
||||
|
||||
response = {
|
||||
"tools": [
|
||||
{"name": "tool1", "description": "First tool"},
|
||||
{"name": "tool2", "description": "Second tool"},
|
||||
{"name": "tool3", "description": "Third tool"},
|
||||
],
|
||||
"other_data": "preserved",
|
||||
}
|
||||
|
||||
filtered = filter.filter_tools_response(response)
|
||||
|
||||
assert len(filtered["tools"]) == 2
|
||||
assert filtered["tools"][0]["name"] == "tool1"
|
||||
assert filtered["tools"][1]["name"] == "tool3"
|
||||
assert filtered["other_data"] == "preserved"
|
||||
|
||||
def test_filter_tools_response_no_tools_key(self):
|
||||
"""Test filtering response without 'tools' key."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
response = {"other_data": "value"}
|
||||
filtered = filter.filter_tools_response(response)
|
||||
|
||||
assert filtered == response
|
||||
|
||||
def test_filter_tools_response_immutable(self):
|
||||
"""Test that original response is not mutated."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
original = {
|
||||
"tools": [
|
||||
{"name": "tool1"},
|
||||
{"name": "tool2"},
|
||||
]
|
||||
}
|
||||
|
||||
filtered = filter.filter_tools_response(original)
|
||||
|
||||
# Original should still have 2 tools
|
||||
assert len(original["tools"]) == 2
|
||||
# Filtered should have 1 tool
|
||||
assert len(filtered["tools"]) == 1
|
||||
|
||||
def test_empty_tool_list(self):
|
||||
"""Test filtering empty tool list."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
result = filter.filter_tools_list([])
|
||||
assert result == []
|
||||
|
||||
def test_tool_with_no_name(self):
|
||||
"""Test handling tool without name field."""
|
||||
filter = ToolFilter(enabled_tools=["tool1"])
|
||||
|
||||
tools = [
|
||||
{"name": "tool1"},
|
||||
{"description": "No name"},
|
||||
{"name": "tool2"},
|
||||
]
|
||||
|
||||
filtered = filter.filter_tools_list(tools)
|
||||
|
||||
# Only tool1 should match, tool without name is excluded
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["name"] == "tool1"
|
||||
137
tests/test_mcp_endpoints.py
Normal file
137
tests/test_mcp_endpoints.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Tests for MCP protocol endpoints and health checks."""
|
||||
import pytest
|
||||
import json
|
||||
import re
|
||||
from starlette.testclient import TestClient
|
||||
from gitea_mcp_remote.server_http import create_app
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env():
|
||||
"""Mock environment variables for testing."""
|
||||
env = {
|
||||
"GITEA_URL": "https://gitea.example.com",
|
||||
"GITEA_TOKEN": "test_token",
|
||||
"GITEA_OWNER": "test_owner",
|
||||
}
|
||||
with patch.dict("os.environ", env):
|
||||
yield env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_env):
|
||||
"""Create test client."""
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Health Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_health_endpoint(client):
|
||||
"""Test GET /health returns status ok."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_endpoint_no_auth_required(mock_env):
|
||||
"""Test health endpoint works even with AUTH_TOKEN set."""
|
||||
with patch.dict("os.environ", {**mock_env, "AUTH_TOKEN": "secret123"}):
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
# Health should bypass auth
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MCP Protocol Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def parse_sse_message(sse_text: str) -> dict:
|
||||
"""Parse SSE message data."""
|
||||
data_match = re.search(r'data: (.+)', sse_text)
|
||||
if data_match:
|
||||
return json.loads(data_match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def test_mcp_head_endpoint(client):
|
||||
"""Test HEAD /mcp returns protocol version header."""
|
||||
response = client.head("/mcp")
|
||||
assert response.status_code == 200
|
||||
assert "x-mcp-protocol-version" in response.headers
|
||||
assert response.headers["x-mcp-protocol-version"] == "2024-11-05"
|
||||
|
||||
|
||||
def test_mcp_initialize(client):
|
||||
"""Test MCP initialize request."""
|
||||
initialize_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "test-client",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json=initialize_request,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Parse SSE response
|
||||
data = parse_sse_message(response.text)
|
||||
assert data is not None
|
||||
assert data.get("jsonrpc") == "2.0"
|
||||
assert "result" in data
|
||||
assert data["result"].get("protocolVersion") == "2024-11-05"
|
||||
assert "serverInfo" in data["result"]
|
||||
assert data["result"]["serverInfo"]["name"] == "gitea-mcp-remote"
|
||||
|
||||
|
||||
def test_mcp_missing_accept_header(client):
|
||||
"""Test MCP request without required Accept header."""
|
||||
initialize_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "test-client",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/mcp",
|
||||
json=initialize_request,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json" # Missing text/event-stream
|
||||
}
|
||||
)
|
||||
|
||||
# Should return error about missing accept header
|
||||
assert response.status_code == 406
|
||||
162
tests/test_middleware.py
Normal file
162
tests/test_middleware.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Tests for HTTP authentication middleware."""
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from gitea_mcp_remote.middleware import (
|
||||
BearerAuthMiddleware,
|
||||
HealthCheckBypassMiddleware,
|
||||
)
|
||||
|
||||
|
||||
# Test application endpoint
|
||||
async def test_endpoint(request):
|
||||
return JSONResponse({"message": "success"})
|
||||
|
||||
|
||||
class TestBearerAuthMiddleware:
|
||||
"""Test BearerAuthMiddleware."""
|
||||
|
||||
def test_no_auth_configured(self):
|
||||
"""Test that requests pass through when no auth token is configured."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token=None)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "success"
|
||||
|
||||
def test_auth_configured_valid_token(self):
|
||||
"""Test successful authentication with valid token."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test", headers={"Authorization": "Bearer secret_token"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "success"
|
||||
|
||||
def test_auth_configured_missing_header(self):
|
||||
"""Test rejection when Authorization header is missing."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing Authorization header" in response.json()["message"]
|
||||
|
||||
def test_auth_configured_invalid_format(self):
|
||||
"""Test rejection when Authorization header has wrong format."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Test with wrong scheme
|
||||
response = client.get("/test", headers={"Authorization": "Basic secret_token"})
|
||||
assert response.status_code == 401
|
||||
assert "Bearer scheme" in response.json()["message"]
|
||||
|
||||
# Test with no scheme
|
||||
response = client.get("/test", headers={"Authorization": "secret_token"})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_auth_configured_invalid_token(self):
|
||||
"""Test rejection when token is invalid."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/test", headers={"Authorization": "Bearer wrong_token"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Invalid authentication token" in response.json()["message"]
|
||||
|
||||
def test_auth_case_sensitive_token(self):
|
||||
"""Test that token comparison is case-sensitive."""
|
||||
app = Starlette(routes=[Route("/test", test_endpoint)])
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="Secret_Token")
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Correct case
|
||||
response = client.get("/test", headers={"Authorization": "Bearer Secret_Token"})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Wrong case
|
||||
response = client.get("/test", headers={"Authorization": "Bearer secret_token"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestHealthCheckBypassMiddleware:
|
||||
"""Test HealthCheckBypassMiddleware."""
|
||||
|
||||
def test_default_health_check_paths(self):
|
||||
"""Test that default health check paths bypass auth."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/health", test_endpoint),
|
||||
Route("/healthz", test_endpoint),
|
||||
Route("/ping", test_endpoint),
|
||||
Route("/test", test_endpoint),
|
||||
]
|
||||
)
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
app.add_middleware(HealthCheckBypassMiddleware)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Health checks should work without auth
|
||||
assert client.get("/health").status_code == 200
|
||||
assert client.get("/healthz").status_code == 200
|
||||
assert client.get("/ping").status_code == 200
|
||||
|
||||
# Regular endpoint should require auth
|
||||
assert client.get("/test").status_code == 401
|
||||
|
||||
def test_custom_health_check_paths(self):
|
||||
"""Test custom health check paths."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/custom-health", test_endpoint),
|
||||
Route("/test", test_endpoint),
|
||||
]
|
||||
)
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
app.add_middleware(
|
||||
HealthCheckBypassMiddleware,
|
||||
health_check_paths=["/custom-health"],
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Custom health check should work without auth
|
||||
assert client.get("/custom-health").status_code == 200
|
||||
|
||||
# Regular endpoint should require auth
|
||||
assert client.get("/test").status_code == 401
|
||||
|
||||
def test_middleware_order(self):
|
||||
"""Test that middleware order is correct."""
|
||||
# HealthCheckBypass should be added BEFORE BearerAuth
|
||||
# so it can bypass the auth check
|
||||
|
||||
app = Starlette(routes=[Route("/health", test_endpoint)])
|
||||
|
||||
# Correct order: HealthCheck bypass first, then Auth
|
||||
app.add_middleware(BearerAuthMiddleware, auth_token="secret_token")
|
||||
app.add_middleware(HealthCheckBypassMiddleware)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/health")
|
||||
|
||||
# Should succeed without auth
|
||||
assert response.status_code == 200
|
||||
Reference in New Issue
Block a user