mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
First git integration implementation + KB fixes and upgrades
This commit is contained in:
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user