Files
syndarix/mcp-servers/git-ops/models.py
Felipe Cardoso 76d7de5334 **feat(git-ops): enhance MCP server with Git provider updates and SSRF protection**
- Added `mcp-git-ops` service to `docker-compose.dev.yml` with health checks and configurations.
- Integrated SSRF protection in repository URL validation for enhanced security.
- Expanded `pyproject.toml` mypy settings and adjusted code to meet stricter type checking.
- Improved workspace management and GitWrapper operations with error handling refinements.
- Updated input validation, branching, and repository operations to align with new error structure.
- Shut down thread pool executor gracefully during server cleanup.
2026-01-07 09:17:00 +01:00

691 lines
24 KiB
Python

"""
Data models for Git Operations MCP Server.
Defines data structures for git operations, workspace management,
and provider interactions.
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class FileChangeType(str, Enum):
"""Types of file changes in git."""
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
RENAMED = "renamed"
COPIED = "copied"
UNTRACKED = "untracked"
IGNORED = "ignored"
class MergeStrategy(str, Enum):
"""Merge strategies for pull requests."""
MERGE = "merge" # Create a merge commit
SQUASH = "squash" # Squash and merge
REBASE = "rebase" # Rebase and merge
class PRState(str, Enum):
"""Pull request states."""
OPEN = "open"
CLOSED = "closed"
MERGED = "merged"
class ProviderType(str, Enum):
"""Supported git providers."""
GITEA = "gitea"
GITHUB = "github"
GITLAB = "gitlab"
class WorkspaceState(str, Enum):
"""Workspace lifecycle states."""
INITIALIZING = "initializing"
READY = "ready"
LOCKED = "locked"
STALE = "stale"
DELETED = "deleted"
# Dataclasses for internal data structures
@dataclass
class FileChange:
"""A file change in git status."""
path: str
change_type: FileChangeType
old_path: str | None = None # For renames
additions: int = 0
deletions: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"path": self.path,
"change_type": self.change_type.value,
"old_path": self.old_path,
"additions": self.additions,
"deletions": self.deletions,
}
@dataclass
class BranchInfo:
"""Information about a git branch."""
name: str
is_current: bool = False
is_remote: bool = False
tracking_branch: str | None = None
commit_sha: str | None = None
commit_message: str | None = None
ahead: int = 0
behind: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"name": self.name,
"is_current": self.is_current,
"is_remote": self.is_remote,
"tracking_branch": self.tracking_branch,
"commit_sha": self.commit_sha,
"commit_message": self.commit_message,
"ahead": self.ahead,
"behind": self.behind,
}
@dataclass
class CommitInfo:
"""Information about a git commit."""
sha: str
short_sha: str
message: str
author_name: str
author_email: str
authored_date: datetime
committer_name: str
committer_email: str
committed_date: datetime
parents: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"sha": self.sha,
"short_sha": self.short_sha,
"message": self.message,
"author_name": self.author_name,
"author_email": self.author_email,
"authored_date": self.authored_date.isoformat(),
"committer_name": self.committer_name,
"committer_email": self.committer_email,
"committed_date": self.committed_date.isoformat(),
"parents": self.parents,
}
@dataclass
class DiffHunk:
"""A hunk of diff content."""
old_start: int
old_lines: int
new_start: int
new_lines: int
content: str
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"old_start": self.old_start,
"old_lines": self.old_lines,
"new_start": self.new_start,
"new_lines": self.new_lines,
"content": self.content,
}
@dataclass
class FileDiff:
"""Diff for a single file."""
path: str
change_type: FileChangeType
old_path: str | None = None
hunks: list[DiffHunk] = field(default_factory=list)
additions: int = 0
deletions: int = 0
is_binary: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"path": self.path,
"change_type": self.change_type.value,
"old_path": self.old_path,
"hunks": [h.to_dict() for h in self.hunks],
"additions": self.additions,
"deletions": self.deletions,
"is_binary": self.is_binary,
}
@dataclass
class PRInfo:
"""Information about a pull request."""
number: int
title: str
body: str
state: PRState
source_branch: str
target_branch: str
author: str
created_at: datetime
updated_at: datetime
merged_at: datetime | None = None
closed_at: datetime | None = None
url: str | None = None
labels: list[str] = field(default_factory=list)
assignees: list[str] = field(default_factory=list)
reviewers: list[str] = field(default_factory=list)
mergeable: bool | None = None
draft: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"number": self.number,
"title": self.title,
"body": self.body,
"state": self.state.value,
"source_branch": self.source_branch,
"target_branch": self.target_branch,
"author": self.author,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"merged_at": self.merged_at.isoformat() if self.merged_at else None,
"closed_at": self.closed_at.isoformat() if self.closed_at else None,
"url": self.url,
"labels": self.labels,
"assignees": self.assignees,
"reviewers": self.reviewers,
"mergeable": self.mergeable,
"draft": self.draft,
}
@dataclass
class WorkspaceInfo:
"""Information about a project workspace."""
project_id: str
path: str
state: WorkspaceState
repo_url: str | None = None
current_branch: str | None = None
last_accessed: datetime = field(default_factory=lambda: datetime.now(UTC))
size_bytes: int = 0
lock_holder: str | None = None
lock_expires: datetime | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"project_id": self.project_id,
"path": self.path,
"state": self.state.value,
"repo_url": self.repo_url,
"current_branch": self.current_branch,
"last_accessed": self.last_accessed.isoformat(),
"size_bytes": self.size_bytes,
"lock_holder": self.lock_holder,
"lock_expires": self.lock_expires.isoformat()
if self.lock_expires
else None,
}
# Pydantic Request/Response Models
class CloneRequest(BaseModel):
"""Request to clone a repository."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
repo_url: str = Field(..., description="Repository URL to clone")
branch: str | None = Field(
default=None, description="Branch to checkout after clone"
)
depth: int | None = Field(
default=None, ge=1, description="Shallow clone depth (None = full clone)"
)
class CloneResult(BaseModel):
"""Result of a clone operation."""
success: bool = Field(..., description="Whether clone succeeded")
project_id: str = Field(..., description="Project ID")
workspace_path: str = Field(..., description="Path to cloned workspace")
branch: str = Field(..., description="Current branch after clone")
commit_sha: str = Field(..., description="HEAD commit SHA")
error: str | None = Field(default=None, description="Error message if failed")
class StatusRequest(BaseModel):
"""Request for git status."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
include_untracked: bool = Field(default=True, description="Include untracked files")
class StatusResult(BaseModel):
"""Result of a status operation."""
project_id: str = Field(..., description="Project ID")
branch: str = Field(..., description="Current branch")
commit_sha: str = Field(..., description="HEAD commit SHA")
is_clean: bool = Field(..., description="Whether working tree is clean")
staged: list[dict[str, Any]] = Field(
default_factory=list, description="Staged changes"
)
unstaged: list[dict[str, Any]] = Field(
default_factory=list, description="Unstaged changes"
)
untracked: list[str] = Field(default_factory=list, description="Untracked files")
ahead: int = Field(default=0, description="Commits ahead of upstream")
behind: int = Field(default=0, description="Commits behind upstream")
class BranchRequest(BaseModel):
"""Request for branch operations."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
branch_name: str = Field(..., description="Branch name")
from_ref: str | None = Field(
default=None, description="Reference to create branch from"
)
checkout: bool = Field(default=True, description="Checkout after creation")
class BranchResult(BaseModel):
"""Result of a branch operation."""
success: bool = Field(..., description="Whether operation succeeded")
branch: str = Field(..., description="Branch name")
commit_sha: str | None = Field(default=None, description="HEAD commit SHA")
is_current: bool = Field(default=False, description="Whether branch is checked out")
error: str | None = Field(default=None, description="Error message if failed")
class ListBranchesRequest(BaseModel):
"""Request to list branches."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
include_remote: bool = Field(default=False, description="Include remote branches")
class ListBranchesResult(BaseModel):
"""Result of listing branches."""
project_id: str = Field(..., description="Project ID")
current_branch: str = Field(..., description="Currently checked out branch")
local_branches: list[dict[str, Any]] = Field(
default_factory=list, description="Local branches"
)
remote_branches: list[dict[str, Any]] = Field(
default_factory=list, description="Remote branches"
)
class CheckoutRequest(BaseModel):
"""Request to checkout a branch or ref."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
ref: str = Field(..., description="Branch, tag, or commit to checkout")
create_branch: bool = Field(default=False, description="Create new branch")
force: bool = Field(default=False, description="Force checkout (discard changes)")
class CheckoutResult(BaseModel):
"""Result of a checkout operation."""
success: bool = Field(..., description="Whether checkout succeeded")
ref: str = Field(..., description="Checked out reference")
commit_sha: str | None = Field(default=None, description="HEAD commit SHA")
error: str | None = Field(default=None, description="Error message if failed")
class CommitRequest(BaseModel):
"""Request to create a commit."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
message: str = Field(..., description="Commit message")
files: list[str] | None = Field(
default=None, description="Files to commit (None = all staged)"
)
author_name: str | None = Field(default=None, description="Author name override")
author_email: str | None = Field(default=None, description="Author email override")
allow_empty: bool = Field(default=False, description="Allow empty commit")
class CommitResult(BaseModel):
"""Result of a commit operation."""
success: bool = Field(..., description="Whether commit succeeded")
commit_sha: str | None = Field(default=None, description="New commit SHA")
short_sha: str | None = Field(default=None, description="Short commit SHA")
message: str | None = Field(default=None, description="Commit message")
files_changed: int = Field(default=0, description="Number of files changed")
insertions: int = Field(default=0, description="Lines added")
deletions: int = Field(default=0, description="Lines removed")
error: str | None = Field(default=None, description="Error message if failed")
class PushRequest(BaseModel):
"""Request to push to remote."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
branch: str | None = Field(
default=None, description="Branch to push (None = current)"
)
remote: str = Field(default="origin", description="Remote name")
force: bool = Field(default=False, description="Force push")
set_upstream: bool = Field(default=True, description="Set upstream tracking")
class PushResult(BaseModel):
"""Result of a push operation."""
success: bool = Field(..., description="Whether push succeeded")
branch: str = Field(..., description="Pushed branch")
remote: str = Field(..., description="Remote name")
commits_pushed: int = Field(default=0, description="Number of commits pushed")
error: str | None = Field(default=None, description="Error message if failed")
class PullRequest(BaseModel):
"""Request to pull from remote."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
branch: str | None = Field(
default=None, description="Branch to pull (None = current)"
)
remote: str = Field(default="origin", description="Remote name")
rebase: bool = Field(default=False, description="Rebase instead of merge")
class PullResult(BaseModel):
"""Result of a pull operation."""
success: bool = Field(..., description="Whether pull succeeded")
branch: str = Field(..., description="Pulled branch")
commits_received: int = Field(default=0, description="New commits received")
fast_forward: bool = Field(default=False, description="Was fast-forward")
conflicts: list[str] = Field(
default_factory=list, description="Conflicting files if any"
)
error: str | None = Field(default=None, description="Error message if failed")
class DiffRequest(BaseModel):
"""Request for diff."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
base: str | None = Field(
default=None, description="Base reference (None = working tree)"
)
head: str | None = Field(default=None, description="Head reference (None = HEAD)")
files: list[str] | None = Field(default=None, description="Specific files to diff")
context_lines: int = Field(default=3, ge=0, description="Context lines")
class DiffResult(BaseModel):
"""Result of a diff operation."""
project_id: str = Field(..., description="Project ID")
base: str | None = Field(default=None, description="Base reference")
head: str | None = Field(default=None, description="Head reference")
files: list[dict[str, Any]] = Field(default_factory=list, description="File diffs")
total_additions: int = Field(default=0, description="Total lines added")
total_deletions: int = Field(default=0, description="Total lines removed")
files_changed: int = Field(default=0, description="Number of files changed")
class LogRequest(BaseModel):
"""Request for commit log."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
ref: str | None = Field(default=None, description="Reference to start from")
limit: int = Field(default=20, ge=1, le=100, description="Max commits to return")
skip: int = Field(default=0, ge=0, description="Commits to skip")
path: str | None = Field(default=None, description="Filter by path")
class LogResult(BaseModel):
"""Result of a log operation."""
project_id: str = Field(..., description="Project ID")
commits: list[dict[str, Any]] = Field(
default_factory=list, description="Commit history"
)
total_commits: int = Field(default=0, description="Total commits in range")
# PR Operations
class CreatePRRequest(BaseModel):
"""Request to create a pull request."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
title: str = Field(..., description="PR title")
body: str = Field(default="", description="PR description")
source_branch: str = Field(..., description="Source branch")
target_branch: str = Field(default="main", description="Target branch")
draft: bool = Field(default=False, description="Create as draft")
labels: list[str] = Field(default_factory=list, description="Labels to add")
assignees: list[str] = Field(default_factory=list, description="Assignees")
reviewers: list[str] = Field(default_factory=list, description="Reviewers")
class CreatePRResult(BaseModel):
"""Result of creating a pull request."""
success: bool = Field(..., description="Whether creation succeeded")
pr_number: int | None = Field(default=None, description="PR number")
pr_url: str | None = Field(default=None, description="PR URL")
error: str | None = Field(default=None, description="Error message if failed")
class GetPRRequest(BaseModel):
"""Request to get a pull request."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
pr_number: int = Field(..., description="PR number")
class GetPRResult(BaseModel):
"""Result of getting a pull request."""
success: bool = Field(..., description="Whether fetch succeeded")
pr: dict[str, Any] | None = Field(default=None, description="PR info")
error: str | None = Field(default=None, description="Error message if failed")
class ListPRsRequest(BaseModel):
"""Request to list pull requests."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
state: PRState | None = Field(default=None, description="Filter by state")
author: str | None = Field(default=None, description="Filter by author")
limit: int = Field(default=20, ge=1, le=100, description="Max PRs to return")
class ListPRsResult(BaseModel):
"""Result of listing pull requests."""
success: bool = Field(..., description="Whether list succeeded")
pull_requests: list[dict[str, Any]] = Field(default_factory=list, description="PRs")
total_count: int = Field(default=0, description="Total matching PRs")
error: str | None = Field(default=None, description="Error message if failed")
class MergePRRequest(BaseModel):
"""Request to merge a pull request."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
pr_number: int = Field(..., description="PR number")
merge_strategy: MergeStrategy = Field(
default=MergeStrategy.MERGE, description="Merge strategy"
)
commit_message: str | None = Field(
default=None, description="Custom merge commit message"
)
delete_branch: bool = Field(
default=True, description="Delete source branch after merge"
)
class MergePRResult(BaseModel):
"""Result of merging a pull request."""
success: bool = Field(..., description="Whether merge succeeded")
merge_commit_sha: str | None = Field(default=None, description="Merge commit SHA")
branch_deleted: bool = Field(
default=False, description="Whether branch was deleted"
)
error: str | None = Field(default=None, description="Error message if failed")
class UpdatePRRequest(BaseModel):
"""Request to update a pull request."""
project_id: str = Field(..., description="Project ID for scoping")
agent_id: str = Field(..., description="Agent ID making the request")
pr_number: int = Field(..., description="PR number")
title: str | None = Field(default=None, description="New title")
body: str | None = Field(default=None, description="New description")
state: PRState | None = Field(default=None, description="New state")
labels: list[str] | None = Field(default=None, description="Replace labels")
assignees: list[str] | None = Field(default=None, description="Replace assignees")
class UpdatePRResult(BaseModel):
"""Result of updating a pull request."""
success: bool = Field(..., description="Whether update succeeded")
pr: dict[str, Any] | None = Field(default=None, description="Updated PR info")
error: str | None = Field(default=None, description="Error message if failed")
# Workspace Operations
class GetWorkspaceRequest(BaseModel):
"""Request to get or create workspace."""
project_id: str = Field(..., description="Project ID")
agent_id: str = Field(..., description="Agent ID making the request")
class GetWorkspaceResult(BaseModel):
"""Result of getting workspace."""
success: bool = Field(..., description="Whether operation succeeded")
workspace: dict[str, Any] | None = Field(default=None, description="Workspace info")
error: str | None = Field(default=None, description="Error message if failed")
class LockWorkspaceRequest(BaseModel):
"""Request to lock a workspace."""
project_id: str = Field(..., description="Project ID")
agent_id: str = Field(..., description="Agent ID requesting lock")
timeout: int = Field(
default=300, ge=10, le=3600, description="Lock timeout seconds"
)
class LockWorkspaceResult(BaseModel):
"""Result of locking workspace."""
success: bool = Field(..., description="Whether lock acquired")
lock_holder: str | None = Field(default=None, description="Current lock holder")
lock_expires: str | None = Field(
default=None, description="Lock expiry ISO timestamp"
)
error: str | None = Field(default=None, description="Error message if failed")
class UnlockWorkspaceRequest(BaseModel):
"""Request to unlock a workspace."""
project_id: str = Field(..., description="Project ID")
agent_id: str = Field(..., description="Agent ID releasing lock")
force: bool = Field(default=False, description="Force unlock (admin only)")
class UnlockWorkspaceResult(BaseModel):
"""Result of unlocking workspace."""
success: bool = Field(..., description="Whether unlock succeeded")
error: str | None = Field(default=None, description="Error message if failed")
# Health and Status
class HealthStatus(BaseModel):
"""Health status response."""
status: str = Field(..., description="Health status")
version: str = Field(..., description="Server version")
workspace_count: int = Field(default=0, description="Active workspaces")
gitea_connected: bool = Field(default=False, description="Gitea connectivity")
github_connected: bool = Field(default=False, description="GitHub connectivity")
gitlab_connected: bool = Field(default=False, description="GitLab connectivity")
redis_connected: bool = Field(default=False, description="Redis connectivity")
class ProviderStatus(BaseModel):
"""Provider connection status."""
provider: str = Field(..., description="Provider name")
connected: bool = Field(..., description="Connection status")
url: str | None = Field(default=None, description="Provider URL")
user: str | None = Field(default=None, description="Authenticated user")
error: str | None = Field(default=None, description="Error if not connected")