First git integration implementation + KB fixes and upgrades

This commit is contained in:
Renn F
2025-12-30 18:55:32 +01:00
parent 0a5988a18f
commit 22d1401020
51 changed files with 7519 additions and 178 deletions
+9
View File
@@ -0,0 +1,9 @@
"""
Git MCP Server Package
Provides git operations for agents working on code tasks.
"""
from roboco.mcp.git.git_server import create_git_mcp_server
__all__ = ["create_git_mcp_server"]
+340
View File
@@ -0,0 +1,340 @@
"""
Git MCP Server
Exposes git operations to Claude Code agents with built-in
enforcement of branch policies and access controls.
Tools (Developer):
- roboco_git_status: Check branch and working tree status
- roboco_git_commit: Create a commit with message
- roboco_git_push: Push commits to remote
- roboco_git_diff: View staged/unstaged changes
Tools (Developer - PR workflow):
- roboco_git_create_pr: Create a PR for the current branch
Tools (PM - Branch management):
- roboco_git_create_branch: Create a task branch
- roboco_git_checkout: Switch to a branch
- roboco_git_merge_pr: Merge a PR (approve and merge)
Tools (All - Read-only):
- roboco_git_log: View recent commits
- roboco_git_branch_list: List branches
"""
from typing import Any
from mcp.server.fastmcp import FastMCP
from roboco.mcp.git.handlers import (
handle_git_branch_list,
handle_git_checkout,
handle_git_commit,
handle_git_create_branch,
handle_git_create_pr,
handle_git_diff,
handle_git_log,
handle_git_merge_pr,
handle_git_push,
handle_git_status,
)
from roboco.mcp.utils import ApiClient
def _register_readonly_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
"""Register read-only git tools available to all agents."""
@mcp.tool()
async def roboco_git_status(
project_slug: str,
task_id: str | None = None,
) -> dict[str, Any]:
"""
Check git status for a project.
Shows current branch, staged changes, unstaged changes, and untracked files.
Args:
project_slug: Project identifier (e.g., 'roboco', 'roboco-panel')
task_id: Optional task ID for context
Returns:
Git status with branch info and file changes
"""
return await handle_git_status(client, project_slug, task_id, agent_id)
@mcp.tool()
async def roboco_git_log(
project_slug: str,
limit: int = 10,
branch: str | None = None,
) -> dict[str, Any]:
"""
View recent git commits.
Args:
project_slug: Project identifier
limit: Number of commits to show (default 10, max 50)
branch: Branch to show commits from (default: current)
Returns:
List of commits with hash, message, author, date
"""
return await handle_git_log(client, project_slug, limit, branch, agent_id)
@mcp.tool()
async def roboco_git_branch_list(
project_slug: str,
include_remote: bool = False,
) -> dict[str, Any]:
"""
List git branches.
Args:
project_slug: Project identifier
include_remote: Include remote branches
Returns:
List of branches with current branch marked
"""
return await handle_git_branch_list(
client, project_slug, include_remote, agent_id
)
@mcp.tool()
async def roboco_git_diff(
project_slug: str,
staged: bool = False,
file_path: str | None = None,
) -> dict[str, Any]:
"""
View git diff (changes).
Args:
project_slug: Project identifier
staged: If True, show staged changes; otherwise unstaged
file_path: Optional specific file to diff
Returns:
Diff output with file changes
"""
return await handle_git_diff(client, project_slug, staged, file_path, agent_id)
def _register_developer_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
"""Register developer git tools."""
@mcp.tool()
async def roboco_git_commit(
project_slug: str,
message: str,
task_id: str,
files: list[str] | None = None,
) -> dict[str, Any]:
"""
Create a git commit.
ENFORCEMENT:
- Must be on a task branch (not main/master)
- Commit message should reference the task
- You must be assigned to the task
Args:
project_slug: Project identifier
message: Commit message (will be prefixed with task ID)
task_id: Task ID this commit is for
files: Optional list of files to stage; if None, stages all
Returns:
Commit details with hash and files changed
"""
return await handle_git_commit(
client, project_slug, message, task_id, files, agent_id
)
@mcp.tool()
async def roboco_git_push(
project_slug: str,
task_id: str,
force: bool = False,
) -> dict[str, Any]:
"""
Push commits to remote.
ENFORCEMENT:
- Must be on a task branch (not main/master)
- Protected branches cannot be pushed to directly
Args:
project_slug: Project identifier
task_id: Task ID for validation
force: Force push (use with caution, PM approval may be needed)
Returns:
Push result with remote branch info
"""
return await handle_git_push(client, project_slug, task_id, force, agent_id)
@mcp.tool()
async def roboco_git_create_pr(
project_slug: str,
task_id: str,
title: str,
body: str,
) -> dict[str, Any]:
"""
Create a Pull Request for the current branch.
ENFORCEMENT:
- Task must be in AWAITING_DOCUMENTATION status (QA passed)
- You must be the developer assigned to the task
- PR will target the parent task's branch or main
Args:
project_slug: Project identifier
task_id: Task ID this PR is for
title: PR title
body: PR description (include what was done, testing notes)
Returns:
PR details with URL and number
"""
return await handle_git_create_pr(
client, project_slug, task_id, title, body, agent_id
)
def _register_pm_branch_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
"""Register PM branch management tools."""
@mcp.tool()
async def roboco_git_create_branch(
project_slug: str,
task_id: str,
branch_type: str,
parent_branch: str | None = None,
) -> dict[str, Any]:
"""
Create a task branch (PM only).
Branch naming follows: {type}/{team}/{task_id}[/{subtask_id}]
ENFORCEMENT:
- Only PMs can create task branches
- Branch type must be: feature, bug, chore, docs, hotfix
- Branch is created from parent_branch or default branch
Args:
project_slug: Project identifier
task_id: Task ID for the branch
branch_type: One of: feature, bug, chore, docs, hotfix
parent_branch: Branch to create from (default: main)
Returns:
Created branch info with checkout instructions
"""
return await handle_git_create_branch(
client, project_slug, task_id, branch_type, parent_branch, agent_id
)
@mcp.tool()
async def roboco_git_checkout(
project_slug: str,
branch: str,
) -> dict[str, Any]:
"""
Switch to a branch.
Args:
project_slug: Project identifier
branch: Branch name to checkout
Returns:
Checkout result with current branch
"""
return await handle_git_checkout(client, project_slug, branch, agent_id)
@mcp.tool()
async def roboco_git_merge_pr(
project_slug: str,
pr_number: int,
task_id: str,
merge_method: str = "squash",
) -> dict[str, Any]:
"""
Merge a Pull Request (PM only).
ENFORCEMENT:
- Only the appropriate PM can merge (Cell PM for subtasks, Main PM for parent)
- For CEO approval tasks, only CEO can merge to main
- All required approvals must be in place
Args:
project_slug: Project identifier
pr_number: PR number to merge
task_id: Task ID for validation
merge_method: One of: merge, squash, rebase (default: squash)
Returns:
Merge result with final commit hash
"""
return await handle_git_merge_pr(
client, project_slug, pr_number, task_id, merge_method, agent_id
)
def create_git_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Git MCP server for a specific agent.
Tools are registered based on role:
- All agents: read-only tools (status, log, branch list, diff)
- Developers: commit, push, create PR
- PMs: create branch, checkout, merge PR
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server with role-appropriate tools
"""
from roboco.agents_config import get_agent_role
mcp = FastMCP(f"roboco-git-{agent_id}", json_response=True)
client = ApiClient(agent_id)
role = get_agent_role(agent_id)
# Read-only tools available to ALL agents
_register_readonly_tools(mcp, client, agent_id)
# Role-specific tool registration
if role == "developer":
_register_developer_tools(mcp, client, agent_id)
elif role in ("cell_pm", "main_pm"):
# PMs get both developer tools and branch management
_register_developer_tools(mcp, client, agent_id)
_register_pm_branch_tools(mcp, client, agent_id)
elif role in ("product_owner", "head_marketing", "auditor", "ceo"):
# Board/Management: same as Main PM
_register_developer_tools(mcp, client, agent_id)
_register_pm_branch_tools(mcp, client, agent_id)
# QA and Documenter: only read-only tools (already registered)
return mcp
if __name__ == "__main__":
import sys
_MIN_ARGS = 2
if len(sys.argv) < _MIN_ARGS:
print("Usage: python git_server.py <agent_id>")
sys.exit(1)
agent_id_cli = sys.argv[1]
server = create_git_mcp_server(agent_id_cli)
server.run()
+409
View File
@@ -0,0 +1,409 @@
"""
Git MCP Server Handlers
Handler functions for git operations. Each handler:
1. Validates permissions and state
2. Calls the internal API
3. Returns formatted response with guidance
"""
from typing import Any
from roboco.mcp.utils import (
ApiClient,
format_error_response,
format_success_response,
)
# =============================================================================
# READ-ONLY HANDLERS
# =============================================================================
async def handle_git_status(
client: ApiClient,
project_slug: str,
task_id: str | None,
_agent_id: str,
) -> dict[str, Any]:
"""Handle git status request."""
params: dict[str, Any] = {"project_slug": project_slug}
if task_id:
params["task_id"] = task_id
resp = await client.get("/git/status", params=params)
if not resp.ok:
return format_error_response(
"GIT_STATUS_FAILED",
"Failed to get git status",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
return format_success_response(
data,
guidance=_get_status_guidance(data),
next_step="COMMIT" if data.get("has_changes") else None,
)
def _get_status_guidance(data: dict[str, Any]) -> str:
"""Generate guidance based on git status."""
staged = data.get("staged_files", [])
unstaged = data.get("unstaged_files", [])
untracked = data.get("untracked_files", [])
if not staged and not unstaged and not untracked:
return "Working tree is clean. No changes to commit."
parts = []
if staged:
parts.append(f"{len(staged)} staged file(s) ready to commit")
if unstaged:
parts.append(f"{len(unstaged)} modified file(s) not staged")
if untracked:
parts.append(f"{len(untracked)} untracked file(s)")
guidance = ". ".join(parts) + "."
if staged:
guidance += "\nUse roboco_git_commit() to create a commit."
elif unstaged or untracked:
guidance += (
"\nUse roboco_git_commit() with files parameter to stage and commit."
)
return guidance
async def handle_git_log(
client: ApiClient,
project_slug: str,
limit: int,
branch: str | None,
_agent_id: str,
) -> dict[str, Any]:
"""Handle git log request."""
# Enforce max limit
max_limit = 50
limit = min(limit, max_limit)
params: dict[str, Any] = {"project_slug": project_slug, "limit": limit}
if branch:
params["branch"] = branch
resp = await client.get("/git/log", params=params)
if not resp.ok:
return format_error_response(
"GIT_LOG_FAILED",
"Failed to get git log",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
return format_success_response(
data,
guidance=f"Showing {len(data.get('commits', []))} recent commits.",
)
async def handle_git_branch_list(
client: ApiClient,
project_slug: str,
include_remote: bool,
_agent_id: str,
) -> dict[str, Any]:
"""Handle git branch list request."""
params: dict[str, Any] = {
"project_slug": project_slug,
"include_remote": include_remote,
}
resp = await client.get("/git/branches", params=params)
if not resp.ok:
return format_error_response(
"GIT_BRANCH_LIST_FAILED",
"Failed to list branches",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
branches = data.get("branches", [])
current = data.get("current_branch", "unknown")
return format_success_response(
data,
guidance=f"Current branch: {current}. {len(branches)} total branch(es).",
)
async def handle_git_diff(
client: ApiClient,
project_slug: str,
staged: bool,
file_path: str | None,
_agent_id: str,
) -> dict[str, Any]:
"""Handle git diff request."""
params: dict[str, Any] = {"project_slug": project_slug, "staged": staged}
if file_path:
params["file_path"] = file_path
resp = await client.get("/git/diff", params=params)
if not resp.ok:
return format_error_response(
"GIT_DIFF_FAILED",
"Failed to get diff",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
diff_type = "staged" if staged else "unstaged"
return format_success_response(
data,
guidance=f"Showing {diff_type} changes."
+ (" No changes." if not data.get("diff") else ""),
)
# =============================================================================
# DEVELOPER HANDLERS
# =============================================================================
async def handle_git_commit( # noqa: PLR0913
client: ApiClient,
project_slug: str,
message: str,
task_id: str,
files: list[str] | None,
agent_id: str,
) -> dict[str, Any]:
"""Handle git commit request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"message": message,
"task_id": task_id,
"agent_id": agent_id,
}
if files:
payload["files"] = files
resp = await client.post("/git/commit", json=payload)
if not resp.ok:
return format_error_response(
"GIT_COMMIT_FAILED",
"Failed to create commit",
{"status": resp.status_code, "detail": resp.text},
hint="Check you are on a task branch and have staged changes.",
)
data = resp.json()
commit_hash = data.get("commit_hash", "unknown")[:8]
files_changed = data.get("files_changed", 0)
return format_success_response(
data,
guidance=f"Commit {commit_hash} created with {files_changed} file(s).\n"
"Use roboco_git_push() when ready to push to remote.",
next_step="PUSH",
)
async def handle_git_push(
client: ApiClient,
project_slug: str,
task_id: str,
force: bool,
agent_id: str,
) -> dict[str, Any]:
"""Handle git push request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"agent_id": agent_id,
"force": force,
}
resp = await client.post("/git/push", json=payload)
if not resp.ok:
return format_error_response(
"GIT_PUSH_FAILED",
"Failed to push",
{"status": resp.status_code, "detail": resp.text},
hint="Check branch is not protected and you have commits to push.",
)
data = resp.json()
branch = data.get("branch", "unknown")
commits_pushed = data.get("commits_pushed", 0)
return format_success_response(
data,
guidance=f"Pushed {commits_pushed} commit(s) to {branch}.\n"
"Continue working or create a PR when ready.",
next_step="CREATE_PR" if data.get("ready_for_pr") else None,
)
async def handle_git_create_pr( # noqa: PLR0913
client: ApiClient,
project_slug: str,
task_id: str,
title: str,
body: str,
agent_id: str,
) -> dict[str, Any]:
"""Handle PR creation request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"title": title,
"body": body,
"agent_id": agent_id,
}
resp = await client.post("/git/pr/create", json=payload)
if not resp.ok:
return format_error_response(
"PR_CREATE_FAILED",
"Failed to create PR",
{"status": resp.status_code, "detail": resp.text},
hint="Ensure QA passed and you have pushed commits.",
)
data = resp.json()
pr_url = data.get("pr_url", "")
pr_number = data.get("pr_number", 0)
return format_success_response(
data,
guidance=f"PR #{pr_number} created: {pr_url}\n"
"The PM will review and merge when ready.",
next_step="AWAIT_MERGE",
)
# =============================================================================
# PM HANDLERS
# =============================================================================
async def handle_git_create_branch( # noqa: PLR0913
client: ApiClient,
project_slug: str,
task_id: str,
branch_type: str,
parent_branch: str | None,
agent_id: str,
) -> dict[str, Any]:
"""Handle branch creation request (PM only)."""
valid_types = {"feature", "bug", "chore", "docs", "hotfix"}
if branch_type not in valid_types:
return format_error_response(
"INVALID_BRANCH_TYPE",
f"Branch type must be one of: {', '.join(valid_types)}",
{"provided": branch_type},
)
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"branch_type": branch_type,
"agent_id": agent_id,
}
if parent_branch:
payload["parent_branch"] = parent_branch
resp = await client.post("/git/branch/create", json=payload)
if not resp.ok:
return format_error_response(
"BRANCH_CREATE_FAILED",
"Failed to create branch",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
branch_name = data.get("branch_name", "unknown")
return format_success_response(
data,
guidance=f"Branch '{branch_name}' created.\n"
"Assign the task to a developer who will work on this branch.",
next_step="ASSIGN_TASK",
)
async def handle_git_checkout(
client: ApiClient,
project_slug: str,
branch: str,
agent_id: str,
) -> dict[str, Any]:
"""Handle branch checkout request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"branch": branch,
"agent_id": agent_id,
}
resp = await client.post("/git/checkout", json=payload)
if not resp.ok:
return format_error_response(
"CHECKOUT_FAILED",
f"Failed to checkout branch '{branch}'",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
return format_success_response(
data,
guidance=f"Switched to branch '{branch}'.",
)
async def handle_git_merge_pr( # noqa: PLR0913
client: ApiClient,
project_slug: str,
pr_number: int,
task_id: str,
merge_method: str,
agent_id: str,
) -> dict[str, Any]:
"""Handle PR merge request (PM only)."""
valid_methods = {"merge", "squash", "rebase"}
if merge_method not in valid_methods:
return format_error_response(
"INVALID_MERGE_METHOD",
f"Merge method must be one of: {', '.join(valid_methods)}",
{"provided": merge_method},
)
payload: dict[str, Any] = {
"project_slug": project_slug,
"pr_number": pr_number,
"task_id": task_id,
"merge_method": merge_method,
"agent_id": agent_id,
}
resp = await client.post("/git/pr/merge", json=payload)
if not resp.ok:
return format_error_response(
"PR_MERGE_FAILED",
f"Failed to merge PR #{pr_number}",
{"status": resp.status_code, "detail": resp.text},
hint="Check all approvals are in place and there are no conflicts.",
)
data = resp.json()
merged_into = data.get("target_branch", "unknown")
commit_hash = data.get("merge_commit", "unknown")[:8]
return format_success_response(
data,
guidance=f"PR #{pr_number} merged into {merged_into} (commit {commit_hash}).\n"
"The work session is now complete.",
next_step="COMPLETE_TASK",
)
+34 -1
View File
@@ -38,6 +38,7 @@ Tools (PM/Board):
- roboco_task_activate: Move task from backlog to pending
- roboco_task_complete: Mark task complete (after full workflow)
- roboco_task_cancel: Cancel a task
- roboco_task_escalate_to_ceo: Escalate task to CEO for approval (sends notification)
Tools (Sessions - PM/Board):
- roboco_session_create_for_tasks: Create work session for tasks
@@ -64,6 +65,7 @@ from roboco.mcp.schemas import (
from roboco.mcp.tasks.handlers import (
handle_agent_idle,
handle_docs_complete,
handle_escalate_to_ceo,
handle_group_create,
handle_session_create_for_tasks,
handle_session_get_for_task,
@@ -584,6 +586,36 @@ def _register_pm_completion_tools(
client, task_id, agent_id, force_with_cancelled, justification
)
@mcp.tool()
async def roboco_task_escalate_to_ceo(
task_id: str, notes: str | None = None
) -> dict[str, Any]:
"""
Escalate a task to CEO for final approval (PM only).
Use this for major tasks that require CEO sign-off before completion:
- Parent tasks with multiple subtasks
- High-priority or high-risk features
- Breaking changes or architectural decisions
- Tasks that need final executive approval
ENFORCEMENT:
- Only PMs and management can escalate to CEO
- Task must be in 'awaiting_pm_review' status
After escalation, the CEO will:
- roboco_task_ceo_approve: Complete the task
- roboco_task_ceo_reject: Send back for revision
Args:
task_id: The task UUID to escalate
notes: Optional notes for the CEO explaining the escalation
Returns:
Task in awaiting_ceo_approval status
"""
return await handle_escalate_to_ceo(client, task_id, agent_id, notes)
def _register_pm_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
"""Register PM delegation and management tools."""
@@ -871,7 +903,8 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
_register_blocking_tools(mcp, client, agent_id)
elif role in ("product_owner", "head_marketing", "auditor", "ceo"):
# Board/Management: PM tools + completion + groups
# Board/CEO: PM tools + completion + groups
# Note: CEO is human (HiTL), uses API directly for approvals
_register_pm_completion_tools(mcp, client, agent_id)
_register_pm_tools(mcp, client, agent_id)
_register_session_tools(mcp, client, agent_id)
+6
View File
@@ -12,7 +12,10 @@ from roboco.mcp.tasks.handlers.blocking import (
from roboco.mcp.tasks.handlers.claim import handle_task_claim
from roboco.mcp.tasks.handlers.lifecycle import (
handle_agent_idle,
handle_ceo_approve,
handle_ceo_reject,
handle_docs_complete,
handle_escalate_to_ceo,
handle_submit_pm_review,
handle_task_cancel,
handle_task_complete,
@@ -46,7 +49,10 @@ from roboco.mcp.tasks.handlers.work import (
__all__ = [
"handle_agent_idle",
"handle_ceo_approve",
"handle_ceo_reject",
"handle_docs_complete",
"handle_escalate_to_ceo",
"handle_group_create",
"handle_session_create_for_tasks",
"handle_session_get_for_task",
+163
View File
@@ -232,6 +232,169 @@ async def handle_task_complete(
)
# =============================================================================
# CEO APPROVAL WORKFLOW
# =============================================================================
def _validate_ceo_role(agent_id: str) -> dict[str, Any] | None:
"""Validate agent is CEO. Returns error or None."""
agent_role = get_agent_role(agent_id)
if agent_role != "ceo":
return format_error_response(
"NOT_CEO",
"Only CEO can perform this action.",
{"your_role": agent_role},
)
return None
async def handle_escalate_to_ceo(
client: ApiClient,
task_id: str,
agent_id: str,
notes: str | None = None,
) -> dict[str, Any]:
"""Handle PM escalation to CEO for final approval.
Used for major tasks requiring CEO sign-off:
- Parent tasks with subtasks
- High-priority features
- Breaking changes
"""
if error := _validate_pm_role(agent_id, "escalate to CEO"):
return error
task, error = await fetch_task_or_error(client, task_id)
if error:
return error
assert task is not None
current_status = task.get("status")
if current_status != "awaiting_pm_review":
return format_error_response(
"INVALID_STATE",
f"Cannot escalate to CEO - task is '{current_status}', "
"expected 'awaiting_pm_review'.",
{"current_status": current_status},
)
payload = {}
if notes:
payload["notes"] = notes
resp = await client.post(f"/tasks/{task_id}/escalate-to-ceo", json=payload)
if not resp.ok:
return format_error_response(
"ESCALATE_FAILED",
"Failed to escalate to CEO",
{"status_code": resp.status_code, "api_error": resp.text},
)
guidance = (
"Task escalated to CEO for final approval.\n"
"The CEO will review and either approve (complete) or reject (revision).\n"
"You can continue with other tasks via roboco_task_scan."
)
return format_task_response(resp.json(), "AWAITING_CEO_APPROVAL", guidance)
async def handle_ceo_approve(
client: ApiClient,
task_id: str,
agent_id: str,
notes: str | None = None,
) -> dict[str, Any]:
"""Handle CEO approval of a task.
Final approval step - completes the task.
"""
if error := _validate_ceo_role(agent_id):
return error
task, error = await fetch_task_or_error(client, task_id)
if error:
return error
assert task is not None
current_status = task.get("status")
if current_status != "awaiting_ceo_approval":
return format_error_response(
"INVALID_STATE",
f"Cannot approve - task is '{current_status}', "
"expected 'awaiting_ceo_approval'.",
{"current_status": current_status},
)
payload = {}
if notes:
payload["notes"] = notes
resp = await client.post(f"/tasks/{task_id}/ceo-approve", json=payload)
if not resp.ok:
return format_error_response(
"APPROVE_FAILED",
"Failed to approve task",
{"status_code": resp.status_code, "api_error": resp.text},
)
return format_task_response(
resp.json(),
"DONE",
"Task approved and completed by CEO.\n"
"Use roboco_task_scan to review other pending approvals.",
)
async def handle_ceo_reject(
client: ApiClient,
task_id: str,
agent_id: str,
reason: str,
) -> dict[str, Any]:
"""Handle CEO rejection of a task.
Sends task back for revision with feedback.
"""
if error := _validate_ceo_role(agent_id):
return error
if not reason or not reason.strip():
return format_error_response(
"REASON_REQUIRED",
"CEO rejection requires a reason explaining what needs fixing.",
)
task, error = await fetch_task_or_error(client, task_id)
if error:
return error
assert task is not None
current_status = task.get("status")
if current_status != "awaiting_ceo_approval":
return format_error_response(
"INVALID_STATE",
f"Cannot reject - task is '{current_status}', "
"expected 'awaiting_ceo_approval'.",
{"current_status": current_status},
)
resp = await client.post(f"/tasks/{task_id}/ceo-reject", json={"notes": reason})
if not resp.ok:
return format_error_response(
"REJECT_FAILED",
"Failed to reject task",
{"status_code": resp.status_code, "api_error": resp.text},
)
return format_task_response(
resp.json(),
"NEEDS_REVISION",
f"Task rejected and returned for revision.\nReason: {reason}\n"
"The developer will address feedback and resubmit.",
)
def _validate_not_qa_on_dev_work(
task: dict[str, Any], agent_id: str
) -> dict[str, Any] | None:
+9
View File
@@ -0,0 +1,9 @@
"""
Test MCP Server Package
Provides test and CI/CD operations for agents working on code tasks.
"""
from roboco.mcp.test.test_server import create_test_mcp_server
__all__ = ["create_test_mcp_server"]
+269
View File
@@ -0,0 +1,269 @@
"""
Test MCP Server Handlers
Handler functions for test/CI operations. Each handler:
1. Validates project has the required command configured
2. Calls the internal API to execute the command
3. Returns formatted response with results and guidance
"""
from typing import Any
from roboco.mcp.utils import (
ApiClient,
format_error_response,
format_success_response,
)
# =============================================================================
# READ-ONLY HANDLERS
# =============================================================================
async def handle_test_status(
client: ApiClient,
project_slug: str,
task_id: str | None,
_agent_id: str,
) -> dict[str, Any]:
"""Handle test status request."""
params: dict[str, Any] = {"project_slug": project_slug}
if task_id:
params["task_id"] = task_id
resp = await client.get("/test/status", params=params)
if not resp.ok:
return format_error_response(
"TEST_STATUS_FAILED",
"Failed to get test status",
{"status": resp.status_code, "detail": resp.text},
)
data = resp.json()
passed = data.get("passed", False)
summary = data.get("summary", "No test results available")
return format_success_response(
data,
guidance=f"Last run: {'PASSED' if passed else 'FAILED'}. {summary}",
)
# =============================================================================
# TEST EXECUTION HANDLERS
# =============================================================================
async def handle_test_run( # noqa: PLR0913
client: ApiClient,
project_slug: str,
task_id: str,
test_path: str | None,
verbose: bool,
agent_id: str,
) -> dict[str, Any]:
"""Handle test run request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"agent_id": agent_id,
"verbose": verbose,
}
if test_path:
payload["test_path"] = test_path
resp = await client.post("/test/run", json=payload)
if not resp.ok:
return format_error_response(
"TEST_RUN_FAILED",
"Failed to run tests",
{"status": resp.status_code, "detail": resp.text},
hint="Check project has test_command configured.",
)
data = resp.json()
passed = data.get("passed", False)
pass_count = data.get("passed_count", 0)
fail_count = data.get("failed_count", 0)
total = pass_count + fail_count
if passed:
guidance = f"All {total} tests passed!"
next_step = "SUBMIT_VERIFICATION" if total > 0 else None
else:
guidance = f"{fail_count}/{total} tests failed. Fix issues before proceeding."
next_step = "FIX_TESTS"
return format_success_response(data, guidance=guidance, next_step=next_step)
async def handle_test_lint( # noqa: PLR0913
client: ApiClient,
project_slug: str,
task_id: str,
fix: bool,
path: str | None,
agent_id: str,
) -> dict[str, Any]:
"""Handle lint request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"agent_id": agent_id,
"fix": fix,
}
if path:
payload["path"] = path
resp = await client.post("/test/lint", json=payload)
if not resp.ok:
return format_error_response(
"LINT_FAILED",
"Failed to run linter",
{"status": resp.status_code, "detail": resp.text},
hint="Check project has lint_command configured.",
)
data = resp.json()
issues = data.get("issues", [])
fixed = data.get("fixed_count", 0)
if not issues:
guidance = "No lint issues found!"
if fixed:
guidance = f"Fixed {fixed} issues. No remaining issues."
else:
guidance = f"{len(issues)} lint issue(s) found."
if fix:
guidance += f" {fixed} auto-fixed, {len(issues)} remaining."
guidance += " Review and fix before proceeding."
return format_success_response(
data,
guidance=guidance,
next_step="FIX_LINT" if issues else None,
)
async def handle_test_format( # noqa: PLR0913
client: ApiClient,
project_slug: str,
task_id: str,
check_only: bool,
path: str | None,
agent_id: str,
) -> dict[str, Any]:
"""Handle format request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"agent_id": agent_id,
"check_only": check_only,
}
if path:
payload["path"] = path
resp = await client.post("/test/format", json=payload)
if not resp.ok:
return format_error_response(
"FORMAT_FAILED",
"Failed to run formatter",
{"status": resp.status_code, "detail": resp.text},
hint="Check project has format_command configured.",
)
data = resp.json()
files_modified = data.get("files_modified", 0)
if check_only:
if files_modified == 0:
guidance = "All files properly formatted!"
else:
guidance = (
f"{files_modified} file(s) need formatting. Run without check_only."
)
elif files_modified == 0:
guidance = "All files already formatted."
else:
guidance = f"Formatted {files_modified} file(s)."
return format_success_response(data, guidance=guidance)
async def handle_test_typecheck(
client: ApiClient,
project_slug: str,
task_id: str,
path: str | None,
agent_id: str,
) -> dict[str, Any]:
"""Handle type check request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"agent_id": agent_id,
}
if path:
payload["path"] = path
resp = await client.post("/test/typecheck", json=payload)
if not resp.ok:
return format_error_response(
"TYPECHECK_FAILED",
"Failed to run type checker",
{"status": resp.status_code, "detail": resp.text},
hint="Check project has typecheck_command configured.",
)
data = resp.json()
errors = data.get("errors", [])
error_count = len(errors)
if error_count == 0:
guidance = "No type errors found!"
else:
guidance = f"{error_count} type error(s) found. Fix before proceeding."
return format_success_response(
data,
guidance=guidance,
next_step="FIX_TYPES" if errors else None,
)
async def handle_test_build(
client: ApiClient,
project_slug: str,
task_id: str,
agent_id: str,
) -> dict[str, Any]:
"""Handle build request."""
payload: dict[str, Any] = {
"project_slug": project_slug,
"task_id": task_id,
"agent_id": agent_id,
}
resp = await client.post("/test/build", json=payload)
if not resp.ok:
return format_error_response(
"BUILD_FAILED",
"Build failed",
{"status": resp.status_code, "detail": resp.text},
hint="Check project has build_command configured and deps installed.",
)
data = resp.json()
success = data.get("success", False)
duration = data.get("duration_seconds", 0)
if success:
guidance = f"Build succeeded in {duration:.1f}s!"
else:
guidance = "Build failed. Check output for errors."
return format_success_response(
data,
guidance=guidance,
next_step="FIX_BUILD" if not success else None,
)
+225
View File
@@ -0,0 +1,225 @@
"""
Test MCP Server
Exposes test and CI/CD operations to Claude Code agents.
Uses project-configured commands for tests, linting, formatting, etc.
Tools (Developer/QA):
- roboco_test_run: Run project tests
- roboco_test_lint: Run linter
- roboco_test_format: Run code formatter
- roboco_test_typecheck: Run type checker
- roboco_test_build: Run build command
Tools (All - Read-only):
- roboco_test_status: Check last test run status
"""
from typing import Any
from mcp.server.fastmcp import FastMCP
from roboco.mcp.test.handlers import (
handle_test_build,
handle_test_format,
handle_test_lint,
handle_test_run,
handle_test_status,
handle_test_typecheck,
)
from roboco.mcp.utils import ApiClient
def _register_readonly_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
"""Register read-only test tools available to all agents."""
@mcp.tool()
async def roboco_test_status(
project_slug: str,
task_id: str | None = None,
) -> dict[str, Any]:
"""
Check the status of the last test run.
Args:
project_slug: Project identifier (e.g., 'roboco', 'roboco-panel')
task_id: Optional task ID for context
Returns:
Last test run status with pass/fail and summary
"""
return await handle_test_status(client, project_slug, task_id, agent_id)
def _register_test_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
"""Register test execution tools for developers and QA."""
@mcp.tool()
async def roboco_test_run(
project_slug: str,
task_id: str,
test_path: str | None = None,
verbose: bool = False,
) -> dict[str, Any]:
"""
Run project tests.
Uses the project's configured test_command (e.g., 'uv run pytest').
Results are recorded for the task.
Args:
project_slug: Project identifier
task_id: Task ID for tracking
test_path: Optional specific test file/directory
verbose: Enable verbose output
Returns:
Test results with pass/fail counts and output
"""
return await handle_test_run(
client, project_slug, task_id, test_path, verbose, agent_id
)
@mcp.tool()
async def roboco_test_lint(
project_slug: str,
task_id: str,
fix: bool = False,
path: str | None = None,
) -> dict[str, Any]:
"""
Run linter on project code.
Uses the project's configured lint_command (e.g., 'uv run ruff check .').
Args:
project_slug: Project identifier
task_id: Task ID for tracking
fix: Auto-fix issues if possible
path: Optional specific path to lint
Returns:
Lint results with issues found
"""
return await handle_test_lint(
client, project_slug, task_id, fix, path, agent_id
)
@mcp.tool()
async def roboco_test_format(
project_slug: str,
task_id: str,
check_only: bool = False,
path: str | None = None,
) -> dict[str, Any]:
"""
Run code formatter.
Uses the project's configured format_command (e.g., 'uv run ruff format .').
Args:
project_slug: Project identifier
task_id: Task ID for tracking
check_only: Only check, don't modify files
path: Optional specific path to format
Returns:
Format results with files modified
"""
return await handle_test_format(
client, project_slug, task_id, check_only, path, agent_id
)
@mcp.tool()
async def roboco_test_typecheck(
project_slug: str,
task_id: str,
path: str | None = None,
) -> dict[str, Any]:
"""
Run type checker.
Uses the project's configured typecheck_command (e.g., 'uv run mypy src/').
Args:
project_slug: Project identifier
task_id: Task ID for tracking
path: Optional specific path to check
Returns:
Type check results with errors found
"""
return await handle_test_typecheck(
client, project_slug, task_id, path, agent_id
)
@mcp.tool()
async def roboco_test_build(
project_slug: str,
task_id: str,
) -> dict[str, Any]:
"""
Run project build command.
Uses the project's configured build_command (e.g., 'pnpm build').
Args:
project_slug: Project identifier
task_id: Task ID for tracking
Returns:
Build results with success/failure
"""
return await handle_test_build(client, project_slug, task_id, agent_id)
def create_test_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Test MCP server for a specific agent.
Tools are registered based on role:
- All agents: status check (read-only)
- Developers/QA: run tests, lint, format, typecheck, build
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server with role-appropriate tools
"""
from roboco.agents_config import get_agent_role
mcp = FastMCP(f"roboco-test-{agent_id}", json_response=True)
client = ApiClient(agent_id)
role = get_agent_role(agent_id)
# Read-only tools available to ALL agents
_register_readonly_tools(mcp, client, agent_id)
# Test execution tools for developers, QA, PMs, and management
test_roles = (
"developer",
"qa",
"cell_pm",
"main_pm",
"product_owner",
"auditor",
"ceo",
)
if role in test_roles:
_register_test_tools(mcp, client, agent_id)
return mcp
if __name__ == "__main__":
import sys
_MIN_ARGS = 2
if len(sys.argv) < _MIN_ARGS:
print("Usage: python test_server.py <agent_id>")
sys.exit(1)
agent_id_cli = sys.argv[1]
server = create_test_mcp_server(agent_id_cli)
server.run()
+1 -1
View File
@@ -128,7 +128,7 @@ async def resolve_agent_uuid(
return agent_id
# Look up by slug - GET /agents/{id} accepts both UUID and slug
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
try:
resp = await client.get(
f"{settings.internal_api_url}/agents/{agent_id}",