mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Moved out of "src"
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user