From d50183c3d7b325c4ec02a6a18e67b661a9320161 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 27 Mar 2026 14:30:15 -0400 Subject: [PATCH 1/5] feat(agent): tool profiles and LLM router (rebased on docker/merge base) - tools.toolProfiles / tools.toolRouting in config; filter tools per turn - Router picks profile; expandOnMissingTool widens to full registry once - Wire gateway and CLI AgentLoop; ToolRegistry.get_definitions_subset - Ruff: fix tool_routing exception handling and format touched files Made-with: Cursor --- nanobot/agent/loop.py | 81 +++++++++++++- nanobot/agent/tool_profiles.py | 59 ++++++++++ nanobot/agent/tool_routing.py | 82 ++++++++++++++ nanobot/agent/tools/registry.py | 36 +++--- nanobot/cli/commands.py | 189 +++++++++++++++++--------------- nanobot/config/schema.py | 36 +++++- 6 files changed, 375 insertions(+), 108 deletions(-) create mode 100644 nanobot/agent/tool_profiles.py create mode 100644 nanobot/agent/tool_routing.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e04be3d..636ab91 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -22,7 +22,7 @@ from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.config.schema import ExecToolConfig +from nanobot.config.schema import ExecToolConfig, ToolRoutingConfig from nanobot.cron.service import CronService from nanobot.providers.base import LLMProvider from nanobot.session.manager import Session, SessionManager @@ -56,6 +56,9 @@ class AgentLoop: restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, mcp_servers: dict | None = None, + tool_profiles: dict | None = None, + default_tool_profile: str = "default", + tool_routing: ToolRoutingConfig | None = None, ): self.bus = bus self.provider = provider @@ -89,6 +92,9 @@ class AgentLoop: self._mcp_servers = mcp_servers or {} self._mcp_stack: AsyncExitStack | None = None self._mcp_connected = False + self._tool_profiles: dict = tool_profiles or {} + self._default_tool_profile = default_tool_profile + self._tool_routing = tool_routing or ToolRoutingConfig() self._register_default_tools() def _register_default_tools(self) -> None: @@ -194,6 +200,41 @@ class AgentLoop: return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")' return ", ".join(_fmt(tc) for tc in tool_calls) + @staticmethod + def _extract_routing_text(messages: list[dict]) -> str: + """Last user message text (string or multimodal) for the tool-profile router.""" + for m in reversed(messages): + if m.get("role") != "user": + continue + c = m.get("content") + if isinstance(c, str): + return c.strip() + if isinstance(c, list): + parts: list[str] = [] + for block in c: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text") or "")) + return "\n".join(parts).strip() + return "" + + async def _pick_tool_profile(self, user_text: str) -> str: + """Resolve profile key when tools.toolProfiles is configured.""" + if not self._tool_profiles: + return self._default_tool_profile + if self._tool_routing.enabled: + from nanobot.agent.tool_routing import route_tool_profile + + return await route_tool_profile( + self.provider, + model=self.model, + user_message=user_text, + profiles=self._tool_profiles, + default_profile=self._default_tool_profile, + temperature=self._tool_routing.router_temperature, + max_tokens=self._tool_routing.router_max_tokens, + ) + return self._default_tool_profile + async def _run_agent_loop( self, initial_messages: list[dict], @@ -214,15 +255,41 @@ class AgentLoop: final_content = None tools_used: list[str] = [] + from nanobot.agent.tool_profiles import compute_allowed_tool_names + from nanobot.agent.tool_routing import is_tool_not_found_error + + tools_full = self.tools.get_definitions() + tools_expanded = False + allowed_names: set[str] | None = None + if self._tool_profiles: + routing_text = self._extract_routing_text(initial_messages) + profile_key = await self._pick_tool_profile(routing_text) + prof = self._tool_profiles[profile_key] + always = set(self._tool_routing.always_include_tools) + allowed_names = compute_allowed_tool_names( + self.tools, + prof, + list(self._mcp_servers.keys()), + always, + ) + logger.info( + f"Tool profile '{profile_key}': {len(allowed_names)}/{len(self.tools)} tools exposed" + ) + while iteration < self.max_iterations: iteration += 1 logger.debug(f"Agent loop iteration {iteration}/{self.max_iterations}, calling LLM provider...") + if allowed_names is not None and not tools_expanded: + tool_defs = self.tools.get_definitions_subset(allowed_names) + else: + tool_defs = tools_full + try: response = await asyncio.wait_for( self.provider.chat( messages=messages, - tools=self.tools.get_definitions(), + tools=tool_defs, model=self.model, temperature=self.temperature, max_tokens=self.max_tokens, @@ -264,6 +331,16 @@ class AgentLoop: logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") result = await self.tools.execute(tool_call.name, tool_call.arguments) logger.info(f"Tool result length: {len(result) if result else 0}, preview: {result[:200] if result else 'None'}") + if ( + allowed_names is not None + and self._tool_routing.expand_on_missing_tool + and not tools_expanded + and is_tool_not_found_error(result) + ): + tools_expanded = True + logger.info( + "Expanded tool set to full registry (missing tool after profile filter)" + ) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) diff --git a/nanobot/agent/tool_profiles.py b/nanobot/agent/tool_profiles.py new file mode 100644 index 0000000..c1e9a4a --- /dev/null +++ b/nanobot/agent/tool_profiles.py @@ -0,0 +1,59 @@ +"""Tool profile: compute which tools are visible to the LLM for a given config profile.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nanobot.agent.tools.registry import ToolRegistry + from nanobot.config.schema import ToolProfileConfig + + +def mcp_server_for_tool(tool_name: str, mcp_server_keys: list[str]) -> str | None: + """ + Infer MCP server config key from nanobot's tool name pattern mcp__. + + Server keys are matched longest-first so names with underscores resolve unambiguously. + """ + prefix = "mcp_" + if not tool_name.startswith(prefix): + return None + rest = tool_name[len(prefix) :] + for key in sorted(mcp_server_keys, key=len, reverse=True): + sep = f"{key}_" + if rest.startswith(sep): + return key + return None + + +def compute_allowed_tool_names( + registry: ToolRegistry, + profile: ToolProfileConfig, + mcp_server_keys: list[str], + always_include: set[str], +) -> set[str]: + """Union of profile-filtered builtins + MCP tools + always-include (intersected with registered names).""" + all_names = set(registry.tool_names) + mcp_keys = list(mcp_server_keys) + + builtins = {n for n in all_names if mcp_server_for_tool(n, mcp_keys) is None} + + if profile.builtin_tools is None: + allowed_builtins = set(builtins) + else: + allowed_builtins = set(profile.builtin_tools) & builtins + + if profile.mcp_servers is None: + allowed_mcp = { + n for n in all_names if mcp_server_for_tool(n, mcp_keys) is not None + } + else: + allow_srv = set(profile.mcp_servers) + allowed_mcp = { + n + for n in all_names + if (srv := mcp_server_for_tool(n, mcp_keys)) is not None and srv in allow_srv + } + + extras = always_include & all_names + return allowed_builtins | allowed_mcp | extras diff --git a/nanobot/agent/tool_routing.py b/nanobot/agent/tool_routing.py new file mode 100644 index 0000000..b9ffdc9 --- /dev/null +++ b/nanobot/agent/tool_routing.py @@ -0,0 +1,82 @@ +"""LLM-based router: choose a tools.toolProfiles key from the user message.""" + +from __future__ import annotations + +import json_repair +from loguru import logger + +from nanobot.config.schema import ToolProfileConfig +from nanobot.providers.base import LLMProvider + + +async def route_tool_profile( + provider: LLMProvider, + *, + model: str, + user_message: str, + profiles: dict[str, ToolProfileConfig], + default_profile: str, + temperature: float = 0.2, + max_tokens: int = 128, +) -> str: + """ + Ask a small LLM call to return JSON {"profile": ""}. + + Falls back to default_profile on any failure or unknown key. + """ + if not profiles: + return default_profile + + lines = [] + for name, p in profiles.items(): + desc = (p.description or "").strip() or "(no description)" + lines.append(f"- {name}: {desc}") + catalog = "\n".join(lines) + allowed = ", ".join(f'"{k}"' for k in profiles) + + system = ( + "You are a tool-profile router. Pick exactly one profile key for the assistant's next turn. " + "Respond with JSON only: {\"profile\": \"\"} where is one of: " + f"{allowed}. " + "Prefer narrower profiles when the request is clearly scoped (e.g. only read files). " + "Use the broadest profile only when multiple unrelated capabilities are needed." + ) + user = f"Available profiles:\n{catalog}\n\nUser message:\n{user_message.strip()[:8000]}" + + try: + response = await provider.chat( + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + tools=None, + model=model, + temperature=temperature, + max_tokens=max_tokens, + ) + text = (response.content or "").strip() + if not text: + return default_profile + if text.startswith("```"): + text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() + data = json_repair.loads(text) + if not isinstance(data, dict): + return default_profile + name = data.get("profile") + if isinstance(name, str) and name in profiles: + logger.info(f"Tool router selected profile '{name}'") + return name + logger.warning(f"Tool router returned invalid profile {name!r}, using default") + except (TypeError, ValueError) as e: + logger.warning(f"Tool router JSON parse failed: {e}") + except Exception as e: + logger.warning(f"Tool router failed: {e}") + + return default_profile + + +def is_tool_not_found_error(result: str) -> bool: + """Detect registry execute() message for missing tools.""" + if not result: + return False + return result.startswith("Error: Tool '") and "' not found" in result diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index f948c0f..a15c508 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -8,44 +8,52 @@ from nanobot.agent.tools.base import Tool class ToolRegistry: """ Registry for agent tools. - + Allows dynamic registration and execution of tools. """ - + def __init__(self): self._tools: dict[str, Tool] = {} - + def register(self, tool: Tool) -> None: """Register a tool.""" self._tools[tool.name] = tool - + def unregister(self, name: str) -> None: """Unregister a tool by name.""" self._tools.pop(name, None) - + def get(self, name: str) -> Tool | None: """Get a tool by name.""" return self._tools.get(name) - + def has(self, name: str) -> bool: """Check if a tool is registered.""" return name in self._tools - + def get_definitions(self) -> list[dict[str, Any]]: """Get all tool definitions in OpenAI format.""" return [tool.to_schema() for tool in self._tools.values()] - + + def get_definitions_subset(self, names: set[str]) -> list[dict[str, Any]]: + """Tool definitions for the given names only (preserves registration order).""" + out: list[dict[str, Any]] = [] + for key, tool in self._tools.items(): + if key in names: + out.append(tool.to_schema()) + return out + async def execute(self, name: str, params: dict[str, Any]) -> str: """ Execute a tool by name with given parameters. - + Args: name: Tool name. params: Tool parameters. - + Returns: Tool execution result as string. - + Raises: KeyError: If tool not found. """ @@ -62,14 +70,14 @@ class ToolRegistry: return await tool.execute(**coerced_params) except Exception as e: return f"Error executing {name}: {str(e)}" - + @property def tool_names(self) -> list[str]: """Get list of registered tool names.""" return list(self._tools.keys()) - + def __len__(self) -> int: return len(self._tools) - + def __contains__(self, name: str) -> bool: return name in self._tools diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 46033df..c64365a 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -2,23 +2,22 @@ import asyncio import os -import signal -from pathlib import Path import select +import signal import sys +from pathlib import Path import typer +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.history import FileHistory +from prompt_toolkit.patch_stdout import patch_stdout from rich.console import Console from rich.markdown import Markdown from rich.table import Table from rich.text import Text -from prompt_toolkit import PromptSession -from prompt_toolkit.formatted_text import HTML -from prompt_toolkit.history import FileHistory -from prompt_toolkit.patch_stdout import patch_stdout - -from nanobot import __version__, __logo__ +from nanobot import __logo__, __version__ from nanobot.config.schema import Config app = typer.Typer( @@ -159,9 +158,9 @@ def onboard(): from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config from nanobot.utils.helpers import get_workspace_path - + config_path = get_config_path() - + if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") @@ -177,17 +176,17 @@ def onboard(): else: save_config(Config()) console.print(f"[green]✓[/green] Created config at {config_path}") - + # Create workspace workspace = get_workspace_path() - + if not workspace.exists(): workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]✓[/green] Created workspace at {workspace}") - + # Create default bootstrap files _create_workspace_templates(workspace) - + console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") @@ -239,13 +238,13 @@ Information about the user goes here. - Language: (your preferred language) """, } - + for filename, content in templates.items(): file_path = workspace / filename if not file_path.exists(): file_path.write_text(content) console.print(f" [dim]Created {filename}[/dim]") - + # Create memory directory and MEMORY.md memory_dir = workspace / "memory" memory_dir.mkdir(exist_ok=True) @@ -268,7 +267,7 @@ This file stores important information that should persist across sessions. (Things to remember) """) console.print(" [dim]Created memory/MEMORY.md[/dim]") - + history_file = memory_dir / "HISTORY.md" if not history_file.exists(): history_file.write_text("") @@ -281,9 +280,9 @@ This file stores important information that should persist across sessions. def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" + from nanobot.providers.custom_provider import CustomProvider from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.providers.custom_provider import CustomProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) @@ -310,7 +309,7 @@ def _make_provider(config: Config): airllm_config = getattr(config.providers, "airllm", None) model_path = None compression = None - + # Try to get model from airllm config's api_key field (repurposed as model path) # or from the default model if airllm_config and airllm_config.api_key: @@ -325,7 +324,7 @@ def _make_provider(config: Config): else: model_path = model hf_token = None - + # Check for compression setting in extra_headers or api_base if airllm_config: if airllm_config.api_base: @@ -335,7 +334,7 @@ def _make_provider(config: Config): # Check for HF token in extra_headers if not hf_token and airllm_config.extra_headers and "hf_token" in airllm_config.extra_headers: hf_token = airllm_config.extra_headers["hf_token"] - + return AirLLMProvider( api_key=airllm_config.api_key if airllm_config else None, api_base=compression if compression else None, @@ -375,30 +374,30 @@ def gateway( verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """Start the nanobot gateway.""" - from nanobot.config.loader import load_config, get_data_dir - from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.session.manager import SessionManager + from nanobot.config.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService - + from nanobot.session.manager import SessionManager + if verbose: import logging logging.basicConfig(level=logging.DEBUG) - + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - + config = load_config() bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - + # Create cron service first (callback set after agent creation) cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) - + # Create agent with cron service agent = AgentLoop( bus=bus, @@ -415,8 +414,11 @@ def gateway( restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, mcp_servers=config.tools.mcp_servers, + tool_profiles=config.tools.tool_profiles, + default_tool_profile=config.tools.default_tool_profile, + tool_routing=config.tools.tool_routing, ) - + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" @@ -449,33 +451,33 @@ def gateway( )) return response cron.on_job = on_cron_job - + # Create heartbeat service async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" return await agent.process_direct(prompt, session_key="heartbeat") - + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, interval_s=30 * 60, # 30 minutes enabled=True ) - + # Create channel manager channels = ChannelManager(config, bus) - + if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") - + cron_status = cron.status() if cron_status["jobs"] > 0: console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") - - console.print(f"[green]✓[/green] Heartbeat: every 30m") - + + console.print("[green]✓[/green] Heartbeat: every 30m") + async def run(): try: await cron.start() @@ -492,7 +494,7 @@ def gateway( cron.stop() agent.stop() await channels.stop_all() - + asyncio.run(run()) @@ -511,15 +513,16 @@ def agent( logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" - from nanobot.config.loader import load_config, get_data_dir - from nanobot.bus.queue import MessageBus - from nanobot.agent.loop import AgentLoop - from nanobot.cron.service import CronService from loguru import logger - + + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + from nanobot.config.loader import get_data_dir, load_config + from nanobot.cron.service import CronService + # Load config (this also loads .env file into environment) config = load_config() - + bus = MessageBus() provider = _make_provider(config) @@ -531,7 +534,7 @@ def agent( logger.enable("nanobot") else: logger.disable("nanobot") - + agent_loop = AgentLoop( bus=bus, provider=provider, @@ -546,8 +549,11 @@ def agent( cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, + tool_profiles=config.tools.tool_profiles, + default_tool_profile=config.tools.default_tool_profile, + tool_routing=config.tools.tool_routing, ) - + # Show spinner when logs are off (no output to miss); skip when logs are on def _thinking_ctx(): if logs: @@ -573,7 +579,7 @@ def agent( console.print(f"[red]Error: {e}[/red]") console.print(f"[dim]{traceback.format_exc()}[/dim]") raise - + asyncio.run(run_once()) else: # Interactive mode @@ -586,7 +592,7 @@ def agent( os._exit(0) signal.signal(signal.SIGINT, _exit_on_sigint) - + async def run_interactive(): try: while True: @@ -601,7 +607,7 @@ def agent( _restore_terminal() console.print("\nGoodbye!") break - + with _thinking_ctx(): response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) @@ -615,7 +621,7 @@ def agent( break finally: await agent_loop.close_mcp() - + asyncio.run(run_interactive()) @@ -672,7 +678,7 @@ def channels_status(): "✓" if mc.enabled else "✗", mc_base ) - + # Telegram tg = config.channels.telegram tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" @@ -698,57 +704,57 @@ def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" import shutil import subprocess - + # User's bridge location user_bridge = Path.home() / ".nanobot" / "bridge" - + # Check if already built if (user_bridge / "dist" / "index.js").exists(): return user_bridge - + # Check for npm if not shutil.which("npm"): console.print("[red]npm not found. Please install Node.js >= 18.[/red]") raise typer.Exit(1) - + # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - + source = None if (pkg_bridge / "package.json").exists(): source = pkg_bridge elif (src_bridge / "package.json").exists(): source = src_bridge - + if not source: console.print("[red]Bridge source not found.[/red]") console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) - + console.print(f"{__logo__} Setting up bridge...") - + # Copy to user directory user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): shutil.rmtree(user_bridge) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - + # Install and build try: console.print(" Installing dependencies...") subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - + console.print(" Building...") subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - + console.print("[green]✓[/green] Bridge ready\n") except subprocess.CalledProcessError as e: console.print(f"[red]Build failed: {e}[/red]") if e.stderr: console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") raise typer.Exit(1) - + return user_bridge @@ -756,18 +762,19 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess + from nanobot.config.loader import load_config - + config = load_config() bridge_dir = _get_bridge_dir() - + console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") - + env = {**os.environ} if config.channels.whatsapp.bridge_token: env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token - + try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) except subprocess.CalledProcessError as e: @@ -791,23 +798,23 @@ def cron_list( """List scheduled jobs.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + jobs = service.list_jobs(include_disabled=all) - + if not jobs: console.print("No scheduled jobs.") return - + table = Table(title="Scheduled Jobs") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Schedule") table.add_column("Status") table.add_column("Next Run") - + import time from datetime import datetime as _dt from zoneinfo import ZoneInfo @@ -819,7 +826,7 @@ def cron_list( sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") else: sched = "one-time" - + # Format next run next_run = "" if job.state.next_run_at_ms: @@ -829,11 +836,11 @@ def cron_list( next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") except Exception: next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - + table.add_row(job.id, job.name, sched, status, next_run) - + console.print(table) @@ -853,7 +860,7 @@ def cron_add( from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule - + if tz and not cron_expr: console.print("[red]Error: --tz can only be used with --cron[/red]") raise typer.Exit(1) @@ -870,10 +877,10 @@ def cron_add( else: console.print("[red]Error: Must specify --every, --cron, or --at[/red]") raise typer.Exit(1) - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.add_job( name=name, schedule=schedule, @@ -882,7 +889,7 @@ def cron_add( to=to, channel=channel, ) - + console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") @@ -893,10 +900,10 @@ def cron_remove( """Remove a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + if service.remove_job(job_id): console.print(f"[green]✓[/green] Removed job {job_id}") else: @@ -911,10 +918,10 @@ def cron_enable( """Enable or disable a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.enable_job(job_id, enabled=not disable) if job: status = "disabled" if disable else "enabled" @@ -931,15 +938,15 @@ def cron_run( """Manually run a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + async def run(): return await service.run_job(job_id, force=force) - + if asyncio.run(run()): - console.print(f"[green]✓[/green] Job executed") + console.print("[green]✓[/green] Job executed") else: console.print(f"[red]Failed to run job {job_id}[/red]") @@ -952,7 +959,7 @@ def cron_run( @app.command() def status(): """Show nanobot status.""" - from nanobot.config.loader import load_config, get_config_path + from nanobot.config.loader import get_config_path, load_config config_path = get_config_path() config = load_config() @@ -967,7 +974,7 @@ def status(): from nanobot.providers.registry import PROVIDERS console.print(f"Model: {config.agents.defaults.model}") - + # Check API keys from registry for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 46d8d0a..09537b3 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,7 +1,8 @@ """Configuration schema using Pydantic.""" from pathlib import Path -from pydantic import BaseModel, Field, ConfigDict + +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings @@ -270,6 +271,26 @@ class MCPServerConfig(Base): url: str = "" # HTTP: streamable HTTP endpoint URL +class ToolProfileConfig(Base): + """Subset of tools exposed to the LLM when this profile is active.""" + + description: str = "" # Shown to the router model when toolRouting is enabled + builtin_tools: list[str] | None = None # None = all non-MCP tools; [] = none (except always-include) + mcp_servers: list[str] | None = None # None = all configured MCP servers; [] = no MCP tools + + +class ToolRoutingConfig(Base): + """Optional LLM router that picks a tool profile from the user message (phase 2).""" + + enabled: bool = False + router_temperature: float = 0.2 + router_max_tokens: int = 128 + # Always merged into the allowed set (if registered), e.g. channel reply + subagent spawn + always_include_tools: list[str] = Field(default_factory=lambda: ["message", "spawn"]) + # If the model calls a missing tool, retry the loop once with all tools registered + expand_on_missing_tool: bool = True + + class ToolsConfig(Base): """Tools configuration.""" @@ -278,6 +299,19 @@ class ToolsConfig(Base): calendar: CalendarConfig = Field(default_factory=CalendarConfig) restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) + tool_profiles: dict[str, ToolProfileConfig] = Field(default_factory=dict) + default_tool_profile: str = "default" + tool_routing: ToolRoutingConfig = Field(default_factory=ToolRoutingConfig) + + @model_validator(mode="after") + def _tool_profiles_consistent(self) -> "ToolsConfig": + if self.tool_profiles and self.default_tool_profile not in self.tool_profiles: + raise ValueError( + f"defaultToolProfile '{self.default_tool_profile}' is missing from tools.toolProfiles" + ) + if self.tool_routing.enabled and not self.tool_profiles: + raise ValueError("toolRouting.enabled requires a non-empty tools.toolProfiles map") + return self class Config(BaseSettings): From 7901f090f9bc77b894646dad8fb3327809195858 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 30 Mar 2026 13:20:38 -0400 Subject: [PATCH 2/5] Add per-agent workspaces and MCP/skills backlog doc - Add agent_workspaces/{ilia,family,wife} skeletons (AGENTS, USER, SOUL, memory) - Add scripts/init-agent-workspaces.sh to populate ~/.nanobot/workspaces/ - Mount ~/.nanobot/workspaces/{ilia,family,wife} in multi compose as /workspace - Document Step 0 and layout in DOCKER_MULTI_BOT_GUIDE.md - Track docs/mcp_and_skills_backlog.md (force-add; docs/ is gitignored) Made-with: Cursor --- DOCKER_MULTI_BOT_GUIDE.md | 47 +- agent_workspaces/README.md | 30 ++ agent_workspaces/family/AGENTS.md | 14 + agent_workspaces/family/SOUL.md | 7 + agent_workspaces/family/USER.md | 8 + agent_workspaces/family/memory/HISTORY.md | 3 + agent_workspaces/family/memory/MEMORY.md | 5 + agent_workspaces/ilia/AGENTS.md | 14 + agent_workspaces/ilia/SOUL.md | 7 + agent_workspaces/ilia/USER.md | 7 + agent_workspaces/ilia/memory/HISTORY.md | 3 + agent_workspaces/ilia/memory/MEMORY.md | 5 + agent_workspaces/wife/AGENTS.md | 11 + agent_workspaces/wife/SOUL.md | 7 + agent_workspaces/wife/USER.md | 6 + agent_workspaces/wife/memory/HISTORY.md | 3 + agent_workspaces/wife/memory/MEMORY.md | 5 + docker-compose.multi.dev.yml | 4 + docker-compose.multi.env.yml | 4 + docker-compose.multi.yml | 18 + docs/mcp_and_skills_backlog.md | 573 ++++++++++++++++++++++ scripts/init-agent-workspaces.sh | 49 ++ 22 files changed, 823 insertions(+), 7 deletions(-) create mode 100644 agent_workspaces/README.md create mode 100644 agent_workspaces/family/AGENTS.md create mode 100644 agent_workspaces/family/SOUL.md create mode 100644 agent_workspaces/family/USER.md create mode 100644 agent_workspaces/family/memory/HISTORY.md create mode 100644 agent_workspaces/family/memory/MEMORY.md create mode 100644 agent_workspaces/ilia/AGENTS.md create mode 100644 agent_workspaces/ilia/SOUL.md create mode 100644 agent_workspaces/ilia/USER.md create mode 100644 agent_workspaces/ilia/memory/HISTORY.md create mode 100644 agent_workspaces/ilia/memory/MEMORY.md create mode 100644 agent_workspaces/wife/AGENTS.md create mode 100644 agent_workspaces/wife/SOUL.md create mode 100644 agent_workspaces/wife/USER.md create mode 100644 agent_workspaces/wife/memory/HISTORY.md create mode 100644 agent_workspaces/wife/memory/MEMORY.md create mode 100644 docs/mcp_and_skills_backlog.md create mode 100755 scripts/init-agent-workspaces.sh diff --git a/DOCKER_MULTI_BOT_GUIDE.md b/DOCKER_MULTI_BOT_GUIDE.md index 2361421..7fa4bfc 100644 --- a/DOCKER_MULTI_BOT_GUIDE.md +++ b/DOCKER_MULTI_BOT_GUIDE.md @@ -59,20 +59,28 @@ Each bot runs in its own Docker container with: ``` nanobot/ ├── .env.shared # Shared settings (API keys, model, etc.) -├── .env.user1 # Bot 1 overrides -├── .env.user2 # Bot 2 overrides -├── .env.user3 # Bot 3 overrides +├── .env.user1 # Bot 1 (@ilia) overrides +├── .env.user2 # Bot 2 (@family) overrides +├── .env.user3 # Bot 3 (@wife) overrides +├── agent_workspaces/ # Templates copied by scripts/init-agent-workspaces.sh +├── scripts/init-agent-workspaces.sh ├── docker-compose.multi.env.yml # Production compose file ├── docker-compose.multi.dev.yml # Development compose file │ -└── ~/.nanobot-user1/ - └── config.json # Bot 1 channel config (Telegram token, allowFrom) -└── ~/.nanobot-user2/ - └── config.json # Bot 2 channel config +├── ~/.nanobot/workspaces/ +│ ├── ilia/ # Mounted as /workspace for user1 — AGENTS.md, memory/, … +│ ├── family/ # user2 +│ └── wife/ # user3 +├── ~/.nanobot-user1/ +│ └── config.json # Bot 1 channel config (Telegram token, allowFrom) +├── ~/.nanobot-user2/ +│ └── config.json # Bot 2 channel config └── ~/.nanobot-user3/ └── config.json # Bot 3 channel config ``` +`./workspace` in the repo remains for **single-bot** `docker-compose.yml` only; multi-bot uses `~/.nanobot/workspaces/*` per container. + ### Configuration Loading 1. **Docker Compose** loads environment files: @@ -91,6 +99,31 @@ nanobot/ ## Setup Instructions +### Step 0: Per-agent workspaces (personalities + isolated memory) + +Multi-bot compose mounts **separate** workspace directories so each bot has its own `AGENTS.md`, `SOUL.md`, `USER.md`, and `memory/` (no shared `./workspace`). + +On the host, from the repo root: + +```bash +./scripts/init-agent-workspaces.sh +``` + +This creates: + +``` +~/.nanobot/workspaces/ + ilia/ # nanobot-user1 — dev / infra persona + family/ # nanobot-user2 — household persona + wife/ # nanobot-user3 — personal assistant persona +``` + +Templates live in-repo under `agent_workspaces/`. Re-run the script anytime: it **skips** files that already exist. Adjust ownership if Docker runs as root: + +```bash +sudo chown -R "$(whoami):$(whoami)" ~/.nanobot/workspaces +``` + ### Step 1: Create Environment Files Run the setup script: diff --git a/agent_workspaces/README.md b/agent_workspaces/README.md new file mode 100644 index 0000000..9711a41 --- /dev/null +++ b/agent_workspaces/README.md @@ -0,0 +1,30 @@ +# Agent workspace skeletons + +These directories are **templates** for per-agent workspaces on the host: + +`~/.nanobot/workspaces/ilia/` +`~/.nanobot/workspaces/family/` +`~/.nanobot/workspaces/wife/` + +Each contains bootstrap files (`AGENTS.md`, `USER.md`, `SOUL.md`) and `memory/` (`MEMORY.md`, `HISTORY.md`) loaded by nanobot’s `ContextBuilder` and `MemoryStore`. + +## Initialise on the host + +From the repo root (after clone): + +```bash +chmod +x scripts/init-agent-workspaces.sh +./scripts/init-agent-workspaces.sh +``` + +Override destination root (default `$HOME/.nanobot`): + +```bash +NANOBOT_HOME=/path/to/.nanobot ./scripts/init-agent-workspaces.sh +``` + +The script **does not overwrite** existing files so you can safely re-run after editing. + +## Docker + +Multi-bot compose mounts each path into `/workspace` in the matching container. See `DOCKER_MULTI_BOT_GUIDE.md`. diff --git a/agent_workspaces/family/AGENTS.md b/agent_workspaces/family/AGENTS.md new file mode 100644 index 0000000..78446fc --- /dev/null +++ b/agent_workspaces/family/AGENTS.md @@ -0,0 +1,14 @@ +# @family — Agent instructions + +You are the **family** assistant: shared calendar, household coordination, and kid- or home-related questions. + +## Scope +- Schedules, reminders, and “what’s this week” style questions. +- Simple web lookups (school, activities, recipes) when tools allow. +- Warm, inclusive language for all family members. + +## Out of scope +- Production servers, SSH, Proxmox, or source-code repositories unless explicitly asked by an adult and tools are available. + +## Tone +Friendly, organized, patient. Offer clear summaries and next steps. diff --git a/agent_workspaces/family/SOUL.md b/agent_workspaces/family/SOUL.md new file mode 100644 index 0000000..1c05ed5 --- /dev/null +++ b/agent_workspaces/family/SOUL.md @@ -0,0 +1,7 @@ +# Personality — @family + +**Voice:** Warm, clear, and reassuring. Good with busy parents and kids’ contexts. + +**Values:** Inclusivity, clarity on dates/times, respect for privacy between family members where relevant. + +**Avoid:** Cold or corporate tone; assumption that everyone shares one email account. diff --git a/agent_workspaces/family/USER.md b/agent_workspaces/family/USER.md new file mode 100644 index 0000000..f881775 --- /dev/null +++ b/agent_workspaces/family/USER.md @@ -0,0 +1,8 @@ +# User profile — Family + +This workspace represents the **household** (not one individual). List members, ages if relevant, schools, and recurring commitments. + +## Edit this file +- Family members and how you refer to them. +- Default calendar names or shared inboxes (if any). +- Anything the agent should know for scheduling and coordination. diff --git a/agent_workspaces/family/memory/HISTORY.md b/agent_workspaces/family/memory/HISTORY.md new file mode 100644 index 0000000..3af5dd8 --- /dev/null +++ b/agent_workspaces/family/memory/HISTORY.md @@ -0,0 +1,3 @@ +# Event log — Family + +Append-only style log for this household agent. diff --git a/agent_workspaces/family/memory/MEMORY.md b/agent_workspaces/family/memory/MEMORY.md new file mode 100644 index 0000000..d46ca3a --- /dev/null +++ b/agent_workspaces/family/memory/MEMORY.md @@ -0,0 +1,5 @@ +# Long-term memory — Family + +Household-level facts (recurring events, preferences, school names). **Do not store secrets** (passwords, full IDs). + +_Empty placeholder — add bullet facts here over time._ diff --git a/agent_workspaces/ilia/AGENTS.md b/agent_workspaces/ilia/AGENTS.md new file mode 100644 index 0000000..bf66933 --- /dev/null +++ b/agent_workspaces/ilia/AGENTS.md @@ -0,0 +1,14 @@ +# @ilia — Agent instructions + +You are the personal assistant for **Ilia**. You focus on development, homelab infrastructure, code review, and technical research. + +## Scope +- Software development (Gitea, PRs, issues, shell, git) and clear technical explanations. +- Homelab / Proxmox / networking when those tools are available. +- Email and calendar for Ilia’s accounts when configured. + +## Tone +Concise, accurate, and direct. Prefer actionable steps over long preambles. + +## Tools +Use nanobot tools as configured for this instance. Do not assume tools that are not in your tool list. diff --git a/agent_workspaces/ilia/SOUL.md b/agent_workspaces/ilia/SOUL.md new file mode 100644 index 0000000..1298f6e --- /dev/null +++ b/agent_workspaces/ilia/SOUL.md @@ -0,0 +1,7 @@ +# Personality — @ilia + +**Voice:** Technical, calm, efficient. Short paragraphs. No fluff. + +**Values:** Correctness, security-minded defaults, reproducible steps. + +**Avoid:** Unnecessary apologies, over-explaining basic concepts unless asked. diff --git a/agent_workspaces/ilia/USER.md b/agent_workspaces/ilia/USER.md new file mode 100644 index 0000000..2441e2f --- /dev/null +++ b/agent_workspaces/ilia/USER.md @@ -0,0 +1,7 @@ +# User profile — Ilia + +**Name:** Ilia +**Role:** Primary operator of this nanobot stack; dev and infra. + +## Edit this file +Add preferences, timezone, important contacts, repos, and anything this agent should remember about *you* (not generic assistant behavior — that belongs in `SOUL.md` / `AGENTS.md`). diff --git a/agent_workspaces/ilia/memory/HISTORY.md b/agent_workspaces/ilia/memory/HISTORY.md new file mode 100644 index 0000000..d7ac4b4 --- /dev/null +++ b/agent_workspaces/ilia/memory/HISTORY.md @@ -0,0 +1,3 @@ +# Event log — Ilia + +Append-only style log. Search with grep when recalling past events. diff --git a/agent_workspaces/ilia/memory/MEMORY.md b/agent_workspaces/ilia/memory/MEMORY.md new file mode 100644 index 0000000..3e1b0f7 --- /dev/null +++ b/agent_workspaces/ilia/memory/MEMORY.md @@ -0,0 +1,5 @@ +# Long-term memory — Ilia + +Facts and preferences worth keeping across sessions. The agent may update this file when you confirm something should be remembered. + +_Empty placeholder — add bullet facts here over time._ diff --git a/agent_workspaces/wife/AGENTS.md b/agent_workspaces/wife/AGENTS.md new file mode 100644 index 0000000..da3f53b --- /dev/null +++ b/agent_workspaces/wife/AGENTS.md @@ -0,0 +1,11 @@ +# @wife — Agent instructions + +You are the personal assistant for **Ilia’s wife**. Focus on her calendar, email (when connected), daily tasks, and practical lookups. + +## Scope +- Scheduling, reminders, messages, and life-admin tasks. +- Summaries of mail or web pages when tools allow. +- Respectful, private handling of personal topics. + +## Tone +Supportive and efficient. Match the user’s formality preferences over time. diff --git a/agent_workspaces/wife/SOUL.md b/agent_workspaces/wife/SOUL.md new file mode 100644 index 0000000..478d6ed --- /dev/null +++ b/agent_workspaces/wife/SOUL.md @@ -0,0 +1,7 @@ +# Personality — @wife + +**Voice:** Friendly, attentive, and tactful. + +**Values:** Privacy, accuracy on appointments and commitments, gentle reminders. + +**Avoid:** Dismissive or overly technical jargon unless the user prefers it. diff --git a/agent_workspaces/wife/USER.md b/agent_workspaces/wife/USER.md new file mode 100644 index 0000000..5eefb6e --- /dev/null +++ b/agent_workspaces/wife/USER.md @@ -0,0 +1,6 @@ +# User profile — Wife + +**Name:** _(preferred name / how to address her)_ + +## Edit this file +Add preferences, timezone, health or routine notes *you are comfortable storing in plain text*, and communication preferences. diff --git a/agent_workspaces/wife/memory/HISTORY.md b/agent_workspaces/wife/memory/HISTORY.md new file mode 100644 index 0000000..47078d6 --- /dev/null +++ b/agent_workspaces/wife/memory/HISTORY.md @@ -0,0 +1,3 @@ +# Event log — Wife + +Append-only style log. Search with grep when recalling past events. diff --git a/agent_workspaces/wife/memory/MEMORY.md b/agent_workspaces/wife/memory/MEMORY.md new file mode 100644 index 0000000..2fc7cf1 --- /dev/null +++ b/agent_workspaces/wife/memory/MEMORY.md @@ -0,0 +1,5 @@ +# Long-term memory — Wife + +Facts and preferences worth keeping across sessions. The agent may update when you confirm. + +_Empty placeholder — add bullet facts here over time._ diff --git a/docker-compose.multi.dev.yml b/docker-compose.multi.dev.yml index 41ef6f9..7cd0095 100644 --- a/docker-compose.multi.dev.yml +++ b/docker-compose.multi.dev.yml @@ -1,3 +1,4 @@ +# user1=@ilia, user2=@family, user3=@wife — workspaces ~/.nanobot/workspaces/{ilia,family,wife} # Development version - mounts source code for live updates # Use this when developing nanobot code # Changes to nanobot/ directory will be picked up automatically (may need container restart) @@ -15,6 +16,7 @@ services: - .env.user1 volumes: - ~/.nanobot-user1:/root/.nanobot + - ~/.nanobot/workspaces/ilia:/workspace # Mount source code for development (changes picked up immediately) - ./nanobot:/app/nanobot:ro # Read-only mount (safer) # Or use this for read-write (if you edit inside container): @@ -42,6 +44,7 @@ services: - .env.user2 volumes: - ~/.nanobot-user2:/root/.nanobot + - ~/.nanobot/workspaces/family:/workspace - ./nanobot:/app/nanobot:ro ports: - "18791:18790" @@ -66,6 +69,7 @@ services: - .env.user3 volumes: - ~/.nanobot-user3:/root/.nanobot + - ~/.nanobot/workspaces/wife:/workspace - ./nanobot:/app/nanobot:ro ports: - "18792:18790" diff --git a/docker-compose.multi.env.yml b/docker-compose.multi.env.yml index 9c6d47c..1b7d99b 100644 --- a/docker-compose.multi.env.yml +++ b/docker-compose.multi.env.yml @@ -1,3 +1,4 @@ +# Multi-bot: user1 = @ilia, user2 = @family, user3 = @wife (see ~/.nanobot/workspaces/* and scripts/init-agent-workspaces.sh). # Using separate env files per container: # - .env.shared: Common settings (API keys, model, etc.) - loaded first # - .env.user1, .env.user2, .env.user3: Bot-specific overrides - loaded after @@ -16,6 +17,7 @@ services: - .env.user1 # Bot-specific overrides (loaded second, overrides shared) volumes: - ~/.nanobot-user1:/root/.nanobot + - ~/.nanobot/workspaces/ilia:/workspace ports: - "18790:18790" deploy: @@ -39,6 +41,7 @@ services: - .env.user2 # Bot-specific overrides (loaded second, overrides shared) volumes: - ~/.nanobot-user2:/root/.nanobot + - ~/.nanobot/workspaces/family:/workspace ports: - "18791:18790" deploy: @@ -62,6 +65,7 @@ services: - .env.user3 # Bot-specific overrides (loaded second, overrides shared) volumes: - ~/.nanobot-user3:/root/.nanobot + - ~/.nanobot/workspaces/wife:/workspace ports: - "18792:18790" deploy: diff --git a/docker-compose.multi.yml b/docker-compose.multi.yml index 914758d..4cc46aa 100644 --- a/docker-compose.multi.yml +++ b/docker-compose.multi.yml @@ -1,3 +1,6 @@ +# Multi-bot: nanobot-user1 = @ilia, user2 = @family, user3 = @wife. +# Each container uses ~/.nanobot/workspaces// → /workspace (run scripts/init-agent-workspaces.sh first). + services: nanobot-user1: build: @@ -6,8 +9,13 @@ services: container_name: nanobot-user1 command: ["gateway"] restart: unless-stopped + env_file: + - .env.shared + - .env.user1 volumes: - ~/.nanobot-user1:/root/.nanobot + # @ilia — isolated workspace + memory (host: ~/.nanobot/workspaces/ilia) + - ~/.nanobot/workspaces/ilia:/workspace ports: - "18790:18790" deploy: @@ -26,8 +34,13 @@ services: container_name: nanobot-user2 command: ["gateway"] restart: unless-stopped + env_file: + - .env.shared + - .env.user2 volumes: - ~/.nanobot-user2:/root/.nanobot + # @family — isolated workspace + memory + - ~/.nanobot/workspaces/family:/workspace ports: - "18791:18790" deploy: @@ -46,8 +59,13 @@ services: container_name: nanobot-user3 command: ["gateway"] restart: unless-stopped + env_file: + - .env.shared + - .env.user3 volumes: - ~/.nanobot-user3:/root/.nanobot + # @wife — isolated workspace + memory + - ~/.nanobot/workspaces/wife:/workspace ports: - "18792:18790" deploy: diff --git a/docs/mcp_and_skills_backlog.md b/docs/mcp_and_skills_backlog.md new file mode 100644 index 0000000..84ba698 --- /dev/null +++ b/docs/mcp_and_skills_backlog.md @@ -0,0 +1,573 @@ +# MCP Integrations & Skills Backlog + +> **Living document** — update this file as items are implemented, reprioritized, or new candidates emerge. +> +> Last updated: 2026-03-30 + +--- + +## Table of Contents + +1. [Current State](#current-state) +2. [Security: Local-Clone Policy](#security-local-clone-policy) +3. [Shortlist — Next Phase](#shortlist--next-phase) +4. [Backlog — Later](#backlog--later) +5. [Skill Catalog](#skill-catalog) +6. [Phase 1 Priorities](#phase-1-priorities) +7. [Implementation Notes](#implementation-notes) + +--- + +## Current State + +| Category | What we have today | +|---|---| +| **Built-in tools** | `filesystem` (read/write/edit/list), `exec` (shell), `web` (search + fetch), `message`, `spawn`, `cron`, `email` (IMAP), `calendar` (Google Calendar via built-in tool) | +| **MCP servers** | 1 connected — Gmail MCP (`@gongrzhe/server-gmail-autoauth-mcp`, stdio/npx). See [docs/gmail_mcp_setup.md](gmail_mcp_setup.md). | +| **Skills** | 10 bundled in `nanobot/skills/`: `github`, `gitea`, `calendar`, `cron`, `weather`, `summarize`, `tmux`, `clawhub`, `skill-creator`, `memory` | +| **Agent architecture** | 3 named agents, each running as a **separate Docker container** with its own workspace, personality, and memory (Option B). See below. | +| **Config schema** | `tools.mcpServers` → `MCPServerConfig` (stdio or HTTP), `tools.toolProfiles` → `ToolProfileConfig` can further filter tools within a single agent. See `nanobot/config/schema.py`. | + +### Agent Workspaces + +Each agent is a separate nanobot instance (Docker container) with an isolated workspace under `~/.nanobot/workspaces/`. The workspace contains bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`) that define the agent's personality and instructions, plus a `memory/` directory for long-term memory that is private to that agent. + +``` +~/.nanobot/workspaces/ +├── ilia/ # @ilia — personal dev, infra, research +│ ├── AGENTS.md # Dev/infra-focused instructions +│ ├── USER.md # Ilia's profile, preferences +│ ├── SOUL.md # Personality: technical, concise +│ └── memory/ +│ └── MEMORY.md +├── family/ # @family — shared household agent +│ ├── AGENTS.md # Family scheduling, coordination +│ ├── USER.md # Family members, kids' info +│ ├── SOUL.md # Personality: warm, organized +│ └── memory/ +│ └── MEMORY.md +└── wife/ # @wife — personal assistant for wife + ├── AGENTS.md # Personal tasks, calendar, email + ├── USER.md # Wife's profile, preferences + ├── SOUL.md # Personality: friendly, helpful + └── memory/ + └── MEMORY.md +``` + +Each container mounts its workspace and its own `config.json` (with agent-specific MCP servers, channels, and `allowFrom` lists). Compose service names are `nanobot-user1` … `user3`. + +| Service | Persona | Config dir | Workspace (host → `/workspace`) | Typical channels | +|---|---|---|---|---| +| `nanobot-user1` | @ilia | `~/.nanobot-user1/` | `~/.nanobot/workspaces/ilia` | Telegram, email (Ilia) | +| `nanobot-user2` | @family | `~/.nanobot-user2/` | `~/.nanobot/workspaces/family` | Family Telegram | +| `nanobot-user3` | @wife | `~/.nanobot-user3/` | `~/.nanobot/workspaces/wife` | Telegram, email (wife) | + +_Use `scripts/init-agent-workspaces.sh` to create the three workspace trees under `~/.nanobot/workspaces/`._ + +--- + +## Security: Local-Clone Policy + +All new MCP servers are **cloned locally** into the repository rather than fetched at runtime from npm/PyPI registries. This gives us: + +- **Audit control** — we can review every line before running it. +- **Reproducibility** — pinned commits, no surprise upstream updates. +- **Air-gap friendliness** — works on isolated networks after initial clone. + +### Directory layout + +``` +nanobot/ +├── mcp-servers/ # <-- NEW: local MCP server clones +│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp +│ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-mcp +│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox +│ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser +├── nanobot/ +├── docs/ +└── ... +``` + +### Config pattern (local stdio) + +```jsonc +{ + "tools": { + "mcpServers": { + "gitea": { + "command": "./mcp-servers/gitea-mcp/gitea-mcp", + "args": ["--token", "$NANOBOT_GITLE_TOKEN", "--url", "http://10.0.30.169:3000"], + "env": {} + } + } + } +} +``` + +Each server's README in `mcp-servers//` documents build steps, required env vars, and update procedure. + +--- + +## Shortlist — Next Phase + +These are the 4 MCP servers we plan to integrate in the immediate next phase. Each entry is detailed enough to create implementation tickets directly. + +--- + +### S1. Gitea MCP + +| Field | Detail | +|---|---| +| **Upstream** | `gitea.com/gitea/gitea-mcp` (official, Go, v1.0.2, 56 stars, Apache-2.0) | +| **Transport** | Stdio (recommended) or SSE | +| **Auth** | Gitea personal-access token — reuse existing `$NANOBOT_GITLE_TOKEN` | +| **Complexity** | **Low** — token and network route to `http://10.0.30.169:3000` already exist | +| **Replaces** | Current curl-based `gitea` skill and hardcoded API commands in `AGENTS.md` | +| **Target agents** | `@ilia` only (dev tooling; not exposed to `@family` or `@wife`) | + +#### User stories + +- **US-G1**: As `@ilia`, I can say "list open PRs on nanobot" and get a formatted summary without writing curl commands. +- **US-G2**: As `@ilia`, I can say "search code for `MCPServerConfig`" and the agent returns matching files and lines from Gitea. +- **US-G3**: As `@ilia`, I can say "create an issue titled 'Add Proxmox MCP' with label `enhancement`" and the agent creates it in Gitea. +- **US-G4**: As `@ilia`, I can say "show diff for PR #42" and get a readable summary of changes. + +#### Technical notes + +- **Build**: Go 1.24+. Clone, `go build`, produces single binary `gitea-mcp`. +- **Local clone path**: `mcp-servers/gitea-mcp/` +- **Config entry**: + ```jsonc + "gitea": { + "command": "./mcp-servers/gitea-mcp/gitea-mcp", + "args": [], + "env": { + "GITEA_URL": "http://10.0.30.169:3000", + "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" + } + } + ``` +- **Expected tool names**: `mcp_gitea_list_repos`, `mcp_gitea_search_code`, `mcp_gitea_create_issue`, `mcp_gitea_list_pulls`, etc. +- **Safety**: Read operations are safe. Issue/PR creation and file writes should require user confirmation via tool-profile constraints. + +--- + +### S2. Google Calendar MCP + +| Field | Detail | +|---|---| +| **Upstream** | `github.com/nspady/google-calendar-mcp` (TypeScript, v2.6.1, 1071 stars, MIT) | +| **Transport** | Stdio via `node` | +| **Auth** | Google OAuth2 (same pattern as Gmail MCP — credentials in `~/.gmail-mcp/`) | +| **Complexity** | **Medium** — OAuth flow is already a solved pattern from Gmail MCP setup; multi-calendar config adds small overhead | +| **Complements** | Existing built-in `calendar` tool; MCP version adds multi-calendar, recurring events, and free/busy queries | +| **Target agents** | All three — `@ilia`, `@family`, `@wife` (each with their own calendar scope) | + +#### User stories + +- **US-C1**: As `@family`, I can ask "what's on the family calendar this week?" and get a merged view of all family members' events. +- **US-C2**: As `@ilia`, I can say "find a free 1-hour slot tomorrow afternoon" and the agent checks busy/free across my calendars. +- **US-C3**: As `@family`, I can say "add 'Soccer practice' to the family calendar on Saturday at 10am" and it creates the event. +- **US-C4**: As `@ilia`, I can say "reschedule my 2pm meeting to 4pm" and the agent updates the event after confirmation. +- **US-C5**: As `@wife`, I can say "what do I have on Thursday?" and see only events on my personal calendar. + +#### Technical notes + +- **Build**: `npm install` in cloned repo, run via `node dist/index.js`. +- **Local clone path**: `mcp-servers/google-calendar-mcp/` +- **OAuth setup**: Same Google Cloud project as Gmail MCP. Enable Calendar API, reuse existing OAuth client. Token stored alongside Gmail tokens. +- **Config entry**: + ```jsonc + "google_calendar": { + "command": "node", + "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], + "env": { + "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" + } + } + ``` +- **Expected tool names**: `mcp_google_calendar_list_events`, `mcp_google_calendar_create_event`, `mcp_google_calendar_freebusy`, `mcp_google_calendar_update_event`, `mcp_google_calendar_delete_event` +- **Migration path**: Phase out built-in `calendar` tool once MCP version is validated. Keep both available during transition via tool profiles. + +--- + +### S3. Proxmox MCP + +| Field | Detail | +|---|---| +| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) | +| **Transport** | Stdio via `python -m mcp_proxmox` | +| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) | +| **Complexity** | **Medium** — requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping | +| **New capability** | Homelab infrastructure visibility and management from chat | +| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) | + +#### User stories + +- **US-P1**: As `@ilia`, I can say "show me the status of all VMs" and get a table of names, states, CPU, and RAM usage. +- **US-P2**: As `@ilia`, I can say "how much storage is left on the cluster?" and get aggregate numbers. +- **US-P3**: As `@ilia`, I can say "restart the dev-runner VM" and the agent does so after asking for confirmation. +- **US-P4**: As `@ilia`, I can say "take a snapshot of the nanobot VM before I upgrade" and the agent creates a named snapshot. + +#### Technical notes + +- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv. +- **Local clone path**: `mcp-servers/mcp-proxmox/` +- **Proxmox setup**: + 1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`). + 2. Assign minimum roles: `PVEAuditor` for read-only, `PVEVMAdmin` for lifecycle ops (Phase 1 starts read-only). + 3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file. +- **Config entry**: + ```jsonc + "proxmox": { + "command": "python", + "args": ["-m", "mcp_proxmox"], + "env": { + "PROXMOX_HOST": "https://10.0.30.1:8006", + "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", + "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", + "PROXMOX_VERIFY_SSL": "false" + } + } + ``` +- **Expected tool names**: `mcp_proxmox_list_nodes`, `mcp_proxmox_list_vms`, `mcp_proxmox_list_containers`, `mcp_proxmox_vm_status`, `mcp_proxmox_start_vm`, `mcp_proxmox_stop_vm`, `mcp_proxmox_create_snapshot`, `mcp_proxmox_list_storage` +- **Safety**: Phase 1 deploys with `PVEAuditor` role (read-only). Write operations (start/stop/snapshot) added in Phase 2 behind confirmation prompts. Restricted to `@ilia` profile only — never exposed to `@family`. + +--- + +### S4. Web Fetch / Scraping MCP + +| Field | Detail | +|---|---| +| **Upstream** | `github.com/TheSethRose/Fetch-Browser` (TypeScript, headless Chromium, MIT) | +| **Alt candidate** | `github.com/odgrim/mcp-fetch` (TypeScript, Puppeteer, simpler) | +| **Transport** | Stdio via `node` | +| **Auth** | None — no API keys required | +| **Complexity** | **Low** — clone, `npm install`, run; headless Chromium bundled by Puppeteer/Playwright | +| **Augments** | Built-in `web_fetch` tool (which does basic HTTP GET without JS rendering) | +| **Target agents** | All three — `@ilia`, `@family`, `@wife` | + +#### User stories + +- **US-W1**: As `@ilia`, I can say "fetch the Proxmox release notes page and summarize what's new" and the agent renders the JS-heavy page and extracts content. +- **US-W2**: As `@family`, I can say "get the lunch menu from the school website" and the agent scrapes the dynamically loaded content. +- **US-W3**: As `@ilia`, I can say "grab the pricing table from this SaaS page" and get structured data back. +- **US-W4**: As `@wife`, I can say "find me the best-rated recipe for lasagna" and the agent fetches and summarizes real recipe pages. + +#### Technical notes + +- **Build**: `npm install` in cloned repo. +- **Local clone path**: `mcp-servers/fetch-browser/` +- **Config entry**: + ```jsonc + "web_scraper": { + "command": "node", + "args": ["./mcp-servers/fetch-browser/dist/index.js"], + "env": {} + } + ``` +- **Expected tool names**: `mcp_web_scraper_fetch_url`, `mcp_web_scraper_search_google`, `mcp_web_scraper_screenshot` +- **Resource note**: Headless Chromium uses ~200–400 MB RAM per instance. Consider setting a process timeout or pool limit. +- **Safety**: Read-only by nature. No write side-effects. Safe for both `@ilia` and `@family`. + +--- + +## Backlog — Later + +Items below are future candidates, not yet scheduled. Grouped by domain. Each includes a candidate upstream project where one exists. + +### Family / Life + +| # | Integration | Upstream candidate | Notes | +|---|---|---|---| +| B-F1 | **CalDAV MCP** | `github.com/dominik1001/caldav-mcp` (Python, v0.4.0) | Universal calendar protocol. Enables Nextcloud, iCloud, ownCloud calendars. Useful if family moves off Google. | +| B-F2 | **Shared Todo / Household Tasks MCP** | `github.com/thijs-hakkenberg/mcp_todo` (Python, git-backed) | Git-backed collaborative task list with assignees, due dates, priorities, Kanban web UI, and Telegram bot. Good fit for family chores and grocery lists. | +| B-F3 | **Microsoft To Do MCP** | `github.com/akkilesh-a/microsoft-todo-mcp-server-self-hosted` (TypeScript) | Self-hosted HTTP transport. 15 tools for full task CRUD. Only relevant if family adopts Microsoft ecosystem. | +| B-F4 | **Home Assistant MCP** | TBD (community projects emerging) | Smart home control — lights, thermostat, locks, sensors. Requires Home Assistant instance on LAN. | +| B-F5 | **Shared Documents MCP** | TBD (Nextcloud WebDAV or Google Drive MCP) | Access family shared documents, photos, notes from chat. | + +### Research + +| # | Integration | Upstream candidate | Notes | +|---|---|---|---| +| B-R1 | **PDF RAG MCP** | `github.com/wesleygriffin/pdfrag` (Python, ChromaDB + sentence-transformers) | Semantic search over PDF papers. OCR support for scanned docs. Persistent vector index. | +| B-R2 | **Knowledge Base / Notes RAG MCP** | `github.com/alejandro-ao/RAG-MCP` (Python, FastMCP + ChromaDB) | Ingest markdown notes, docs, slides. Query with natural language. Supports LlamaParse for multi-format ETL. | +| B-R3 | **Zotero / Reference Manager MCP** | TBD | If user manages academic references in Zotero. Would expose library search, citation export, PDF retrieval. | +| B-R4 | **Arxiv / Semantic Scholar MCP** | TBD (API wrappers exist) | Direct paper search and metadata retrieval from academic APIs. | + +### Dev / Infra + +| # | Integration | Upstream candidate | Notes | +|---|---|---|---| +| B-D1 | **Filesystem MCP** | `github.com/mark3labs/mcp-filesystem-server` (Go, 622 stars) | Richer file ops than nanobot built-in (search, diff, metadata, copy trees). Useful for workspace automation. | +| B-D2 | **Docker / Portainer MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Portainer) | Container lifecycle, image management, compose operations. | +| B-D3 | **CI/CD Pipeline MCP** | TBD (Gitea Actions API or Drone) | Query pipeline status, trigger builds, view logs. Partially achievable through Gitea MCP's API. | +| B-D4 | **Logs & Monitoring MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Grafana, Uptime Kuma) | Query Grafana dashboards, check uptime monitors, search Loki logs. | +| B-D5 | **Backup Status MCP** | TBD (Proxmox Backup Server API or restic wrapper) | Check last backup timestamps, success/failure, storage usage. Could be a thin wrapper skill rather than full MCP. | +| B-D6 | **Database MCP** | TBD (PostgreSQL / SQLite MCP servers exist) | Run read-only queries against app databases for debugging and reporting. | + +--- + +## Skill Catalog + +Skills are higher-level task patterns that compose one or more tools (built-in or MCP) into a reusable workflow. Each skill lives as a `SKILL.md` in `nanobot/skills//` and is loaded by the skills system. + +Because agents are **separate containers with separate workspaces**, a skill is available to an agent only if (a) the skill file is present in that workspace's `skills/` dir or in the shared bundled skills, and (b) the MCP servers it depends on are configured in that agent's `config.json`. + +### Legend + +| Column | Meaning | +|---|---| +| **Skill** | Natural-language trigger name | +| **Description** | What the skill does | +| **MCP deps** | Which MCP servers must be connected in the agent's config | +| **Built-in deps** | Which nanobot built-in tools are also needed | +| **Target agents** | Which agent containers should have this skill deployed (`@ilia`, `@family`, `@wife`) | +| **Safety tier** | `read-only` / `write-confirm` (mutates after user confirmation) / `admin` (restricted + confirm) | + +--- + +### Scheduling Skills + +| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier | +|---|---|---|---|---|---| +| **Plan my week** | List events across all calendars for the next 7 days, highlight conflicts, suggest time blocks for focus work | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only | +| **Reschedule meeting** | Find a specific event, propose 3 alternative conflict-free times, update the event after user picks one | Google Calendar MCP | — | `@ilia`, `@wife` | write-confirm | +| **Find conflict-free times** | Query free/busy across calendars for a given duration and date range, return available slots | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only | + +### Email Skills + +| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier | +|---|---|---|---|---|---| +| **Triage inbox** | Fetch unread emails, categorize by urgency (action-required / FYI / low-priority), surface top action items | Gmail MCP | `read_emails` | `@ilia`, `@wife` | read-only | +| **Draft replies** | For each action-required email, generate a draft reply. Present drafts for user approval before sending | Gmail MCP | — | `@ilia`, `@wife` | write-confirm | +| **Summarize today's mail** | Produce a concise digest of all emails received today, grouped by sender or topic | Gmail MCP | `read_emails` | `@ilia`, `@family`, `@wife` | read-only | + +### Research Skills + +| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier | +|---|---|---|---|---|---| +| **Find relevant papers** | Web-search for papers on a given topic, fetch top results, return title + abstract + URL for each | Web Fetch MCP | `web_search` | `@ilia` | read-only | +| **Summarize URL/PDF** | Fetch a URL (with JS rendering if needed) or read a local PDF, produce a structured summary | Web Fetch MCP | `read_file` | `@ilia`, `@family`, `@wife` | read-only | +| **Generate experiment checklist** | Given a goal description, produce a structured checklist of steps, tools needed, and success criteria | — | — | `@ilia` | read-only | + +### Infra Skills + +| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier | +|---|---|---|---|---|---| +| **Show VM status** | List all VMs/containers across Proxmox nodes with state, CPU%, RAM%, and uptime | Proxmox MCP | — | `@ilia` | read-only | +| **Restart non-critical service** | Stop and start a VM by name, but only if it is tagged `non-critical`. Refuse if tagged `critical`. Requires confirmation | Proxmox MCP | — | `@ilia` | admin | +| **Summarize cluster resources** | Aggregate CPU, RAM, and storage usage across all Proxmox nodes, flag any node above 80% utilization | Proxmox MCP | — | `@ilia` | read-only | +| **Pre-upgrade snapshot** | Before a maintenance window, create a named snapshot of specified VMs. Requires confirmation | Proxmox MCP | — | `@ilia` | admin | + +### Dev Skills + +| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier | +|---|---|---|---|---|---| +| **Summarize open PRs** | List all open PRs on the nanobot repo with title, author, age, review status, and CI state | Gitea MCP | — | `@ilia` | read-only | +| **Triage Gitea issues** | Fetch open issues, group by label, suggest priority ordering based on age and activity | Gitea MCP | — | `@ilia` | read-only | +| **Search codebase** | Search Gitea-hosted code for a symbol or string pattern, return matching files and line numbers | Gitea MCP | — | `@ilia` | read-only | +| **Create issue from chat** | Turn a conversation excerpt into a well-formatted Gitea issue with title, description, and labels. Requires confirmation | Gitea MCP | — | `@ilia` | write-confirm | + +--- + +## Phase 1 Priorities + +These are the items we commit to implementing first, chosen for maximum daily value with manageable complexity. + +### Phase 1 MCP Integrations + +| Priority | MCP Server | Rationale | +|---|---|---| +| **P1** | **Gitea MCP** | Directly replaces fragile curl-based Gitea access scattered across `AGENTS.md` and the `gitea` skill. Token and network route already exist. Aligns with daily dev workflow — PRs, issues, code search are used every day. | +| **P2** | **Google Calendar MCP** | Complements the existing built-in `calendar` tool with multi-calendar views and free/busy queries. OAuth is already a solved pattern from Gmail MCP. Deployed to all three agents — `@ilia` (work calendar), `@family` (shared family calendar), `@wife` (personal calendar). | +| **P3** | **Proxmox MCP** | Homelab infrastructure is checked frequently but currently requires opening the Proxmox web UI. Starting with read-only (`PVEAuditor`) makes it safe to deploy immediately. Write ops follow in a later phase. | + +### Phase 1 Skills + +| Priority | Skill | MCP dep | Agents | Safety | Why first | +|---|---|---|---|---|---| +| **S1** | Summarize open PRs | Gitea MCP | `@ilia` | read-only | Used daily; validates Gitea MCP end-to-end | +| **S2** | Plan my week | Google Calendar MCP | `@ilia`, `@family`, `@wife` | read-only | High value for every agent; validates Calendar MCP | +| **S3** | Triage inbox | Gmail MCP (already live) | `@ilia`, `@wife` | read-only | Formalizes an existing ad-hoc pattern; no new MCP needed | +| **S4** | Show VM status | Proxmox MCP | `@ilia` | read-only | Safe first infra skill; validates Proxmox MCP | +| **S5** | Summarize today's mail | Gmail MCP (already live) | `@ilia`, `@family`, `@wife` | read-only | Daily value for all agents; no new MCP needed | + +--- + +## Implementation Notes + +### Local clone workflow + +```bash +# One-time setup +mkdir -p mcp-servers && cd mcp-servers + +# Gitea MCP (Go) +git clone https://gitea.com/gitea/gitea-mcp.git +cd gitea-mcp && go build -o gitea-mcp . && cd .. + +# Google Calendar MCP (TypeScript) +git clone https://github.com/nspady/google-calendar-mcp.git +cd google-calendar-mcp && npm install && npm run build && cd .. + +# Proxmox MCP (Python) +git clone https://github.com/antonio-mello-ai/mcp-proxmox.git +cd mcp-proxmox && pip install -e . && cd .. + +# Fetch Browser (TypeScript) +git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser +cd fetch-browser && npm install && npm run build && cd .. +``` + +To update a server: `cd mcp-servers/ && git pull && `. Pin to a known-good commit with `git checkout ` for production stability. + +### Per-agent MCP wiring + +Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. An agent only gets the MCP servers listed in its config -- no routing needed. + +**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers): + +```jsonc +{ + "tools": { + "mcpServers": { + "gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] }, + "gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" } }, + "google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } }, + "proxmox": { "command": "python", "args": ["-m", "mcp_proxmox"], "env": { "PROXMOX_HOST": "https://10.0.30.1:8006", "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", "PROXMOX_VERIFY_SSL": "false" } }, + "web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} } + } + } +} +``` + +**`~/.nanobot-user2/config.json`** (@family — scheduling + web only, no dev/infra): + +```jsonc +{ + "tools": { + "mcpServers": { + "google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } }, + "web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} } + } + } +} +``` + +**`~/.nanobot-user3/config.json`** (@wife — email + calendar + web, no dev/infra): + +```jsonc +{ + "tools": { + "mcpServers": { + "gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] }, + "google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } }, + "web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} } + } + } +} +``` + +**MCP server allocation summary:** + +| MCP Server | `@ilia` | `@family` | `@wife` | +|---|---|---|---| +| Gmail MCP | yes | -- | yes | +| Gitea MCP | yes | -- | -- | +| Google Calendar MCP | yes | yes | yes | +| Proxmox MCP | yes | -- | -- | +| Web Fetch MCP | yes | yes | yes | + +Key points: +- `@family` and `@wife` never see Gitea or Proxmox tools -- those MCP servers are simply absent from their configs. +- `@family` has no email MCP (it's a shared household bot, not tied to one inbox). It still has the built-in `calendar` and `web` tools. +- Each container spawns its own MCP server processes via stdio from the shared `mcp-servers/` directory (mounted read-only into all containers). + +### Safety tiers + +| Tier | Behavior | Implementation | +|---|---|---| +| **read-only** | Tool executes immediately, no confirmation prompt | Default for query/list/search operations | +| **write-confirm** | Agent presents a summary of what it will do, waits for user "yes" before executing | Enforced in SKILL.md instructions: "Before calling `create_event`, show the user the details and ask for confirmation" | +| **admin** | Same as write-confirm but tool is only available in the `@ilia` container | Enforced by omitting the MCP server from other agents' `config.json` + SKILL.md confirmation instructions | + +With separate containers, the strongest security boundary is **not configuring an MCP server at all** in an agent's config. Proxmox and Gitea are never in `@family` or `@wife` configs, so those agents physically cannot call those tools. + +Phase 1 deploys **only read-only skills**. Write skills (draft replies, reschedule meeting, create issue, restart VM) are Phase 2 once we validate the read paths. + +### Skill file template + +New skills follow the existing format in `nanobot/skills/`: + +```markdown +--- +name: summarize-open-prs +description: "List and summarize all open pull requests on the nanobot Gitea repo." +metadata: {"nanobot":{"emoji":"📋","requires":{"mcp":["gitea"]}}} +--- + +# Summarize Open PRs + +## When to use +User asks about open PRs, pending reviews, or code review status. + +## Steps +1. Call `mcp_gitea_list_pulls` with state=open. +2. For each PR, extract: title, author, created date, review status, CI status. +3. Format as a numbered list sorted by age (oldest first). +4. Highlight PRs with no reviews or failing CI. + +## Safety +Read-only. No confirmation needed. +``` + +### Docker considerations + +All three containers (`nanobot-user1`, `nanobot-user2`, `nanobot-user3`) share the same Docker image. MCP server processes are spawned inside each container as needed. The Dockerfile must include: +- **Go** (for Gitea MCP binary — or copy pre-built binary) +- **Node.js 18+** (for Calendar MCP and Fetch Browser) +- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar) +- **Chromium** (for Fetch Browser headless rendering — `npx puppeteer browsers install chrome` or use Playwright) + +The `mcp-servers/` directory is mounted read-only into all containers so each agent can spawn the MCP servers listed in its config. Alternatively, build MCP binaries in a multi-stage Docker build and copy only the artifacts into the image. + +**Volume mounts (per container)** — compose services remain `nanobot-user1` / `user2` / `user3`; they map to `@ilia` / `@family` / `@wife` workspaces. + +```yaml +nanobot-user1: # @ilia + volumes: + - ~/.nanobot-user1:/root/.nanobot + - ~/.nanobot/workspaces/ilia:/workspace + # Optional: ./mcp-servers:/app/mcp-servers:ro + +nanobot-user2: # @family + volumes: + - ~/.nanobot-user2:/root/.nanobot + - ~/.nanobot/workspaces/family:/workspace + +nanobot-user3: # @wife + volumes: + - ~/.nanobot-user3:/root/.nanobot + - ~/.nanobot/workspaces/wife:/workspace +``` + +### Rollout sequence + +``` +Week 1: Clone repos, build locally, verify each MCP server starts and lists tools +Week 2: Wire Gitea MCP + "Summarize open PRs" skill, validate end-to-end +Week 3: Wire Calendar MCP + "Plan my week" skill, formalize "Triage inbox" skill +Week 4: Wire Proxmox MCP (read-only) + "Show VM status" skill +Week 5: Add "Summarize today's mail" skill, integrate Web Fetch MCP +Week 6: Retrospective, update this document, plan Phase 2 write-skills +``` + +--- + +## Changelog + +| Date | Change | +|---|---| +| 2026-03-30 | Updated to reflect multi-container workspace architecture (Option B). Added `@wife` as third agent. Rewrote per-agent MCP wiring with separate config.json per container. Updated skill assignments across all three agents. | +| 2026-03-30 | Initial version — shortlist (4 MCP), backlog (16 ideas), skill catalog (16 skills), Phase 1 defined (3 MCP + 5 skills) | diff --git a/scripts/init-agent-workspaces.sh b/scripts/init-agent-workspaces.sh new file mode 100755 index 0000000..853cb5d --- /dev/null +++ b/scripts/init-agent-workspaces.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Create ~/.nanobot/workspaces/{ilia,family,wife}/ from repo templates (Option B). +# Does not overwrite existing files — safe to re-run. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NANOBOT_HOME="${NANOBOT_HOME:-$HOME/.nanobot}" +DEST="${NANOBOT_HOME}/workspaces" +SKEL="${REPO_ROOT}/agent_workspaces" + +if [[ ! -d "${SKEL}/ilia" ]]; then + echo "error: missing ${SKEL}/ilia — run from nanobot repo root" >&2 + exit 1 +fi + +install_skel() { + local agent="$1" + local d="${DEST}/${agent}" + mkdir -p "${d}/memory" + for f in AGENTS.md USER.md SOUL.md; do + if [[ ! -f "${d}/${f}" ]]; then + cp "${SKEL}/${agent}/${f}" "${d}/${f}" + echo "created ${d}/${f}" + else + echo "skip (exists): ${d}/${f}" + fi + done + for f in MEMORY.md HISTORY.md; do + if [[ ! -f "${d}/memory/${f}" ]]; then + cp "${SKEL}/${agent}/memory/${f}" "${d}/memory/${f}" + echo "created ${d}/memory/${f}" + else + echo "skip (exists): ${d}/memory/${f}" + fi + done +} + +echo "NANOBOT_HOME=${NANOBOT_HOME}" +echo "DEST=${DEST}" +mkdir -p "${DEST}" + +for agent in ilia family wife; do + echo "--- ${agent} ---" + install_skel "${agent}" +done + +echo "done. Fix ownership if needed, e.g.:" +echo " sudo chown -R \"\$(whoami):\$(whoami)\" \"${DEST}\"" From a6bd3e0e9b7c07b37ce211f2cd85efc371a3069b Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 30 Mar 2026 13:27:46 -0400 Subject: [PATCH 3/5] Agent: tool profiles and profile-scoped MCP connections - Extend tool profile helpers for MCP server key resolution and filtering - Lazily connect/disconnect MCP servers per active profile in AgentLoop - Harden MCP client (timeouts, tool naming, connect_mcp_server entry) - Adjust context and tool modules to align with profile-aware tooling - docker-compose: minor gateway/workspace notes Made-with: Cursor --- docker-compose.yml | 2 + nanobot/agent/context.py | 8 +- nanobot/agent/loop.py | 114 +++++++++++++++---- nanobot/agent/tool_profiles.py | 31 ++++- nanobot/agent/tools/email.py | 16 ++- nanobot/agent/tools/filesystem.py | 16 ++- nanobot/agent/tools/mcp.py | 182 ++++++++++++++++++++---------- 7 files changed, 278 insertions(+), 91 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5c27f81..6835300 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,8 @@ x-common-config: &common-config dockerfile: Dockerfile volumes: - ~/.nanobot:/root/.nanobot + # Host repo ./workspace → /workspace in container. Set agents.defaults.workspace to /workspace. + - ./workspace:/workspace services: nanobot-gateway: diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 8861d30..b19764b 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -101,6 +101,12 @@ Your workspace is at: {workspace_path} - History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md +**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**—no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory. + +**Answering after tools:** When a tool already returned what the user needs, base your reply **only on that tool output**—same topic as the user’s question, no hijacking. +- After **`list_dir`:** If they asked for PDFs (or another extension), list **only** matching names (paths under `{workspace_path}` if useful). If none, say so briefly. No essays, no calling the folder "code" unless they asked for analysis. +- After **`read_emails`:** Answer **only** from the email text the tool returned (From, Subject, Date, attachments, downloaded paths, body as needed). Do **not** switch to unrelated topics (Git, Gitea, this repo, workspace docs, coding help, general chit-chat). Do **not** apologize at length or describe "what an email is". Match the question: e.g. “latest email” → sender + subject (+ date) in a few lines unless they asked for the full body. + ## Gitea API (This Repository) **CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub. - Repository: `ilia/nanobot` @@ -120,7 +126,7 @@ Always be helpful, accurate, and concise. Before calling tools, briefly tell the When remembering something important, write to {workspace_path}/memory/MEMORY.md To recall past events, grep {workspace_path}/memory/HISTORY.md -IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails.""" +IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails. Once read_emails returns, your assistant reply must **only** satisfy that email question from the tool result—ignore Gitea/workspace/bootstrap content unless the user tied their question to it.""" def _load_bootstrap_files(self) -> str: """Load all bootstrap files from workspace.""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 636ab91..bd50a1a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -90,8 +90,8 @@ class AgentLoop: self._running = False self._mcp_servers = mcp_servers or {} - self._mcp_stack: AsyncExitStack | None = None - self._mcp_connected = False + self._mcp_stacks: dict[str, AsyncExitStack] = {} + self._mcp_connected_servers: set[str] = set() self._tool_profiles: dict = tool_profiles or {} self._default_tool_profile = default_tool_profile self._tool_routing = tool_routing or ToolRoutingConfig() @@ -135,7 +135,10 @@ class AgentLoop: from nanobot.config.loader import load_config config = load_config() if config.channels.email.enabled: - email_tool = EmailTool(email_config=config.channels.email) + email_tool = EmailTool( + email_config=config.channels.email, + workspace=self.workspace, + ) self.tools.register(email_tool) logger.info(f"Email tool '{email_tool.name}' registered successfully") else: @@ -159,15 +162,69 @@ class AgentLoop: logger.warning(f"Calendar tool not available: {e}") # Calendar tool not available or not configured - silently skip - async def _connect_mcp(self) -> None: - """Connect to configured MCP servers (one-time, lazy).""" - if self._mcp_connected or not self._mcp_servers: + def _unregister_mcp_tools_for_server(self, server_key: str) -> None: + """Remove tools registered from one MCP server (prefix mcp__).""" + prefix = f"mcp_{server_key}_" + for name in list(self.tools.tool_names): + if name.startswith(prefix): + self.tools.unregister(name) + + async def _disconnect_mcp_server(self, server_key: str) -> None: + """Close one MCP server and remove its tools (used when switching tool profiles).""" + stack = self._mcp_stacks.pop(server_key, None) + if stack is not None: + try: + await stack.aclose() + except (RuntimeError, BaseExceptionGroup): + pass + self._unregister_mcp_tools_for_server(server_key) + self._mcp_connected_servers.discard(server_key) + logger.info(f"MCP server '{server_key}': disconnected") + + async def _sync_mcp_to_profile_needs(self, needed_keys: list[str]) -> None: + """ + Ensure only MCP servers in needed_keys are connected: tear down extras, connect missing. + + When tools.toolProfiles is empty, pass the full configured key list so all servers stay up. + """ + if not self._mcp_servers: return - self._mcp_connected = True - from nanobot.agent.tools.mcp import connect_mcp_servers - self._mcp_stack = AsyncExitStack() - await self._mcp_stack.__aenter__() - await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack) + needed = set(needed_keys) + for key in list(self._mcp_connected_servers): + if key not in needed: + await self._disconnect_mcp_server(key) + connect_order = [k for k in self._mcp_servers.keys() if k in needed] + await self._ensure_mcp_servers_connected(connect_order) + + async def _ensure_mcp_servers_connected(self, server_keys: list[str]) -> None: + """Lazily connect MCP servers (each gets its own AsyncExitStack for per-server teardown).""" + if not self._mcp_servers or not server_keys: + return + pending = [ + k + for k in server_keys + if k in self._mcp_servers and k not in self._mcp_connected_servers + ] + if not pending: + return + + from nanobot.agent.tools.mcp import connect_mcp_server + + for key in pending: + stack = AsyncExitStack() + await stack.__aenter__() + try: + await connect_mcp_server( + key, self._mcp_servers[key], self.tools, stack + ) + self._mcp_stacks[key] = stack + self._mcp_connected_servers.add(key) + except Exception as e: + logger.error(f"MCP server '{key}': failed to connect: {e}") + try: + await stack.aclose() + except (RuntimeError, BaseExceptionGroup): + pass def _set_tool_context(self, channel: str, chat_id: str) -> None: """Update context for all tools that need routing info.""" @@ -255,26 +312,37 @@ class AgentLoop: final_content = None tools_used: list[str] = [] - from nanobot.agent.tool_profiles import compute_allowed_tool_names + from nanobot.agent.tool_profiles import ( + compute_allowed_tool_names, + mcp_keys_to_connect, + ) from nanobot.agent.tool_routing import is_tool_not_found_error - tools_full = self.tools.get_definitions() + configured_mcp = list(self._mcp_servers.keys()) tools_expanded = False allowed_names: set[str] | None = None + if self._tool_profiles: routing_text = self._extract_routing_text(initial_messages) profile_key = await self._pick_tool_profile(routing_text) prof = self._tool_profiles[profile_key] + await self._sync_mcp_to_profile_needs( + mcp_keys_to_connect(prof, configured_mcp) + ) always = set(self._tool_routing.always_include_tools) allowed_names = compute_allowed_tool_names( self.tools, prof, - list(self._mcp_servers.keys()), + configured_mcp, always, ) logger.info( f"Tool profile '{profile_key}': {len(allowed_names)}/{len(self.tools)} tools exposed" ) + else: + await self._sync_mcp_to_profile_needs(configured_mcp) + + tools_full = self.tools.get_definitions() while iteration < self.max_iterations: iteration += 1 @@ -338,6 +406,8 @@ class AgentLoop: and is_tool_not_found_error(result) ): tools_expanded = True + await self._sync_mcp_to_profile_needs(configured_mcp) + tools_full = self.tools.get_definitions() logger.info( "Expanded tool set to full registry (missing tool after profile filter)" ) @@ -358,7 +428,6 @@ class AgentLoop: async def run(self) -> None: """Run the agent loop, processing messages from the bus.""" self._running = True - await self._connect_mcp() logger.info("Agent loop started") while self._running: @@ -382,13 +451,13 @@ class AgentLoop: continue async def close_mcp(self) -> None: - """Close MCP connections.""" - if self._mcp_stack: - try: - await self._mcp_stack.aclose() - except (RuntimeError, BaseExceptionGroup): - pass # MCP SDK cancel scope cleanup is noisy but harmless - self._mcp_stack = None + """Close all MCP connections and drop MCP tools from the registry.""" + for key in list( + set(self._mcp_stacks.keys()) | self._mcp_connected_servers + ): + await self._disconnect_mcp_server(key) + self._mcp_stacks.clear() + self._mcp_connected_servers.clear() def stop(self) -> None: """Stop the agent loop.""" @@ -658,7 +727,6 @@ Respond with ONLY valid JSON, no markdown fences.""" Returns: The agent's response. """ - await self._connect_mcp() msg = InboundMessage( channel=channel, sender_id="user", diff --git a/nanobot/agent/tool_profiles.py b/nanobot/agent/tool_profiles.py index c1e9a4a..6cae3e1 100644 --- a/nanobot/agent/tool_profiles.py +++ b/nanobot/agent/tool_profiles.py @@ -4,9 +4,12 @@ from __future__ import annotations from typing import TYPE_CHECKING +from loguru import logger + +from nanobot.config.schema import ToolProfileConfig + if TYPE_CHECKING: from nanobot.agent.tools.registry import ToolRegistry - from nanobot.config.schema import ToolProfileConfig def mcp_server_for_tool(tool_name: str, mcp_server_keys: list[str]) -> str | None: @@ -26,6 +29,32 @@ def mcp_server_for_tool(tool_name: str, mcp_server_keys: list[str]) -> str | Non return None +def mcp_keys_to_connect( + profile: ToolProfileConfig, configured_mcp_keys: list[str] +) -> list[str]: + """ + Config keys for MCP servers to connect for this profile, in config order. + + None on profile.mcp_servers means all configured servers; [] means none. + Unknown keys in the profile list are logged and skipped. + """ + if not configured_mcp_keys: + return [] + configured_set = set(configured_mcp_keys) + if profile.mcp_servers is None: + return list(configured_mcp_keys) + out: list[str] = [] + for k in profile.mcp_servers: + if k in configured_set: + out.append(k) + else: + logger.warning( + f"tools.toolProfiles entry references unknown MCP server {k!r}; " + "not in tools.mcpServers keys" + ) + return out + + def compute_allowed_tool_names( registry: ToolRegistry, profile: ToolProfileConfig, diff --git a/nanobot/agent/tools/email.py b/nanobot/agent/tools/email.py index f73344d..8c429a9 100644 --- a/nanobot/agent/tools/email.py +++ b/nanobot/agent/tools/email.py @@ -3,6 +3,7 @@ import asyncio import imaplib import ssl +from pathlib import Path from datetime import date from email import policy from email.header import decode_header, make_header @@ -36,17 +37,20 @@ class EmailTool(Tool): "unread_only (bool, default false), mark_seen (bool, default false), download_attachments (bool, default false " "- set to true to download all attachments to workspace), attachment_name (string, optional - filter emails by " "attachment filename, case-insensitive partial match). Returns formatted email list with sender, subject, date, " - "attachments (if any), downloaded file paths (if downloaded), and body." + "attachments (if any), downloaded file paths (if downloaded), and body. After you receive this output, your " + "reply to the user must address their email question using only this data—no unrelated topics." ) - def __init__(self, email_config: Any = None): + def __init__(self, email_config: Any = None, workspace: Path | None = None): """ Initialize email tool with email configuration. Args: email_config: Optional EmailConfig instance. If None, loads from config. + workspace: Directory for downloaded attachments (defaults to config workspace_path). """ self._email_config = email_config + self._workspace = workspace @property def config(self) -> Any: @@ -315,8 +319,12 @@ class EmailTool(Tool): if download_attachments and attachments: import logging logger = logging.getLogger(__name__) - from pathlib import Path - workspace = Path("/mnt/data/nanobot/workspace") + if self._workspace is not None: + workspace = Path(self._workspace).expanduser().resolve() + else: + from nanobot.config.loader import load_config + + workspace = load_config().workspace_path.expanduser().resolve() workspace.mkdir(parents=True, exist_ok=True) # Build a map of attachment parts by decoded filename for efficient lookup diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bf451d9..e77e483 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -28,7 +28,9 @@ class ReadFileTool(Tool): @property def description(self) -> str: - return """Read the contents of a file at the given path. + return """Read the contents of a file at the given path. + +`path` must be a single file path under the configured workspace (no `*` globs). ALWAYS use this tool to read files - it supports: - Text files (plain text, code, markdown, etc.) @@ -44,7 +46,7 @@ For reading files, use read_file FIRST. Only use exec for complex data processin "properties": { "path": { "type": "string", - "description": "The file path to read" + "description": "Absolute or workspace-relative path to one file (no wildcards)", } }, "required": ["path"] @@ -115,7 +117,7 @@ class WriteFileTool(Tool): @property def description(self) -> str: - return "Write content to a file at the given path. Creates parent directories if needed. IMPORTANT: Always provide both 'path' and 'content' parameters. If no full path is specified, use the workspace directory (/mnt/data/nanobot/workspace/)." + return "Write content to a file at the given path. Creates parent directories if needed. IMPORTANT: Always provide both 'path' and 'content' parameters. Paths must be under the workspace root from the system prompt (no globs)." @property def parameters(self) -> dict[str, Any]: @@ -219,7 +221,11 @@ class ListDirTool(Tool): @property def description(self) -> str: - return "List the contents of a directory." + return ( + "List files and subfolders in one directory. " + "`path` must be a directory that exists under the workspace root—no `*` or `*.pdf` wildcards. " + "To list PDFs, list the directory and read names ending in .pdf, or use exec with find." + ) @property def parameters(self) -> dict[str, Any]: @@ -228,7 +234,7 @@ class ListDirTool(Tool): "properties": { "path": { "type": "string", - "description": "The directory path to list" + "description": "Path to an existing directory under the workspace (no wildcards)", } }, "required": ["path"] diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index d01c724..dec184f 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -1,5 +1,8 @@ """MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" +import asyncio +import json +import re from contextlib import AsyncExitStack from typing import Any @@ -9,15 +12,65 @@ from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry +_SAFE_TOOL_NAME_RE = re.compile(r"[^A-Za-z0-9_]+") + + +def _normalize_tool_segment(segment: str) -> str: + """ + Normalize MCP server/tool names into a safe function name segment. + + - Replace non [A-Za-z0-9_] with underscore + - Collapse repeated underscores + - Trim leading/trailing underscores + - Ensure non-empty + """ + s = _SAFE_TOOL_NAME_RE.sub("_", (segment or "").strip()) + s = re.sub(r"_+", "_", s).strip("_") + return s or "tool" + + +def _render_mcp_content_blocks(blocks: list[Any]) -> str: + """Render MCP content blocks into a stable, readable string.""" + from mcp import types + + parts: list[str] = [] + for block in blocks or []: + if isinstance(block, types.TextContent): + parts.append(block.text) + continue + + # Prefer structured JSON for non-text blocks when possible. + dump = getattr(block, "model_dump", None) + if callable(dump): + try: + parts.append(json.dumps(dump(), ensure_ascii=False, indent=2)) + continue + except Exception: + pass + + parts.append(str(block)) + return "\n".join([p for p in parts if p is not None]).strip() + + class MCPToolWrapper(Tool): """Wraps a single MCP server tool as a nanobot Tool.""" - def __init__(self, session, server_name: str, tool_def): + def __init__( + self, + session, + *, + server_key: str, + tool_def, + registered_name: str, + call_timeout_s: float = 30.0, + ): self._session = session self._original_name = tool_def.name - self._name = f"mcp_{server_name}_{tool_def.name}" + self._server_key = server_key + self._name = registered_name self._description = tool_def.description or tool_def.name self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} + self._call_timeout_s = call_timeout_s @property def name(self) -> str: @@ -32,71 +85,86 @@ class MCPToolWrapper(Tool): return self._parameters async def execute(self, **kwargs: Any) -> str: - from mcp import types - import json - result = await self._session.call_tool(self._original_name, arguments=kwargs) - parts = [] - for block in result.content: - if isinstance(block, types.TextContent): - parts.append(block.text) - else: - parts.append(str(block)) - output = "\n".join(parts) - - # For empty results from search/list operations, provide clearer feedback - if not output or output.strip() == "": - # Check if this is a search/list operation (common patterns) - if "search" in self._original_name.lower() or "list" in self._original_name.lower(): - if "unread" in str(kwargs).lower() or "is:unread" in str(kwargs).lower(): - return "No unread emails found." - return "No results found." - - # Try to parse JSON to check for empty arrays/lists + try: + result = await asyncio.wait_for( + self._session.call_tool(self._original_name, arguments=kwargs), + timeout=self._call_timeout_s, + ) + except asyncio.TimeoutError: + return ( + f"Error: MCP tool timed out after {self._call_timeout_s:.0f}s " + f"({self._server_key}:{self._original_name})" + ) + + output = _render_mcp_content_blocks(getattr(result, "content", [])) + if not output: + return "(no output)" + + # If the tool returned JSON, normalize empty collections to a clearer message. try: parsed = json.loads(output) - if isinstance(parsed, list) and len(parsed) == 0: - if "search" in self._original_name.lower() or "list" in self._original_name.lower(): - if "unread" in str(kwargs).lower() or "is:unread" in str(kwargs).lower(): - return "No unread emails found." - return "No results found." + if parsed == [] or parsed == {}: + return "No results found." except (json.JSONDecodeError, ValueError): pass # Not JSON, continue with original output - - return output or "(no output)" + + return output + + +async def connect_mcp_server( + name: str, cfg: Any, registry: ToolRegistry, stack: AsyncExitStack +) -> None: + """Connect one MCP server and register its tools (used for lazy profile-scoped connections).""" + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + if cfg.command: + params = StdioServerParameters( + command=cfg.command, args=cfg.args, env=cfg.env or None + ) + read, write = await stack.enter_async_context(stdio_client(params)) + elif cfg.url: + from mcp.client.streamable_http import streamable_http_client + + read, write, _ = await stack.enter_async_context( + streamable_http_client(cfg.url) + ) + else: + logger.warning(f"MCP server '{name}': no command or url configured, skipping") + return + + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + + tools = await session.list_tools() + for tool_def in tools.tools: + safe_server = _normalize_tool_segment(name) + safe_tool = _normalize_tool_segment(tool_def.name) + base = f"mcp_{safe_server}_{safe_tool}" + registered_name = base + i = 2 + while registry.has(registered_name): + registered_name = f"{base}_{i}" + i += 1 + + wrapper = MCPToolWrapper( + session, + server_key=name, + tool_def=tool_def, + registered_name=registered_name, + ) + registry.register(wrapper) + logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'") + + logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") async def connect_mcp_servers( mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack ) -> None: - """Connect to configured MCP servers and register their tools.""" - from mcp import ClientSession, StdioServerParameters - from mcp.client.stdio import stdio_client - + """Connect to every configured MCP server and register their tools.""" for name, cfg in mcp_servers.items(): try: - if cfg.command: - params = StdioServerParameters( - command=cfg.command, args=cfg.args, env=cfg.env or None - ) - read, write = await stack.enter_async_context(stdio_client(params)) - elif cfg.url: - from mcp.client.streamable_http import streamable_http_client - read, write, _ = await stack.enter_async_context( - streamable_http_client(cfg.url) - ) - else: - logger.warning(f"MCP server '{name}': no command or url configured, skipping") - continue - - session = await stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - - tools = await session.list_tools() - for tool_def in tools.tools: - wrapper = MCPToolWrapper(session, name, tool_def) - registry.register(wrapper) - logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'") - - logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") + await connect_mcp_server(name, cfg, registry, stack) except Exception as e: logger.error(f"MCP server '{name}': failed to connect: {e}") From 93b34bc214f9cd08ef55051a5aa9f42dfd073e65 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Mon, 30 Mar 2026 14:15:09 -0400 Subject: [PATCH 4/5] Enhance CalendarTool action handling - Introduce logic to set default action to "list_events" when `action` is omitted and only a single key-value pair is present. - Expand handling for various input scenarios to ensure correct action assignment based on known actions and input keys. - Improve robustness of the coerced dictionary to accommodate different input formats. Made-with: Cursor --- nanobot/agent/tools/calendar.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/calendar.py b/nanobot/agent/tools/calendar.py index 4943952..3f655ad 100644 --- a/nanobot/agent/tools/calendar.py +++ b/nanobot/agent/tools/calendar.py @@ -179,7 +179,34 @@ class CalendarTool(Tool): coerced["action"] = "list_events" if value == "calendar" else value coerced.pop(key, None) break - + + # Models often omit `action` and pass only a range ("this week", "1 week") under a junk key. + if not coerced.get("action"): + _known_actions = { + "list_events", + "create_event", + "delete_event", + "delete_events", + "update_event", + "check_availability", + "calendar", + } + _create_keys = {"title", "start_time", "end_time", "event_id", "event_ids", "description", "location", "attendees"} + if len(coerced) == 1: + only_k, only_v = next(iter(coerced.items())) + if only_k == "time_min" and isinstance(only_v, str): + coerced["action"] = "list_events" + elif isinstance(only_v, str) and only_k not in _create_keys: + if only_v in _known_actions: + coerced["action"] = "list_events" if only_v == "calendar" else only_v + coerced.pop(only_k, None) + else: + coerced = {"action": "list_events", "time_min": only_v} + else: + coerced["action"] = "list_events" + else: + coerced = {**coerced, "action": "list_events"} + return coerced @property From 7050e032e82f52c2bcf814018d5120fd71c6f761 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 31 Mar 2026 12:15:05 -0400 Subject: [PATCH 5/5] Improve MCP tool calling and routing Add explicit JSON tool-call protocol for local providers, improve parsing of JSON-only tool calls, and add heuristic routing to MCP-capable profiles for repo/PR intents. Also document and mount local-cloned MCP servers and expand MCP env var handling. Made-with: Cursor --- .gitignore | 5 ++ create-bot-configs.sh | 11 +++ docker-compose.multi.dev.yml | 4 + docker-compose.multi.env.yml | 4 + docker-compose.multi.yml | 4 + mcp-servers/.gitkeep | 1 + mcp-servers/README.md | 34 ++++++++ nanobot/agent/context.py | 16 ++++ nanobot/agent/loop.py | 19 +++++ nanobot/agent/tool_routing.py | 36 +++++++++ nanobot/agent/tools/mcp.py | 20 ++++- nanobot/providers/custom_provider.py | 72 ++++++++++++----- scripts/setup-mcp-servers.sh | 113 +++++++++++++++++++++++++++ 13 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 mcp-servers/.gitkeep create mode 100644 mcp-servers/README.md create mode 100755 scripts/setup-mcp-servers.sh diff --git a/.gitignore b/.gitignore index 7a059b3..f6c033e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ poetry.lock .pytest_cache/ botpy.log tests/ + +# Local-cloned MCP servers (kept out of git; clone/build locally) +mcp-servers/* +!mcp-servers/README.md +!mcp-servers/.gitkeep diff --git a/create-bot-configs.sh b/create-bot-configs.sh index 7df03c6..f725a73 100755 --- a/create-bot-configs.sh +++ b/create-bot-configs.sh @@ -18,6 +18,17 @@ cat > ~/.nanobot-user1/config.json << 'EOF' "enabled": true, "allowFrom": ["adayear2025@gmail.com"] } + }, + "tools": { + "mcpServers": { + "gitea": { + "command": "/app/mcp-servers/gitea-mcp/gitea-mcp", + "args": ["-t", "stdio", "--host", "http://10.0.30.169:3000", "-r"], + "env": { + "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" + } + } + } } } EOF diff --git a/docker-compose.multi.dev.yml b/docker-compose.multi.dev.yml index 7cd0095..c160ced 100644 --- a/docker-compose.multi.dev.yml +++ b/docker-compose.multi.dev.yml @@ -19,6 +19,8 @@ services: - ~/.nanobot/workspaces/ilia:/workspace # Mount source code for development (changes picked up immediately) - ./nanobot:/app/nanobot:ro # Read-only mount (safer) + # Local-cloned MCP servers (see scripts/setup-mcp-servers.sh) + - ./mcp-servers:/app/mcp-servers:ro # Or use this for read-write (if you edit inside container): # - ./nanobot:/app/nanobot ports: @@ -46,6 +48,7 @@ services: - ~/.nanobot-user2:/root/.nanobot - ~/.nanobot/workspaces/family:/workspace - ./nanobot:/app/nanobot:ro + - ./mcp-servers:/app/mcp-servers:ro ports: - "18791:18790" deploy: @@ -71,6 +74,7 @@ services: - ~/.nanobot-user3:/root/.nanobot - ~/.nanobot/workspaces/wife:/workspace - ./nanobot:/app/nanobot:ro + - ./mcp-servers:/app/mcp-servers:ro ports: - "18792:18790" deploy: diff --git a/docker-compose.multi.env.yml b/docker-compose.multi.env.yml index 1b7d99b..d19ad2f 100644 --- a/docker-compose.multi.env.yml +++ b/docker-compose.multi.env.yml @@ -18,6 +18,8 @@ services: volumes: - ~/.nanobot-user1:/root/.nanobot - ~/.nanobot/workspaces/ilia:/workspace + # Local-cloned MCP servers (see scripts/setup-mcp-servers.sh) + - ./mcp-servers:/app/mcp-servers:ro ports: - "18790:18790" deploy: @@ -42,6 +44,7 @@ services: volumes: - ~/.nanobot-user2:/root/.nanobot - ~/.nanobot/workspaces/family:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18791:18790" deploy: @@ -66,6 +69,7 @@ services: volumes: - ~/.nanobot-user3:/root/.nanobot - ~/.nanobot/workspaces/wife:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18792:18790" deploy: diff --git a/docker-compose.multi.yml b/docker-compose.multi.yml index 4cc46aa..a4178c7 100644 --- a/docker-compose.multi.yml +++ b/docker-compose.multi.yml @@ -16,6 +16,8 @@ services: - ~/.nanobot-user1:/root/.nanobot # @ilia — isolated workspace + memory (host: ~/.nanobot/workspaces/ilia) - ~/.nanobot/workspaces/ilia:/workspace + # Local-cloned MCP servers (see scripts/setup-mcp-servers.sh) + - ./mcp-servers:/app/mcp-servers:ro ports: - "18790:18790" deploy: @@ -41,6 +43,7 @@ services: - ~/.nanobot-user2:/root/.nanobot # @family — isolated workspace + memory - ~/.nanobot/workspaces/family:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18791:18790" deploy: @@ -66,6 +69,7 @@ services: - ~/.nanobot-user3:/root/.nanobot # @wife — isolated workspace + memory - ~/.nanobot/workspaces/wife:/workspace + - ./mcp-servers:/app/mcp-servers:ro ports: - "18792:18790" deploy: diff --git a/mcp-servers/.gitkeep b/mcp-servers/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mcp-servers/.gitkeep @@ -0,0 +1 @@ + diff --git a/mcp-servers/README.md b/mcp-servers/README.md new file mode 100644 index 0000000..059ccae --- /dev/null +++ b/mcp-servers/README.md @@ -0,0 +1,34 @@ +# Local MCP servers + +This repo uses a **local-clone policy** for MCP servers: clone upstream repos into `./mcp-servers/` and run them from disk (instead of fetching from npm/PyPI at runtime). + +## Gitea MCP + +- **Upstream**: `https://gitea.com/gitea/gitea-mcp.git` +- **Local path**: `mcp-servers/gitea-mcp/` +- **Binary**: `mcp-servers/gitea-mcp/gitea-mcp` + +Build it with: + +```bash +./scripts/setup-mcp-servers.sh gitea +``` + +Then configure nanobot (example): + +```jsonc +{ + "tools": { + "mcpServers": { + "gitea": { + "command": "./mcp-servers/gitea-mcp/gitea-mcp", + "args": ["-t", "stdio", "--host", "http://10.0.30.169:3000"], + "env": { + "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" + } + } + } + } +} +``` + diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index b19764b..5552027 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -89,6 +89,22 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you - Send messages to users on chat channels - Spawn subagents for complex background tasks +## Tool calling (IMPORTANT) +Some LLM backends may not support native function-calling. When you decide to use a tool, you MUST output a single JSON object in one of these formats (and no other surrounding text): + +1) Standard tool call: +{{"name":"","parameters":{{...}}}} + +2) Calendar shortcut (allowed only for the built-in `calendar` tool): +{{"action":"list_events", ...}} + +After a tool result is returned, respond normally in plain text unless the user asks for another tool action. + +### MCP quick mappings (use these when the intent matches) +- If the user asks for **my Gitea user info** (who am I / my profile / my account): call `mcp_gitea_get_me` with `{{}}`. +- If the user asks for **Gitea MCP server version**: call `mcp_gitea_get_gitea_mcp_server_version` with `{{}}`. +- If the user asks to **list my repos**: call `mcp_gitea_list_my_repos` with pagination defaults. + ## Current Time {now} ({tz}) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bd50a1a..c403c08 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -311,6 +311,7 @@ class AgentLoop: iteration = 0 final_content = None tools_used: list[str] = [] + empty_final_retry_used = False from nanobot.agent.tool_profiles import ( compute_allowed_tool_names, @@ -418,6 +419,24 @@ class AgentLoop: else: final_content = self._strip_think(response.content) logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}") + # Some local OpenAI-compatible backends occasionally return an empty assistant message. + # Retry once with an explicit nudge to either call a tool or answer in text. + if (not final_content or not final_content.strip()) and not empty_final_retry_used: + empty_final_retry_used = True + logger.warning( + "LLM returned empty final content; retrying once with a non-empty response nudge" + ) + messages = messages + [ + { + "role": "system", + "content": ( + "Your previous reply was empty. You MUST either (a) call an appropriate tool, " + "or (b) respond with a short helpful text answer. Do not return an empty message." + ), + } + ] + final_content = None + continue break if final_content is None and iteration >= self.max_iterations: diff --git a/nanobot/agent/tool_routing.py b/nanobot/agent/tool_routing.py index b9ffdc9..9dfc3f3 100644 --- a/nanobot/agent/tool_routing.py +++ b/nanobot/agent/tool_routing.py @@ -27,6 +27,42 @@ async def route_tool_profile( if not profiles: return default_profile + # Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos), + # prefer an MCP-enabled profile without spending an LLM call. + msg_l = (user_message or "").lower() + needs_forge = any( + k in msg_l + for k in [ + "pull request", + "pull requests", + "open pr", + "open prs", + " list prs", + "pr ", + "prs", + "merge request", + "issue", + "issues", + "gitea", + "repo", + "repository", + "branches", + "commits", + "tags", + "release", + ] + ) + if needs_forge: + # Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers. + for key in profiles.keys(): + if "mcp" in key.lower(): + logger.info(f"Tool router selected profile '{key}' (heuristic)") + return key + for key, p in profiles.items(): + if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0): + logger.info(f"Tool router selected profile '{key}' (heuristic)") + return key + lines = [] for name, p in profiles.items(): desc = (p.description or "").strip() or "(no description)" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index dec184f..0e000a2 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -3,6 +3,7 @@ import asyncio import json import re +import os from contextlib import AsyncExitStack from typing import Any @@ -118,9 +119,26 @@ async def connect_mcp_server( from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client + def _expand_env(env: dict[str, str]) -> dict[str, str]: + """ + Expand $VARS in cfg.env using the current process environment. + + This lets configs safely reference secrets that are already injected into the + container environment (e.g. via .env.shared), without duplicating them in JSON: + { "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" } + """ + if not env: + return {} + expanded: dict[str, str] = {} + for k, v in env.items(): + if v is None: + continue + expanded[k] = os.path.expandvars(str(v)) + return expanded + if cfg.command: params = StdioServerParameters( - command=cfg.command, args=cfg.args, env=cfg.env or None + command=cfg.command, args=cfg.args, env=_expand_env(cfg.env) or None ) read, write = await stack.enter_async_context(stdio_client(params)) elif cfg.url: diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index dadda1e..02cd966 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -59,15 +59,38 @@ class CustomProvider(LLMProvider): for tc in (msg.tool_calls or []) ] - # If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content) + # If no structured tool calls, try to parse from content (some OpenAI-compatible backends return JSON in content) # Only parse if content looks like it contains a tool call JSON (to avoid false positives) content = msg.content or "" + stripped = content.strip() + # Note: This list should match tools registered in AgentLoop._register_default_tools(). + # MCP tools are registered dynamically and are prefixed with "mcp_" (allow those too). + valid_tools = [ + # File tools + "read_file", "write_file", "edit_file", "list_dir", + # Shell tool + "exec", + # Web tools + "web_search", "web_fetch", + # Communication tools + "message", "spawn", + # Calendar tool + "calendar", + # Cron tool + "cron", + # Email tool + "email", + ] # Check for standard format: {"name": "...", "parameters": {...}} has_standard_format = '"name"' in content and '"parameters"' in content # Check for calendar tool format: {"action": "...", ...} has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"])) - if not tool_calls and content and (has_standard_format or has_calendar_format): + # Some backends will return *only* a JSON object as the entire message content. + # If it looks like a JSON object, attempt parsing even if our heuristics missed it. + looks_like_json_object = stripped.startswith("{") and stripped.endswith("}") + + if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object): import re # Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...} # Find complete JSON objects by matching braces @@ -131,28 +154,11 @@ class CustomProvider(LLMProvider): continue # Handle standard format: {"name": "...", "parameters": {...}} - # Note: This list should match tools registered in AgentLoop._register_default_tools() - valid_tools = [ - # File tools - "read_file", "write_file", "edit_file", "list_dir", - # Shell tool - "exec", - # Web tools - "web_search", "web_fetch", - # Communication tools - "message", "spawn", - # Calendar tool - "calendar", - # Cron tool - "cron", - # Email tool - "email", - ] if (isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj and isinstance(tool_obj["name"], str) and - tool_obj["name"] in valid_tools): + (tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_"))): tool_calls.append(ToolCallRequest( id=f"call_{len(tool_calls)}", name=tool_obj["name"], @@ -166,6 +172,32 @@ class CustomProvider(LLMProvider): pass # If parsing fails, skip this match start_pos = json_start + 1 # Move past this match + + # If we still didn't match embedded objects, try parsing the whole message as a single tool-call JSON object. + if not tool_calls and looks_like_json_object: + try: + tool_obj = json_repair.loads(stripped) + if isinstance(tool_obj, dict) and "action" in tool_obj: + action = tool_obj.get("action") + if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]: + tool_calls.append(ToolCallRequest( + id="call_0", + name="calendar", + arguments=tool_obj, + )) + content = "" + if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj: + if isinstance(tool_obj["name"], str) and ( + tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_") + ): + tool_calls.append(ToolCallRequest( + id="call_0", + name=tool_obj["name"], + arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])}, + )) + content = "" + except Exception: + pass u = response.usage return LLMResponse( diff --git a/scripts/setup-mcp-servers.sh b/scripts/setup-mcp-servers.sh new file mode 100755 index 0000000..ffdf0df --- /dev/null +++ b/scripts/setup-mcp-servers.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Clone/build local MCP servers into ./mcp-servers (local-clone policy). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MCP_DIR="${REPO_ROOT}/mcp-servers" + +usage() { + cat <<'EOF' +usage: + ./scripts/setup-mcp-servers.sh gitea + +notes: + - clones into ./mcp-servers/ + - builds artifacts needed to run the MCP server locally +EOF +} + +need_cmd() { + local cmd="$1" + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "error: missing '${cmd}' on PATH" >&2 + return 1 + fi +} + +need_go_min() { + local want_major="$1" + local want_minor="$2" + + local v + v="$(go version 2>/dev/null || true)" + # Example: "go version go1.26.0 linux/amd64" + local ver + ver="$(echo "${v}" | awk '{print $3}' | sed 's/^go//')" + local major minor + major="$(echo "${ver}" | cut -d. -f1)" + minor="$(echo "${ver}" | cut -d. -f2)" + + if [[ -z "${major}" || -z "${minor}" ]]; then + echo "error: could not parse Go version from: ${v}" >&2 + return 1 + fi + + # Compare major/minor only (sufficient for our use). + if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then + echo "error: Go ${want_major}.${want_minor}+ required; found ${ver}" >&2 + return 1 + fi +} + +setup_gitea() { + need_cmd git + + if ! command -v go >/dev/null 2>&1; then + cat <<'EOF' >&2 +error: Go toolchain not found (required to build gitea-mcp). + +install one of: + - Debian/Ubuntu: sudo apt-get update && sudo apt-get install -y golang + - Or install Go from https://go.dev/dl/ + +then rerun: + ./scripts/setup-mcp-servers.sh gitea +EOF + exit 2 + fi + + if ! need_go_min 1 26; then + cat <<'EOF' >&2 + +gitea-mcp currently requires a newer Go toolchain than Debian stable typically ships. +If you already installed a newer Go under /usr/local (example: /usr/local/go1.26/bin/go), +rerun with PATH overridden, e.g.: + + PATH="/usr/local/go1.26/bin:$PATH" ./scripts/setup-mcp-servers.sh gitea +EOF + exit 2 + fi + + mkdir -p "${MCP_DIR}" + + if [[ ! -d "${MCP_DIR}/gitea-mcp/.git" ]]; then + git clone https://gitea.com/gitea/gitea-mcp.git "${MCP_DIR}/gitea-mcp" + else + echo "info: gitea-mcp already cloned, skipping clone" + fi + + (cd "${MCP_DIR}/gitea-mcp" && go build -o gitea-mcp .) + + echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp" +} + +main() { + if [[ "${#}" -ne 1 ]]; then + usage + exit 1 + fi + + case "$1" in + gitea) setup_gitea ;; + -h|--help|help) usage ;; + *) + echo "error: unknown target '$1'" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" +