**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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user