Files
agentrunner/mcp_server.py
CI System 1aee8779c7 feat: orchestrator UI, dashboard improvements, and workflow fixes
Orchestrator:
- Add orchestrator chat interface with streaming responses
- MCP server integration for YouTrack queries
- Quick actions for backlog review, triage analysis
- Dynamic suggestions based on conversation context
- Action approval/rejection workflow

Dashboard improvements:
- Add font preloading to prevent FOUC
- CSS spinner for loading state (no icon font dependency)
- Wait for fonts before showing UI
- Fix workflow pipeline alignment
- Fix user message contrast (dark blue background)
- Auto-scroll chat, actions, suggestions panels
- Add keyboard shortcuts system
- Add toast notifications
- Add theme toggle (dark/light mode)
- New pages: orchestrator, repos, system, analytics

Workflow fixes:
- Skip Build state when agent determines no changes needed
- Check branch exists before attempting push
- Include comments in get_issues MCP response
- Simplified orchestrator prompt focused on Backlog management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 15:37:49 -07:00

318 lines
11 KiB
Python

#!/usr/bin/env python3
"""
MCP Server for ClearGrow Agent Runner.
Exposes internal APIs as tools for the orchestrator agent.
"""
import json
import sys
from typing import Any
# MCP protocol constants
JSONRPC_VERSION = "2.0"
class MCPServer:
"""Simple MCP server exposing agent runner APIs."""
def __init__(self, runner):
self.runner = runner
self.tools = {
"get_issues": self.get_issues,
"get_issue": self.get_issue,
"get_agent_status": self.get_agent_status,
"transition_issue": self.transition_issue,
"add_comment": self.add_comment,
}
def get_tool_definitions(self) -> list[dict]:
"""Return tool definitions for MCP."""
return [
{
"name": "get_issues",
"description": "Get issues from YouTrack by state. Returns issue ID, summary, type, priority, description, and all comments for each issue.",
"inputSchema": {
"type": "object",
"properties": {
"state": {
"type": "string",
"description": "Issue state to filter by (Triage, Ready, In Progress, Build, Verify, Document, Review)",
},
"limit": {
"type": "integer",
"description": "Maximum number of issues to return (default 20)",
"default": 20,
},
},
"required": ["state"],
},
},
{
"name": "get_issue",
"description": "Get detailed information about a specific issue by ID, including all comments. Use this to understand issue history and current status.",
"inputSchema": {
"type": "object",
"properties": {
"issue_id": {
"type": "string",
"description": "Issue ID (e.g., CG-123)",
},
},
"required": ["issue_id"],
},
},
{
"name": "get_agent_status",
"description": "Get current status of the agent pool including active agents and running tasks.",
"inputSchema": {
"type": "object",
"properties": {},
},
},
{
"name": "transition_issue",
"description": "Move an issue to a new state. Use this to suggest state transitions.",
"inputSchema": {
"type": "object",
"properties": {
"issue_id": {
"type": "string",
"description": "Issue ID to transition (e.g., CG-123)",
},
"new_state": {
"type": "string",
"description": "Target state (Ready, In Progress, etc.)",
},
},
"required": ["issue_id", "new_state"],
},
},
{
"name": "add_comment",
"description": "Add a comment to an issue.",
"inputSchema": {
"type": "object",
"properties": {
"issue_id": {
"type": "string",
"description": "Issue ID to comment on (e.g., CG-123)",
},
"text": {
"type": "string",
"description": "Comment text",
},
},
"required": ["issue_id", "text"],
},
},
]
def get_issues(self, state: str, limit: int = 20) -> dict:
"""Get issues by state, including comments for each issue."""
try:
project = self.runner.config.get("project", {}).get("name", "CG")
issues = self.runner.youtrack.get_issues_by_state(project, state)
if not issues:
return {"issues": [], "count": 0, "state": state}
result = []
for issue in issues[:limit]:
# Get comments for this issue
comments = []
try:
raw_comments = self.runner.youtrack.get_issue_comments(issue.id)
for c in raw_comments:
comments.append({
"author": getattr(c, "author", "Unknown"),
"text": getattr(c, "text", ""),
"created": str(getattr(c, "created", "")),
})
except Exception:
pass
result.append({
"id": issue.id,
"summary": issue.summary,
"type": getattr(issue, "type", "Task"),
"priority": getattr(issue, "priority", "Normal"),
"description": getattr(issue, "description", ""),
"created": str(getattr(issue, "created", "")),
"comments": comments,
})
return {"issues": result, "count": len(issues), "state": state}
except Exception as e:
return {"error": str(e)}
def get_issue(self, issue_id: str) -> dict:
"""Get single issue details including comments."""
try:
issue = self.runner.youtrack.get_issue(issue_id)
if not issue:
return {"error": f"Issue {issue_id} not found"}
# Get comments for this issue
comments = []
try:
raw_comments = self.runner.youtrack.get_issue_comments(issue_id)
for c in raw_comments:
comments.append({
"author": getattr(c, "author", "Unknown"),
"text": getattr(c, "text", ""),
"created": str(getattr(c, "created", "")),
})
except Exception:
# Don't fail if comments can't be fetched
pass
return {
"id": issue.id,
"summary": issue.summary,
"type": getattr(issue, "type", "Task"),
"priority": getattr(issue, "priority", "Normal"),
"state": getattr(issue, "state", "Unknown"),
"description": getattr(issue, "description", ""),
"created": str(getattr(issue, "created", "")),
"updated": str(getattr(issue, "updated", "")),
"comments": comments,
}
except Exception as e:
return {"error": str(e)}
def get_agent_status(self) -> dict:
"""Get agent pool status."""
try:
status = self.runner.agent_pool.get_status()
return {
"active": status.get("active", 0),
"max_agents": status.get("max_agents", 10),
"tasks": status.get("tasks", []),
}
except Exception as e:
return {"error": str(e)}
def transition_issue(self, issue_id: str, new_state: str) -> dict:
"""Transition an issue to a new state."""
try:
self.runner.youtrack.update_issue_state(issue_id, new_state)
return {"success": True, "issue_id": issue_id, "new_state": new_state}
except Exception as e:
return {"error": str(e)}
def add_comment(self, issue_id: str, text: str) -> dict:
"""Add a comment to an issue."""
try:
self.runner.youtrack.add_comment(issue_id, text)
return {"success": True, "issue_id": issue_id}
except Exception as e:
return {"error": str(e)}
def handle_request(self, request: dict) -> dict:
"""Handle a JSON-RPC request."""
method = request.get("method", "")
params = request.get("params", {})
request_id = request.get("id")
if method == "initialize":
return {
"jsonrpc": JSONRPC_VERSION,
"id": request_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "cleargrow-agent-runner",
"version": "1.0.0",
},
},
}
elif method == "tools/list":
return {
"jsonrpc": JSONRPC_VERSION,
"id": request_id,
"result": {"tools": self.get_tool_definitions()},
}
elif method == "tools/call":
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
if tool_name not in self.tools:
return {
"jsonrpc": JSONRPC_VERSION,
"id": request_id,
"error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
}
try:
result = self.tools[tool_name](**tool_args)
return {
"jsonrpc": JSONRPC_VERSION,
"id": request_id,
"result": {
"content": [{"type": "text", "text": json.dumps(result, indent=2)}],
},
}
except Exception as e:
return {
"jsonrpc": JSONRPC_VERSION,
"id": request_id,
"error": {"code": -32000, "message": str(e)},
}
elif method == "notifications/initialized":
# Client notification, no response needed
return None
else:
return {
"jsonrpc": JSONRPC_VERSION,
"id": request_id,
"error": {"code": -32601, "message": f"Unknown method: {method}"},
}
def run_stdio(self):
"""Run the MCP server on stdio."""
while True:
try:
line = sys.stdin.readline()
if not line:
break
request = json.loads(line)
response = self.handle_request(request)
if response: # Don't send response for notifications
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
except json.JSONDecodeError:
continue
except Exception as e:
sys.stderr.write(f"MCP Server error: {e}\n")
sys.stderr.flush()
def create_mcp_config(socket_path: str = "/tmp/cleargrow-mcp.sock") -> dict:
"""Create MCP configuration for Claude."""
return {
"mcpServers": {
"cleargrow": {
"command": sys.executable,
"args": [__file__, "--stdio"],
"env": {},
}
}
}
if __name__ == "__main__":
# For testing - in production, runner will instantiate this
print("MCP Server for ClearGrow Agent Runner")
print("Run with --stdio for MCP protocol mode")