"""MCP server exposing the INFJ companion as an external tool. Supports stdio transport (default) and a simple HTTP transport for local orchestration. """ import asyncio import os from typing import Any, Dict, List, Optional import logging from collections import deque from mcp.server.fastmcp import FastMCP from fastapi import FastAPI, HTTPException, Request import uvicorn import time from infj_bot.core.brain import DriftBrain from infj_bot.core.cognition import map_dissonance from infj_bot.core.plugins.documents import DocumentStore, format_doc_results from infj_bot.core.plugins.emotion import detect_emotion from infj_bot.core.plugins.goals import GoalsDB from infj_bot.core.memory import DriftMemory from infj_bot.core.global_workspace import GlobalWorkspace try: from hive_mind.orchestrator import HiveOrchestrator except Exception: HiveOrchestrator = None # type: ignore[misc,assignment] mcp = FastMCP( "infj_companion", instructions=""" You are interfacing with the INFJ Companion Bot — a local AI companion with deep memory, emotional awareness, cognitive dissonance mapping, and document retrieval. Use these tools when: - The user needs emotional clarity or support - The user seems torn between options - The user references past conversations or knowledge - The user asks about documents they have ingested - The user needs help tracking goals or todos """, ) brain: Optional[DriftBrain] = None memory: Optional[DriftMemory] = None goals_db: Optional[GoalsDB] = None doc_store: Optional[DocumentStore] = None def get_brain() -> DriftBrain: global brain if brain is None: brain = DriftBrain() return brain def get_memory() -> DriftMemory: global memory if memory is None: memory = DriftMemory() return memory def get_goals_db() -> GoalsDB: global goals_db if goals_db is None: goals_db = GoalsDB() return goals_db def get_doc_store() -> DocumentStore: global doc_store if doc_store is None: doc_store = DocumentStore() return doc_store def create_http_app(token: str | None = None) -> FastAPI: """Create a minimal FastAPI app that exposes the available tools as HTTP endpoints. POST /invoke/{tool_name} with JSON body {"args": [], "kwargs": {}} will call the corresponding function and return {"result": ...}. """ app = FastAPI(title="INFJ Companion (HTTP bridge)") # token may be provided explicitly for tests; otherwise read from env token = token if token is not None else os.getenv("MCP_HTTP_TOKEN") # Map public tool names to callables TOOLS: Dict[str, Any] = { "emotional_clarity": emotional_clarity, "dissonance_map": dissonance_map, "memory_search": memory_search, "document_search": document_search, "todo_list": todo_list, "todo_add": todo_add, "todo_complete": todo_complete, "companion_think": companion_think, "ingest_document": ingest_document, } # Concurrency and cooldown controls concurrency = int(os.getenv("MCP_AUTONOMY_CONCURRENCY", "2")) min_interval = float(os.getenv("MCP_AUTONOMY_MIN_INTERVAL", "1.0")) semaphore = asyncio.Semaphore(concurrency) last_run: Dict[str, float] = {} # Simple in-memory scheduled tasks store: id -> {run_at, plan, token} scheduled: Dict[str, Dict[str, Any]] = {} # Metrics and rate-limiting metrics = { "invoke_count": 0, "autonomy_count": 0, "scheduled_count": 0, } rate_limit_per_min = int(os.getenv("MCP_RATE_LIMIT_PER_MIN", "60")) rate_buckets: Dict[str, deque] = {} # configure logging logging.basicConfig(level=os.getenv("MCP_LOG_LEVEL", "INFO")) logger = logging.getLogger("infj_mcp") # Bounded task tracking for scheduled jobs _scheduled_tasks: set = set() _max_scheduled_tasks = int(os.getenv("MCP_MAX_SCHEDULED_TASKS", "50")) async def schedule_worker(): while True: now = time.time() to_run = [] for tid, t in list(scheduled.items()): if t.get("run_at", 0) <= now and not t.get("running"): to_run.append((tid, t)) for tid, t in to_run: t["running"] = True if len(_scheduled_tasks) >= _max_scheduled_tasks: logger.warning( "Max scheduled tasks (%d) reached; dropping task %s", _max_scheduled_tasks, tid, ) scheduled.pop(tid, None) continue async def run_and_cleanup(tid=tid, t=t): try: plan = t["plan"] async with semaphore: results = [] for step in plan: tool_name = step.get("tool") fn = TOOLS.get(tool_name) if fn is None: results.append( {"tool": tool_name, "error": "tool not found"} ) continue args = step.get("args") or [] kwargs = step.get("kwargs") or {} try: out = fn(*args, **kwargs) results.append({"tool": tool_name, "result": out}) except Exception as exc: results.append( {"tool": tool_name, "error": str(exc)} ) t["result"] = results finally: scheduled.pop(tid, None) task = asyncio.create_task(run_and_cleanup()) _scheduled_tasks.add(task) task.add_done_callback(_scheduled_tasks.discard) await asyncio.sleep(0.5) # start background worker safely async def _start_worker(): try: asyncio.create_task(schedule_worker()) except Exception: pass try: loop = asyncio.get_running_loop() loop.create_task(_start_worker()) except RuntimeError: # No running loop yet (e.g. during import or stdio mode) pass @app.get("/health") def health() -> Dict[str, str]: return {"status": "ok", "transport": "http"} @app.get("/metrics") def metrics_endpoint() -> Dict[str, Any]: return { "metrics": metrics, "rate_limit_per_min": rate_limit_per_min, "scheduled": len(scheduled), } def check_rate_limit(client_ip: str) -> None: now = time.time() window_start = now - 60 q = rate_buckets.get(client_ip) if q is None: q = deque() rate_buckets[client_ip] = q while q and q[0] < window_start: q.popleft() if len(q) >= rate_limit_per_min: raise HTTPException(status_code=429, detail="Too many requests") q.append(now) @app.post("/invoke/{tool_name}") async def invoke(tool_name: str, body: Dict[str, Any], request: Request): # Rate limit by client IP client_ip = request.client.host if request.client else "unknown" check_rate_limit(client_ip) fn = TOOLS.get(tool_name) if fn is None: raise HTTPException(status_code=404, detail=f"Tool {tool_name} not found") # Authentication: prefer Authorization header Bearer auth_header = request.headers.get("authorization") auth_token = None if auth_header and auth_header.lower().startswith("bearer "): auth_token = auth_header.split(None, 1)[1] # fallback to body._auth for tests/legacy if not auth_token and isinstance(body, dict): auth_token = body.get("_auth") if token: if not auth_token: raise HTTPException(status_code=401, detail="Missing auth token") if auth_token != token: raise HTTPException(status_code=403, detail="Invalid auth token") args: List[Any] = body.get("args") or [] kwargs: Dict[str, Any] = body.get("kwargs") or {} try: result = fn(*args, **kwargs) metrics["invoke_count"] += 1 return {"result": result} except Exception as exc: # pragma: no cover - surface errors to caller raise HTTPException(status_code=500, detail=str(exc)) @app.post("/autonomy") async def autonomy(body: Dict[str, Any], request: Request): """Execute a small plan of tool invocations sequentially. Body shape: {"plan": [{"tool": "name", "args": [...], "kwargs": {...}}], "_auth": "token"} Returns: {"results": [ {"tool": name, "result": ..., "error": ... }, ... ]} """ # Authentication header auth_header = request.headers.get("authorization") auth_token = None if auth_header and auth_header.lower().startswith("bearer "): auth_token = auth_header.split(None, 1)[1] if not auth_token and isinstance(body, dict): auth_token = body.get("_auth") if token: if not auth_token: raise HTTPException(status_code=401, detail="Missing auth token") if auth_token != token: raise HTTPException(status_code=403, detail="Invalid auth token") plan = body.get("plan") or [] if not isinstance(plan, list): raise HTTPException(status_code=400, detail="Plan must be a list") # Rate-limit / cooldown per token (or 'anon') key = auth_token or "anon" now = time.time() last = last_run.get(key, 0) if now - last < min_interval: raise HTTPException(status_code=429, detail="Autonomy calls too frequent") last_run[key] = now # Execute the entire plan under concurrency semaphore results = [] async with semaphore: for step in plan: tool_name = step.get("tool") if not tool_name: results.append({"tool": None, "error": "missing tool name"}) continue fn = TOOLS.get(tool_name) if fn is None: results.append({"tool": tool_name, "error": "tool not found"}) continue args = step.get("args") or [] kwargs = step.get("kwargs") or {} try: out = fn(*args, **kwargs) results.append({"tool": tool_name, "result": out}) except Exception as exc: results.append({"tool": tool_name, "error": str(exc)}) return {"results": results} return app @mcp.tool() def emotional_clarity(text: str) -> str: """Analyze emotional tone and return a gentle, structured reading.""" emotion = detect_emotion(text) return ( f"Emotional reading:\n" f"- Primary: {emotion['label']} (confidence {emotion['confidence']:.2f})\n" f"- Intensity: {emotion['intensity']:.2f}\n" f"- Valence: {emotion['valence']:.2f} | Arousal: {emotion['arousal']:.2f}\n" f"- Needs: {emotion['needs']}\n\n" f"Suggested posture: {emotion['label']}\n" f"Detector: {emotion['detector']}" ) @mcp.tool() def dissonance_map(text: str) -> str: """Map cognitive dissonance in a situation and suggest a small next step.""" return map_dissonance(text) @mcp.tool() def memory_search(query: str, n_results: int = 5) -> str: """Search the bot's long-term memory for relevant past interactions and concepts.""" results = get_memory().search(query, n_results=n_results) if not results: return "No matching memories found." lines = [] for document, metadata in results: label = ( metadata.get("concept") or metadata.get("title") or metadata.get("type", "memory") ) lines.append(f"[{label}]\n{document}") return "\n---\n".join(lines) @mcp.tool() def document_search(query: str, n_results: int = 5) -> str: """Search ingested documents (PDFs, notes, code) for relevant passages.""" results = get_doc_store().search(query, n_results=n_results) return format_doc_results(results) @mcp.tool() def todo_list(status: str = "active") -> str: """List active or completed goals/todos.""" goals = get_goals_db().list_goals(status=status, limit=20) if not goals: return f"No {status} goals." lines = [] for g in goals: p = "high" if g.priority == 2 else ("low" if g.priority == 0 else "normal") due = f" (due {g.due_at})" if g.due_at else "" lines.append(f"[{g.id}] ({p}) {g.title}{due}") return "\n".join(lines) @mcp.tool() def todo_add(title: str, description: str = "", priority: str = "normal") -> str: """Add a new goal or todo. Priority: low, normal, high.""" pmap = {"low": 0, "normal": 1, "high": 2} p = pmap.get(priority.lower(), 1) gid = get_goals_db().add_goal(title, description=description, priority=p) return f"Added goal [{gid}]: {title}" @mcp.tool() def todo_complete(goal_id: str) -> str: """Mark a goal as done.""" if get_goals_db().complete_goal(goal_id): return f"Marked [{goal_id}] as done." return f"Goal [{goal_id}] not found or already done." @mcp.tool() def companion_think(prompt: str) -> str: """Ask the INFJ companion to think deeply about a prompt and return its response.""" return get_brain().think(prompt) @mcp.tool() def ingest_document(path: str, tags: str = "") -> str: """Ingest a file or directory into the document RAG store.""" tag_list = [t.strip() for t in tags.split(",") if t.strip()] try: count = get_doc_store().ingest(path, tags=tag_list) return f"Ingested {count} chunks from {path}." except Exception as exc: return f"Ingest failed: {exc}" @mcp.tool() def hive_status() -> str: """Return current hive mind node status, consensus state, and drift bridge health.""" if HiveOrchestrator is None: return "HiveOrchestrator not available (hive_mind integration missing)." try: hive = HiveOrchestrator() status = hive.get_status() # Demo lightweight consensus using the engine hive.consensus.run_simple_consensus( topic="Current hive health check", proposals=[ { "node": "spark-0", "role": "PRIMARY", "position": "healthy", "confidence": 0.9, }, { "node": "seed-1", "role": "CRITIC", "position": "healthy", "confidence": 0.75, }, ], ) alive_nodes = ( hive.list_alive_nodes() if hasattr(hive, "list_alive_nodes") else [] ) return ( f"Hive nodes: {status.get('nodes', 0)} ({status.get('alive', 0)} alive)\n" f"Active: {', '.join(alive_nodes[:4]) if alive_nodes else 'none'}\n" f"Consensus: {status.get('consensus', 'idle')}\n" f"Drift bridge: {status.get('drift_bridge', 'ok')}" ) except Exception as e: return f"Hive status unavailable: {e}" @mcp.tool() def workspace_snapshot() -> str: """Get a snapshot of the current global workspace (active concepts, attention, bindings).""" try: gw = GlobalWorkspace() snap = gw.snapshot() concepts = snap.get("concepts", [])[:3] return f"Concepts: {len(snap.get('concepts', []))} | Focus: {snap.get('focus') or 'none'}\nTop: {' | '.join(concepts) if concepts else 'empty'}" except Exception as e: return f"Workspace snapshot unavailable: {e}" if __name__ == "__main__": # Choose transport via MCP_TRANSPORT env var: 'stdio' (default) or 'http' transport = os.getenv("MCP_TRANSPORT", "stdio").lower() if transport in ("stdio", "stdio_async", "stdio-async"): asyncio.run(mcp.run_stdio_async()) elif transport in ("http", "fastapi", "rest"): app = create_http_app() host = os.getenv("MCP_HOST", "127.0.0.1") port = int(os.getenv("MCP_PORT", "8080")) # Run uvicorn directly so this single process can be started by scripts uvicorn.run(app, host=host, port=port) else: print(f"Unknown MCP_TRANSPORT={transport!r}, defaulting to stdio") asyncio.run(mcp.run_stdio_async())