mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-29 05:00:05 -05:00
* feat: MCP server optimization with tool consolidation and vertical slice architecture - Consolidated MCP tools from ~20 to 8 tools for improved UX - Restructured to vertical slice architecture (features/domain pattern) - Optimized payload sizes with truncation and array count replacements - Changed default include_closed to true for better task visibility - Moved RAG module to features directory structure - Removed legacy modules directory in favor of feature-based organization Key improvements: - list_tasks, manage_task (create/update/delete consolidated) - list_projects, manage_project (create/update/delete consolidated) - list_documents, manage_document (create/update/delete consolidated) - list_versions, manage_version (create/restore consolidated) - Reduced default page size from 50 to 10 items - Added search query support to list operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Consolidate MCP tools and rename list_* to find_* Major refactoring of MCP tools to reduce complexity and improve naming: ## Tool Consolidation (22 → ~10 tools) - Consolidated CRUD operations into two tools per domain: - find_[resource]: Handles list, search, and get single item - manage_[resource]: Handles create, update, delete with "action" parameter - Removed backward compatibility/legacy function mappings - Optimized response payloads with truncation (1000 char limit for projects/tasks) ## Renamed Functions - list_projects → find_projects - list_tasks → find_tasks - list_documents → find_documents - list_versions → find_versions ## Bug Fixes - Fixed supabase query chaining bug where .or_() calls overwrote previous conditions - Fixed search implementation to handle single vs multiple terms correctly ## Test Updates - Updated all tests to use new consolidated tools - Removed problematic test_consolidated_tools.py - Fixed error type assertions to match actual responses - All 44 tests passing ## Documentation Updates - Updated CLAUDE.md with new tool names and patterns - Updated MCP instructions with consolidated tool examples - Added guidance to avoid backward compatibility code ## API Changes - Updated API route defaults: include_closed=True, per_page=10 - Aligned defaults with consolidated tool implementations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
331 lines
15 KiB
Python
331 lines
15 KiB
Python
"""
|
|
Consolidated project management tools for Archon MCP Server.
|
|
|
|
Reduces the number of individual CRUD operations while maintaining full functionality.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from urllib.parse import urljoin
|
|
|
|
import httpx
|
|
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
from src.mcp_server.utils.error_handling import MCPErrorFormatter
|
|
from src.mcp_server.utils.timeout_config import (
|
|
get_default_timeout,
|
|
get_max_polling_attempts,
|
|
get_polling_interval,
|
|
get_polling_timeout,
|
|
)
|
|
from src.server.config.service_discovery import get_api_url
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Optimization constants
|
|
MAX_DESCRIPTION_LENGTH = 1000
|
|
DEFAULT_PAGE_SIZE = 10 # Reduced from 50
|
|
|
|
def truncate_text(text: str, max_length: int = MAX_DESCRIPTION_LENGTH) -> str:
|
|
"""Truncate text to maximum length with ellipsis."""
|
|
if text and len(text) > max_length:
|
|
return text[:max_length - 3] + "..."
|
|
return text
|
|
|
|
def optimize_project_response(project: dict) -> dict:
|
|
"""Optimize project object for MCP response."""
|
|
project = project.copy() # Don't modify original
|
|
|
|
# Truncate description if present
|
|
if "description" in project and project["description"]:
|
|
project["description"] = truncate_text(project["description"])
|
|
|
|
# Remove or summarize large fields
|
|
if "features" in project and isinstance(project["features"], list):
|
|
project["features_count"] = len(project["features"])
|
|
if len(project["features"]) > 3:
|
|
project["features"] = project["features"][:3] # Keep first 3
|
|
|
|
return project
|
|
|
|
|
|
def register_project_tools(mcp: FastMCP):
|
|
"""Register consolidated project management tools with the MCP server."""
|
|
|
|
@mcp.tool()
|
|
async def find_projects(
|
|
ctx: Context,
|
|
project_id: str | None = None, # For getting single project
|
|
query: str | None = None, # Search capability
|
|
page: int = 1,
|
|
per_page: int = DEFAULT_PAGE_SIZE,
|
|
) -> str:
|
|
"""
|
|
List and search projects (consolidated: list + search + get).
|
|
|
|
Args:
|
|
project_id: Get specific project by ID (returns full details)
|
|
query: Keyword search in title/description
|
|
page: Page number for pagination
|
|
per_page: Items per page (default: 10)
|
|
|
|
Returns:
|
|
JSON array of projects or single project (optimized payloads for lists)
|
|
|
|
Examples:
|
|
list_projects() # All projects
|
|
list_projects(query="auth") # Search projects
|
|
list_projects(project_id="proj-123") # Get specific project
|
|
"""
|
|
try:
|
|
api_url = get_api_url()
|
|
timeout = get_default_timeout()
|
|
|
|
# Single project get mode
|
|
if project_id:
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.get(urljoin(api_url, f"/api/projects/{project_id}"))
|
|
|
|
if response.status_code == 200:
|
|
project = response.json()
|
|
# Don't optimize single project get - return full details
|
|
return json.dumps({"success": True, "project": project})
|
|
elif response.status_code == 404:
|
|
return MCPErrorFormatter.format_error(
|
|
error_type="not_found",
|
|
message=f"Project {project_id} not found",
|
|
suggestion="Verify the project ID is correct",
|
|
http_status=404,
|
|
)
|
|
else:
|
|
return MCPErrorFormatter.from_http_error(response, "get project")
|
|
|
|
# List mode
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.get(urljoin(api_url, "/api/projects"))
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
projects = data.get("projects", [])
|
|
|
|
# Apply search filter if provided
|
|
if query:
|
|
query_lower = query.lower()
|
|
projects = [
|
|
p for p in projects
|
|
if query_lower in p.get("title", "").lower()
|
|
or query_lower in p.get("description", "").lower()
|
|
]
|
|
|
|
# Apply pagination
|
|
start_idx = (page - 1) * per_page
|
|
end_idx = start_idx + per_page
|
|
paginated = projects[start_idx:end_idx]
|
|
|
|
# Optimize project responses
|
|
optimized = [optimize_project_response(p) for p in paginated]
|
|
|
|
return json.dumps({
|
|
"success": True,
|
|
"projects": optimized,
|
|
"count": len(optimized),
|
|
"total": len(projects),
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"query": query
|
|
})
|
|
else:
|
|
return MCPErrorFormatter.from_http_error(response, "list projects")
|
|
|
|
except httpx.RequestError as e:
|
|
return MCPErrorFormatter.from_exception(e, "list projects")
|
|
except Exception as e:
|
|
logger.error(f"Error listing projects: {e}", exc_info=True)
|
|
return MCPErrorFormatter.from_exception(e, "list projects")
|
|
|
|
@mcp.tool()
|
|
async def manage_project(
|
|
ctx: Context,
|
|
action: str, # "create" | "update" | "delete"
|
|
project_id: str | None = None,
|
|
title: str | None = None,
|
|
description: str | None = None,
|
|
github_repo: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Manage projects (consolidated: create/update/delete).
|
|
|
|
Args:
|
|
action: "create" | "update" | "delete"
|
|
project_id: Project UUID for update/delete
|
|
title: Project title (required for create)
|
|
description: Project goals and scope
|
|
github_repo: GitHub URL (e.g. "https://github.com/org/repo")
|
|
|
|
Examples:
|
|
manage_project("create", title="Auth System")
|
|
manage_project("update", project_id="p-1", description="Updated")
|
|
manage_project("delete", project_id="p-1")
|
|
|
|
Returns: {success: bool, project?: object, message: string}
|
|
"""
|
|
try:
|
|
api_url = get_api_url()
|
|
timeout = get_default_timeout()
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
if action == "create":
|
|
if not title:
|
|
return MCPErrorFormatter.format_error(
|
|
"validation_error",
|
|
"title required for create"
|
|
)
|
|
|
|
response = await client.post(
|
|
urljoin(api_url, "/api/projects"),
|
|
json={
|
|
"title": title,
|
|
"description": description or "",
|
|
"github_repo": github_repo
|
|
}
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
result = response.json()
|
|
|
|
# Handle async project creation with polling
|
|
if "progress_id" in result:
|
|
max_attempts = get_max_polling_attempts()
|
|
polling_timeout = get_polling_timeout()
|
|
|
|
for attempt in range(max_attempts):
|
|
try:
|
|
# Exponential backoff
|
|
sleep_interval = get_polling_interval(attempt)
|
|
await asyncio.sleep(sleep_interval)
|
|
|
|
async with httpx.AsyncClient(timeout=polling_timeout) as poll_client:
|
|
poll_response = await poll_client.get(
|
|
urljoin(api_url, f"/api/progress/{result['progress_id']}")
|
|
)
|
|
|
|
if poll_response.status_code == 200:
|
|
poll_data = poll_response.json()
|
|
|
|
if poll_data.get("status") == "completed":
|
|
project = poll_data.get("result", {}).get("project", {})
|
|
return json.dumps({
|
|
"success": True,
|
|
"project": optimize_project_response(project),
|
|
"project_id": project.get("id"),
|
|
"message": poll_data.get("result", {}).get("message", "Project created successfully")
|
|
})
|
|
elif poll_data.get("status") == "failed":
|
|
error_msg = poll_data.get("error", "Project creation failed")
|
|
return MCPErrorFormatter.format_error(
|
|
"creation_failed",
|
|
error_msg,
|
|
details=poll_data.get("details")
|
|
)
|
|
# Continue polling if still processing
|
|
|
|
except httpx.RequestError as poll_error:
|
|
logger.warning(f"Polling attempt {attempt + 1} failed: {poll_error}")
|
|
if attempt == max_attempts - 1:
|
|
return MCPErrorFormatter.format_error(
|
|
"timeout",
|
|
"Project creation timed out",
|
|
suggestion="Check project status manually"
|
|
)
|
|
|
|
return MCPErrorFormatter.format_error(
|
|
"timeout",
|
|
"Project creation timed out after maximum attempts",
|
|
details={"progress_id": result.get("progress_id")}
|
|
)
|
|
else:
|
|
# Synchronous response
|
|
project = result.get("project", {})
|
|
return json.dumps({
|
|
"success": True,
|
|
"project": optimize_project_response(project),
|
|
"project_id": project.get("id"),
|
|
"message": result.get("message", "Project created successfully")
|
|
})
|
|
else:
|
|
return MCPErrorFormatter.from_http_error(response, "create project")
|
|
|
|
elif action == "update":
|
|
if not project_id:
|
|
return MCPErrorFormatter.format_error(
|
|
"validation_error",
|
|
"project_id required for update"
|
|
)
|
|
|
|
update_data = {}
|
|
if title is not None:
|
|
update_data["title"] = title
|
|
if description is not None:
|
|
update_data["description"] = description
|
|
if github_repo is not None:
|
|
update_data["github_repo"] = github_repo
|
|
|
|
if not update_data:
|
|
return MCPErrorFormatter.format_error(
|
|
"validation_error",
|
|
"No fields to update"
|
|
)
|
|
|
|
response = await client.put(
|
|
urljoin(api_url, f"/api/projects/{project_id}"),
|
|
json=update_data
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
result = response.json()
|
|
project = result.get("project")
|
|
|
|
if project:
|
|
project = optimize_project_response(project)
|
|
|
|
return json.dumps({
|
|
"success": True,
|
|
"project": project,
|
|
"message": result.get("message", "Project updated successfully")
|
|
})
|
|
else:
|
|
return MCPErrorFormatter.from_http_error(response, "update project")
|
|
|
|
elif action == "delete":
|
|
if not project_id:
|
|
return MCPErrorFormatter.format_error(
|
|
"validation_error",
|
|
"project_id required for delete"
|
|
)
|
|
|
|
response = await client.delete(
|
|
urljoin(api_url, f"/api/projects/{project_id}")
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
result = response.json()
|
|
return json.dumps({
|
|
"success": True,
|
|
"message": result.get("message", "Project deleted successfully")
|
|
})
|
|
else:
|
|
return MCPErrorFormatter.from_http_error(response, "delete project")
|
|
|
|
else:
|
|
return MCPErrorFormatter.format_error(
|
|
"invalid_action",
|
|
f"Unknown action: {action}"
|
|
)
|
|
|
|
except httpx.RequestError as e:
|
|
return MCPErrorFormatter.from_exception(e, f"{action} project")
|
|
except Exception as e:
|
|
logger.error(f"Error managing project ({action}): {e}", exc_info=True)
|
|
return MCPErrorFormatter.from_exception(e, f"{action} project")
|