Files
leo-claude-mktplace/docs/references/MCP-GITEA.md

23 KiB

Gitea MCP Server Reference

Overview

The Gitea MCP Server provides integration with Gitea for issue management, label operations, and repository tracking. It's shared by both projman and projman-pmo plugins, detecting its operating mode based on environment variables.

Location: mcp-servers/gitea/ (repository root)

Key Features:

  • Issue CRUD operations
  • Label taxonomy management (43-label system)
  • Mode detection (project-scoped vs company-wide)
  • Hybrid configuration (system + project level)
  • Python 3.11+ implementation

Architecture

Mode Detection

The MCP server operates in two modes based on environment variables:

Project Mode (projman):

  • When GITEA_REPO is present
  • Operates on single repository
  • Used by projman plugin

Company Mode (pmo):

  • When GITEA_REPO is absent
  • Operates on all repositories in organization
  • Used by projman-pmo plugin
# mcp-servers/gitea/mcp_server/config.py
def load(self):
    # ... load configs ...

    self.repo = os.getenv('GITEA_REPO')  # Optional

    if self.repo:
        self.mode = 'project'
        logger.info(f"Running in project mode: {self.repo}")
    else:
        self.mode = 'company'
        logger.info("Running in company-wide mode (PMO)")

    return {
        'api_url': self.api_url,
        'api_token': self.api_token,
        'owner': self.owner,
        'repo': self.repo,
        'mode': self.mode
    }

Configuration

System-Level Configuration

File: ~/.config/claude/gitea.env

GITEA_API_URL=https://gitea.hyperhivelabs.com/api/v1
GITEA_API_TOKEN=your_gitea_token
GITEA_OWNER=hyperhivelabs

Setup:

# Create config directory
mkdir -p ~/.config/claude

# Create gitea.env
cat > ~/.config/claude/gitea.env << EOF
GITEA_API_URL=https://gitea.hyperhivelabs.com/api/v1
GITEA_API_TOKEN=your_token
GITEA_OWNER=hyperhivelabs
EOF

# Secure the file
chmod 600 ~/.config/claude/gitea.env

Project-Level Configuration

File: project-root/.env

# Repository name (project mode only)
GITEA_REPO=cuisineflow

Setup:

# In each project root
echo "GITEA_REPO=cuisineflow" > .env

# Add to .gitignore
echo ".env" >> .gitignore

Configuration Loading Strategy

# mcp-servers/gitea/mcp_server/config.py
from pathlib import Path
from dotenv import load_dotenv
import os
from typing import Dict, Optional

class GiteaConfig:
    """Hybrid configuration loader"""

    def __init__(self):
        self.api_url: Optional[str] = None
        self.api_token: Optional[str] = None
        self.owner: Optional[str] = None
        self.repo: Optional[str] = None
        self.mode: str = 'project'

    def load(self) -> Dict[str, str]:
        """
        Load configuration from system and project levels.
        Project-level configuration overrides system-level.
        """
        # Load system config
        system_config = Path.home() / '.config' / 'claude' / 'gitea.env'
        if system_config.exists():
            load_dotenv(system_config)
        else:
            raise FileNotFoundError(
                f"System config not found: {system_config}\n"
                "Create it with: mkdir -p ~/.config/claude && "
                "cat > ~/.config/claude/gitea.env"
            )

        # Load project config (overrides system)
        project_config = Path.cwd() / '.env'
        if project_config.exists():
            load_dotenv(project_config, override=True)

        # Extract values
        self.api_url = os.getenv('GITEA_API_URL')
        self.api_token = os.getenv('GITEA_API_TOKEN')
        self.owner = os.getenv('GITEA_OWNER')
        self.repo = os.getenv('GITEA_REPO')  # Optional for PMO

        # Detect mode
        self.mode = 'project' if self.repo else 'company'

        # Validate required variables
        self._validate()

        return {
            'api_url': self.api_url,
            'api_token': self.api_token,
            'owner': self.owner,
            'repo': self.repo,
            'mode': self.mode
        }

    def _validate(self) -> None:
        """Validate that required configuration is present"""
        required = {
            'GITEA_API_URL': self.api_url,
            'GITEA_API_TOKEN': self.api_token,
            'GITEA_OWNER': self.owner
        }

        missing = [key for key, value in required.items() if not value]

        if missing:
            raise ValueError(
                f"Missing required configuration: {', '.join(missing)}\n"
                "Check your ~/.config/claude/gitea.env file"
            )

Directory Structure

mcp-servers/gitea/
├── .venv/                      # Python virtual environment
├── requirements.txt            # Python dependencies
├── .env.example               # Configuration template
├── mcp_server/
│   ├── __init__.py
│   ├── server.py              # MCP server entry point
│   ├── config.py              # Configuration loader
│   ├── gitea_client.py        # Gitea API client
│   └── tools/
│       ├── __init__.py
│       ├── issues.py          # Issue CRUD tools
│       └── labels.py          # Label management tools
└── tests/
    ├── test_config.py
    ├── test_gitea_client.py
    └── test_tools.py

Dependencies

File: mcp-servers/gitea/requirements.txt

anthropic-sdk>=0.18.0    # MCP SDK
python-dotenv>=1.0.0     # Environment variable loading
requests>=2.31.0         # HTTP client for Gitea API
pydantic>=2.5.0          # Data validation
pytest>=7.4.3            # Testing framework
pytest-asyncio>=0.23.0   # Async testing support

Installation:

cd mcp-servers/gitea
python -m venv .venv
source .venv/bin/activate  # Linux/Mac
# or .venv\Scripts\activate  # Windows
pip install -r requirements.txt

Gitea API Client

# mcp-servers/gitea/mcp_server/gitea_client.py
import requests
from typing import List, Dict, Optional
from .config import GiteaConfig

class GiteaClient:
    """Client for interacting with Gitea API"""

    def __init__(self):
        config = GiteaConfig()
        config_dict = config.load()

        self.base_url = config_dict['api_url']
        self.token = config_dict['api_token']
        self.owner = config_dict['owner']
        self.repo = config_dict.get('repo')  # Optional for PMO
        self.mode = config_dict['mode']

        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': f'token {self.token}',
            'Content-Type': 'application/json'
        })

    def list_issues(
        self,
        state: str = 'open',
        labels: Optional[List[str]] = None,
        repo: Optional[str] = None
    ) -> List[Dict]:
        """
        List issues from Gitea repository.

        Args:
            state: Issue state (open, closed, all)
            labels: Filter by labels
            repo: Override configured repo (for PMO multi-repo)
        """
        target_repo = repo or self.repo
        if not target_repo:
            raise ValueError("Repository not specified")

        url = f"{self.base_url}/repos/{self.owner}/{target_repo}/issues"
        params = {'state': state}

        if labels:
            params['labels'] = ','.join(labels)

        response = self.session.get(url, params=params)
        response.raise_for_status()
        return response.json()

    def get_issue(
        self,
        issue_number: int,
        repo: Optional[str] = None
    ) -> Dict:
        """Get specific issue details"""
        target_repo = repo or self.repo
        if not target_repo:
            raise ValueError("Repository not specified")

        url = f"{self.base_url}/repos/{self.owner}/{target_repo}/issues/{issue_number}"
        response = self.session.get(url)
        response.raise_for_status()
        return response.json()

    def create_issue(
        self,
        title: str,
        body: str,
        labels: Optional[List[str]] = None,
        repo: Optional[str] = None
    ) -> Dict:
        """Create a new issue in Gitea"""
        target_repo = repo or self.repo
        if not target_repo:
            raise ValueError("Repository not specified")

        url = f"{self.base_url}/repos/{self.owner}/{target_repo}/issues"
        data = {
            'title': title,
            'body': body
        }

        if labels:
            data['labels'] = labels

        response = self.session.post(url, json=data)
        response.raise_for_status()
        return response.json()

    def update_issue(
        self,
        issue_number: int,
        title: Optional[str] = None,
        body: Optional[str] = None,
        state: Optional[str] = None,
        labels: Optional[List[str]] = None,
        repo: Optional[str] = None
    ) -> Dict:
        """Update existing issue"""
        target_repo = repo or self.repo
        if not target_repo:
            raise ValueError("Repository not specified")

        url = f"{self.base_url}/repos/{self.owner}/{target_repo}/issues/{issue_number}"
        data = {}

        if title is not None:
            data['title'] = title
        if body is not None:
            data['body'] = body
        if state is not None:
            data['state'] = state
        if labels is not None:
            data['labels'] = labels

        response = self.session.patch(url, json=data)
        response.raise_for_status()
        return response.json()

    def add_comment(
        self,
        issue_number: int,
        comment: str,
        repo: Optional[str] = None
    ) -> Dict:
        """Add comment to issue"""
        target_repo = repo or self.repo
        if not target_repo:
            raise ValueError("Repository not specified")

        url = f"{self.base_url}/repos/{self.owner}/{target_repo}/issues/{issue_number}/comments"
        data = {'body': comment}

        response = self.session.post(url, json=data)
        response.raise_for_status()
        return response.json()

    def get_labels(
        self,
        repo: Optional[str] = None
    ) -> List[Dict]:
        """Get all labels from repository"""
        target_repo = repo or self.repo
        if not target_repo:
            raise ValueError("Repository not specified")

        url = f"{self.base_url}/repos/{self.owner}/{target_repo}/labels"
        response = self.session.get(url)
        response.raise_for_status()
        return response.json()

    def get_org_labels(self) -> List[Dict]:
        """Get organization-level labels"""
        url = f"{self.base_url}/orgs/{self.owner}/labels"
        response = self.session.get(url)
        response.raise_for_status()
        return response.json()

    # PMO-specific methods

    def list_repos(self) -> List[Dict]:
        """List all repositories in organization (PMO mode)"""
        url = f"{self.base_url}/orgs/{self.owner}/repos"
        response = self.session.get(url)
        response.raise_for_status()
        return response.json()

    def aggregate_issues(
        self,
        state: str = 'open',
        labels: Optional[List[str]] = None
    ) -> Dict[str, List[Dict]]:
        """
        Fetch issues across all repositories (PMO mode).
        Returns dict keyed by repository name.
        """
        repos = self.list_repos()
        aggregated = {}

        for repo in repos:
            repo_name = repo['name']
            try:
                issues = self.list_issues(
                    state=state,
                    labels=labels,
                    repo=repo_name
                )
                if issues:
                    aggregated[repo_name] = issues
            except Exception as e:
                # Log error but continue with other repos
                print(f"Error fetching issues from {repo_name}: {e}")

        return aggregated

Label Taxonomy System

43-Label System

Organization Labels (27):

  • Agent/2
  • Complexity/3
  • Efforts/5
  • Priority/4
  • Risk/3
  • Source/4
  • Type/6 (includes Type/Refactor)

Repository Labels (16):

  • Component/9
  • Tech/7

Label Suggestion Logic

# mcp-servers/gitea/mcp_server/tools/labels.py
from typing import List, Dict
import re

class LabelTools:
    def __init__(self, gitea_client):
        self.gitea = gitea_client

    async def get_labels(self, repo: str = None) -> List[Dict]:
        """Get all labels (org + repo)"""
        org_labels = self.gitea.get_org_labels()

        if repo or self.gitea.repo:
            target_repo = repo or self.gitea.repo
            repo_labels = self.gitea.get_labels(target_repo)
            return org_labels + repo_labels

        return org_labels

    async def suggest_labels(self, context: str) -> List[str]:
        """
        Analyze context and suggest appropriate labels.

        Args:
            context: Issue title + description or sprint context
        """
        suggested = []
        context_lower = context.lower()

        # Type detection (exclusive - only one)
        if any(word in context_lower for word in ['bug', 'error', 'fix', 'broken']):
            suggested.append('Type/Bug')
        elif any(word in context_lower for word in ['refactor', 'extract', 'restructure', 'architecture', 'service extraction']):
            suggested.append('Type/Refactor')
        elif any(word in context_lower for word in ['feature', 'add', 'implement', 'new']):
            suggested.append('Type/Feature')
        elif any(word in context_lower for word in ['docs', 'documentation', 'readme']):
            suggested.append('Type/Documentation')
        elif any(word in context_lower for word in ['test', 'testing', 'spec']):
            suggested.append('Type/Test')
        elif any(word in context_lower for word in ['chore', 'maintenance', 'update']):
            suggested.append('Type/Chore')

        # Priority detection
        if any(word in context_lower for word in ['critical', 'urgent', 'blocker', 'blocking']):
            suggested.append('Priority/Critical')
        elif any(word in context_lower for word in ['high', 'important', 'asap']):
            suggested.append('Priority/High')
        elif any(word in context_lower for word in ['low', 'nice-to-have', 'optional']):
            suggested.append('Priority/Low')
        else:
            suggested.append('Priority/Medium')

        # Component detection (based on keywords)
        component_keywords = {
            'Component/Backend': ['backend', 'server', 'api', 'database', 'service'],
            'Component/Frontend': ['frontend', 'ui', 'interface', 'react', 'vue'],
            'Component/API': ['api', 'endpoint', 'rest', 'graphql'],
            'Component/Database': ['database', 'db', 'sql', 'migration', 'schema'],
            'Component/Auth': ['auth', 'authentication', 'login', 'oauth', 'token'],
            'Component/Deploy': ['deploy', 'deployment', 'docker', 'kubernetes'],
            'Component/Testing': ['test', 'testing', 'spec', 'jest', 'pytest'],
            'Component/Docs': ['docs', 'documentation', 'readme', 'guide']
        }

        for label, keywords in component_keywords.items():
            if any(keyword in context_lower for keyword in keywords):
                suggested.append(label)

        # Source detection (based on git branch or context)
        if 'development' in context_lower or 'dev/' in context_lower:
            suggested.append('Source/Development')
        elif 'staging' in context_lower:
            suggested.append('Source/Staging')
        elif 'production' in context_lower or 'prod' in context_lower:
            suggested.append('Source/Production')

        return suggested

MCP Tools

Issue Tools

# mcp-servers/gitea/mcp_server/tools/issues.py
class IssueTools:
    def __init__(self, gitea_client):
        self.gitea = gitea_client

    async def list_issues(self, state='open', labels=None, repo=None):
        """List issues in repository"""
        return self.gitea.list_issues(state=state, labels=labels, repo=repo)

    async def get_issue(self, issue_number, repo=None):
        """Get specific issue details"""
        return self.gitea.get_issue(issue_number, repo=repo)

    async def create_issue(self, title, body, labels=None, repo=None):
        """Create new issue"""
        return self.gitea.create_issue(title, body, labels=labels, repo=repo)

    async def update_issue(self, issue_number, title=None, body=None, state=None, labels=None, repo=None):
        """Update existing issue"""
        return self.gitea.update_issue(
            issue_number,
            title=title,
            body=body,
            state=state,
            labels=labels,
            repo=repo
        )

    async def add_comment(self, issue_number, comment, repo=None):
        """Add comment to issue"""
        return self.gitea.add_comment(issue_number, comment, repo=repo)

    # PMO-specific
    async def aggregate_issues(self, state='open', labels=None):
        """Aggregate issues across all repositories (PMO mode)"""
        if self.gitea.mode != 'company':
            raise ValueError("aggregate_issues only available in company mode")
        return self.gitea.aggregate_issues(state=state, labels=labels)

Testing

Unit Tests

# tests/test_config.py
import pytest
from pathlib import Path
from mcp_server.config import GiteaConfig

def test_load_system_config(tmp_path, monkeypatch):
    """Test loading system-level configuration"""
    # Mock home directory
    config_dir = tmp_path / '.config' / 'claude'
    config_dir.mkdir(parents=True)

    config_file = config_dir / 'gitea.env'
    config_file.write_text(
        "GITEA_API_URL=https://test.com/api/v1\n"
        "GITEA_API_TOKEN=test_token\n"
        "GITEA_OWNER=test_owner\n"
    )

    monkeypatch.setenv('HOME', str(tmp_path))

    config = GiteaConfig()
    result = config.load()

    assert result['api_url'] == 'https://test.com/api/v1'
    assert result['api_token'] == 'test_token'
    assert result['owner'] == 'test_owner'
    assert result['mode'] == 'company'  # No repo specified

def test_project_config_override(tmp_path, monkeypatch):
    """Test that project config overrides system config"""
    # Set up system config
    system_config_dir = tmp_path / '.config' / 'claude'
    system_config_dir.mkdir(parents=True)

    system_config = system_config_dir / 'gitea.env'
    system_config.write_text(
        "GITEA_API_URL=https://test.com/api/v1\n"
        "GITEA_API_TOKEN=test_token\n"
        "GITEA_OWNER=test_owner\n"
    )

    # Set up project config
    project_dir = tmp_path / 'project'
    project_dir.mkdir()

    project_config = project_dir / '.env'
    project_config.write_text("GITEA_REPO=test_repo\n")

    monkeypatch.setenv('HOME', str(tmp_path))
    monkeypatch.chdir(project_dir)

    config = GiteaConfig()
    result = config.load()

    assert result['repo'] == 'test_repo'
    assert result['mode'] == 'project'

def test_missing_required_config():
    """Test error handling for missing configuration"""
    with pytest.raises(FileNotFoundError):
        config = GiteaConfig()
        config.load()

Integration Tests

# tests/test_gitea_client.py
import pytest
from mcp_server.gitea_client import GiteaClient

@pytest.fixture
def gitea_client():
    """Fixture providing configured Gitea client"""
    return GiteaClient()

def test_list_issues(gitea_client):
    """Test listing issues from Gitea"""
    issues = gitea_client.list_issues(state='open')
    assert isinstance(issues, list)

def test_create_issue(gitea_client):
    """Test creating an issue in Gitea"""
    issue = gitea_client.create_issue(
        title="Test Issue",
        body="Test body",
        labels=["Type/Bug", "Priority/Low"]
    )
    assert issue['title'] == "Test Issue"
    assert any(label['name'] == "Type/Bug" for label in issue['labels'])

def test_get_labels(gitea_client):
    """Test fetching labels"""
    labels = gitea_client.get_labels()
    assert isinstance(labels, list)
    assert len(labels) > 0

def test_pmo_mode_aggregate_issues():
    """Test PMO mode aggregation (no repo specified)"""
    # Set up client without repo
    client = GiteaClient()  # Should detect company mode

    if client.mode == 'company':
        aggregated = client.aggregate_issues(state='open')
        assert isinstance(aggregated, dict)
        # Each key should be a repo name
        for repo_name, issues in aggregated.items():
            assert isinstance(issues, list)

Running Tests

# Activate virtual environment
source .venv/bin/activate

# Run all tests
pytest

# Run with coverage
pytest --cov=mcp_server --cov-report=html

# Run specific test file
pytest tests/test_config.py

# Run with verbose output
pytest -v

Performance Optimization

Caching

from functools import lru_cache

@lru_cache(maxsize=128)
def get_labels_cached(self, repo: str) -> List[Dict]:
    """Cached label retrieval to reduce API calls"""
    return self.get_labels(repo)

Retry Logic

import time
from typing import Callable

def retry_on_failure(max_retries=3, delay=1):
    """Decorator for retrying failed API calls"""
    def decorator(func: Callable):
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except requests.exceptions.RequestException as e:
                    if attempt == max_retries - 1:
                        raise
                    time.sleep(delay * (attempt + 1))
        return wrapper
    return decorator

@retry_on_failure(max_retries=3)
def list_issues(self, state='open', labels=None, repo=None):
    """List issues with automatic retry"""
    # Implementation

Troubleshooting

Common Issues

Issue: Module not found

# Solution: Ensure PYTHONPATH is set in .mcp.json
"env": {
  "PYTHONPATH": "${CLAUDE_PLUGIN_ROOT}/../mcp-servers/gitea"
}

Issue: Configuration not loading

# Solution: Check file permissions
chmod 600 ~/.config/claude/gitea.env

# Verify file exists
cat ~/.config/claude/gitea.env

Issue: API authentication failing

# Solution: Test token manually
curl -H "Authorization: token YOUR_TOKEN" \
  https://your-gitea.com/api/v1/user

Issue: PMO mode not working

# Solution: Ensure GITEA_REPO is NOT set
# Check environment variables
env | grep GITEA

Security

Best Practices

  1. Token Storage:

    • Store tokens in ~/.config/claude/gitea.env
    • Set file permissions to 600
    • Never commit tokens to git
  2. Input Validation:

    • Validate all user input before API calls
    • Sanitize issue titles and descriptions
    • Prevent injection attacks
  3. Error Handling:

    • Don't leak tokens in error messages
    • Log errors without sensitive data
    • Provide user-friendly error messages
  4. API Rate Limiting:

    • Implement exponential backoff
    • Cache frequently accessed data
    • Batch requests where possible

Next Steps

  1. Set up system configuration
  2. Create virtual environment
  3. Install dependencies
  4. Run tests against actual Gitea instance
  5. Integrate with projman plugin