generated from personal-projects/leo-claude-mktplace
Compare commits
39 Commits
feat/3-iss
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ca5cd644 | |||
| 2dbb66deae | |||
| 88c16c840b | |||
| 9fea0683f7 | |||
| a5390a3086 | |||
| c3caf4e169 | |||
| cd8718c114 | |||
| 53a0006a3f | |||
| fb8cc08112 | |||
| 809eef132a | |||
| 1c55eed7c0 | |||
| 5075139841 | |||
| 16436c847a | |||
| c9961293d9 | |||
| 4f43109797 | |||
| f237c5de01 | |||
| f2ca2a65a2 | |||
| efedce2059 | |||
| 1c63210f1d | |||
| 8105879d71 | |||
| 1733600876 | |||
| eb7e97c967 | |||
| 52f1a9d7e7 | |||
| bcd1cf8841 | |||
| 5a1f708e86 | |||
| ee3ec0e0e4 | |||
| e21f1226c6 | |||
| 4eac323977 | |||
| 6c8e6b4b0a | |||
| b041d1568a | |||
| 0e0c34f735 | |||
| c378840492 | |||
| cd55d53f1b | |||
| b94dcebfc7 | |||
| 201cc680ca | |||
| 0653a4f70e | |||
| 13ffd8a543 | |||
| 2230bceb51 | |||
| ab8c9069da |
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
|
# 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
|
## Requirements
|
||||||
|
|
||||||
- Python >= 3.10
|
- Python >= 3.10
|
||||||
|
- Official `gitea-mcp-server` package (auto-installed as dependency)
|
||||||
- Gitea instance with API access
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
|
See [Quick Start](#quick-start-with-docker) above or [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||||
|
|
||||||
|
### Option 2: From Source
|
||||||
|
|
||||||
```bash
|
```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 .
|
pip install -e .
|
||||||
|
|
||||||
|
# Or use requirements.txt
|
||||||
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
### For Development
|
||||||
|
|
||||||
Install with development dependencies:
|
Install with development dependencies:
|
||||||
|
|
||||||
@@ -29,12 +89,314 @@ Install with development dependencies:
|
|||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests:
|
## Configuration
|
||||||
|
|
||||||
|
The wrapper uses environment variables or a `.env` file for configuration.
|
||||||
|
|
||||||
|
### Required Configuration
|
||||||
|
|
||||||
```bash
|
```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
|
## 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]
|
[project]
|
||||||
name = "gitea-mcp-remote"
|
name = "gitea-mcp-remote"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
description = "MCP server for Gitea API integration"
|
description = "HTTP transport wrapper for Gitea MCP server"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Leo Miranda", email = "lmiranda@example.com" }
|
{ name = "Leo Miranda", email = "lmiranda@example.com" }
|
||||||
]
|
]
|
||||||
keywords = ["mcp", "gitea", "api", "server"]
|
keywords = ["mcp", "gitea", "api", "server", "http", "wrapper"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
@@ -24,19 +24,32 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp>=0.1.0",
|
# THE MARKETPLACE PACKAGE — this is the whole point of this repo
|
||||||
"httpx>=0.24.0",
|
"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",
|
"python-dotenv>=1.0.0",
|
||||||
|
# Auth
|
||||||
|
"pyjwt>=2.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0.0",
|
"pytest>=7.0.0",
|
||||||
"pytest-asyncio>=0.21.0",
|
"pytest-asyncio>=0.21.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"httpx>=0.24.0",
|
||||||
|
"starlette>=0.36.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
gitea-mcp = "gitea_mcp.server:main"
|
gitea-mcp-remote = "gitea_mcp_remote.server_http:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/lmiranda/gitea-mcp-remote"
|
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
|
||||||
@@ -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,173 +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,
|
|
||||||
get_label_tools,
|
|
||||||
handle_label_tool,
|
|
||||||
get_milestone_tools,
|
|
||||||
handle_milestone_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, label, and milestone operations.
|
|
||||||
"""
|
|
||||||
# Get issue, label, and milestone tools
|
|
||||||
tools = get_issue_tools()
|
|
||||||
tools.extend(get_label_tools())
|
|
||||||
tools.extend(get_milestone_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)
|
|
||||||
|
|
||||||
# Handle label tools
|
|
||||||
if name.startswith("gitea_") and any(
|
|
||||||
name.endswith(suffix) for suffix in ["_labels", "_label"]
|
|
||||||
):
|
|
||||||
return await handle_label_tool(gitea_client, name, arguments)
|
|
||||||
|
|
||||||
# Handle milestone tools
|
|
||||||
if name.startswith("gitea_") and any(
|
|
||||||
name.endswith(suffix) for suffix in ["_milestones", "_milestone"]
|
|
||||||
):
|
|
||||||
return await handle_milestone_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,14 +0,0 @@
|
|||||||
"""Gitea MCP tools package."""
|
|
||||||
|
|
||||||
from .issues import get_issue_tools, handle_issue_tool
|
|
||||||
from .labels import get_label_tools, handle_label_tool
|
|
||||||
from .milestones import get_milestone_tools, handle_milestone_tool
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"get_issue_tools",
|
|
||||||
"handle_issue_tool",
|
|
||||||
"get_label_tools",
|
|
||||||
"handle_label_tool",
|
|
||||||
"get_milestone_tools",
|
|
||||||
"handle_milestone_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)]
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"""Gitea label operations MCP tools."""
|
|
||||||
|
|
||||||
from mcp.types import Tool, TextContent
|
|
||||||
|
|
||||||
from ..client import GiteaClient, GiteaClientError
|
|
||||||
|
|
||||||
|
|
||||||
def get_label_tools() -> list[Tool]:
|
|
||||||
"""Get label operation tool definitions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Tool definitions for label operations.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
Tool(
|
|
||||||
name="gitea_list_labels",
|
|
||||||
description="List all labels in a Gitea repository",
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"owner": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Repository owner (user or organization)",
|
|
||||||
},
|
|
||||||
"repo": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Repository name",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["owner", "repo"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="gitea_create_label",
|
|
||||||
description="Create a new label in a Gitea repository",
|
|
||||||
inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"owner": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Repository owner (user or organization)",
|
|
||||||
},
|
|
||||||
"repo": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Repository name",
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Label name",
|
|
||||||
},
|
|
||||||
"color": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Label color (hex without #, e.g., 'ff0000' for red)",
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Label description (optional)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["owner", "repo", "name", "color"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_label_tool(
|
|
||||||
client: GiteaClient, name: str, arguments: dict
|
|
||||||
) -> list[TextContent]:
|
|
||||||
"""Handle label tool execution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Gitea client instance.
|
|
||||||
name: Tool name.
|
|
||||||
arguments: Tool arguments.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Tool response content.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with client:
|
|
||||||
if name == "gitea_list_labels":
|
|
||||||
return await _list_labels(client, arguments)
|
|
||||||
elif name == "gitea_create_label":
|
|
||||||
return await _create_label(client, arguments)
|
|
||||||
else:
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"Unknown label tool: {name}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
except GiteaClientError as e:
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"Gitea API error: {e}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _list_labels(client: GiteaClient, arguments: dict) -> list[TextContent]:
|
|
||||||
"""List labels in a repository.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Gitea client instance.
|
|
||||||
arguments: Tool arguments with owner and repo.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Label listing response.
|
|
||||||
"""
|
|
||||||
owner = arguments["owner"]
|
|
||||||
repo = arguments["repo"]
|
|
||||||
|
|
||||||
labels = await client.get(f"/repos/{owner}/{repo}/labels")
|
|
||||||
|
|
||||||
if not labels:
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"No labels found in {owner}/{repo}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Format labels for display
|
|
||||||
lines = [f"Labels in {owner}/{repo}:", ""]
|
|
||||||
for label in labels:
|
|
||||||
color = label.get("color", "")
|
|
||||||
desc = label.get("description", "")
|
|
||||||
desc_text = f" - {desc}" if desc else ""
|
|
||||||
lines.append(f" • {label['name']} (#{color}){desc_text}")
|
|
||||||
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text="\n".join(lines),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_label(client: GiteaClient, arguments: dict) -> list[TextContent]:
|
|
||||||
"""Create a new label.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Gitea client instance.
|
|
||||||
arguments: Tool arguments with owner, repo, name, color, and optional description.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Creation response.
|
|
||||||
"""
|
|
||||||
owner = arguments["owner"]
|
|
||||||
repo = arguments["repo"]
|
|
||||||
name = arguments["name"]
|
|
||||||
color = arguments["color"].lstrip("#") # Remove # if present
|
|
||||||
description = arguments.get("description", "")
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"name": name,
|
|
||||||
"color": color,
|
|
||||||
}
|
|
||||||
if description:
|
|
||||||
payload["description"] = description
|
|
||||||
|
|
||||||
label = await client.post(f"/repos/{owner}/{repo}/labels", payload)
|
|
||||||
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"Created label '{label['name']}' (#{label['color']}) in {owner}/{repo}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"""Gitea milestone operations tools for MCP server."""
|
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
from mcp.types import Tool, TextContent
|
|
||||||
|
|
||||||
from ..client import GiteaClient, GiteaClientError
|
|
||||||
|
|
||||||
|
|
||||||
def get_milestone_tools() -> list[Tool]:
|
|
||||||
"""Get list of milestone operation tools.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[Tool]: List of MCP tools for milestone operations.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
Tool(
|
|
||||||
name="gitea_list_milestones",
|
|
||||||
description="List milestones in a Gitea repository with optional state filter",
|
|
||||||
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 (default: open)",
|
|
||||||
"enum": ["open", "closed", "all"],
|
|
||||||
"default": "open",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["owner", "repo"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="gitea_create_milestone",
|
|
||||||
description="Create a new milestone 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": "Milestone title",
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Milestone description (optional)",
|
|
||||||
},
|
|
||||||
"due_on": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Due date in ISO 8601 format, e.g., '2024-12-31T23:59:59Z' (optional)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["owner", "repo", "title"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_milestone_tool(
|
|
||||||
name: str, arguments: dict[str, Any], client: GiteaClient
|
|
||||||
) -> list[TextContent]:
|
|
||||||
"""Handle milestone tool calls.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Tool name.
|
|
||||||
arguments: Tool arguments.
|
|
||||||
client: Gitea API client instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[TextContent]: Tool response.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if name == "gitea_list_milestones":
|
|
||||||
return await _list_milestones(arguments, client)
|
|
||||||
elif name == "gitea_create_milestone":
|
|
||||||
return await _create_milestone(arguments, client)
|
|
||||||
else:
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"Unknown milestone tool: {name}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
except GiteaClientError as e:
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"Error: {str(e)}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _list_milestones(
|
|
||||||
arguments: dict[str, Any], client: GiteaClient
|
|
||||||
) -> list[TextContent]:
|
|
||||||
"""List milestones in a repository.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
arguments: Tool arguments containing owner, repo, and optional state filter.
|
|
||||||
client: Gitea API client instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[TextContent]: List of milestones.
|
|
||||||
"""
|
|
||||||
owner = arguments["owner"]
|
|
||||||
repo = arguments["repo"]
|
|
||||||
state = arguments.get("state", "open")
|
|
||||||
|
|
||||||
params = {"state": state}
|
|
||||||
|
|
||||||
async with client:
|
|
||||||
milestones = await client.get(
|
|
||||||
f"/repos/{owner}/{repo}/milestones", params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
# Format response
|
|
||||||
if not milestones:
|
|
||||||
return [
|
|
||||||
TextContent(
|
|
||||||
type="text",
|
|
||||||
text=f"No {state} milestones found in {owner}/{repo}",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = f"Found {len(milestones)} {state} milestone(s) in {owner}/{repo}:\n\n"
|
|
||||||
for milestone in milestones:
|
|
||||||
result += f"{milestone.get('title', 'Untitled')}\n"
|
|
||||||
result += f" State: {milestone.get('state', 'unknown')}\n"
|
|
||||||
if milestone.get("description"):
|
|
||||||
result += f" Description: {milestone['description']}\n"
|
|
||||||
if milestone.get("due_on"):
|
|
||||||
result += f" Due: {milestone['due_on']}\n"
|
|
||||||
result += f" Open Issues: {milestone.get('open_issues', 0)}\n"
|
|
||||||
result += f" Closed Issues: {milestone.get('closed_issues', 0)}\n"
|
|
||||||
result += f" Created: {milestone.get('created_at', 'N/A')}\n\n"
|
|
||||||
|
|
||||||
return [TextContent(type="text", text=result)]
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_milestone(
|
|
||||||
arguments: dict[str, Any], client: GiteaClient
|
|
||||||
) -> list[TextContent]:
|
|
||||||
"""Create a new milestone.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
arguments: Tool arguments containing owner, repo, title, and optional fields.
|
|
||||||
client: Gitea API client instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[TextContent]: Created milestone details.
|
|
||||||
"""
|
|
||||||
owner = arguments["owner"]
|
|
||||||
repo = arguments["repo"]
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"title": arguments["title"],
|
|
||||||
}
|
|
||||||
|
|
||||||
if "description" in arguments:
|
|
||||||
data["description"] = arguments["description"]
|
|
||||||
|
|
||||||
if "due_on" in arguments:
|
|
||||||
data["due_on"] = arguments["due_on"]
|
|
||||||
|
|
||||||
async with client:
|
|
||||||
milestone = await client.post(
|
|
||||||
f"/repos/{owner}/{repo}/milestones", json=data
|
|
||||||
)
|
|
||||||
|
|
||||||
result = f"Created milestone: {milestone['title']}\n"
|
|
||||||
if milestone.get("description"):
|
|
||||||
result += f"Description: {milestone['description']}\n"
|
|
||||||
if milestone.get("due_on"):
|
|
||||||
result += f"Due: {milestone['due_on']}\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