Implement MCP system, Event Bus, and Sub-Agents

## 1. MCP (Model Context Protocol) Implementation

### Registry (src/mcp/registry.py)
- Tool registration with JSON schemas
- Dynamic tool discovery
- Health tracking per tool
- Metrics collection (latency, error rates)
- @register_tool decorator for easy registration

### Server (src/mcp/server.py)
- MCPServer class implementing MCP protocol
- MCPHTTPServer for FastAPI integration
- Standard endpoints: list_tools, call_tool, get_schema

### Schemas (src/mcp/schemas/base.py)
- create_tool_schema() helper
- Common parameter types
- Standard return types

### Bootstrap (src/mcp/bootstrap.py)
- Automatic tool module loading
- Status reporting

## 2. MCP-Compliant Tools (src/tools/)

| Tool | Purpose | Category |
|------|---------|----------|
| web_search | DuckDuckGo search | research |
| read_file | File reading | files |
| write_file | File writing (confirmation) | files |
| list_directory | Directory listing | files |
| python | Python code execution | code |
| memory_search | Vector memory search | memory |

All tools have proper schemas, error handling, and MCP registration.

## 3. Event Bus (src/events/bus.py)

- Async publish/subscribe pattern
- Pattern matching with wildcards (agent.task.*)
- Event history tracking
- Concurrent handler execution
- Module-level singleton for system-wide use

## 4. Sub-Agents (src/agents/)

All agents inherit from BaseAgent with:
- Agno Agent integration
- MCP tool registry access
- Event bus connectivity
- Structured logging

### Agent Roster

| Agent | Role | Tools | Purpose |
|-------|------|-------|---------|
| Seer | Research | web_search, read_file, memory_search | Information gathering |
| Forge | Code | python, write_file, read_file | Code generation |
| Quill | Writing | write_file, read_file, memory_search | Content creation |
| Echo | Memory | memory_search, read_file, write_file | Context retrieval |
| Helm | Routing | memory_search | Task routing decisions |
| Timmy | Orchestrator | All tools | Coordination & user interface |

### Timmy Orchestrator
- Analyzes user requests
- Routes to appropriate sub-agent
- Handles direct queries
- Manages swarm coordination
- create_timmy_swarm() factory function

## 5. Integration

All components wired together:
- Tools auto-register on import
- Agents connect to event bus
- MCP server provides HTTP API
- Ready for dashboard integration

## Tests
- All 973 existing tests pass
- New components tested manually
- Import verification successful

Next steps: Cascade Router, Self-Upgrade Loop, Dashboard integration
This commit is contained in:
Alexander Payne
2026-02-25 19:26:24 -05:00
parent 16b65b28e8
commit a719c7538d
18 changed files with 2099 additions and 0 deletions

17
src/mcp/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""MCP (Model Context Protocol) package.
Provides tool registry, server, and schema management.
"""
from mcp.registry import tool_registry, register_tool
from mcp.server import mcp_server, MCPServer, MCPHTTPServer
from mcp.schemas.base import create_tool_schema
__all__ = [
"tool_registry",
"register_tool",
"mcp_server",
"MCPServer",
"MCPHTTPServer",
"create_tool_schema",
]

71
src/mcp/bootstrap.py Normal file
View File

@@ -0,0 +1,71 @@
"""Bootstrap the MCP system by loading all tools.
This module is responsible for:
1. Loading all tool modules from src/tools/
2. Registering them with the tool registry
3. Verifying tool health
4. Reporting status
"""
import importlib
import logging
from pathlib import Path
from mcp.registry import tool_registry
logger = logging.getLogger(__name__)
# Tool modules to load
TOOL_MODULES = [
"tools.web_search",
"tools.file_ops",
"tools.code_exec",
"tools.memory_tool",
]
def bootstrap_mcp() -> dict:
"""Initialize the MCP system by loading all tools.
Returns:
Status dict with loaded tools and any errors
"""
loaded = []
errors = []
for module_name in TOOL_MODULES:
try:
# Import the module (this triggers @register_tool decorators)
importlib.import_module(module_name)
loaded.append(module_name)
logger.info("Loaded tool module: %s", module_name)
except Exception as exc:
errors.append({"module": module_name, "error": str(exc)})
logger.error("Failed to load tool module %s: %s", module_name, exc)
# Get registry status
registry_status = tool_registry.to_dict()
status = {
"loaded_modules": loaded,
"errors": errors,
"total_tools": len(registry_status.get("tools", [])),
"tools_by_category": registry_status.get("categories", {}),
"tool_names": tool_registry.list_tools(),
}
logger.info(
"MCP Bootstrap complete: %d tools loaded from %d modules",
status["total_tools"],
len(loaded)
)
return status
def get_tool_status() -> dict:
"""Get current status of all tools."""
return {
"tools": tool_registry.to_dict(),
"metrics": tool_registry.get_metrics(),
}

340
src/mcp/registry.py Normal file
View File

@@ -0,0 +1,340 @@
"""MCP Tool Registry — Dynamic tool discovery and management.
The registry maintains a catalog of all available tools, their schemas,
and health status. Tools can be registered dynamically at runtime.
Usage:
from mcp.registry import tool_registry
# Register a tool
tool_registry.register("web_search", web_search_schema, web_search_func)
# Discover tools
tools = tool_registry.discover(capabilities=["search"])
# Execute a tool
result = tool_registry.execute("web_search", {"query": "Bitcoin"})
"""
import asyncio
import inspect
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from mcp.schemas.base import create_tool_schema
logger = logging.getLogger(__name__)
@dataclass
class ToolRecord:
"""A registered tool with metadata."""
name: str
schema: dict
handler: Callable
category: str = "general"
health_status: str = "unknown" # healthy, degraded, unhealthy
last_execution: Optional[float] = None
execution_count: int = 0
error_count: int = 0
avg_latency_ms: float = 0.0
added_at: float = field(default_factory=time.time)
requires_confirmation: bool = False
class ToolRegistry:
"""Central registry for all MCP tools."""
def __init__(self) -> None:
self._tools: dict[str, ToolRecord] = {}
self._categories: dict[str, list[str]] = {}
logger.info("ToolRegistry initialized")
def register(
self,
name: str,
schema: dict,
handler: Callable,
category: str = "general",
requires_confirmation: bool = False,
) -> ToolRecord:
"""Register a new tool.
Args:
name: Unique tool name
schema: JSON schema describing inputs/outputs
handler: Function to execute
category: Tool category for organization
requires_confirmation: If True, user must approve before execution
Returns:
The registered ToolRecord
"""
if name in self._tools:
logger.warning("Tool '%s' already registered, replacing", name)
record = ToolRecord(
name=name,
schema=schema,
handler=handler,
category=category,
requires_confirmation=requires_confirmation,
)
self._tools[name] = record
# Add to category
if category not in self._categories:
self._categories[category] = []
if name not in self._categories[category]:
self._categories[category].append(name)
logger.info("Registered tool: %s (category: %s)", name, category)
return record
def unregister(self, name: str) -> bool:
"""Remove a tool from the registry."""
if name not in self._tools:
return False
record = self._tools.pop(name)
# Remove from category
if record.category in self._categories:
if name in self._categories[record.category]:
self._categories[record.category].remove(name)
logger.info("Unregistered tool: %s", name)
return True
def get(self, name: str) -> Optional[ToolRecord]:
"""Get a tool record by name."""
return self._tools.get(name)
def get_handler(self, name: str) -> Optional[Callable]:
"""Get just the handler function for a tool."""
record = self._tools.get(name)
return record.handler if record else None
def get_schema(self, name: str) -> Optional[dict]:
"""Get the JSON schema for a tool."""
record = self._tools.get(name)
return record.schema if record else None
def list_tools(self, category: Optional[str] = None) -> list[str]:
"""List all tool names, optionally filtered by category."""
if category:
return self._categories.get(category, [])
return list(self._tools.keys())
def list_categories(self) -> list[str]:
"""List all tool categories."""
return list(self._categories.keys())
def discover(
self,
query: Optional[str] = None,
category: Optional[str] = None,
healthy_only: bool = True,
) -> list[ToolRecord]:
"""Discover tools matching criteria.
Args:
query: Search in tool names and descriptions
category: Filter by category
healthy_only: Only return healthy tools
Returns:
List of matching ToolRecords
"""
results = []
for name, record in self._tools.items():
# Category filter
if category and record.category != category:
continue
# Health filter
if healthy_only and record.health_status == "unhealthy":
continue
# Query filter
if query:
query_lower = query.lower()
name_match = query_lower in name.lower()
desc = record.schema.get("description", "")
desc_match = query_lower in desc.lower()
if not (name_match or desc_match):
continue
results.append(record)
return results
async def execute(self, name: str, params: dict) -> Any:
"""Execute a tool by name with given parameters.
Args:
name: Tool name
params: Parameters to pass to the tool
Returns:
Tool execution result
Raises:
ValueError: If tool not found
RuntimeError: If tool execution fails
"""
record = self._tools.get(name)
if not record:
raise ValueError(f"Tool '{name}' not found in registry")
start_time = time.time()
try:
# Check if handler is async
if inspect.iscoroutinefunction(record.handler):
result = await record.handler(**params)
else:
result = record.handler(**params)
# Update metrics
latency_ms = (time.time() - start_time) * 1000
record.last_execution = time.time()
record.execution_count += 1
# Update rolling average latency
if record.execution_count == 1:
record.avg_latency_ms = latency_ms
else:
record.avg_latency_ms = (
record.avg_latency_ms * 0.9 + latency_ms * 0.1
)
# Mark healthy on success
record.health_status = "healthy"
logger.debug("Tool '%s' executed in %.2fms", name, latency_ms)
return result
except Exception as exc:
record.error_count += 1
record.execution_count += 1
# Degrade health on repeated errors
error_rate = record.error_count / record.execution_count
if error_rate > 0.5:
record.health_status = "unhealthy"
logger.error("Tool '%s' marked unhealthy (error rate: %.1f%%)",
name, error_rate * 100)
elif error_rate > 0.2:
record.health_status = "degraded"
logger.warning("Tool '%s' degraded (error rate: %.1f%%)",
name, error_rate * 100)
raise RuntimeError(f"Tool '{name}' execution failed: {exc}") from exc
def check_health(self, name: str) -> str:
"""Check health status of a tool."""
record = self._tools.get(name)
if not record:
return "not_found"
return record.health_status
def get_metrics(self, name: Optional[str] = None) -> dict:
"""Get metrics for a tool or all tools."""
if name:
record = self._tools.get(name)
if not record:
return {}
return {
"name": record.name,
"category": record.category,
"health": record.health_status,
"executions": record.execution_count,
"errors": record.error_count,
"avg_latency_ms": round(record.avg_latency_ms, 2),
}
# Return metrics for all tools
return {
name: self.get_metrics(name)
for name in self._tools.keys()
}
def to_dict(self) -> dict:
"""Export registry as dictionary (for API/dashboard)."""
return {
"tools": [
{
"name": r.name,
"schema": r.schema,
"category": r.category,
"health": r.health_status,
"requires_confirmation": r.requires_confirmation,
}
for r in self._tools.values()
],
"categories": self._categories,
"total_tools": len(self._tools),
}
# Module-level singleton
tool_registry = ToolRegistry()
def register_tool(
name: Optional[str] = None,
category: str = "general",
schema: Optional[dict] = None,
requires_confirmation: bool = False,
):
"""Decorator for registering a function as an MCP tool.
Usage:
@register_tool(name="web_search", category="research")
def web_search(query: str, max_results: int = 5) -> str:
...
"""
def decorator(func: Callable) -> Callable:
tool_name = name or func.__name__
# Auto-generate schema if not provided
if schema is None:
# Try to infer from type hints
sig = inspect.signature(func)
params = {}
required = []
for param_name, param in sig.parameters.items():
if param.default == inspect.Parameter.empty:
required.append(param_name)
params[param_name] = {"type": "string"}
else:
params[param_name] = {
"type": "string",
"default": str(param.default),
}
tool_schema = create_tool_schema(
name=tool_name,
description=func.__doc__ or f"Execute {tool_name}",
parameters=params,
required=required,
)
else:
tool_schema = schema
tool_registry.register(
name=tool_name,
schema=tool_schema,
handler=func,
category=category,
requires_confirmation=requires_confirmation,
)
return func
return decorator

52
src/mcp/schemas/base.py Normal file
View File

@@ -0,0 +1,52 @@
"""Base schemas for MCP (Model Context Protocol) tools.
All tools must provide a JSON schema describing their interface.
This enables dynamic discovery and type-safe invocation.
"""
from typing import Any
def create_tool_schema(
name: str,
description: str,
parameters: dict[str, Any],
required: list[str] | None = None,
returns: dict[str, Any] | None = None,
) -> dict:
"""Create a standard MCP tool schema.
Args:
name: Tool name (must be unique)
description: Human-readable description
parameters: JSON schema for input parameters
required: List of required parameter names
returns: JSON schema for return value
Returns:
Complete tool schema dict
"""
return {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": parameters,
"required": required or [],
},
"returns": returns or {"type": "string"},
}
# Common parameter schemas
PARAM_STRING = {"type": "string"}
PARAM_INTEGER = {"type": "integer"}
PARAM_BOOLEAN = {"type": "boolean"}
PARAM_ARRAY_STRINGS = {"type": "array", "items": {"type": "string"}}
PARAM_OBJECT = {"type": "object"}
# Common return schemas
RETURN_STRING = {"type": "string"}
RETURN_OBJECT = {"type": "object"}
RETURN_ARRAY = {"type": "array"}
RETURN_BOOLEAN = {"type": "boolean"}

210
src/mcp/server.py Normal file
View File

@@ -0,0 +1,210 @@
"""MCP (Model Context Protocol) Server.
Implements the MCP protocol for tool discovery and execution.
Agents communicate with this server to discover and invoke tools.
The server can run:
1. In-process (direct method calls) — fastest, for local agents
2. HTTP API — for external clients
3. Stdio — for subprocess-based agents
"""
import asyncio
import json
import logging
from typing import Any, Optional
from mcp.registry import tool_registry
logger = logging.getLogger(__name__)
class MCPServer:
"""Model Context Protocol server for tool management.
Provides standard MCP endpoints:
- list_tools: Discover available tools
- call_tool: Execute a tool
- get_schema: Get tool input/output schemas
"""
def __init__(self) -> None:
self.registry = tool_registry
logger.info("MCP Server initialized")
def list_tools(
self,
category: Optional[str] = None,
query: Optional[str] = None,
) -> list[dict]:
"""List available tools.
MCP Protocol: tools/list
"""
tools = self.registry.discover(
query=query,
category=category,
healthy_only=True,
)
return [
{
"name": t.name,
"description": t.schema.get("description", ""),
"parameters": t.schema.get("parameters", {}),
"category": t.category,
}
for t in tools
]
async def call_tool(self, name: str, arguments: dict) -> dict:
"""Execute a tool with given arguments.
MCP Protocol: tools/call
Args:
name: Tool name
arguments: Tool parameters
Returns:
Result dict with content or error
"""
try:
result = await self.registry.execute(name, arguments)
return {
"content": [
{"type": "text", "text": str(result)}
],
"isError": False,
}
except Exception as exc:
logger.error("Tool execution failed: %s", exc)
return {
"content": [
{"type": "text", "text": f"Error: {exc}"}
],
"isError": True,
}
def get_schema(self, name: str) -> Optional[dict]:
"""Get the JSON schema for a tool.
MCP Protocol: tools/schema
"""
return self.registry.get_schema(name)
def get_tool_info(self, name: str) -> Optional[dict]:
"""Get detailed info about a tool including health metrics."""
record = self.registry.get(name)
if not record:
return None
return {
"name": record.name,
"schema": record.schema,
"category": record.category,
"health": record.health_status,
"metrics": {
"executions": record.execution_count,
"errors": record.error_count,
"avg_latency_ms": round(record.avg_latency_ms, 2),
},
"requires_confirmation": record.requires_confirmation,
}
def health_check(self) -> dict:
"""Server health status."""
tools = self.registry.list_tools()
healthy = sum(
1 for t in tools
if self.registry.check_health(t) == "healthy"
)
return {
"status": "healthy",
"total_tools": len(tools),
"healthy_tools": healthy,
"degraded_tools": sum(
1 for t in tools
if self.registry.check_health(t) == "degraded"
),
"unhealthy_tools": sum(
1 for t in tools
if self.registry.check_health(t) == "unhealthy"
),
}
class MCPHTTPServer:
"""HTTP API wrapper for MCP Server."""
def __init__(self) -> None:
self.mcp = MCPServer()
def get_routes(self) -> dict:
"""Get FastAPI route handlers."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/mcp", tags=["mcp"])
class ToolCallRequest(BaseModel):
name: str
arguments: dict = {}
@router.get("/tools")
async def list_tools(
category: Optional[str] = None,
query: Optional[str] = None,
):
"""List available tools."""
return {"tools": self.mcp.list_tools(category, query)}
@router.post("/tools/call")
async def call_tool(request: ToolCallRequest):
"""Execute a tool."""
result = await self.mcp.call_tool(request.name, request.arguments)
return result
@router.get("/tools/{name}")
async def get_tool(name: str):
"""Get tool info."""
info = self.mcp.get_tool_info(name)
if not info:
raise HTTPException(404, f"Tool '{name}' not found")
return info
@router.get("/tools/{name}/schema")
async def get_schema(name: str):
"""Get tool schema."""
schema = self.mcp.get_schema(name)
if not schema:
raise HTTPException(404, f"Tool '{name}' not found")
return schema
@router.get("/health")
async def health():
"""Server health check."""
return self.mcp.health_check()
return router
# Module-level singleton
mcp_server = MCPServer()
# Convenience functions for agents
def discover_tools(query: Optional[str] = None) -> list[dict]:
"""Quick tool discovery."""
return mcp_server.list_tools(query=query)
async def use_tool(name: str, **kwargs) -> str:
"""Execute a tool and return result text."""
result = await mcp_server.call_tool(name, kwargs)
if result.get("isError"):
raise RuntimeError(result["content"][0]["text"])
return result["content"][0]["text"]