**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.
This commit is contained in:
2026-01-07 09:17:00 +01:00
parent 1779239c07
commit 76d7de5334
11 changed files with 781 additions and 181 deletions

View File

@@ -56,13 +56,56 @@ def _validate_branch(value: str) -> str | None:
def _validate_url(value: str) -> str | None:
"""Validate repository URL format."""
"""
Validate repository URL format with SSRF protection.
Validates the URL format and optionally checks against allowed hosts.
"""
if not isinstance(value, str):
return "Repository URL must be a string"
if not value:
return "Repository URL is required"
if not URL_PATTERN.match(value):
return "Invalid repository URL: must be a valid HTTPS or SSH git URL"
# SSRF protection: check allowed hosts if configured
if _settings and _settings.allowed_hosts:
from urllib.parse import urlparse
parsed = urlparse(value)
hostname = parsed.hostname
# Block localhost and loopback addresses
blocked_hosts = {
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
"169.254.169.254", # Cloud metadata endpoint
}
if hostname and hostname.lower() in blocked_hosts:
return f"Repository URL not allowed: blocked host '{hostname}'"
# Block private IP ranges (simplified check)
if hostname:
import ipaddress
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local:
return (
f"Repository URL not allowed: private IP address '{hostname}'"
)
except ValueError:
pass # Not an IP address, continue with hostname check
# Check against allowed hosts list
if hostname and hostname.lower() not in [
h.lower() for h in _settings.allowed_hosts
]:
return f"Repository URL not allowed: host '{hostname}' not in allowed list"
return None
@@ -92,7 +135,8 @@ def _get_provider_for_url(repo_url: str) -> BaseProvider | None:
if "github.com" in url_lower or (
_settings.github_api_url
and _settings.github_api_url != "https://api.github.com"
and _settings.github_api_url.replace("https://", "").replace("/api/v3", "") in url_lower
and _settings.github_api_url.replace("https://", "").replace("/api/v3", "")
in url_lower
):
return _github_provider
@@ -166,6 +210,13 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
if _github_provider:
await _github_provider.close()
# Shutdown thread pool executor for git operations
from git_wrapper import _executor
if _executor:
logger.info("Shutting down git operations thread pool...")
_executor.shutdown(wait=True)
logger.info("Git Operations MCP Server shut down")
@@ -479,7 +530,9 @@ async def clone_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"),
branch: str | None = Field(
default=None, description="Branch to checkout after clone"
),
depth: int | None = Field(default=None, ge=1, description="Shallow clone depth"),
) -> dict[str, Any]:
"""
@@ -490,11 +543,23 @@ async def clone_repository(
try:
# Validate inputs
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_url(repo_url):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
# Create workspace
workspace = await _workspace_manager.create_workspace(project_id, repo_url) # type: ignore[union-attr]
@@ -504,7 +569,9 @@ async def clone_repository(
# Clone repository
git = GitWrapper(workspace.path, _settings)
result = await git.clone(repo_url, branch=branch, depth=depth, auth_token=auth_token)
result = await git.clone(
repo_url, branch=branch, depth=depth, auth_token=auth_token
)
# Update workspace metadata
await _workspace_manager.update_workspace_branch(project_id, result.branch) # type: ignore[union-attr]
@@ -522,14 +589,20 @@ async def clone_repository(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected clone error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
async def 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"),
include_untracked: bool = Field(
default=True, description="Include untracked files"
),
) -> dict[str, Any]:
"""
Get git status for a project workspace.
@@ -538,13 +611,25 @@ async def git_status(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.status(include_untracked=include_untracked)
@@ -567,7 +652,11 @@ async def git_status(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected status error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
# MCP Tools - Branch Operations
@@ -578,7 +667,9 @@ async def create_branch(
project_id: str = Field(..., description="Project ID for scoping"),
agent_id: str = Field(..., description="Agent ID making the request"),
branch_name: str = Field(..., description="Name for the new branch"),
from_ref: str | None = Field(default=None, description="Reference to create from (default: HEAD)"),
from_ref: str | None = Field(
default=None, description="Reference to create from (default: HEAD)"
),
checkout: bool = Field(default=True, description="Checkout after creation"),
) -> dict[str, Any]:
"""
@@ -586,18 +677,36 @@ async def create_branch(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_branch(branch_name):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.create_branch(branch_name, from_ref=from_ref, checkout=checkout)
result = await git.create_branch(
branch_name, from_ref=from_ref, checkout=checkout
)
if checkout:
await _workspace_manager.update_workspace_branch(project_id, branch_name) # type: ignore[union-attr]
@@ -614,7 +723,11 @@ async def create_branch(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected create branch error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
@@ -628,13 +741,25 @@ async def list_branches(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.list_branches(include_remote=include_remote)
@@ -652,7 +777,11 @@ async def list_branches(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected list branches error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
@@ -668,13 +797,25 @@ async def checkout(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.checkout(ref, create_branch=create_branch, force=force)
@@ -692,7 +833,11 @@ async def checkout(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected checkout error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
# MCP Tools - Commit Operations
@@ -703,7 +848,9 @@ async def 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="Specific files to commit (None = all staged)"),
files: list[str] | None = Field(
default=None, description="Specific 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"),
) -> dict[str, Any]:
@@ -714,13 +861,25 @@ async def commit(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.commit(
@@ -745,14 +904,20 @@ async def commit(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected commit error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
async def push(
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)"),
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"),
@@ -762,13 +927,25 @@ async def push(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
# Get auth token appropriate for the repository URL
auth_token = _get_auth_token_for_url(workspace.repo_url or "")
@@ -794,14 +971,20 @@ async def push(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected push error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
async def pull(
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)"),
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"),
) -> dict[str, Any]:
@@ -810,13 +993,25 @@ async def pull(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.pull(branch=branch, remote=remote, rebase=rebase)
@@ -834,7 +1029,11 @@ async def pull(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected pull error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
# MCP Tools - Diff and Log
@@ -854,16 +1053,30 @@ async def diff(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.diff(base=base, head=head, files=files, context_lines=context_lines)
result = await git.diff(
base=base, head=head, files=files, context_lines=context_lines
)
return {
"success": True,
@@ -881,7 +1094,11 @@ async def diff(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected diff error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
@@ -898,13 +1115,25 @@ async def log(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
git = GitWrapper(workspace.path, _settings)
result = await git.log(ref=ref, limit=limit, skip=skip, path=path)
@@ -921,7 +1150,11 @@ async def log(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected log error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
# MCP Tools - Pull Request Operations
@@ -938,27 +1171,49 @@ async def create_pull_request(
draft: bool = Field(default=False, description="Create as draft"),
labels: list[str] | None = Field(default=None, description="Labels to add"),
assignees: list[str] | None = Field(default=None, description="Users to assign"),
reviewers: list[str] | None = Field(default=None, description="Users to request review from"),
reviewers: list[str] | None = Field(
default=None, description="Users to request review from"
),
) -> dict[str, Any]:
"""
Create a pull request on the remote provider.
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
if not workspace.repo_url:
return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": "Workspace has no repository URL",
"code": ErrorCode.INVALID_REQUEST.value,
}
provider = _get_provider_for_url(workspace.repo_url)
if not provider:
return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value}
return {
"success": False,
"error": "No provider configured for this repository",
"code": ErrorCode.PROVIDER_NOT_FOUND.value,
}
owner, repo = provider.parse_repo_url(workspace.repo_url)
@@ -987,7 +1242,11 @@ async def create_pull_request(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected create PR error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
@@ -1001,20 +1260,40 @@ async def get_pull_request(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
if not workspace.repo_url:
return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": "Workspace has no repository URL",
"code": ErrorCode.INVALID_REQUEST.value,
}
provider = _get_provider_for_url(workspace.repo_url)
if not provider:
return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value}
return {
"success": False,
"error": "No provider configured for this repository",
"code": ErrorCode.PROVIDER_NOT_FOUND.value,
}
owner, repo = provider.parse_repo_url(workspace.repo_url)
result = await provider.get_pr(owner, repo, pr_number)
@@ -1030,14 +1309,20 @@ async def get_pull_request(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected get PR error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
async def list_pull_requests(
project_id: str = Field(..., description="Project ID for scoping"),
agent_id: str = Field(..., description="Agent ID making the request"),
state: str | None = Field(default=None, description="Filter by state: open, closed, merged"),
state: str | None = Field(
default=None, description="Filter by state: open, closed, merged"
),
author: str | None = Field(default=None, description="Filter by author"),
limit: int = Field(default=20, ge=1, le=100, description="Max PRs"),
) -> dict[str, Any]:
@@ -1046,20 +1331,40 @@ async def list_pull_requests(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
if not workspace.repo_url:
return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": "Workspace has no repository URL",
"code": ErrorCode.INVALID_REQUEST.value,
}
provider = _get_provider_for_url(workspace.repo_url)
if not provider:
return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value}
return {
"success": False,
"error": "No provider configured for this repository",
"code": ErrorCode.PROVIDER_NOT_FOUND.value,
}
# Parse state
pr_state = None
@@ -1067,10 +1372,16 @@ async def list_pull_requests(
try:
pr_state = PRState(state.lower())
except ValueError:
return {"success": False, "error": f"Invalid state: {state}. Valid: open, closed, merged", "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": f"Invalid state: {state}. Valid: open, closed, merged",
"code": ErrorCode.INVALID_REQUEST.value,
}
owner, repo = provider.parse_repo_url(workspace.repo_url)
result = await provider.list_prs(owner, repo, state=pr_state, author=author, limit=limit)
result = await provider.list_prs(
owner, repo, state=pr_state, author=author, limit=limit
)
return {
"success": result.success,
@@ -1084,7 +1395,11 @@ async def list_pull_requests(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected list PRs error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
@@ -1092,39 +1407,71 @@ async def merge_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="Pull request number"),
merge_strategy: str = Field(default="merge", description="Strategy: merge, squash, rebase"),
commit_message: str | None = Field(default=None, description="Custom commit message"),
delete_branch: bool = Field(default=True, description="Delete source branch after merge"),
merge_strategy: str = Field(
default="merge", description="Strategy: merge, squash, rebase"
),
commit_message: str | None = Field(
default=None, description="Custom commit message"
),
delete_branch: bool = Field(
default=True, description="Delete source branch after merge"
),
) -> dict[str, Any]:
"""
Merge a pull request.
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
if not workspace.repo_url:
return {"success": False, "error": "Workspace has no repository URL", "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": "Workspace has no repository URL",
"code": ErrorCode.INVALID_REQUEST.value,
}
provider = _get_provider_for_url(workspace.repo_url)
if not provider:
return {"success": False, "error": "No provider configured for this repository", "code": ErrorCode.PROVIDER_NOT_FOUND.value}
return {
"success": False,
"error": "No provider configured for this repository",
"code": ErrorCode.PROVIDER_NOT_FOUND.value,
}
# Parse merge strategy
try:
strategy = MergeStrategy(merge_strategy.lower())
except ValueError:
return {"success": False, "error": f"Invalid strategy: {merge_strategy}. Valid: merge, squash, rebase", "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": f"Invalid strategy: {merge_strategy}. Valid: merge, squash, rebase",
"code": ErrorCode.INVALID_REQUEST.value,
}
owner, repo = provider.parse_repo_url(workspace.repo_url)
result = await provider.merge_pr(
owner, repo, pr_number,
owner,
repo,
pr_number,
merge_strategy=strategy,
commit_message=commit_message,
delete_branch=delete_branch,
@@ -1142,7 +1489,11 @@ async def merge_pull_request(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected merge PR error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
# MCP Tools - Workspace Operations
@@ -1158,13 +1509,25 @@ async def get_workspace(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
if not workspace:
return {"success": False, "error": f"Workspace not found for project: {project_id}", "code": ErrorCode.WORKSPACE_NOT_FOUND.value}
return {
"success": False,
"error": f"Workspace not found for project: {project_id}",
"code": ErrorCode.WORKSPACE_NOT_FOUND.value,
}
return {
"success": True,
@@ -1176,14 +1539,20 @@ async def get_workspace(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected get workspace error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
async def lock_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 in seconds"),
timeout: int = Field(
default=300, ge=10, le=3600, description="Lock timeout in seconds"
),
) -> dict[str, Any]:
"""
Acquire a lock on a workspace.
@@ -1192,9 +1561,17 @@ async def lock_workspace(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
success = await _workspace_manager.lock_workspace(project_id, agent_id, timeout) # type: ignore[union-attr]
workspace = await _workspace_manager.get_workspace(project_id) # type: ignore[union-attr]
@@ -1202,7 +1579,9 @@ async def lock_workspace(
return {
"success": success,
"lock_holder": workspace.lock_holder if workspace else None,
"lock_expires": workspace.lock_expires.isoformat() if workspace and workspace.lock_expires else None,
"lock_expires": workspace.lock_expires.isoformat()
if workspace and workspace.lock_expires
else None,
}
except GitOpsError as e:
@@ -1210,7 +1589,11 @@ async def lock_workspace(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected lock workspace error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
@mcp.tool()
@@ -1224,9 +1607,17 @@ async def unlock_workspace(
"""
try:
if error := _validate_id(project_id, "project_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
if error := _validate_id(agent_id, "agent_id"):
return {"success": False, "error": error, "code": ErrorCode.INVALID_REQUEST.value}
return {
"success": False,
"error": error,
"code": ErrorCode.INVALID_REQUEST.value,
}
success = await _workspace_manager.unlock_workspace(project_id, agent_id, force) # type: ignore[union-attr]
@@ -1237,7 +1628,11 @@ async def unlock_workspace(
return {"success": False, "error": e.message, "code": e.code.value}
except Exception as e:
logger.error(f"Unexpected unlock workspace error: {e}")
return {"success": False, "error": str(e), "code": ErrorCode.INTERNAL_ERROR.value}
return {
"success": False,
"error": str(e),
"code": ErrorCode.INTERNAL_ERROR.value,
}
# Register all tools