Moved out of "src"

This commit is contained in:
Renn F
2025-12-10 17:37:24 +01:00
parent 7f5bc4b8b8
commit f0f6f77d68
89 changed files with 118 additions and 118 deletions
+24
View File
@@ -0,0 +1,24 @@
"""
MCP Servers for RoboCo
These MCP servers bridge Claude Code agents to the RoboCo APIs,
providing tool interfaces with built-in enforcement and guidance.
Servers:
- Task MCP Server: Task lifecycle management
- Message MCP Server: Channel messaging
- Notify MCP Server: Formal notifications
- Journal MCP Server: Personal journaling
"""
from roboco.mcp.journal_server import create_journal_mcp_server
from roboco.mcp.message_server import create_message_mcp_server
from roboco.mcp.notify_server import create_notify_mcp_server
from roboco.mcp.task_server import create_task_mcp_server
__all__ = [
"create_journal_mcp_server",
"create_message_mcp_server",
"create_notify_mcp_server",
"create_task_mcp_server",
]
+600
View File
@@ -0,0 +1,600 @@
"""
Journal MCP Server
Exposes journal tools to Claude Code agents for personal reflection,
learning tracking, and context persistence.
Tools:
- roboco_journal_entry: Create a journal entry
- roboco_journal_reflect: Add task reflection (when completing task)
- roboco_journal_decision: Log a decision
- roboco_journal_learning: Log something learned
- roboco_journal_struggle: Log a struggle
- roboco_journal_search: Search past entries
- roboco_journal_stats: Get journal statistics
"""
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
from roboco.config import settings
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
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 {},
}
}
# =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_journal_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Journal MCP server for a specific agent.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-journal-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
# =========================================================================
# GENERAL ENTRY
# =========================================================================
@mcp.tool()
async def roboco_journal_entry(
title: str,
content: str,
entry_type: str = "general",
task_id: str | None = None,
tags: list[str] | None = None,
is_private: bool = False,
) -> dict[str, Any]:
"""
Create a general journal entry.
Your journal is personal - use it to:
- Track your thoughts and progress
- Record context for future sessions
- Document your journey on tasks
- Note things you've learned or struggled with
Args:
title: Entry title (short description)
content: Entry content (detailed text)
entry_type: Type of entry (general, task_reflection, decision_log, learning, struggle)
task_id: Optional related task
tags: Optional list of tags
is_private: If true, only you and CEO/Auditor can see
Returns:
Created entry
"""
valid_types = [
"general",
"task_reflection",
"decision_log",
"learning",
"struggle",
]
if entry_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid entry type. Must be one of: {valid_types}",
)
async with httpx.AsyncClient() as client:
payload = {
"type": entry_type,
"title": title,
"content": content,
"task_id": task_id,
"tags": tags or [],
"is_private": is_private,
}
resp = await client.post(
f"{_get_api_url()}/journals/me/entries",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create journal entry",
{"api_error": resp.text},
)
entry = resp.json()
return {
"status": "created",
"entry": entry,
"guidance": "Journal entry saved. Use roboco_journal_search to find past entries.",
}
# =========================================================================
# TASK REFLECTION (Important - called at task completion)
# =========================================================================
@mcp.tool()
async def roboco_journal_reflect(
task_id: str,
title: str,
what_done: str,
what_learned: str,
what_struggled: str,
next_steps: list[str] | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Add a task reflection entry.
IMPORTANT: Call this when completing a task. Reflections help you:
- Build institutional memory
- Track your growth
- Provide context for future similar tasks
Args:
task_id: The task UUID you're reflecting on
title: Reflection title
what_done: What was accomplished
what_learned: Key learnings from this task
what_struggled: What was difficult or challenging
next_steps: Optional list of follow-up items
tags: Optional list of tags
Returns:
Created reflection entry
"""
async with httpx.AsyncClient() as client:
payload = {
"task_id": task_id,
"title": title,
"what_done": what_done,
"what_learned": what_learned,
"what_struggled": what_struggled,
"next_steps": next_steps or [],
"tags": tags or [],
}
resp = await client.post(
f"{_get_api_url()}/journals/me/reflections",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create reflection",
{"api_error": resp.text},
)
entry = resp.json()
return {
"status": "created",
"entry": entry,
"guidance": (
"Reflection saved. This will help you (and future you) "
"when working on similar tasks."
),
}
# =========================================================================
# DECISION LOG
# =========================================================================
@mcp.tool()
async def roboco_journal_decision(
title: str,
context: str,
options: list[dict[str, str]],
chosen: str,
rationale: str,
consequences: list[str] | None = None,
task_id: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Log a decision you made.
Use this when you:
- Choose between multiple approaches
- Make architectural decisions
- Pick one solution over another
This creates a record of WHY you made the decision,
which is valuable for future context.
Args:
title: Decision title
context: What situation led to this decision
options: List of options considered, each with 'option' and 'pros_cons' keys
chosen: Which option was chosen
rationale: Why this option was chosen
consequences: Expected consequences of this decision
task_id: Optional related task
tags: Optional list of tags
Returns:
Created decision log entry
"""
if len(options) < 2:
return _format_error_response(
"INVALID_OPTIONS",
"Decision log requires at least 2 options",
)
async with httpx.AsyncClient() as client:
payload = {
"title": title,
"context": context,
"options": options,
"chosen": chosen,
"rationale": rationale,
"consequences": consequences or [],
"task_id": task_id,
"tags": tags or [],
}
resp = await client.post(
f"{_get_api_url()}/journals/me/decisions",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create decision log",
{"api_error": resp.text},
)
entry = resp.json()
return {
"status": "created",
"entry": entry,
"guidance": (
"Decision logged. If you need to revisit this decision later, "
"you'll have the context of why it was made."
),
}
# =========================================================================
# LEARNING
# =========================================================================
@mcp.tool()
async def roboco_journal_learning(
title: str,
what_learned: str,
how_applied: str | None = None,
source: str | None = None,
task_id: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Log something you learned.
Track learnings to:
- Build your knowledge base
- Help future you with similar problems
- Share knowledge with the team (if not private)
Args:
title: Learning title
what_learned: The actual learning/insight
how_applied: How you applied or plan to apply this
source: Where you learned this (docs, experiment, colleague, etc.)
task_id: Optional related task
tags: Optional list of tags
Returns:
Created learning entry
"""
async with httpx.AsyncClient() as client:
payload = {
"title": title,
"what_learned": what_learned,
"how_applied": how_applied,
"source": source,
"task_id": task_id,
"tags": tags or [],
}
resp = await client.post(
f"{_get_api_url()}/journals/me/learnings",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create learning entry",
{"api_error": resp.text},
)
entry = resp.json()
return {
"status": "created",
"entry": entry,
"guidance": "Learning recorded. Use tags to make it searchable later.",
}
# =========================================================================
# STRUGGLE
# =========================================================================
@mcp.tool()
async def roboco_journal_struggle(
title: str,
what_struggled: str,
attempted_solutions: list[str] | None = None,
resolution: str | None = None,
help_needed: str | None = None,
task_id: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Log a struggle or challenge.
Recording struggles helps:
- Track problem-solving patterns
- Create documentation for others
- Get help if needed (help_needed field)
- Remember solutions for similar problems
Args:
title: Struggle title
what_struggled: What the challenge was
attempted_solutions: What you tried (even if it didn't work)
resolution: How it was resolved (if resolved)
help_needed: What help you need (if unresolved)
task_id: Optional related task
tags: Optional list of tags
Returns:
Created struggle entry
"""
async with httpx.AsyncClient() as client:
payload = {
"title": title,
"what_struggled": what_struggled,
"attempted_solutions": attempted_solutions or [],
"resolution": resolution,
"help_needed": help_needed,
"task_id": task_id,
"tags": tags or [],
}
resp = await client.post(
f"{_get_api_url()}/journals/me/struggles",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"CREATE_FAILED",
"Failed to create struggle entry",
{"api_error": resp.text},
)
entry = resp.json()
guidance = "Struggle recorded."
if help_needed and not resolution:
guidance += " Since you indicated help is needed, consider asking in your cell channel."
return {
"status": "created",
"entry": entry,
"guidance": guidance,
}
# =========================================================================
# SEARCH
# =========================================================================
@mcp.tool()
async def roboco_journal_search(
query: str,
top_k: int = 5,
) -> dict[str, Any]:
"""
Search your past journal entries.
Uses semantic search to find relevant entries based on meaning,
not just keywords. Great for:
- Finding past decisions on similar topics
- Recalling how you solved similar problems
- Getting context from previous work
Args:
query: What to search for
top_k: Maximum results to return (default 5)
Returns:
Matching journal entries
"""
async with httpx.AsyncClient() as client:
payload = {
"query": query,
"top_k": min(top_k, 20), # Cap at 20
}
resp = await client.post(
f"{_get_api_url()}/journals/me/search",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != 200:
return _format_error_response(
"SEARCH_FAILED",
"Failed to search journal",
{"api_error": resp.text},
)
entries = resp.json()
if not entries:
return {
"entries": [],
"guidance": "No matching entries found. Try different keywords.",
}
return {
"entries": entries,
"count": len(entries),
"guidance": f"Found {len(entries)} relevant entries.",
}
# =========================================================================
# STATS
# =========================================================================
@mcp.tool()
async def roboco_journal_stats() -> dict[str, Any]:
"""
Get statistics about your journal.
Returns counts by entry type, growth metrics, and other stats.
Useful for reflection and tracking your development.
Returns:
Journal statistics
"""
async with httpx.AsyncClient() as client:
# Get basic stats
stats_resp = await client.get(
f"{_get_api_url()}/journals/me/stats",
headers={"X-Agent-Id": agent_id},
)
# Get growth metrics
growth_resp = await client.get(
f"{_get_api_url()}/journals/me/growth",
headers={"X-Agent-Id": agent_id},
)
stats = stats_resp.json() if stats_resp.status_code == 200 else {}
growth = growth_resp.json() if growth_resp.status_code == 200 else {}
return {
"total_entries": stats.get("total_entries", 0),
"entries_by_type": stats.get("entries_by_type", {}),
"last_entry_at": stats.get("last_entry_at"),
"growth_metrics": {
"total_reflections": growth.get("total_reflections", 0),
"total_learnings": growth.get("total_learnings", 0),
"total_struggles": growth.get("total_struggles", 0),
"total_decisions": growth.get("total_decisions", 0),
"struggle_resolution_rate": growth.get("struggle_resolution_rate", 0),
"sentiment_trend": growth.get("sentiment_trend", "stable"),
},
"guidance": (
"These stats reflect your journal activity. "
"Regular journaling helps build context for future sessions."
),
}
# =========================================================================
# LIST RECENT
# =========================================================================
@mcp.tool()
async def roboco_journal_recent(
entry_type: str | None = None,
task_id: str | None = None,
limit: int = 10,
) -> dict[str, Any]:
"""
List recent journal entries.
Args:
entry_type: Optional filter by type (general, task_reflection, decision_log, learning, struggle)
task_id: Optional filter by related task
limit: Maximum entries to return
Returns:
Recent journal entries
"""
async with httpx.AsyncClient() as client:
params: dict[str, Any] = {"limit": min(limit, 50)}
if entry_type:
params["entry_type"] = entry_type
if task_id:
params["task_id"] = task_id
resp = await client.get(
f"{_get_api_url()}/journals/me/entries",
params=params,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != 200:
return _format_error_response(
"LIST_FAILED",
"Failed to list entries",
)
entries = resp.json()
return {
"entries": entries,
"count": len(entries),
}
return mcp
# =============================================================================
# STANDALONE RUNNER
# =============================================================================
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python journal_server.py <agent_id>")
sys.exit(1)
agent_id = sys.argv[1]
server = create_journal_mcp_server(agent_id)
server.run()
+485
View File
@@ -0,0 +1,485 @@
"""
Message MCP Server
Exposes messaging tools to Claude Code agents with built-in
enforcement of channel access rules.
Tools:
- roboco_message_send: Send a message to a channel
- roboco_message_list: List recent messages
- roboco_message_get: Get a specific message
- roboco_channel_list: List available channels
- roboco_channel_history: Get channel message history
"""
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
import httpx
from mcp.server.fastmcp import FastMCP
from roboco.agents_config import CHANNEL_ACCESS
from roboco.config import settings
def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool:
"""Check if agent has access to channel for the given action."""
channel = CHANNEL_ACCESS.get(channel_slug, {})
allowed = channel.get(action, [])
if "*" in allowed:
return True
if agent_id in allowed:
return True
# Silent observers can always read
return bool(action == "read" and agent_id in channel.get("silent", []))
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
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 {},
}
}
# =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_message_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Message MCP server for a specific agent.
The agent_id is embedded in the server to enforce access rules.
Args:
agent_id: The agent identifier (e.g., "be-dev-1")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-message-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
# =========================================================================
# CHANNEL LISTING
# =========================================================================
@mcp.tool()
async def roboco_channel_list() -> dict[str, Any]:
"""
List channels you have access to.
Returns:
Dict with readable and writable channels
"""
readable = []
writable = []
for channel_slug, _access in CHANNEL_ACCESS.items():
if _check_channel_access(agent_id, channel_slug, "read"):
readable.append(channel_slug)
if _check_channel_access(agent_id, channel_slug, "write"):
writable.append(channel_slug)
return {
"readable_channels": readable,
"writable_channels": writable,
"guidance": (
f"You can read from {len(readable)} channel(s) and write to {len(writable)} channel(s). "
"Use roboco_message_send to post messages. "
"Use roboco_channel_history to read recent messages."
),
}
# =========================================================================
# CHANNEL HISTORY
# =========================================================================
@mcp.tool()
async def roboco_channel_history(
channel_slug: str,
limit: int = 50,
hours_back: int = 24,
) -> dict[str, Any]:
"""
Get recent message history from a channel.
ENFORCEMENT:
- You must have read access to the channel
Args:
channel_slug: The channel slug (e.g., "backend-cell")
limit: Maximum messages to return (default 50, max 100)
hours_back: How many hours back to look (default 24)
Returns:
List of messages with metadata
"""
# Check read access
if not _check_channel_access(agent_id, channel_slug, "read"):
return _format_error_response(
"ACCESS_DENIED",
f"You don't have read access to #{channel_slug}",
)
limit = min(limit, 100)
since = datetime.utcnow() - timedelta(hours=hours_back)
async with httpx.AsyncClient() as client:
# Get channel ID from slug
channels_resp = await client.get(
f"{_get_api_url()}/channels",
params={"slug": channel_slug},
)
if channels_resp.status_code != 200:
return _format_error_response("API_ERROR", "Failed to fetch channels")
channels = channels_resp.json()
if not channels:
return _format_error_response(
"NOT_FOUND", f"Channel #{channel_slug} not found"
)
channel_id = channels[0]["id"]
# Get messages
messages_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/messages",
params={
"after": since.isoformat(),
"limit": limit,
},
)
if messages_resp.status_code != 200:
return _format_error_response("API_ERROR", "Failed to fetch messages")
messages = messages_resp.json()
return {
"channel": channel_slug,
"messages": messages.get("items", []),
"total": messages.get("total", 0),
"has_more": messages.get("has_more", False),
"since": since.isoformat(),
}
# =========================================================================
# SEND MESSAGE
# =========================================================================
@mcp.tool()
async def roboco_message_send(
channel_slug: str,
content: str,
message_type: str = "dialogue",
task_id: str | None = None,
reply_to: str | None = None,
mentions: list[str] | None = None,
) -> dict[str, Any]:
"""
Send a message to a channel.
ENFORCEMENT:
- You must have write access to the channel
- Message type must be valid
- Content is required
Args:
channel_slug: The channel slug (e.g., "backend-cell")
content: Message content
message_type: Type of message (reasoning, dialogue, decision, action, blocker, technical)
task_id: Optional task ID this message relates to
reply_to: Optional message ID to reply to
mentions: Optional list of agent IDs to mention (adds @agent-id)
Returns:
Sent message with confirmation
"""
# Validate message type
valid_types = [
"reasoning",
"dialogue",
"decision",
"action",
"blocker",
"technical",
]
if message_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid message type '{message_type}'. Must be one of: {valid_types}",
)
# Check write access
if not _check_channel_access(agent_id, channel_slug, "write"):
return _format_error_response(
"ACCESS_DENIED",
f"You don't have write access to #{channel_slug}",
{
"your_writable_channels": [
ch
for ch in CHANNEL_ACCESS
if _check_channel_access(agent_id, ch, "write")
]
},
)
# Silent observers cannot write even if in read list
if agent_id in CHANNEL_ACCESS.get(channel_slug, {}).get("silent", []):
return _format_error_response(
"SILENT_OBSERVER",
"You are a silent observer on this channel and cannot post messages.",
)
if not content or not content.strip():
return _format_error_response(
"EMPTY_CONTENT",
"Message content cannot be empty.",
)
async with httpx.AsyncClient() as client:
# Get channel and active session
channels_resp = await client.get(
f"{_get_api_url()}/channels",
params={"slug": channel_slug},
)
if channels_resp.status_code != 200 or not channels_resp.json():
return _format_error_response(
"NOT_FOUND", f"Channel #{channel_slug} not found"
)
channel = channels_resp.json()[0]
channel_id = channel["id"]
# Get or create session for the channel
session_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/session",
)
if session_resp.status_code != 200:
# Create a new session
create_resp = await client.post(
f"{_get_api_url()}/sessions",
json={"channel_id": channel_id},
)
if create_resp.status_code not in [200, 201]:
return _format_error_response(
"SESSION_ERROR", "Failed to get or create session"
)
session_id = create_resp.json()["id"]
else:
session_id = session_resp.json()["id"]
# Build message payload
message_data = {
"session_id": session_id,
"type": message_type,
"content": content,
"is_reply": reply_to is not None,
"reply_to": reply_to,
"mentions": mentions or [],
"task_id": task_id,
}
# Send message
send_resp = await client.post(
f"{_get_api_url()}/messages",
json=message_data,
headers={"X-Agent-Id": agent_id},
)
if send_resp.status_code not in [200, 201]:
return _format_error_response(
"SEND_FAILED",
"Failed to send message",
{"api_error": send_resp.text},
)
message = send_resp.json()
return {
"status": "sent",
"message": message,
"channel": channel_slug,
"guidance": "Message sent successfully.",
}
# =========================================================================
# GET MESSAGE
# =========================================================================
@mcp.tool()
async def roboco_message_get(message_id: str) -> dict[str, Any]:
"""
Get a specific message by ID.
Args:
message_id: The message UUID
Returns:
Message details
"""
async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/messages/{message_id}")
if resp.status_code == 404:
return _format_error_response(
"NOT_FOUND", f"Message {message_id} not found"
)
if resp.status_code != 200:
return _format_error_response("API_ERROR", "Failed to fetch message")
message = resp.json()
return {
"message": message,
}
# =========================================================================
# ASK QUESTION (convenience wrapper)
# =========================================================================
@mcp.tool()
async def roboco_ask_question(
channel_slug: str,
question: str,
context: str | None = None,
task_id: str | None = None,
) -> dict[str, Any]:
"""
Ask a question in a channel (convenience wrapper).
This is a common pattern - asking for clarification. The message
is automatically formatted as a question.
IMPORTANT: After asking, you should wait for an answer before
proceeding with work that depends on this question.
Args:
channel_slug: The channel to ask in
question: The question to ask
context: Optional context for the question
task_id: Optional task this relates to
Returns:
Sent question message
"""
content = f"**Question**: {question}"
if context:
content = f"{context}\n\n{content}"
result = await roboco_message_send(
channel_slug=channel_slug,
content=content,
message_type="dialogue",
task_id=task_id,
)
if "error" in result:
return result
result["guidance"] = (
"Question posted. You should now:\n"
"1. Wait for an answer before proceeding with related work\n"
"2. Check roboco_channel_history periodically for responses\n"
"3. If urgent, consider mentioning the PM"
)
return result
# =========================================================================
# REPORT BLOCKER (convenience wrapper)
# =========================================================================
@mcp.tool()
async def roboco_report_blocker(
channel_slug: str,
blocker_description: str,
what_needed: str,
task_id: str | None = None,
) -> dict[str, Any]:
"""
Report a blocker in a channel (convenience wrapper).
This automatically formats the message as a blocker report
and notifies the PM.
Args:
channel_slug: The channel to report in
blocker_description: What is blocking you
what_needed: What is needed to unblock
task_id: Optional task this relates to
Returns:
Sent blocker message
"""
content = (
f"**BLOCKER**\n\n"
f"**Issue**: {blocker_description}\n\n"
f"**Needed to unblock**: {what_needed}"
)
result = await roboco_message_send(
channel_slug=channel_slug,
content=content,
message_type="blocker",
task_id=task_id,
)
if "error" in result:
return result
result["guidance"] = (
"Blocker reported. The PM will be notified.\n"
"You should:\n"
"1. Wait for resolution, or\n"
"2. Switch to another task (call roboco_task_scan)"
)
return result
return mcp
# =============================================================================
# STANDALONE RUNNER
# =============================================================================
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python message_server.py <agent_id>")
sys.exit(1)
agent_id = sys.argv[1]
server = create_message_mcp_server(agent_id)
server.run()
+488
View File
@@ -0,0 +1,488 @@
"""
Notify MCP Server
Exposes notification tools to Claude Code agents with built-in
enforcement of notification permissions.
Tools:
- roboco_notify_list: List your notifications
- roboco_notify_get: Get a specific notification
- roboco_notify_ack: Acknowledge a notification
- roboco_notify_send: Send a notification (PM/Board/Auditor only)
"""
from datetime import datetime
from typing import Any
from uuid import UUID
import httpx
from mcp.server.fastmcp import FastMCP
from roboco.agents_config import (
NOTIFICATION_PERMISSIONS,
get_agent_cell,
get_agent_role,
)
from roboco.config import settings
def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str]:
"""
Check if sender can send notification to recipient.
Returns:
Tuple of (can_send, reason)
"""
role = get_agent_role(sender_id)
permissions = NOTIFICATION_PERMISSIONS.get(role, {"can_send": False})
if not permissions.get("can_send", False):
return False, f"Agents with role '{role}' cannot send notifications"
scope = permissions.get("scope", [])
if scope == "all":
return True, "OK"
if scope == "cell":
sender_cell = get_agent_cell(sender_id)
recipient_cell = get_agent_cell(recipient_id)
if sender_cell and sender_cell == recipient_cell:
return True, "OK"
return (
False,
f"Cell PM can only notify members of their own cell ({sender_cell})",
)
if isinstance(scope, list) and recipient_id in scope:
return True, "OK"
return False, f"You cannot send notifications to {recipient_id}"
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
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 {},
}
}
# =============================================================================
# MCP SERVER FACTORY
# =============================================================================
def create_notify_mcp_server(agent_id: str) -> FastMCP:
"""
Create a Notify MCP server for a specific agent.
The agent_id is embedded in the server to enforce permissions.
Args:
agent_id: The agent identifier (e.g., "be-pm")
Returns:
Configured FastMCP server
"""
mcp = FastMCP(f"roboco-notify-{agent_id}", json_response=True)
# Store agent context
mcp.agent_id = agent_id # type: ignore
# =========================================================================
# LIST NOTIFICATIONS
# =========================================================================
@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.
Args:
unread_only: Only show unread notifications
pending_ack_only: Only show notifications pending acknowledgment
limit: Maximum notifications to return
Returns:
List of notifications with counts
"""
async with httpx.AsyncClient() as client:
params = {
"unread_only": str(unread_only).lower(),
"pending_ack_only": str(pending_ack_only).lower(),
"limit": limit,
}
resp = await client.get(
f"{_get_api_url()}/notifications",
params=params,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code != 200:
return _format_error_response(
"API_ERROR", "Failed to fetch notifications"
)
data = resp.json()
# Add guidance based on counts
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),
}
# =========================================================================
# GET NOTIFICATION
# =========================================================================
@mcp.tool()
async def roboco_notify_get(notification_id: str) -> dict[str, Any]:
"""
Get a specific notification.
This also marks the notification as read.
Args:
notification_id: The notification UUID
Returns:
Notification details
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{_get_api_url()}/notifications/{notification_id}",
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == 404:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == 403:
return _format_error_response(
"NOT_RECIPIENT",
"You are not a recipient of this notification",
)
if resp.status_code != 200:
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,
}
# =========================================================================
# ACKNOWLEDGE NOTIFICATION
# =========================================================================
@mcp.tool()
async def roboco_notify_ack(notification_id: str) -> dict[str, Any]:
"""
Acknowledge a notification.
Some notifications require acknowledgment to confirm receipt
and understanding.
Args:
notification_id: The notification UUID
Returns:
Updated notification
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{_get_api_url()}/notifications/{notification_id}/ack",
headers={"X-Agent-Id": agent_id},
)
if resp.status_code == 404:
return _format_error_response("NOT_FOUND", "Notification not found")
if resp.status_code == 403:
return _format_error_response(
"NOT_RECIPIENT",
"You are not a recipient of this notification",
)
if resp.status_code == 400:
return _format_error_response(
"NO_ACK_REQUIRED",
"This notification does not require acknowledgment",
)
if resp.status_code != 200:
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.",
}
# =========================================================================
# SEND NOTIFICATION (PM/Board/Auditor only)
# =========================================================================
@mcp.tool()
async def roboco_notify_send(
recipients: list[str],
subject: str,
body: str,
notification_type: str = "info",
priority: str = "normal",
requires_ack: bool = True,
related_task_id: str | None = None,
) -> dict[str, Any]:
"""
Send a notification to one or more agents.
ENFORCEMENT:
- Only PMs, Board members, and Auditor can send notifications
- Cell PMs can only notify their own cell
- Developers, QA, and Documenters CANNOT send notifications
Args:
recipients: List of agent IDs to notify
subject: Notification subject
body: Notification body
notification_type: Type (info, alert, task, escalation, approval)
priority: Priority (low, normal, high, urgent)
requires_ack: Whether recipients must acknowledge
related_task_id: Optional related task
Returns:
Sent notification or error
"""
# Check sender permissions
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},
)
# Check each recipient
denied_recipients = []
for recipient in recipients:
can_send, reason = _can_send_notification(agent_id, recipient)
if not can_send:
denied_recipients.append({"recipient": recipient, "reason": reason})
if denied_recipients:
return _format_error_response(
"RECIPIENT_DENIED",
"Cannot send to one or more recipients",
{"denied": denied_recipients},
)
# Validate notification type
valid_types = ["info", "alert", "task", "escalation", "approval"]
if notification_type not in valid_types:
return _format_error_response(
"INVALID_TYPE",
f"Invalid notification type. Must be one of: {valid_types}",
)
# Validate priority
valid_priorities = ["low", "normal", "high", "urgent"]
if priority not in valid_priorities:
return _format_error_response(
"INVALID_PRIORITY",
f"Invalid priority. Must be one of: {valid_priorities}",
)
async with httpx.AsyncClient() as client:
payload = {
"type": notification_type,
"priority": priority,
"to_agents": recipients,
"subject": subject,
"body": body,
"requires_ack": requires_ack,
"related_task_id": related_task_id,
}
resp = await client.post(
f"{_get_api_url()}/notifications",
json=payload,
headers={"X-Agent-Id": agent_id},
)
if resp.status_code not in [200, 201]:
return _format_error_response(
"SEND_FAILED",
"Failed to send notification",
{"api_error": resp.text},
)
notification = resp.json()
ack_note = "Recipients must acknowledge." if requires_ack else ""
return {
"status": "sent",
"notification": notification,
"recipients_count": len(recipients),
"guidance": f"Notification sent to {len(recipients)} recipient(s). {ack_note}",
}
# =========================================================================
# CONVENIENCE: ESCALATE (PM only)
# =========================================================================
@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 (PM convenience wrapper).
This sends a high-priority notification requiring acknowledgment.
Args:
escalate_to: Agent ID to escalate to (e.g., "main-pm")
subject: Escalation subject
description: Detailed description of the issue
task_id: Optional related task
Returns:
Sent escalation notification
"""
role = get_agent_role(agent_id)
if role not in ["cell_pm", "main_pm"]:
return _format_error_response(
"NOT_PM",
"Only PMs can use the escalate function",
)
return await roboco_notify_send(
recipients=[escalate_to],
subject=f"[ESCALATION] {subject}",
body=description,
notification_type="escalation",
priority="high",
requires_ack=True,
related_task_id=task_id,
)
# =========================================================================
# CONVENIENCE: REQUEST APPROVAL (PM/Board only)
# =========================================================================
@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 (PM/Board convenience wrapper).
Args:
approver: Agent ID to request approval from
subject: Approval subject
what_needs_approval: Description of what needs approval
task_id: Optional related task
Returns:
Sent approval request notification
"""
role = get_agent_role(agent_id)
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
return _format_error_response(
"NOT_AUTHORIZED",
"Only PMs and Board can request approvals",
)
return await roboco_notify_send(
recipients=[approver],
subject=f"[APPROVAL NEEDED] {subject}",
body=what_needs_approval,
notification_type="approval",
priority="normal",
requires_ack=True,
related_task_id=task_id,
)
return mcp
# =============================================================================
# STANDALONE RUNNER
# =============================================================================
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python notify_server.py <agent_id>")
sys.exit(1)
agent_id = sys.argv[1]
server = create_notify_mcp_server(agent_id)
server.run()
File diff suppressed because it is too large Load Diff