mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
1. roboco/mcp/utils.py - Created shared utilities:
- ApiClient class with connection pooling and automatic header injection
- ApiResponse wrapper with .ok, .is_status(), .json() methods
- get_agent_headers(), format_error_response(), resolve_agent_uuid()
2. roboco/mcp/schemas/__init__.py - Added new input schemas:
- TaskBlockInput (groups blocker parameters)
- TaskPauseInput (groups pause parameters)
3. Refactored all 4 MCP servers:
- journal_server.py - Uses shared ApiClient
- notify_server.py - Uses shared ApiClient
- message_server.py - Uses shared ApiClient
- task_server.py - Uses shared ApiClient, reduced argument count with dataclasses
This commit is contained in:
+90
-174
@@ -16,12 +16,8 @@ Tools:
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import status
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from roboco.agents_config import get_agent_role, get_agent_team
|
|
||||||
from roboco.config import settings
|
|
||||||
from roboco.llm import ToonAdapter
|
from roboco.llm import ToonAdapter
|
||||||
from roboco.mcp.schemas import (
|
from roboco.mcp.schemas import (
|
||||||
DecisionLogInput,
|
DecisionLogInput,
|
||||||
@@ -30,74 +26,15 @@ from roboco.mcp.schemas import (
|
|||||||
StruggleInput,
|
StruggleInput,
|
||||||
TaskReflectionInput,
|
TaskReflectionInput,
|
||||||
)
|
)
|
||||||
|
from roboco.mcp.utils import ApiClient, format_error_response
|
||||||
|
|
||||||
# Global TOON adapter for encoding journal data
|
# Global TOON adapter for encoding journal data
|
||||||
_toon = ToonAdapter()
|
_toon = ToonAdapter()
|
||||||
|
|
||||||
|
# Valid entry types
|
||||||
# =============================================================================
|
VALID_ENTRY_TYPES = frozenset(
|
||||||
# HELPER FUNCTIONS
|
["general", "task_reflection", "decision_log", "learning", "struggle"]
|
||||||
# =============================================================================
|
)
|
||||||
|
|
||||||
|
|
||||||
def _format_error_response(
|
|
||||||
error_code: str,
|
|
||||||
message: str,
|
|
||||||
details: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Format a standardized error response."""
|
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": error_code,
|
|
||||||
"message": message,
|
|
||||||
"details": details or {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_agent_headers(agent_id: str) -> dict[str, str]:
|
|
||||||
"""Get standard headers for API calls."""
|
|
||||||
headers = {
|
|
||||||
"X-Agent-ID": agent_id,
|
|
||||||
"X-Agent-Role": get_agent_role(agent_id),
|
|
||||||
}
|
|
||||||
team = get_agent_team(agent_id)
|
|
||||||
if team:
|
|
||||||
headers["X-Agent-Team"] = team
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
async def _post_journal_entry(
|
|
||||||
endpoint: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
agent_id: str,
|
|
||||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
||||||
"""Post to a journal endpoint. Returns (data, error)."""
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
try:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{settings.internal_api_url}/journals/me/{endpoint}",
|
|
||||||
json=payload,
|
|
||||||
headers=_get_agent_headers(agent_id),
|
|
||||||
)
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
return None, _format_error_response(
|
|
||||||
"TIMEOUT",
|
|
||||||
f"Request to create {endpoint.rstrip('s')} timed out",
|
|
||||||
)
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
return None, _format_error_response(
|
|
||||||
"CONNECTION_ERROR",
|
|
||||||
f"Failed to connect to API: {type(e).__name__}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code not in [200, 201]:
|
|
||||||
return None, _format_error_response(
|
|
||||||
"CREATE_FAILED",
|
|
||||||
f"Failed to create {endpoint.rstrip('s')}",
|
|
||||||
{"status_code": resp.status_code, "detail": resp.text},
|
|
||||||
)
|
|
||||||
return resp.json(), None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -106,14 +43,13 @@ async def _post_journal_entry(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_journal_entry(
|
async def _handle_journal_entry(
|
||||||
data: JournalEntryInput, agent_id: str
|
data: JournalEntryInput, client: ApiClient
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle journal entry creation."""
|
"""Handle journal entry creation."""
|
||||||
valid_types = ["general", "task_reflection", "decision_log", "learning", "struggle"]
|
if data.entry_type not in VALID_ENTRY_TYPES:
|
||||||
if data.entry_type not in valid_types:
|
return format_error_response(
|
||||||
return _format_error_response(
|
|
||||||
"INVALID_TYPE",
|
"INVALID_TYPE",
|
||||||
f"Invalid entry type. Must be one of: {valid_types}",
|
f"Invalid entry type. Must be one of: {list(VALID_ENTRY_TYPES)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -125,9 +61,14 @@ async def _handle_journal_entry(
|
|||||||
"is_private": data.is_private,
|
"is_private": data.is_private,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, error = await _post_journal_entry("entries", payload, agent_id)
|
entry, error = await client.post_or_error(
|
||||||
|
"/journals/me/entries",
|
||||||
|
json=payload,
|
||||||
|
error_code="CREATE_FAILED",
|
||||||
|
error_message="Failed to create entry",
|
||||||
|
)
|
||||||
if error or entry is None:
|
if error or entry is None:
|
||||||
return error or _format_error_response("ERROR", "Failed to create entry")
|
return error or format_error_response("ERROR", "Failed to create entry")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "created",
|
"status": "created",
|
||||||
@@ -139,7 +80,9 @@ async def _handle_journal_entry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_reflect(data: TaskReflectionInput, agent_id: str) -> dict[str, Any]:
|
async def _handle_reflect(
|
||||||
|
data: TaskReflectionInput, client: ApiClient
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Handle task reflection creation."""
|
"""Handle task reflection creation."""
|
||||||
payload = {
|
payload = {
|
||||||
"task_id": data.task_id,
|
"task_id": data.task_id,
|
||||||
@@ -151,7 +94,12 @@ async def _handle_reflect(data: TaskReflectionInput, agent_id: str) -> dict[str,
|
|||||||
"tags": data.tags,
|
"tags": data.tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, error = await _post_journal_entry("reflections", payload, agent_id)
|
entry, error = await client.post_or_error(
|
||||||
|
"/journals/me/reflections",
|
||||||
|
json=payload,
|
||||||
|
error_code="CREATE_FAILED",
|
||||||
|
error_message="Failed to create reflection",
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
@@ -165,7 +113,7 @@ async def _handle_reflect(data: TaskReflectionInput, agent_id: str) -> dict[str,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_decision(data: DecisionLogInput, agent_id: str) -> dict[str, Any]:
|
async def _handle_decision(data: DecisionLogInput, client: ApiClient) -> dict[str, Any]:
|
||||||
"""Handle decision log creation."""
|
"""Handle decision log creation."""
|
||||||
payload = {
|
payload = {
|
||||||
"title": data.title,
|
"title": data.title,
|
||||||
@@ -178,7 +126,12 @@ async def _handle_decision(data: DecisionLogInput, agent_id: str) -> dict[str, A
|
|||||||
"tags": data.tags,
|
"tags": data.tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, error = await _post_journal_entry("decisions", payload, agent_id)
|
entry, error = await client.post_or_error(
|
||||||
|
"/journals/me/decisions",
|
||||||
|
json=payload,
|
||||||
|
error_code="CREATE_FAILED",
|
||||||
|
error_message="Failed to create decision log",
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
@@ -192,7 +145,7 @@ async def _handle_decision(data: DecisionLogInput, agent_id: str) -> dict[str, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_learning(data: LearningInput, agent_id: str) -> dict[str, Any]:
|
async def _handle_learning(data: LearningInput, client: ApiClient) -> dict[str, Any]:
|
||||||
"""Handle learning entry creation."""
|
"""Handle learning entry creation."""
|
||||||
payload = {
|
payload = {
|
||||||
"title": data.title,
|
"title": data.title,
|
||||||
@@ -203,7 +156,12 @@ async def _handle_learning(data: LearningInput, agent_id: str) -> dict[str, Any]
|
|||||||
"tags": data.tags,
|
"tags": data.tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, error = await _post_journal_entry("learnings", payload, agent_id)
|
entry, error = await client.post_or_error(
|
||||||
|
"/journals/me/learnings",
|
||||||
|
json=payload,
|
||||||
|
error_code="CREATE_FAILED",
|
||||||
|
error_message="Failed to create learning entry",
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
@@ -214,7 +172,7 @@ async def _handle_learning(data: LearningInput, agent_id: str) -> dict[str, Any]
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_struggle(data: StruggleInput, agent_id: str) -> dict[str, Any]:
|
async def _handle_struggle(data: StruggleInput, client: ApiClient) -> dict[str, Any]:
|
||||||
"""Handle struggle entry creation."""
|
"""Handle struggle entry creation."""
|
||||||
payload = {
|
payload = {
|
||||||
"title": data.title,
|
"title": data.title,
|
||||||
@@ -226,7 +184,12 @@ async def _handle_struggle(data: StruggleInput, agent_id: str) -> dict[str, Any]
|
|||||||
"tags": data.tags,
|
"tags": data.tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, error = await _post_journal_entry("struggles", payload, agent_id)
|
entry, error = await client.post_or_error(
|
||||||
|
"/journals/me/struggles",
|
||||||
|
json=payload,
|
||||||
|
error_code="CREATE_FAILED",
|
||||||
|
error_message="Failed to create struggle entry",
|
||||||
|
)
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
@@ -239,34 +202,19 @@ async def _handle_struggle(data: StruggleInput, agent_id: str) -> dict[str, Any]
|
|||||||
return {"status": "created", "entry": entry, "guidance": guidance}
|
return {"status": "created", "entry": entry, "guidance": guidance}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_search(query: str, top_k: int, agent_id: str) -> dict[str, Any]:
|
async def _handle_search(query: str, top_k: int, client: ApiClient) -> dict[str, Any]:
|
||||||
"""Handle journal search."""
|
"""Handle journal search."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
max_results = 20
|
||||||
payload = {"query": query, "top_k": min(top_k, 20)}
|
payload = {"query": query, "top_k": min(top_k, max_results)}
|
||||||
try:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{settings.internal_api_url}/journals/me/search",
|
|
||||||
json=payload,
|
|
||||||
headers=_get_agent_headers(agent_id),
|
|
||||||
)
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
return _format_error_response(
|
|
||||||
"TIMEOUT", "Search request timed out"
|
|
||||||
)
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
return _format_error_response(
|
|
||||||
"CONNECTION_ERROR",
|
|
||||||
f"Failed to connect to API: {type(e).__name__}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
entries, error = await client.post_or_error(
|
||||||
return _format_error_response(
|
"/journals/me/search",
|
||||||
"SEARCH_FAILED",
|
json=payload,
|
||||||
"Failed to search journal",
|
error_code="SEARCH_FAILED",
|
||||||
{"status_code": resp.status_code, "detail": resp.text},
|
error_message="Failed to search journal",
|
||||||
)
|
)
|
||||||
|
if error:
|
||||||
entries = resp.json()
|
return error
|
||||||
|
|
||||||
if not entries:
|
if not entries:
|
||||||
return {
|
return {
|
||||||
@@ -281,33 +229,14 @@ async def _handle_search(query: str, top_k: int, agent_id: str) -> dict[str, Any
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_stats(agent_id: str) -> dict[str, Any]:
|
async def _handle_stats(client: ApiClient) -> dict[str, Any]:
|
||||||
"""Handle journal stats retrieval."""
|
"""Handle journal stats retrieval."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
# Fetch stats and growth in parallel would be better but keep simple for now
|
||||||
try:
|
stats_resp = await client.get("/journals/me/stats")
|
||||||
stats_resp = await client.get(
|
growth_resp = await client.get("/journals/me/growth")
|
||||||
f"{settings.internal_api_url}/journals/me/stats",
|
|
||||||
headers=_get_agent_headers(agent_id),
|
|
||||||
)
|
|
||||||
growth_resp = await client.get(
|
|
||||||
f"{settings.internal_api_url}/journals/me/growth",
|
|
||||||
headers=_get_agent_headers(agent_id),
|
|
||||||
)
|
|
||||||
except httpx.RequestError:
|
|
||||||
# Return empty stats on connection error
|
|
||||||
stats_resp = None
|
|
||||||
growth_resp = None
|
|
||||||
|
|
||||||
stats = (
|
stats = stats_resp.json() if stats_resp.ok else {}
|
||||||
stats_resp.json()
|
growth = growth_resp.json() if growth_resp.ok else {}
|
||||||
if stats_resp and stats_resp.status_code == status.HTTP_200_OK
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
growth = (
|
|
||||||
growth_resp.json()
|
|
||||||
if growth_resp and growth_resp.status_code == status.HTTP_200_OK
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_entries": stats.get("total_entries", 0),
|
"total_entries": stats.get("total_entries", 0),
|
||||||
@@ -332,42 +261,26 @@ async def _handle_recent(
|
|||||||
entry_type: str | None,
|
entry_type: str | None,
|
||||||
task_id: str | None,
|
task_id: str | None,
|
||||||
limit: int,
|
limit: int,
|
||||||
agent_id: str,
|
client: ApiClient,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle recent entries retrieval."""
|
"""Handle recent entries retrieval."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
max_limit = 50
|
||||||
params: dict[str, Any] = {"limit": min(limit, 50)}
|
params: dict[str, Any] = {"limit": min(limit, max_limit)}
|
||||||
if entry_type:
|
if entry_type:
|
||||||
params["entry_type"] = entry_type
|
params["entry_type"] = entry_type
|
||||||
if task_id:
|
if task_id:
|
||||||
params["task_id"] = task_id
|
params["task_id"] = task_id
|
||||||
|
|
||||||
try:
|
entries, error = await client.get_or_error(
|
||||||
resp = await client.get(
|
"/journals/me/entries",
|
||||||
f"{settings.internal_api_url}/journals/me/entries",
|
params=params,
|
||||||
params=params,
|
error_code="LIST_FAILED",
|
||||||
headers=_get_agent_headers(agent_id),
|
error_message="Failed to list entries",
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException:
|
if error:
|
||||||
return _format_error_response(
|
return error
|
||||||
"TIMEOUT", "Request to list entries timed out"
|
|
||||||
)
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
return _format_error_response(
|
|
||||||
"CONNECTION_ERROR",
|
|
||||||
f"Failed to connect to API: {type(e).__name__}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
return {"entries": entries, "count": len(entries) if entries else 0}
|
||||||
return _format_error_response(
|
|
||||||
"LIST_FAILED",
|
|
||||||
"Failed to list entries",
|
|
||||||
{"status_code": resp.status_code, "detail": resp.text},
|
|
||||||
)
|
|
||||||
|
|
||||||
entries = resp.json()
|
|
||||||
|
|
||||||
return {"entries": entries, "count": len(entries)}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -387,6 +300,9 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
|
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
|
||||||
|
|
||||||
|
# Create shared API client for this agent
|
||||||
|
client = ApiClient(agent_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_entry(data: JournalEntryInput) -> dict[str, Any]:
|
async def roboco_journal_entry(data: JournalEntryInput) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -395,7 +311,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
Your journal is personal - use it to track thoughts, progress,
|
Your journal is personal - use it to track thoughts, progress,
|
||||||
and document your journey on tasks.
|
and document your journey on tasks.
|
||||||
"""
|
"""
|
||||||
return await _handle_journal_entry(data, agent_id)
|
return await _handle_journal_entry(data, client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_reflect(data: TaskReflectionInput) -> dict[str, Any]:
|
async def roboco_journal_reflect(data: TaskReflectionInput) -> dict[str, Any]:
|
||||||
@@ -405,7 +321,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
IMPORTANT: Call this when completing a task. Reflections help build
|
IMPORTANT: Call this when completing a task. Reflections help build
|
||||||
institutional memory and track your growth.
|
institutional memory and track your growth.
|
||||||
"""
|
"""
|
||||||
return await _handle_reflect(data, agent_id)
|
return await _handle_reflect(data, client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_decision(data: DecisionLogInput) -> dict[str, Any]:
|
async def roboco_journal_decision(data: DecisionLogInput) -> dict[str, Any]:
|
||||||
@@ -415,7 +331,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
Use when choosing between approaches. Creates a record of WHY
|
Use when choosing between approaches. Creates a record of WHY
|
||||||
you made the decision for future context.
|
you made the decision for future context.
|
||||||
"""
|
"""
|
||||||
return await _handle_decision(data, agent_id)
|
return await _handle_decision(data, client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]:
|
async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]:
|
||||||
@@ -424,7 +340,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
Track learnings to build your knowledge base and help future you.
|
Track learnings to build your knowledge base and help future you.
|
||||||
"""
|
"""
|
||||||
return await _handle_learning(data, agent_id)
|
return await _handle_learning(data, client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_struggle(data: StruggleInput) -> dict[str, Any]:
|
async def roboco_journal_struggle(data: StruggleInput) -> dict[str, Any]:
|
||||||
@@ -434,7 +350,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
Recording struggles helps track problem-solving patterns and
|
Recording struggles helps track problem-solving patterns and
|
||||||
create documentation for others.
|
create documentation for others.
|
||||||
"""
|
"""
|
||||||
return await _handle_struggle(data, agent_id)
|
return await _handle_struggle(data, client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]:
|
async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]:
|
||||||
@@ -443,7 +359,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
Uses semantic search to find relevant entries based on meaning.
|
Uses semantic search to find relevant entries based on meaning.
|
||||||
"""
|
"""
|
||||||
return await _handle_search(query, top_k, agent_id)
|
return await _handle_search(query, top_k, client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_stats() -> dict[str, Any]:
|
async def roboco_journal_stats() -> dict[str, Any]:
|
||||||
@@ -452,7 +368,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
Returns counts by entry type, growth metrics, and other stats.
|
Returns counts by entry type, growth metrics, and other stats.
|
||||||
"""
|
"""
|
||||||
return await _handle_stats(agent_id)
|
return await _handle_stats(client)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_journal_recent(
|
async def roboco_journal_recent(
|
||||||
@@ -466,7 +382,7 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
Filter by entry_type (general, task_reflection, decision_log,
|
Filter by entry_type (general, task_reflection, decision_log,
|
||||||
learning, struggle) or by task_id.
|
learning, struggle) or by task_id.
|
||||||
"""
|
"""
|
||||||
return await _handle_recent(entry_type, task_id, limit, agent_id)
|
return await _handle_recent(entry_type, task_id, limit, client)
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|||||||
+130
-222
@@ -12,73 +12,36 @@ Tools:
|
|||||||
- roboco_channel_history: Get channel message history
|
- roboco_channel_history: Get channel message history
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
from roboco.agents_config import CHANNEL_ACCESS, get_agent_role, get_agent_team
|
from roboco.agents_config import CHANNEL_ACCESS
|
||||||
from roboco.config import settings
|
|
||||||
from roboco.llm import ToonAdapter
|
from roboco.llm import ToonAdapter
|
||||||
from roboco.mcp.schemas import (
|
from roboco.mcp.schemas import (
|
||||||
AskQuestionInput,
|
AskQuestionInput,
|
||||||
ReportBlockerInput,
|
ReportBlockerInput,
|
||||||
SendMessageInput,
|
SendMessageInput,
|
||||||
)
|
)
|
||||||
|
from roboco.mcp.utils import ApiClient, format_error_response, resolve_agent_uuid
|
||||||
|
|
||||||
# Global TOON adapter for encoding message data
|
# Global TOON adapter for encoding message data
|
||||||
_toon = ToonAdapter()
|
_toon = ToonAdapter()
|
||||||
|
|
||||||
|
|
||||||
def _get_agent_headers(agent_id: str) -> dict[str, str]:
|
|
||||||
"""Get standard headers for API calls."""
|
|
||||||
headers = {
|
|
||||||
"X-Agent-ID": agent_id,
|
|
||||||
"X-Agent-Role": get_agent_role(agent_id),
|
|
||||||
}
|
|
||||||
team = get_agent_team(agent_id)
|
|
||||||
if team:
|
|
||||||
headers["X-Agent-Team"] = team
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
# Cache for agent slug -> UUID resolution
|
# Cache for agent slug -> UUID resolution
|
||||||
_agent_uuid_cache: dict[str, str] = {}
|
_agent_uuid_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_agent_uuid(agent_id: str, headers: dict[str, str]) -> str | None:
|
async def _resolve_agent_uuid_cached(agent_id: str, client: ApiClient) -> str | None:
|
||||||
"""Resolve agent slug to UUID. Returns None if not found."""
|
"""Resolve agent slug to UUID with caching. Returns None if not found."""
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
# Check if already a valid UUID
|
|
||||||
try:
|
|
||||||
UUID(agent_id)
|
|
||||||
return agent_id # Already a UUID
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
if agent_id in _agent_uuid_cache:
|
if agent_id in _agent_uuid_cache:
|
||||||
return _agent_uuid_cache[agent_id]
|
return _agent_uuid_cache[agent_id]
|
||||||
|
result = await resolve_agent_uuid(agent_id, client._get_headers())
|
||||||
# Query API to resolve slug to UUID
|
if result:
|
||||||
async with httpx.AsyncClient() as client:
|
_agent_uuid_cache[agent_id] = result
|
||||||
resp = await client.get(
|
return result
|
||||||
f"{settings.internal_api_url}/agents",
|
|
||||||
params={"slug": agent_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
if resp.status_code == status.HTTP_200_OK:
|
|
||||||
agents = resp.json()
|
|
||||||
if agents:
|
|
||||||
uuid: str | None = agents[0].get("id")
|
|
||||||
if uuid:
|
|
||||||
_agent_uuid_cache[agent_id] = uuid
|
|
||||||
return uuid
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -100,21 +63,6 @@ def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool
|
|||||||
return bool(action == "read" and agent_id in channel.get("silent", []))
|
return bool(action == "read" and agent_id in channel.get("silent", []))
|
||||||
|
|
||||||
|
|
||||||
def _format_error_response(
|
|
||||||
error_code: str,
|
|
||||||
message: str,
|
|
||||||
details: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Format a standardized error response."""
|
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": error_code,
|
|
||||||
"message": message,
|
|
||||||
"details": details or {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_message_send(
|
def _validate_message_send(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
channel_slug: str,
|
channel_slug: str,
|
||||||
@@ -131,7 +79,7 @@ def _validate_message_send(
|
|||||||
"technical",
|
"technical",
|
||||||
]
|
]
|
||||||
if message_type not in valid_types:
|
if message_type not in valid_types:
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"INVALID_TYPE",
|
"INVALID_TYPE",
|
||||||
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
|
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
|
||||||
)
|
)
|
||||||
@@ -140,20 +88,20 @@ def _validate_message_send(
|
|||||||
writable = [
|
writable = [
|
||||||
ch for ch in CHANNEL_ACCESS if _check_channel_access(agent_id, ch, "write")
|
ch for ch in CHANNEL_ACCESS if _check_channel_access(agent_id, ch, "write")
|
||||||
]
|
]
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"ACCESS_DENIED",
|
"ACCESS_DENIED",
|
||||||
f"You don't have write access to #{channel_slug}",
|
f"You don't have write access to #{channel_slug}",
|
||||||
{"your_writable_channels": writable},
|
{"your_writable_channels": writable},
|
||||||
)
|
)
|
||||||
|
|
||||||
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
|
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"SILENT_OBSERVER",
|
"SILENT_OBSERVER",
|
||||||
"You are a silent observer on this channel and cannot post messages.",
|
"You are a silent observer on this channel and cannot post messages.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not content or not content.strip():
|
if not content or not content.strip():
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"EMPTY_CONTENT", "Message content cannot be empty."
|
"EMPTY_CONTENT", "Message content cannot be empty."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,26 +109,22 @@ def _validate_message_send(
|
|||||||
|
|
||||||
|
|
||||||
async def _get_default_group(
|
async def _get_default_group(
|
||||||
client: httpx.AsyncClient,
|
client: ApiClient,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
headers: dict[str, str],
|
|
||||||
) -> str | dict[str, Any]:
|
) -> str | dict[str, Any]:
|
||||||
"""Get the default (first) group for a channel. Returns group_id or error dict."""
|
"""Get the default (first) group for a channel. Returns group_id or error dict."""
|
||||||
groups_resp = await client.get(
|
resp = await client.get(f"/channels/{channel_id}/groups")
|
||||||
f"{settings.internal_api_url}/channels/{channel_id}/groups",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if groups_resp.status_code != status.HTTP_200_OK:
|
if not resp.ok:
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"GROUPS_ERROR",
|
"GROUPS_ERROR",
|
||||||
"Failed to get channel groups",
|
"Failed to get channel groups",
|
||||||
{"status": groups_resp.status_code},
|
{"status": resp.status_code},
|
||||||
)
|
)
|
||||||
|
|
||||||
groups = groups_resp.json()
|
groups = resp.json()
|
||||||
if not groups:
|
if not groups:
|
||||||
return _format_error_response("NO_GROUPS", "Channel has no groups")
|
return format_error_response("NO_GROUPS", "Channel has no groups")
|
||||||
|
|
||||||
# Return first active group, or first group if none are active
|
# Return first active group, or first group if none are active
|
||||||
for group in groups:
|
for group in groups:
|
||||||
@@ -190,26 +134,21 @@ async def _get_default_group(
|
|||||||
|
|
||||||
|
|
||||||
async def _get_or_create_session(
|
async def _get_or_create_session(
|
||||||
client: httpx.AsyncClient,
|
client: ApiClient,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
headers: dict[str, str],
|
|
||||||
) -> str | dict[str, Any]:
|
) -> str | dict[str, Any]:
|
||||||
"""Get or create session for channel. Returns session_id or error dict."""
|
"""Get or create session for channel. Returns session_id or error dict."""
|
||||||
# First get the default group for this channel
|
# First get the default group for this channel
|
||||||
group_result = await _get_default_group(client, channel_id, headers)
|
group_result = await _get_default_group(client, channel_id)
|
||||||
if isinstance(group_result, dict):
|
if isinstance(group_result, dict):
|
||||||
return group_result # Error response
|
return group_result # Error response
|
||||||
group_id = group_result
|
group_id = group_result
|
||||||
|
|
||||||
# Check if group has an active session
|
# Check if group has an active session
|
||||||
sessions_resp = await client.get(
|
resp = await client.get("/sessions", params={"group_id": group_id, "limit": 1})
|
||||||
f"{settings.internal_api_url}/sessions",
|
|
||||||
params={"group_id": group_id, "limit": 1},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if sessions_resp.status_code == status.HTTP_200_OK:
|
if resp.ok:
|
||||||
data = sessions_resp.json()
|
data = resp.json()
|
||||||
items = data.get("items", [])
|
items = data.get("items", [])
|
||||||
# Find an active session
|
# Find an active session
|
||||||
for session in items:
|
for session in items:
|
||||||
@@ -217,15 +156,11 @@ async def _get_or_create_session(
|
|||||||
return str(session["id"])
|
return str(session["id"])
|
||||||
|
|
||||||
# Create new session
|
# Create new session
|
||||||
create_resp = await client.post(
|
create_resp = await client.post("/sessions", json={"group_id": group_id})
|
||||||
f"{settings.internal_api_url}/sessions",
|
if create_resp.ok:
|
||||||
json={"group_id": group_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
|
||||||
return str(create_resp.json()["id"])
|
return str(create_resp.json()["id"])
|
||||||
|
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"SESSION_ERROR",
|
"SESSION_ERROR",
|
||||||
"Failed to create session",
|
"Failed to create session",
|
||||||
{"api_error": create_resp.text},
|
{"api_error": create_resp.text},
|
||||||
@@ -263,68 +198,56 @@ async def _handle_channel_list(agent_id: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
async def _get_channel_by_slug(
|
async def _get_channel_by_slug(
|
||||||
client: httpx.AsyncClient,
|
client: ApiClient,
|
||||||
channel_slug: str,
|
channel_slug: str,
|
||||||
headers: dict[str, str],
|
|
||||||
) -> str | dict[str, Any]:
|
) -> str | dict[str, Any]:
|
||||||
"""Get channel ID by slug. Returns channel_id or error dict."""
|
"""Get channel ID by slug. Returns channel_id or error dict."""
|
||||||
resp = await client.get(
|
resp = await client.get("/channels", params={"slug": channel_slug})
|
||||||
f"{settings.internal_api_url}/channels",
|
|
||||||
params={"slug": channel_slug},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
if not resp.ok:
|
||||||
return _format_error_response("API_ERROR", "Failed to fetch channels")
|
return format_error_response("API_ERROR", "Failed to fetch channels")
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
items = data.get("items", data)
|
items = data.get("items", data)
|
||||||
if not items:
|
if not items:
|
||||||
return _format_error_response("NOT_FOUND", f"Channel #{channel_slug} not found")
|
return format_error_response("NOT_FOUND", f"Channel #{channel_slug} not found")
|
||||||
|
|
||||||
channel = items[0] if isinstance(items, list) else items
|
channel = items[0] if isinstance(items, list) else items
|
||||||
return str(channel["id"])
|
return str(channel["id"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_sessions_for_group(
|
async def _get_sessions_for_group(
|
||||||
client: httpx.AsyncClient,
|
client: ApiClient,
|
||||||
group_id: str,
|
group_id: str,
|
||||||
headers: dict[str, str],
|
|
||||||
) -> list | dict[str, Any]:
|
) -> list | dict[str, Any]:
|
||||||
"""Get sessions for a group. Returns session list or error dict."""
|
"""Get sessions for a group. Returns session list or error dict."""
|
||||||
resp = await client.get(
|
resp = await client.get("/sessions", params={"group_id": group_id, "limit": 5})
|
||||||
f"{settings.internal_api_url}/sessions",
|
|
||||||
params={"group_id": group_id, "limit": 5},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
if not resp.ok:
|
||||||
return _format_error_response("API_ERROR", "Failed to fetch sessions")
|
return format_error_response("API_ERROR", "Failed to fetch sessions")
|
||||||
|
|
||||||
items: list = resp.json().get("items", [])
|
items: list = resp.json().get("items", [])
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_messages_from_sessions(
|
async def _fetch_messages_from_sessions(
|
||||||
client: httpx.AsyncClient,
|
client: ApiClient,
|
||||||
sessions: list,
|
sessions: list,
|
||||||
since: datetime,
|
since: datetime,
|
||||||
limit: int,
|
limit: int,
|
||||||
headers: dict[str, str],
|
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Fetch messages from multiple sessions."""
|
"""Fetch messages from multiple sessions."""
|
||||||
all_messages: list = []
|
all_messages: list = []
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{settings.internal_api_url}/messages",
|
"/messages",
|
||||||
params={
|
params={
|
||||||
"session_id": session["id"],
|
"session_id": session["id"],
|
||||||
"after": since.isoformat(),
|
"after": since.isoformat(),
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
},
|
},
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
if resp.status_code == status.HTTP_200_OK:
|
if resp.ok:
|
||||||
all_messages.extend(resp.json().get("items", []))
|
all_messages.extend(resp.json().get("items", []))
|
||||||
if len(all_messages) >= limit:
|
if len(all_messages) >= limit:
|
||||||
break
|
break
|
||||||
@@ -334,6 +257,7 @@ async def _fetch_messages_from_sessions(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_channel_history(
|
async def _handle_channel_history(
|
||||||
|
client: ApiClient,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
channel_slug: str,
|
channel_slug: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
@@ -341,47 +265,44 @@ async def _handle_channel_history(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle channel history retrieval."""
|
"""Handle channel history retrieval."""
|
||||||
if not _check_channel_access(agent_id, channel_slug, "read"):
|
if not _check_channel_access(agent_id, channel_slug, "read"):
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"ACCESS_DENIED", f"You don't have read access to #{channel_slug}"
|
"ACCESS_DENIED", f"You don't have read access to #{channel_slug}"
|
||||||
)
|
)
|
||||||
|
|
||||||
limit = min(limit, 100)
|
max_limit = 100
|
||||||
|
limit = min(limit, max_limit)
|
||||||
since = datetime.now(UTC) - timedelta(hours=hours_back)
|
since = datetime.now(UTC) - timedelta(hours=hours_back)
|
||||||
headers = _get_agent_headers(agent_id)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
# Get channel
|
||||||
# Get channel
|
channel_result = await _get_channel_by_slug(client, channel_slug)
|
||||||
channel_result = await _get_channel_by_slug(client, channel_slug, headers)
|
if isinstance(channel_result, dict):
|
||||||
if isinstance(channel_result, dict):
|
return channel_result
|
||||||
return channel_result
|
channel_id = channel_result
|
||||||
channel_id = channel_result
|
|
||||||
|
|
||||||
# Get group
|
# Get group
|
||||||
group_result = await _get_default_group(client, channel_id, headers)
|
group_result = await _get_default_group(client, channel_id)
|
||||||
if isinstance(group_result, dict):
|
if isinstance(group_result, dict):
|
||||||
return group_result
|
return group_result
|
||||||
group_id = group_result
|
group_id = group_result
|
||||||
|
|
||||||
# Get sessions
|
# Get sessions
|
||||||
sessions_result = await _get_sessions_for_group(client, group_id, headers)
|
sessions_result = await _get_sessions_for_group(client, group_id)
|
||||||
if isinstance(sessions_result, dict):
|
if isinstance(sessions_result, dict):
|
||||||
return sessions_result
|
return sessions_result
|
||||||
sessions = sessions_result
|
sessions = sessions_result
|
||||||
|
|
||||||
# Early return for no sessions
|
# Early return for no sessions
|
||||||
if not sessions:
|
if not sessions:
|
||||||
return {
|
return {
|
||||||
"channel": channel_slug,
|
"channel": channel_slug,
|
||||||
"messages": [],
|
"messages": [],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"has_more": False,
|
"has_more": False,
|
||||||
"since": since.isoformat(),
|
"since": since.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch messages
|
# Fetch messages
|
||||||
messages = await _fetch_messages_from_sessions(
|
messages = await _fetch_messages_from_sessions(client, sessions, since, limit)
|
||||||
client, sessions, since, limit, headers
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"channel": channel_slug,
|
"channel": channel_slug,
|
||||||
@@ -393,6 +314,7 @@ async def _handle_channel_history(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_message_send(
|
async def _handle_message_send(
|
||||||
|
client: ApiClient,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
data: SendMessageInput,
|
data: SendMessageInput,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -402,80 +324,68 @@ async def _handle_message_send(
|
|||||||
):
|
):
|
||||||
return validation_error
|
return validation_error
|
||||||
|
|
||||||
headers = _get_agent_headers(agent_id)
|
# Get channel by slug
|
||||||
async with httpx.AsyncClient() as client:
|
channel_result = await _get_channel_by_slug(client, data.channel_slug)
|
||||||
# Use the helper function that properly handles paginated responses
|
if isinstance(channel_result, dict):
|
||||||
channel_result = await _get_channel_by_slug(client, data.channel_slug, headers)
|
return channel_result # Error response
|
||||||
if isinstance(channel_result, dict):
|
channel_id = channel_result
|
||||||
return channel_result # Error response
|
|
||||||
channel_id = channel_result
|
|
||||||
|
|
||||||
session_result = await _get_or_create_session(client, channel_id, headers)
|
session_result = await _get_or_create_session(client, channel_id)
|
||||||
if isinstance(session_result, dict):
|
if isinstance(session_result, dict):
|
||||||
return session_result
|
return session_result
|
||||||
session_id = session_result
|
session_id = session_result
|
||||||
|
|
||||||
# Resolve mentions (slugs) to UUIDs
|
# Resolve mentions (slugs) to UUIDs
|
||||||
resolved_mentions: list[str] = []
|
resolved_mentions: list[str] = []
|
||||||
if data.mentions:
|
if data.mentions:
|
||||||
for mention in data.mentions:
|
for mention in data.mentions:
|
||||||
resolved = await _resolve_agent_uuid(mention, headers)
|
resolved = await _resolve_agent_uuid_cached(mention, client)
|
||||||
if resolved:
|
if resolved:
|
||||||
resolved_mentions.append(resolved)
|
resolved_mentions.append(resolved)
|
||||||
# Skip unresolved mentions rather than failing
|
# Skip unresolved mentions rather than failing
|
||||||
|
|
||||||
message_data = {
|
message_data = {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"type": data.message_type,
|
"type": data.message_type,
|
||||||
"content": data.content,
|
"content": data.content,
|
||||||
"is_reply": data.reply_to is not None,
|
"is_reply": data.reply_to is not None,
|
||||||
"reply_to": data.reply_to,
|
"reply_to": data.reply_to,
|
||||||
"mentions": resolved_mentions,
|
"mentions": resolved_mentions,
|
||||||
"task_id": data.task_id,
|
"task_id": data.task_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
send_resp = await client.post(
|
resp = await client.post("/messages", json=message_data)
|
||||||
f"{settings.internal_api_url}/messages",
|
|
||||||
json=message_data,
|
if not resp.ok:
|
||||||
headers=headers,
|
return format_error_response(
|
||||||
|
"SEND_FAILED", "Failed to send message", {"api_error": resp.text}
|
||||||
)
|
)
|
||||||
|
|
||||||
if send_resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
return {
|
||||||
return _format_error_response(
|
"status": "sent",
|
||||||
"SEND_FAILED", "Failed to send message", {"api_error": send_resp.text}
|
"message": resp.json(),
|
||||||
)
|
"channel": data.channel_slug,
|
||||||
|
"guidance": "Message sent successfully.",
|
||||||
return {
|
}
|
||||||
"status": "sent",
|
|
||||||
"message": send_resp.json(),
|
|
||||||
"channel": data.channel_slug,
|
|
||||||
"guidance": "Message sent successfully.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_message_get(message_id: str, agent_id: str) -> dict[str, Any]:
|
async def _handle_message_get(client: ApiClient, message_id: str) -> dict[str, Any]:
|
||||||
"""Handle message retrieval."""
|
"""Handle message retrieval."""
|
||||||
headers = _get_agent_headers(agent_id)
|
resp = await client.get(f"/messages/{message_id}")
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{settings.internal_api_url}/messages/{message_id}",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
return _format_error_response(
|
return format_error_response("NOT_FOUND", f"Message {message_id} not found")
|
||||||
"NOT_FOUND", f"Message {message_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
if not resp.ok:
|
||||||
return _format_error_response("API_ERROR", "Failed to fetch message")
|
return format_error_response("API_ERROR", "Failed to fetch message")
|
||||||
|
|
||||||
return {"message": resp.json()}
|
return {"message": resp.json()}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_ask_question(
|
async def _handle_ask_question(
|
||||||
|
client: ApiClient,
|
||||||
|
agent_id: str,
|
||||||
data: AskQuestionInput,
|
data: AskQuestionInput,
|
||||||
send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle asking a question."""
|
"""Handle asking a question."""
|
||||||
content = f"**Question**: {data.question}"
|
content = f"**Question**: {data.question}"
|
||||||
@@ -488,7 +398,7 @@ async def _handle_ask_question(
|
|||||||
message_type="dialogue",
|
message_type="dialogue",
|
||||||
task_id=data.task_id,
|
task_id=data.task_id,
|
||||||
)
|
)
|
||||||
result = await send_fn(msg_data)
|
result = await _handle_message_send(client, agent_id, msg_data)
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return result
|
return result
|
||||||
@@ -503,8 +413,9 @@ async def _handle_ask_question(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_report_blocker(
|
async def _handle_report_blocker(
|
||||||
|
client: ApiClient,
|
||||||
|
agent_id: str,
|
||||||
data: ReportBlockerInput,
|
data: ReportBlockerInput,
|
||||||
send_fn: Callable[[SendMessageInput], Awaitable[dict[str, Any]]],
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle reporting a blocker."""
|
"""Handle reporting a blocker."""
|
||||||
content = (
|
content = (
|
||||||
@@ -519,7 +430,7 @@ async def _handle_report_blocker(
|
|||||||
message_type="blocker",
|
message_type="blocker",
|
||||||
task_id=data.task_id,
|
task_id=data.task_id,
|
||||||
)
|
)
|
||||||
result = await send_fn(msg_data)
|
result = await _handle_message_send(client, agent_id, msg_data)
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return result
|
return result
|
||||||
@@ -550,6 +461,9 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
|
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
|
||||||
|
|
||||||
|
# Create shared API client for this agent
|
||||||
|
client = ApiClient(agent_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_channel_list() -> dict[str, Any]:
|
async def roboco_channel_list() -> dict[str, Any]:
|
||||||
"""List channels you have access to."""
|
"""List channels you have access to."""
|
||||||
@@ -566,7 +480,9 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
You must have read access to the channel.
|
You must have read access to the channel.
|
||||||
"""
|
"""
|
||||||
return await _handle_channel_history(agent_id, channel_slug, limit, hours_back)
|
return await _handle_channel_history(
|
||||||
|
client, agent_id, channel_slug, limit, hours_back
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_message_send(data: SendMessageInput) -> dict[str, Any]:
|
async def roboco_message_send(data: SendMessageInput) -> dict[str, Any]:
|
||||||
@@ -575,12 +491,12 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
You must have write access to the channel.
|
You must have write access to the channel.
|
||||||
"""
|
"""
|
||||||
return await _handle_message_send(agent_id, data)
|
return await _handle_message_send(client, agent_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_message_get(message_id: str) -> dict[str, Any]:
|
async def roboco_message_get(message_id: str) -> dict[str, Any]:
|
||||||
"""Get a specific message by ID."""
|
"""Get a specific message by ID."""
|
||||||
return await _handle_message_get(message_id, agent_id)
|
return await _handle_message_get(client, message_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_ask_question(
|
async def roboco_ask_question(
|
||||||
@@ -594,17 +510,13 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
After asking, wait for an answer before proceeding.
|
After asking, wait for an answer before proceeding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def send_fn(d: SendMessageInput) -> dict[str, Any]:
|
|
||||||
return await _handle_message_send(agent_id, d)
|
|
||||||
|
|
||||||
data = AskQuestionInput(
|
data = AskQuestionInput(
|
||||||
channel_slug=channel_slug,
|
channel_slug=channel_slug,
|
||||||
question=question,
|
question=question,
|
||||||
context=context,
|
context=context,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
return await _handle_ask_question(data, send_fn)
|
return await _handle_ask_question(client, agent_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_report_blocker(
|
async def roboco_report_blocker(
|
||||||
@@ -618,17 +530,13 @@ def create_message_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
|
|
||||||
The PM will be notified automatically.
|
The PM will be notified automatically.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def send_fn(d: SendMessageInput) -> dict[str, Any]:
|
|
||||||
return await _handle_message_send(agent_id, d)
|
|
||||||
|
|
||||||
data = ReportBlockerInput(
|
data = ReportBlockerInput(
|
||||||
channel_slug=channel_slug,
|
channel_slug=channel_slug,
|
||||||
blocker_description=blocker_description,
|
blocker_description=blocker_description,
|
||||||
what_needed=what_needed,
|
what_needed=what_needed,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
)
|
)
|
||||||
return await _handle_report_blocker(data, send_fn)
|
return await _handle_report_blocker(client, agent_id, data)
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|||||||
+72
-121
@@ -13,7 +13,6 @@ Tools:
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
@@ -21,28 +20,15 @@ from roboco.agents_config import (
|
|||||||
NOTIFICATION_PERMISSIONS,
|
NOTIFICATION_PERMISSIONS,
|
||||||
get_agent_cell,
|
get_agent_cell,
|
||||||
get_agent_role,
|
get_agent_role,
|
||||||
get_agent_team,
|
|
||||||
)
|
)
|
||||||
from roboco.config import settings
|
|
||||||
from roboco.mcp.schemas import SendNotificationInput
|
from roboco.mcp.schemas import SendNotificationInput
|
||||||
|
from roboco.mcp.utils import ApiClient, format_error_response
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# HELPER FUNCTIONS
|
# HELPER FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _get_agent_headers(agent_id: str) -> dict[str, str]:
|
|
||||||
"""Get standard headers for API calls."""
|
|
||||||
headers = {
|
|
||||||
"X-Agent-ID": agent_id,
|
|
||||||
"X-Agent-Role": get_agent_role(agent_id),
|
|
||||||
}
|
|
||||||
team = get_agent_team(agent_id)
|
|
||||||
if team:
|
|
||||||
headers["X-Agent-Team"] = team
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def _check_cell_scope(sender_id: str) -> tuple[bool, str]:
|
def _check_cell_scope(sender_id: str) -> tuple[bool, str]:
|
||||||
"""Check if sender can notify within their cell."""
|
"""Check if sender can notify within their cell."""
|
||||||
sender_cell = get_agent_cell(sender_id)
|
sender_cell = get_agent_cell(sender_id)
|
||||||
@@ -75,21 +61,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
|
|||||||
return False, f"You cannot send notifications to {recipient_id}"
|
return False, f"You cannot send notifications to {recipient_id}"
|
||||||
|
|
||||||
|
|
||||||
def _format_error_response(
|
|
||||||
error_code: str,
|
|
||||||
message: str,
|
|
||||||
details: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Format a standardized error response."""
|
|
||||||
return {
|
|
||||||
"error": {
|
|
||||||
"code": error_code,
|
|
||||||
"message": message,
|
|
||||||
"details": details or {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Valid notification types and priorities
|
# Valid notification types and priorities
|
||||||
VALID_NOTIFICATION_TYPES = frozenset(
|
VALID_NOTIFICATION_TYPES = frozenset(
|
||||||
["info", "alert", "task", "escalation", "approval"]
|
["info", "alert", "task", "escalation", "approval"]
|
||||||
@@ -101,7 +72,7 @@ def _validate_notification_type(notification_type: str) -> dict[str, Any] | None
|
|||||||
"""Validate notification type. Returns error dict or None if valid."""
|
"""Validate notification type. Returns error dict or None if valid."""
|
||||||
if notification_type not in VALID_NOTIFICATION_TYPES:
|
if notification_type not in VALID_NOTIFICATION_TYPES:
|
||||||
valid = sorted(VALID_NOTIFICATION_TYPES)
|
valid = sorted(VALID_NOTIFICATION_TYPES)
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"INVALID_TYPE", f"Invalid type. Must be one of: {valid}"
|
"INVALID_TYPE", f"Invalid type. Must be one of: {valid}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -110,7 +81,7 @@ def _validate_notification_type(notification_type: str) -> dict[str, Any] | None
|
|||||||
def _validate_priority(priority: str) -> dict[str, Any] | None:
|
def _validate_priority(priority: str) -> dict[str, Any] | None:
|
||||||
"""Validate priority. Returns error dict or None if valid."""
|
"""Validate priority. Returns error dict or None if valid."""
|
||||||
if priority not in VALID_PRIORITIES:
|
if priority not in VALID_PRIORITIES:
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"INVALID_PRIORITY",
|
"INVALID_PRIORITY",
|
||||||
f"Invalid priority. Must be one of: {sorted(VALID_PRIORITIES)}",
|
f"Invalid priority. Must be one of: {sorted(VALID_PRIORITIES)}",
|
||||||
)
|
)
|
||||||
@@ -123,30 +94,23 @@ def _validate_priority(priority: str) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_list(
|
async def _handle_list(
|
||||||
agent_id: str,
|
client: ApiClient,
|
||||||
unread_only: bool,
|
unread_only: bool,
|
||||||
pending_ack_only: bool,
|
pending_ack_only: bool,
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle notification listing."""
|
"""Handle notification listing."""
|
||||||
async with httpx.AsyncClient() as client:
|
params: dict[str, str | int] = {
|
||||||
params: dict[str, str | int] = {
|
"unread_only": str(unread_only).lower(),
|
||||||
"unread_only": str(unread_only).lower(),
|
"pending_ack_only": str(pending_ack_only).lower(),
|
||||||
"pending_ack_only": str(pending_ack_only).lower(),
|
"limit": limit,
|
||||||
"limit": limit,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get("/notifications", params=params)
|
||||||
f"{settings.internal_api_url}/notifications",
|
if not resp.ok:
|
||||||
params=params,
|
return format_error_response("API_ERROR", "Failed to fetch notifications")
|
||||||
headers=_get_agent_headers(agent_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
|
||||||
return _format_error_response("API_ERROR", "Failed to fetch notifications")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
unread = data.get("unread_count", 0)
|
unread = data.get("unread_count", 0)
|
||||||
pending_ack = data.get("pending_ack_count", 0)
|
pending_ack = data.get("pending_ack_count", 0)
|
||||||
|
|
||||||
@@ -170,27 +134,22 @@ async def _handle_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_get(agent_id: str, notification_id: str) -> dict[str, Any]:
|
async def _handle_get(client: ApiClient, notification_id: str) -> dict[str, Any]:
|
||||||
"""Handle getting a specific notification."""
|
"""Handle getting a specific notification."""
|
||||||
async with httpx.AsyncClient() as client:
|
resp = await client.get(f"/notifications/{notification_id}")
|
||||||
resp = await client.get(
|
|
||||||
f"{settings.internal_api_url}/notifications/{notification_id}",
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
headers=_get_agent_headers(agent_id),
|
return format_error_response("NOT_FOUND", "Notification not found")
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_403_FORBIDDEN):
|
||||||
|
return format_error_response(
|
||||||
|
"NOT_RECIPIENT", "You are not a recipient of this notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
if not resp.ok:
|
||||||
return _format_error_response("NOT_FOUND", "Notification not found")
|
return format_error_response("API_ERROR", "Failed to fetch notification")
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_403_FORBIDDEN:
|
|
||||||
return _format_error_response(
|
|
||||||
"NOT_RECIPIENT", "You are not a recipient of this notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
|
||||||
return _format_error_response("API_ERROR", "Failed to fetch notification")
|
|
||||||
|
|
||||||
notification = resp.json()
|
|
||||||
|
|
||||||
|
notification = resp.json()
|
||||||
guidance = ""
|
guidance = ""
|
||||||
if notification.get("requires_ack") and not notification.get("is_acknowledged"):
|
if notification.get("requires_ack") and not notification.get("is_acknowledged"):
|
||||||
guidance = (
|
guidance = (
|
||||||
@@ -201,34 +160,27 @@ async def _handle_get(agent_id: str, notification_id: str) -> dict[str, Any]:
|
|||||||
return {"notification": notification, "guidance": guidance}
|
return {"notification": notification, "guidance": guidance}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_ack(agent_id: str, notification_id: str) -> dict[str, Any]:
|
async def _handle_ack(client: ApiClient, notification_id: str) -> dict[str, Any]:
|
||||||
"""Handle acknowledging a notification."""
|
"""Handle acknowledging a notification."""
|
||||||
async with httpx.AsyncClient() as client:
|
resp = await client.post(f"/notifications/{notification_id}/ack")
|
||||||
resp = await client.post(
|
|
||||||
f"{settings.internal_api_url}/notifications/{notification_id}/ack",
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||||
headers=_get_agent_headers(agent_id),
|
return format_error_response("NOT_FOUND", "Notification not found")
|
||||||
|
|
||||||
|
if resp.is_status(status.HTTP_403_FORBIDDEN):
|
||||||
|
return format_error_response(
|
||||||
|
"NOT_RECIPIENT", "You are not a recipient of this notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_404_NOT_FOUND:
|
if resp.is_status(status.HTTP_400_BAD_REQUEST):
|
||||||
return _format_error_response("NOT_FOUND", "Notification not found")
|
return format_error_response(
|
||||||
|
"NO_ACK_REQUIRED", "This notification does not require acknowledgment"
|
||||||
|
)
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_403_FORBIDDEN:
|
if not resp.ok:
|
||||||
return _format_error_response(
|
return format_error_response("API_ERROR", "Failed to acknowledge notification")
|
||||||
"NOT_RECIPIENT", "You are not a recipient of this notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == status.HTTP_400_BAD_REQUEST:
|
|
||||||
return _format_error_response(
|
|
||||||
"NO_ACK_REQUIRED", "This notification does not require acknowledgment"
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != status.HTTP_200_OK:
|
|
||||||
return _format_error_response(
|
|
||||||
"API_ERROR", "Failed to acknowledge notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
notification = resp.json()
|
|
||||||
|
|
||||||
|
notification = resp.json()
|
||||||
return {
|
return {
|
||||||
"status": "acknowledged",
|
"status": "acknowledged",
|
||||||
"notification": notification,
|
"notification": notification,
|
||||||
@@ -241,7 +193,7 @@ def _check_send_permission(agent_id: str) -> dict[str, Any] | None:
|
|||||||
role = get_agent_role(agent_id)
|
role = get_agent_role(agent_id)
|
||||||
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
|
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
|
||||||
if not permissions.get("can_send", False):
|
if not permissions.get("can_send", False):
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"NOT_AUTHORIZED",
|
"NOT_AUTHORIZED",
|
||||||
f"Agents with role '{role}' cannot send notifications. "
|
f"Agents with role '{role}' cannot send notifications. "
|
||||||
"Only PMs, Board members, and Auditor can send notifications.",
|
"Only PMs, Board members, and Auditor can send notifications.",
|
||||||
@@ -259,7 +211,7 @@ def _check_recipients(agent_id: str, recipients: list[str]) -> dict[str, Any] |
|
|||||||
if not can_send
|
if not can_send
|
||||||
]
|
]
|
||||||
if denied:
|
if denied:
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"RECIPIENT_DENIED",
|
"RECIPIENT_DENIED",
|
||||||
"Cannot send to one or more recipients",
|
"Cannot send to one or more recipients",
|
||||||
{"denied": denied},
|
{"denied": denied},
|
||||||
@@ -267,7 +219,9 @@ def _check_recipients(agent_id: str, recipients: list[str]) -> dict[str, Any] |
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str, Any]:
|
async def _handle_send(
|
||||||
|
client: ApiClient, agent_id: str, data: SendNotificationInput
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Handle sending a notification."""
|
"""Handle sending a notification."""
|
||||||
# Validate permissions and data
|
# Validate permissions and data
|
||||||
if error := _check_send_permission(agent_id):
|
if error := _check_send_permission(agent_id):
|
||||||
@@ -279,30 +233,24 @@ async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str,
|
|||||||
if error := _validate_priority(data.priority):
|
if error := _validate_priority(data.priority):
|
||||||
return error
|
return error
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
payload = {
|
||||||
payload = {
|
"type": data.notification_type,
|
||||||
"type": data.notification_type,
|
"priority": data.priority,
|
||||||
"priority": data.priority,
|
"to_agents": data.recipients,
|
||||||
"to_agents": data.recipients,
|
"subject": data.subject,
|
||||||
"subject": data.subject,
|
"body": data.body,
|
||||||
"body": data.body,
|
"requires_ack": data.requires_ack,
|
||||||
"requires_ack": data.requires_ack,
|
"related_task_id": data.related_task_id,
|
||||||
"related_task_id": data.related_task_id,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post("/notifications", json=payload)
|
||||||
f"{settings.internal_api_url}/notifications",
|
|
||||||
json=payload,
|
if not resp.ok:
|
||||||
headers=_get_agent_headers(agent_id),
|
return format_error_response(
|
||||||
|
"SEND_FAILED", "Failed to send notification", {"api_error": resp.text}
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code not in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
|
notification = resp.json()
|
||||||
return _format_error_response(
|
|
||||||
"SEND_FAILED", "Failed to send notification", {"api_error": resp.text}
|
|
||||||
)
|
|
||||||
|
|
||||||
notification = resp.json()
|
|
||||||
|
|
||||||
ack_note = "Recipients must acknowledge." if data.requires_ack else ""
|
ack_note = "Recipients must acknowledge." if data.requires_ack else ""
|
||||||
count = len(data.recipients)
|
count = len(data.recipients)
|
||||||
|
|
||||||
@@ -331,6 +279,9 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
|
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
|
||||||
|
|
||||||
|
# Create shared API client for this agent
|
||||||
|
client = ApiClient(agent_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_notify_list(
|
async def roboco_notify_list(
|
||||||
unread_only: bool = False,
|
unread_only: bool = False,
|
||||||
@@ -338,17 +289,17 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""List your notifications."""
|
"""List your notifications."""
|
||||||
return await _handle_list(agent_id, unread_only, pending_ack_only, limit)
|
return await _handle_list(client, unread_only, pending_ack_only, limit)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
|
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
|
||||||
"""Get a specific notification. Also marks it as read."""
|
"""Get a specific notification. Also marks it as read."""
|
||||||
return await _handle_get(agent_id, notification_id)
|
return await _handle_get(client, notification_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
|
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
|
||||||
"""Acknowledge a notification."""
|
"""Acknowledge a notification."""
|
||||||
return await _handle_ack(agent_id, notification_id)
|
return await _handle_ack(client, notification_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
|
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
|
||||||
@@ -358,7 +309,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
Only PMs, Board members, and Auditor can send notifications.
|
Only PMs, Board members, and Auditor can send notifications.
|
||||||
Cell PMs can only notify their own cell.
|
Cell PMs can only notify their own cell.
|
||||||
"""
|
"""
|
||||||
return await _handle_send(agent_id, data)
|
return await _handle_send(client, agent_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_escalate(
|
async def roboco_escalate(
|
||||||
@@ -374,7 +325,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
role = get_agent_role(agent_id)
|
role = get_agent_role(agent_id)
|
||||||
if role not in ["cell_pm", "main_pm"]:
|
if role not in ["cell_pm", "main_pm"]:
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"NOT_PM", "Only PMs can use the escalate function"
|
"NOT_PM", "Only PMs can use the escalate function"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -387,7 +338,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
requires_ack=True,
|
requires_ack=True,
|
||||||
related_task_id=task_id,
|
related_task_id=task_id,
|
||||||
)
|
)
|
||||||
return await _handle_send(agent_id, input_data)
|
return await _handle_send(client, agent_id, input_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def roboco_request_approval(
|
async def roboco_request_approval(
|
||||||
@@ -401,7 +352,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
"""
|
"""
|
||||||
role = get_agent_role(agent_id)
|
role = get_agent_role(agent_id)
|
||||||
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
||||||
return _format_error_response(
|
return format_error_response(
|
||||||
"NOT_AUTHORIZED", "Only PMs and Board can request approvals"
|
"NOT_AUTHORIZED", "Only PMs and Board can request approvals"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,7 +365,7 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|||||||
requires_ack=True,
|
requires_ack=True,
|
||||||
related_task_id=task_id,
|
related_task_id=task_id,
|
||||||
)
|
)
|
||||||
return await _handle_send(agent_id, input_data)
|
return await _handle_send(client, agent_id, input_data)
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|||||||
@@ -194,3 +194,25 @@ class TaskEscalateInput(BaseModel):
|
|||||||
escalate_to: str | None = Field(
|
escalate_to: str | None = Field(
|
||||||
default=None, description="Override default escalation target"
|
default=None, description="Override default escalation target"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBlockInput(BaseModel):
|
||||||
|
"""Input for blocking a task."""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="Task ID to block")
|
||||||
|
reason: str = Field(..., description="Why the task is blocked")
|
||||||
|
blocker_type: str = Field(
|
||||||
|
..., description="Type: external, internal, question, dependency"
|
||||||
|
)
|
||||||
|
what_needed: str = Field(..., description="What is needed to unblock")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPauseInput(BaseModel):
|
||||||
|
"""Input for pausing a task."""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="Task ID to pause")
|
||||||
|
reason: str = Field(..., description="Why pausing")
|
||||||
|
checkpoint_summary: str = Field(..., description="Summary of current state")
|
||||||
|
remaining_work: list[str] = Field(
|
||||||
|
default_factory=list, description="List of remaining sub-tasks"
|
||||||
|
)
|
||||||
|
|||||||
+589
-772
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
MCP Server Utilities
|
||||||
|
|
||||||
|
Shared utilities for all MCP servers to avoid code duplication.
|
||||||
|
Contains common functions for:
|
||||||
|
- Agent header generation
|
||||||
|
- Error response formatting
|
||||||
|
- Agent UUID resolution
|
||||||
|
- API client for internal API calls
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from roboco.agents_config import get_agent_role, get_agent_team
|
||||||
|
from roboco.config import settings
|
||||||
|
|
||||||
|
# UUID format constants
|
||||||
|
_UUID_LENGTH = 36 # Standard UUID string length
|
||||||
|
_UUID_HYPHEN_COUNT = 4 # Number of hyphens in a UUID
|
||||||
|
|
||||||
|
# HTTP status code ranges
|
||||||
|
_HTTP_OK = 200
|
||||||
|
_HTTP_SUCCESS_MIN = 200
|
||||||
|
_HTTP_SUCCESS_MAX = 300 # exclusive
|
||||||
|
|
||||||
|
# Default timeout for API calls (seconds)
|
||||||
|
DEFAULT_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_headers(agent_id: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get standard headers for API calls from an MCP server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent's identifier (slug or UUID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Headers dict with X-Agent-ID, X-Agent-Role, and optionally X-Agent-Team
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"X-Agent-ID": agent_id,
|
||||||
|
"X-Agent-Role": get_agent_role(agent_id),
|
||||||
|
}
|
||||||
|
team = get_agent_team(agent_id)
|
||||||
|
if team:
|
||||||
|
headers["X-Agent-Team"] = team
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def format_error_response(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Format a standardized error response for MCP tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Error code (e.g., "NOT_FOUND", "API_ERROR", "PERMISSION_DENIED")
|
||||||
|
message: Human-readable error message
|
||||||
|
details: Optional additional error details
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Standardized error response dict
|
||||||
|
"""
|
||||||
|
response: dict[str, Any] = {
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if details:
|
||||||
|
response["error"]["details"] = details
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_agent_uuid(
|
||||||
|
agent_id: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Resolve an agent identifier to its UUID.
|
||||||
|
|
||||||
|
If the agent_id is already a UUID, returns it directly.
|
||||||
|
Otherwise, looks up the agent by slug/name via the API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Agent identifier (slug like "be-dev-1" or UUID string)
|
||||||
|
headers: Request headers for API authentication
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UUID string if found, None otherwise
|
||||||
|
"""
|
||||||
|
# If it looks like a UUID already, return it
|
||||||
|
if len(agent_id) == _UUID_LENGTH and agent_id.count("-") == _UUID_HYPHEN_COUNT:
|
||||||
|
return agent_id
|
||||||
|
|
||||||
|
# Look up by slug
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{settings.internal_api_url}/agents/by-slug/{agent_id}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if resp.status_code == _HTTP_OK:
|
||||||
|
data = resp.json()
|
||||||
|
agent_id_result: str | None = data.get("id")
|
||||||
|
return agent_id_result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API CLIENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponse:
|
||||||
|
"""Wrapper for API response with convenience methods."""
|
||||||
|
|
||||||
|
def __init__(self, response: httpx.Response) -> None:
|
||||||
|
self._response = response
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""True if status code indicates success (2xx)."""
|
||||||
|
return (
|
||||||
|
self._response.status_code >= _HTTP_SUCCESS_MIN
|
||||||
|
and self._response.status_code < _HTTP_SUCCESS_MAX
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_code(self) -> int:
|
||||||
|
"""HTTP status code."""
|
||||||
|
return self._response.status_code
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
"""Parse response as JSON."""
|
||||||
|
return self._response.json()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
"""Response body as text."""
|
||||||
|
return self._response.text
|
||||||
|
|
||||||
|
def is_status(self, *codes: int) -> bool:
|
||||||
|
"""Check if status matches any of the given codes."""
|
||||||
|
return self._response.status_code in codes
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
"""
|
||||||
|
HTTP client for MCP servers to call the internal API.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Connection pooling via shared httpx.AsyncClient
|
||||||
|
- Automatic URL building from endpoint paths
|
||||||
|
- Automatic agent header injection
|
||||||
|
- Standardized error response formatting
|
||||||
|
- Configurable timeouts
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = ApiClient(agent_id="be-dev-1")
|
||||||
|
|
||||||
|
# GET request
|
||||||
|
resp = await client.get("/tasks/123")
|
||||||
|
if resp.ok:
|
||||||
|
task = resp.json()
|
||||||
|
|
||||||
|
# POST request
|
||||||
|
resp = await client.post("/tasks", json={"title": "New task"})
|
||||||
|
|
||||||
|
# With custom timeout
|
||||||
|
resp = await client.get("/slow-endpoint", timeout=60.0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Agent identifier for header injection
|
||||||
|
timeout: Default request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.agent_id = agent_id
|
||||||
|
self.timeout = timeout
|
||||||
|
self.base_url = settings.internal_api_url
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
"""Get headers with agent context."""
|
||||||
|
return get_agent_headers(self.agent_id)
|
||||||
|
|
||||||
|
async def _ensure_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Get or create the httpx client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the HTTP client. Call when done with the client."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def _build_url(self, endpoint: str) -> str:
|
||||||
|
"""Build full URL from endpoint path."""
|
||||||
|
# Remove leading slash if present to avoid double slashes
|
||||||
|
if endpoint.startswith("/"):
|
||||||
|
endpoint = endpoint[1:]
|
||||||
|
return f"{self.base_url}/{endpoint}"
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Make GET request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path (e.g., "/tasks/123")
|
||||||
|
params: Query parameters
|
||||||
|
timeout: Override default timeout
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApiResponse wrapper
|
||||||
|
"""
|
||||||
|
client = await self._ensure_client()
|
||||||
|
resp = await client.get(
|
||||||
|
self._build_url(endpoint),
|
||||||
|
params=params,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=timeout or self.timeout,
|
||||||
|
)
|
||||||
|
return ApiResponse(resp)
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Make POST request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path
|
||||||
|
json: JSON body
|
||||||
|
params: Query parameters
|
||||||
|
timeout: Override default timeout
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApiResponse wrapper
|
||||||
|
"""
|
||||||
|
client = await self._ensure_client()
|
||||||
|
resp = await client.post(
|
||||||
|
self._build_url(endpoint),
|
||||||
|
json=json,
|
||||||
|
params=params,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=timeout or self.timeout,
|
||||||
|
)
|
||||||
|
return ApiResponse(resp)
|
||||||
|
|
||||||
|
async def put(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Make PUT request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path
|
||||||
|
json: JSON body
|
||||||
|
timeout: Override default timeout
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApiResponse wrapper
|
||||||
|
"""
|
||||||
|
client = await self._ensure_client()
|
||||||
|
resp = await client.put(
|
||||||
|
self._build_url(endpoint),
|
||||||
|
json=json,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=timeout or self.timeout,
|
||||||
|
)
|
||||||
|
return ApiResponse(resp)
|
||||||
|
|
||||||
|
async def patch(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Make PATCH request (partial update).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path
|
||||||
|
json: JSON body with fields to update
|
||||||
|
timeout: Override default timeout
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApiResponse wrapper
|
||||||
|
"""
|
||||||
|
client = await self._ensure_client()
|
||||||
|
resp = await client.patch(
|
||||||
|
self._build_url(endpoint),
|
||||||
|
json=json,
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=timeout or self.timeout,
|
||||||
|
)
|
||||||
|
return ApiResponse(resp)
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> ApiResponse:
|
||||||
|
"""
|
||||||
|
Make DELETE request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path
|
||||||
|
timeout: Override default timeout
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApiResponse wrapper
|
||||||
|
"""
|
||||||
|
client = await self._ensure_client()
|
||||||
|
resp = await client.delete(
|
||||||
|
self._build_url(endpoint),
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=timeout or self.timeout,
|
||||||
|
)
|
||||||
|
return ApiResponse(resp)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# CONVENIENCE METHODS
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_or_error(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
error_code: str = "API_ERROR",
|
||||||
|
error_message: str = "Request failed",
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||||
|
"""
|
||||||
|
GET request returning (data, error) tuple.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint
|
||||||
|
error_code: Error code if request fails
|
||||||
|
error_message: Error message if request fails
|
||||||
|
params: Query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(json_data, None) on success, (None, error_response) on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = await self.get(endpoint, params=params)
|
||||||
|
if resp.ok:
|
||||||
|
return resp.json(), None
|
||||||
|
return None, format_error_response(
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
{"status": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return None, format_error_response(
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
{"exception": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post_or_error(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
error_code: str = "API_ERROR",
|
||||||
|
error_message: str = "Request failed",
|
||||||
|
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||||
|
"""
|
||||||
|
POST request returning (data, error) tuple.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint
|
||||||
|
json: JSON body
|
||||||
|
error_code: Error code if request fails
|
||||||
|
error_message: Error message if request fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(json_data, None) on success, (None, error_response) on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = await self.post(endpoint, json=json)
|
||||||
|
if resp.ok:
|
||||||
|
return resp.json(), None
|
||||||
|
return None, format_error_response(
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
{"status": resp.status_code, "detail": resp.text},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return None, format_error_response(
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
{"exception": str(e)},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user