Files
odoo-ai/agent_service/main.py
ActiveBlue Build 66b114cdcf feat(mcp): add MCP gateway — 14 tools over SSE, all agent calls forced local
Architecture:
- agent_service/mcp/tools.py: 14 Tool definitions with JSON schemas
    dispatch, finance_query, accounting_query, crm_query, sales_query,
    project_query, elearning_query, expenses_query, employees_query,
    get_health, list_agents, trigger_sweep, get_pending_approvals, approve_directive
- agent_service/mcp/server.py: mcp.Server with list_tools + call_tool handlers
- agent_service/routers/mcp_router.py: Starlette routes at /mcp/sse + /mcp/messages
- main.py: mounts MCP routes alongside existing FastAPI routers (graceful fallback if mcp not installed)

Privacy guarantee (enforced in server.py, not by convention):
- _force_local_context() sets llm_router._privacy_mode = 'local' before EVERY agent call
- _restore_mode() restores original mode after the tool returns
- HIPAA agents (finance, accounting, expenses, employees) were already Ollama-only;
  MCP adds a second enforcement layer for all 8 agents
- MCP client (e.g. Claude Code CLI) receives only tool results — no LLM completions cross the boundary

Usage (Claude Code CLI):
  claude mcp add --transport sse http://192.168.2.47:8001/mcp/sse
  or copy claude_mcp_config.json to ~/.claude/mcp_servers.json

requirements.txt: added mcp==1.3.0
tests/test_mcp_server.py: 13 tests covering tool count, schemas, HIPAA labelling, privacy override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 16:45:49 -04:00

251 lines
8.4 KiB
Python

from __future__ import annotations
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
import asyncpg
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from . import app_state
from .routers import dispatch, approval, registry, sweep, health
logger = logging.getLogger(__name__)
async def _init_db(settings) -> asyncpg.Pool:
pool = await asyncpg.create_pool(
host=settings.postgres_host,
port=settings.postgres_port,
database=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
min_size=settings.postgres_min_connections,
max_size=settings.postgres_max_connections,
max_inactive_connection_lifetime=300,
)
logger.info('DB pool created (min=%d max=%d)', settings.postgres_min_connections, settings.postgres_max_connections)
return pool
async def _db_health_loop(pool: asyncpg.Pool) -> None:
while True:
await asyncio.sleep(60)
try:
async with pool.acquire(timeout=5) as conn:
await conn.fetchval('SELECT 1')
except Exception as exc:
logger.warning('DB health check failed: %s', exc)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
_configure_logging(settings)
# 1. Database
try:
pool = await _init_db(settings)
app_state.set_db_pool(pool)
asyncio.create_task(_db_health_loop(pool))
except Exception as exc:
logger.error('Failed to connect to database: %s', exc)
pool = None
# 2. LLM Router
try:
from .llm.ollama_backend import OllamaBackend
from .llm.llm_config_store import LLMConfigStore
from .llm.llm_router import LLMRouter
ollama = OllamaBackend(
base_url=settings.ollama_url,
model=settings.ollama_model,
timeout=settings.ollama_timeout,
)
config_store = LLMConfigStore(pool) if pool else None
claude = None
if settings.llm_privacy_mode != 'local' and settings.anthropic_api_key:
from .llm.claude_backend import ClaudeBackend
claude = ClaudeBackend(api_key=settings.anthropic_api_key, model=settings.claude_model)
llm_router = LLMRouter(
ollama=ollama,
claude=claude,
config_store=config_store,
privacy_mode=settings.llm_privacy_mode,
env_overrides={
name: settings.agent_backend_override(name)
for name in [
'finance_agent', 'accounting_agent', 'crm_agent', 'sales_agent',
'project_agent', 'elearning_agent', 'expenses_agent', 'employees_agent',
]
if settings.agent_backend_override(name)
},
)
app_state.set_llm_router(llm_router)
logger.info('LLM router ready (mode=%s)', settings.llm_privacy_mode)
except Exception as exc:
logger.error('Failed to init LLM router: %s', exc)
llm_router = None
# 3. Odoo client
try:
from .tools.odoo_client import OdooClient
odoo = OdooClient(
url=settings.odoo_url,
db=settings.odoo_db,
api_key=settings.odoo_api_key,
)
logger.info('Odoo client initialised (%s)', settings.odoo_url)
except Exception as exc:
logger.error('Failed to init Odoo client: %s', exc)
odoo = None
# 4. Agent registry
try:
from .agents.registry import AgentRegistry
agent_registry = AgentRegistry(odoo=odoo, pool=pool)
app_state.set_agent_registry(agent_registry)
except Exception as exc:
logger.error('Failed to init agent registry: %s', exc)
agent_registry = None
# 5. Memory manager
try:
from .memory.memory_manager import MemoryManager
memory_mgr = MemoryManager(pool=pool, llm=llm_router) if pool else None
except Exception as exc:
logger.error('Failed to init memory manager: %s', exc)
memory_mgr = None
# 6. Peer bus + specialist agents
try:
from .agents.peer_bus import PeerBus
peer_bus = PeerBus()
_register_specialist_agents(peer_bus, odoo, llm_router)
except Exception as exc:
logger.error('Failed to init peer bus / specialist agents: %s', exc)
peer_bus = None
# 7. Master agent
try:
from .agents.master_agent import MasterAgent
master = MasterAgent(
odoo=odoo,
llm=llm_router,
memory=memory_mgr,
peer_bus=peer_bus,
registry=agent_registry,
)
app_state.set_master_agent(master)
logger.info('MasterAgent ready')
except Exception as exc:
logger.error('Failed to init MasterAgent: %s', exc)
# 8. Sweep coordinator (lazy import — defined in Step 16)
try:
from .agents.sweep_coordinator import SweepCoordinator
sweep_coord = SweepCoordinator(peer_bus=peer_bus)
app_state.set_sweep_coordinator(sweep_coord)
except ImportError:
pass # not yet implemented
except Exception as exc:
logger.warning('Sweep coordinator not available: %s', exc)
logger.info('ActiveBlue AI agent service started on port %d', settings.agent_service_port)
yield
# Shutdown
if pool:
await pool.close()
logger.info('Agent service shut down')
def _register_specialist_agents(peer_bus, odoo, llm_router) -> None:
try:
from .agents.finance_agent import FinanceAgent
peer_bus.register('finance_agent', FinanceAgent(odoo=odoo, llm=llm_router, peer_bus=peer_bus))
except Exception as exc:
logger.warning('Could not register finance_agent: %s', exc)
specialist_map = {
'accounting_agent': 'AccountingAgent',
'crm_agent': 'CrmAgent',
'sales_agent': 'SalesAgent',
'project_agent': 'ProjectAgent',
'elearning_agent': 'ElearningAgent',
'expenses_agent': 'ExpensesAgent',
'employees_agent': 'EmployeesAgent',
}
for agent_name, class_name in specialist_map.items():
module_name = agent_name.replace('_agent', '_agent')
try:
import importlib
mod = importlib.import_module(f'.agents.{agent_name}', package='agent_service')
cls = getattr(mod, class_name)
peer_bus.register(agent_name, cls(odoo=odoo, llm=llm_router, peer_bus=peer_bus))
except ImportError:
logger.debug('%s module not yet implemented, skipping', agent_name)
except Exception as exc:
logger.warning('Could not register %s: %s', agent_name, exc)
def _configure_logging(settings) -> None:
level = getattr(logging, settings.log_level.upper(), logging.INFO)
if settings.log_format == 'json':
try:
import json_log_formatter
formatter = json_log_formatter.JSONFormatter()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logging.root.handlers = [handler]
except ImportError:
logging.basicConfig(level=level, stream=sys.stdout)
else:
logging.basicConfig(
level=level,
stream=sys.stdout,
format='%(asctime)s %(name)s %(levelname)s %(message)s',
)
logging.root.setLevel(level)
def create_app() -> FastAPI:
app = FastAPI(
title='ActiveBlue AI Agent Service',
version='0.1.0',
docs_url='/docs',
redoc_url=None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_methods=['GET', 'POST', 'DELETE'],
allow_headers=['*'],
)
app.include_router(dispatch.router)
app.include_router(approval.router)
app.include_router(registry.router)
app.include_router(sweep.router)
app.include_router(health.router)
# MCP gateway — mount SSE transport routes
# All agent calls through MCP are forced to local (Ollama) mode.
try:
from .routers.mcp_router import mcp_routes
from starlette.routing import Route
for route in mcp_routes:
app.router.routes.append(route)
logger.info('MCP gateway mounted at /mcp/sse and /mcp/messages')
except ImportError as exc:
logger.warning('MCP package not installed — gateway disabled: %s', exc)
return app
app = create_app()