mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
441 lines
15 KiB
Python
441 lines
15 KiB
Python
"""
|
|
Notify MCP Server
|
|
|
|
Exposes notification tools to Claude Code agents with built-in
|
|
enforcement of notification permissions.
|
|
|
|
Tools available to ALL agents:
|
|
- roboco_notify_list: List your notifications
|
|
- roboco_notify_get: Get a specific notification
|
|
- roboco_notify_ack: Acknowledge a notification
|
|
|
|
Tools available ONLY to PM/Board/Auditor:
|
|
- roboco_notify_send: Send a notification
|
|
- roboco_escalate: Escalate an issue (PMs only)
|
|
- roboco_request_approval: Request approval (PMs/Board only)
|
|
|
|
Note: Developers, QA, and Documenters do not see the sending tools.
|
|
They should use message channels and blocker reporting instead.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import status
|
|
from mcp.server.fastmcp import FastMCP
|
|
|
|
from roboco.agents_config import (
|
|
NOTIFICATION_PERMISSIONS,
|
|
VALID_NOTIFICATION_PRIORITIES,
|
|
VALID_NOTIFICATION_TYPES,
|
|
can_send_notifications,
|
|
get_agent_cell,
|
|
get_agent_role,
|
|
)
|
|
from roboco.mcp.schemas import SendNotificationInput
|
|
from roboco.mcp.utils import ApiClient, format_error_response
|
|
from roboco.models.base import NotificationPriority, NotificationType
|
|
|
|
# =============================================================================
|
|
# HELPER FUNCTIONS
|
|
# =============================================================================
|
|
|
|
|
|
def _check_cell_scope(sender_id: str) -> tuple[bool, str]:
|
|
"""Check if sender can notify within their cell."""
|
|
sender_cell = get_agent_cell(sender_id)
|
|
return sender_cell is not None, sender_cell or ""
|
|
|
|
|
|
def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]:
|
|
"""Check if sender can send notification to recipient."""
|
|
role = get_agent_role(sender_id)
|
|
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
|
|
|
|
can_send = False
|
|
reason = ""
|
|
|
|
if not permissions.get("can_send", False):
|
|
reason = f"Agents with role '{role}' cannot send notifications"
|
|
else:
|
|
scope = permissions.get("scope", [])
|
|
|
|
if scope == "all":
|
|
can_send = True
|
|
reason = "OK"
|
|
elif scope == "cell":
|
|
has_cell, sender_cell = _check_cell_scope(sender_id)
|
|
recipient_cell = get_agent_cell(recipient_id)
|
|
recipient_role = get_agent_role(recipient_id)
|
|
|
|
# Cell PM can notify their own cell members
|
|
if (has_cell and sender_cell == recipient_cell) or recipient_role in {
|
|
"main_pm",
|
|
"cell_pm",
|
|
}:
|
|
can_send = True
|
|
reason = "OK"
|
|
else:
|
|
reason = "Cell PM can only notify cell members, Main PM, or other PMs"
|
|
elif isinstance(scope, list) and recipient_id in scope:
|
|
can_send = True
|
|
reason = "OK"
|
|
else:
|
|
reason = f"You cannot send notifications to {recipient_id}"
|
|
|
|
return can_send, reason
|
|
|
|
|
|
def _validate_notification_type(notification_type: str) -> dict[str, Any] | None:
|
|
"""Validate notification type. Returns error dict or None if valid."""
|
|
if notification_type not in VALID_NOTIFICATION_TYPES:
|
|
valid = sorted(VALID_NOTIFICATION_TYPES)
|
|
return format_error_response(
|
|
"INVALID_TYPE", f"Invalid type. Must be one of: {valid}"
|
|
)
|
|
return None
|
|
|
|
|
|
def _validate_priority(priority: str) -> dict[str, Any] | None:
|
|
"""Validate priority. Returns error dict or None if valid."""
|
|
if priority not in VALID_NOTIFICATION_PRIORITIES:
|
|
valid = sorted(VALID_NOTIFICATION_PRIORITIES)
|
|
return format_error_response(
|
|
"INVALID_PRIORITY",
|
|
f"Invalid priority. Must be one of: {valid}",
|
|
)
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# TOOL IMPLEMENTATIONS
|
|
# =============================================================================
|
|
|
|
|
|
async def _handle_list(
|
|
client: ApiClient,
|
|
unread_only: bool,
|
|
pending_ack_only: bool,
|
|
limit: int,
|
|
) -> dict[str, Any]:
|
|
"""Handle notification listing."""
|
|
params: dict[str, str | int] = {
|
|
"unread_only": str(unread_only).lower(),
|
|
"pending_ack_only": str(pending_ack_only).lower(),
|
|
"limit": limit,
|
|
}
|
|
|
|
resp = await client.get("/notifications", params=params)
|
|
if not resp.ok:
|
|
return format_error_response("API_ERROR", "Failed to fetch notifications")
|
|
|
|
data = resp.json()
|
|
unread = data.get("unread_count", 0)
|
|
pending_ack = data.get("pending_ack_count", 0)
|
|
|
|
guidance_parts = []
|
|
if pending_ack > 0:
|
|
guidance_parts.append(
|
|
f"You have {pending_ack} notification(s) requiring acknowledgment. "
|
|
"Use roboco_notify_ack to acknowledge them."
|
|
)
|
|
if unread > 0:
|
|
guidance_parts.append(f"You have {unread} unread notification(s).")
|
|
if not guidance_parts:
|
|
guidance_parts.append("No new notifications.")
|
|
|
|
return {
|
|
"notifications": data.get("items", []),
|
|
"total": data.get("total", 0),
|
|
"unread_count": unread,
|
|
"pending_ack_count": pending_ack,
|
|
"guidance": " ".join(guidance_parts),
|
|
}
|
|
|
|
|
|
async def _handle_get(client: ApiClient, notification_id: str) -> dict[str, Any]:
|
|
"""Handle getting a specific notification."""
|
|
resp = await client.get(f"/notifications/{notification_id}")
|
|
|
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
|
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 not resp.ok:
|
|
return format_error_response("API_ERROR", "Failed to fetch notification")
|
|
|
|
notification = resp.json()
|
|
guidance = ""
|
|
if notification.get("requires_ack") and not notification.get("is_acknowledged"):
|
|
guidance = (
|
|
"This notification requires acknowledgment. "
|
|
"Use roboco_notify_ack to acknowledge."
|
|
)
|
|
|
|
return {"notification": notification, "guidance": guidance}
|
|
|
|
|
|
async def _handle_ack(client: ApiClient, notification_id: str) -> dict[str, Any]:
|
|
"""Handle acknowledging a notification."""
|
|
resp = await client.post(f"/notifications/{notification_id}/ack")
|
|
|
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
|
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.is_status(status.HTTP_400_BAD_REQUEST):
|
|
return format_error_response(
|
|
"NO_ACK_REQUIRED", "This notification does not require acknowledgment"
|
|
)
|
|
|
|
if not resp.ok:
|
|
return format_error_response("API_ERROR", "Failed to acknowledge notification")
|
|
|
|
notification = resp.json()
|
|
return {
|
|
"status": "acknowledged",
|
|
"notification": notification,
|
|
"guidance": "Notification acknowledged. The sender will be informed.",
|
|
}
|
|
|
|
|
|
def _check_send_permission(agent_id: str) -> dict[str, Any] | None:
|
|
"""Check if agent has permission to send notifications."""
|
|
role = get_agent_role(agent_id)
|
|
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
|
|
if not permissions.get("can_send", False):
|
|
return format_error_response(
|
|
"NOT_AUTHORIZED",
|
|
f"Agents with role '{role}' cannot send notifications. "
|
|
"Only PMs, Board members, and Auditor can send notifications.",
|
|
{"your_role": role},
|
|
)
|
|
return None
|
|
|
|
|
|
def _check_recipients(agent_id: str, recipients: list[str]) -> dict[str, Any] | None:
|
|
"""Check if agent can send to all recipients."""
|
|
denied = [
|
|
{"recipient": r, "reason": reason}
|
|
for r in recipients
|
|
for can_send, reason in [_can_send_notification(agent_id, r)]
|
|
if not can_send
|
|
]
|
|
if denied:
|
|
return format_error_response(
|
|
"RECIPIENT_DENIED",
|
|
"Cannot send to one or more recipients",
|
|
{"denied": denied},
|
|
)
|
|
return None
|
|
|
|
|
|
def _validate_send_input(
|
|
agent_id: str, data: SendNotificationInput
|
|
) -> dict[str, Any] | None:
|
|
"""Validate all send notification inputs. Returns error or None."""
|
|
for check in [
|
|
lambda: _check_send_permission(agent_id),
|
|
lambda: _check_recipients(agent_id, data.recipients),
|
|
lambda: _validate_notification_type(data.notification_type),
|
|
lambda: _validate_priority(data.priority),
|
|
]:
|
|
if error := check():
|
|
return error
|
|
return None
|
|
|
|
|
|
async def _resolve_recipients(
|
|
recipients: list[str], client: "ApiClient"
|
|
) -> tuple[list[str], dict[str, Any] | None]:
|
|
"""Resolve recipient slugs to UUIDs. Returns (resolved_list, error_or_none)."""
|
|
from roboco.mcp.utils import resolve_agent_uuid_cached
|
|
|
|
resolved: list[str] = []
|
|
unresolved: list[str] = []
|
|
for recipient in recipients:
|
|
uuid = await resolve_agent_uuid_cached(recipient, client)
|
|
if uuid:
|
|
resolved.append(uuid)
|
|
else:
|
|
unresolved.append(recipient)
|
|
|
|
if unresolved:
|
|
return [], format_error_response(
|
|
"RECIPIENT_NOT_FOUND",
|
|
f"Could not resolve recipient(s): {', '.join(unresolved)}",
|
|
)
|
|
return resolved, None
|
|
|
|
|
|
async def _handle_send(
|
|
client: ApiClient, agent_id: str, data: SendNotificationInput
|
|
) -> dict[str, Any]:
|
|
"""Handle sending a notification."""
|
|
if error := _validate_send_input(agent_id, data):
|
|
return error
|
|
|
|
resolved_recipients, error = await _resolve_recipients(data.recipients, client)
|
|
if error:
|
|
return error
|
|
|
|
payload = {
|
|
"type": data.notification_type,
|
|
"priority": data.priority,
|
|
"to_agents": resolved_recipients,
|
|
"subject": data.subject,
|
|
"body": data.body,
|
|
"requires_ack": data.requires_ack,
|
|
"related_task_id": data.related_task_id,
|
|
}
|
|
|
|
resp = await client.post("/notifications", json=payload)
|
|
|
|
if not resp.ok:
|
|
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 ""
|
|
count = len(data.recipients)
|
|
|
|
return {
|
|
"status": "sent",
|
|
"notification": notification,
|
|
"recipients_count": count,
|
|
"guidance": f"Notification sent to {count} recipient(s). {ack_note}".strip(),
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# MCP SERVER FACTORY
|
|
# =============================================================================
|
|
|
|
|
|
def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
|
"""
|
|
Create a Notify MCP server for a specific agent.
|
|
|
|
Args:
|
|
agent_id: The agent identifier (e.g., "be-pm")
|
|
|
|
Returns:
|
|
Configured FastMCP server
|
|
"""
|
|
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
|
|
|
|
# Create shared API client for this agent
|
|
client = ApiClient(agent_id)
|
|
|
|
@mcp.tool()
|
|
async def roboco_notify_list(
|
|
unread_only: bool = False,
|
|
pending_ack_only: bool = False,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]:
|
|
"""List your notifications."""
|
|
return await _handle_list(client, unread_only, pending_ack_only, limit)
|
|
|
|
@mcp.tool()
|
|
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
|
|
"""Get a specific notification. Also marks it as read."""
|
|
return await _handle_get(client, notification_id)
|
|
|
|
@mcp.tool()
|
|
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
|
|
"""Acknowledge a notification."""
|
|
return await _handle_ack(client, notification_id)
|
|
|
|
# Only register send/escalate/approval tools for agents who can send notifications
|
|
# This prevents developers, QA, and documenters from even seeing these tools
|
|
if can_send_notifications(agent_id):
|
|
|
|
@mcp.tool()
|
|
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
|
|
"""
|
|
Send a notification to one or more agents.
|
|
|
|
Cell PMs can only notify their own cell.
|
|
Main PM, Board, and Auditor can notify anyone.
|
|
"""
|
|
return await _handle_send(client, agent_id, data)
|
|
|
|
role = get_agent_role(agent_id)
|
|
|
|
# Only PMs can escalate
|
|
if role in ["cell_pm", "main_pm"]:
|
|
|
|
@mcp.tool()
|
|
async def roboco_escalate(
|
|
escalate_to: str,
|
|
subject: str,
|
|
description: str,
|
|
task_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Escalate an issue to a higher level.
|
|
|
|
Sends a high-priority notification requiring acknowledgment.
|
|
"""
|
|
input_data = SendNotificationInput(
|
|
recipients=[escalate_to],
|
|
subject=f"[ESCALATION] {subject}",
|
|
body=description,
|
|
notification_type=NotificationType.BLOCKER_ESCALATION.value,
|
|
priority=NotificationPriority.HIGH.value,
|
|
requires_ack=True,
|
|
related_task_id=task_id,
|
|
)
|
|
return await _handle_send(client, agent_id, input_data)
|
|
|
|
# Only PMs and Board can request approvals
|
|
if role in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
|
|
|
@mcp.tool()
|
|
async def roboco_request_approval(
|
|
approver: str,
|
|
subject: str,
|
|
what_needs_approval: str,
|
|
task_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Request approval from someone.
|
|
"""
|
|
input_data = SendNotificationInput(
|
|
recipients=[approver],
|
|
subject=f"[APPROVAL NEEDED] {subject}",
|
|
body=what_needs_approval,
|
|
notification_type=NotificationType.REVIEW_REQUEST.value,
|
|
priority=NotificationPriority.NORMAL.value,
|
|
requires_ack=True,
|
|
related_task_id=task_id,
|
|
)
|
|
return await _handle_send(client, agent_id, input_data)
|
|
|
|
return mcp
|
|
|
|
|
|
# =============================================================================
|
|
# STANDALONE RUNNER
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
MIN_ARGS = 2
|
|
if len(sys.argv) < MIN_ARGS:
|
|
print("Usage: python notify_server.py <agent_id>")
|
|
sys.exit(1)
|
|
|
|
agent_id_arg = sys.argv[1]
|
|
server = create_notify_mcp_server(agent_id_arg)
|
|
server.run()
|