From cb5964c20149b089f11913b9d33eb8dbe62878ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 12 Feb 2026 10:01:30 +0100 Subject: [PATCH 01/11] feat(tools): add mcp support --- README.md | 2 +- nanobot/agent/context.py | 6 ++- nanobot/agent/loop.py | 30 +++++++++++++ nanobot/agent/subagent.py | 15 +++++-- nanobot/agent/tools/cron.py | 19 +++++++-- nanobot/agent/tools/mcp.py | 82 ++++++++++++++++++++++++++++++++++++ nanobot/channels/dingtalk.py | 11 ++++- nanobot/channels/feishu.py | 13 +++--- nanobot/channels/qq.py | 15 ++++--- nanobot/channels/telegram.py | 11 ++++- nanobot/cli/commands.py | 3 ++ nanobot/config/schema.py | 18 ++++++-- nanobot/skills/cron/SKILL.md | 9 +++- pyproject.toml | 1 + 14 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 nanobot/agent/tools/mcp.py diff --git a/README.md b/README.md index fed25c8..ea606de 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚑️ Delivers core agent functionality in just **~4,000** lines of code β€” **99% smaller** than Clawdbot's 430k+ lines. -πŸ“ Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index d807854..b9c0790 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -73,7 +73,9 @@ Skills with available="false" need dependencies installed first - you can try in def _get_identity(self) -> str: """Get the core identity section.""" from datetime import datetime + import time as _time now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = _time.strftime("%Z") or "UTC" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" @@ -88,7 +90,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you - Spawn subagents for complex background tasks ## Current Time -{now} +{now} ({tz}) ## Runtime {runtime} @@ -103,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). For normal conversation, just respond with text - do not call the message tool. -Always be helpful, accurate, and concise. When using tools, explain what you're doing. +Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. When remembering something, write to {workspace_path}/memory/MEMORY.md""" def _load_bootstrap_files(self) -> str: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b764c3d..a3ab678 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1,6 +1,7 @@ """Agent loop: the core processing engine.""" import asyncio +from contextlib import AsyncExitStack import json from pathlib import Path from typing import Any @@ -46,6 +47,7 @@ class AgentLoop: cron_service: "CronService | None" = None, restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, + mcp_servers: dict | None = None, ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService @@ -73,6 +75,9 @@ class AgentLoop: ) self._running = False + self._mcp_servers = mcp_servers or {} + self._mcp_stack: AsyncExitStack | None = None + self._mcp_connected = False self._register_default_tools() def _register_default_tools(self) -> None: @@ -107,9 +112,20 @@ class AgentLoop: if self.cron_service: self.tools.register(CronTool(self.cron_service)) + async def _connect_mcp(self) -> None: + """Connect to configured MCP servers (one-time, lazy).""" + if self._mcp_connected or 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) + 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: @@ -136,6 +152,15 @@ class AgentLoop: except asyncio.TimeoutError: 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 + def stop(self) -> None: """Stop the agent loop.""" self._running = False @@ -225,6 +250,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) + # Interleaved CoT: reflect before next action + messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: # No tool calls, we're done final_content = response.content @@ -330,6 +357,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) + # Interleaved CoT: reflect before next action + messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: final_content = response.content break @@ -367,6 +396,7 @@ class AgentLoop: Returns: The agent's response. """ + await self._connect_mcp() msg = InboundMessage( channel=channel, sender_id="user", diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 6113efb..9e0cd7c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool +from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool @@ -101,6 +101,7 @@ class SubagentManager: allowed_dir = self.workspace if self.restrict_to_workspace else None tools.register(ReadFileTool(allowed_dir=allowed_dir)) tools.register(WriteFileTool(allowed_dir=allowed_dir)) + tools.register(EditFileTool(allowed_dir=allowed_dir)) tools.register(ListDirTool(allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), @@ -210,12 +211,17 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" + from datetime import datetime + import time as _time + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = _time.strftime("%Z") or "UTC" + return f"""# Subagent -You are a subagent spawned by the main agent to complete a specific task. +## Current Time +{now} ({tz}) -## Your Task -{task} +You are a subagent spawned by the main agent to complete a specific task. ## Rules 1. Stay focused - complete only the assigned task, nothing else @@ -236,6 +242,7 @@ You are a subagent spawned by the main agent to complete a specific task. ## Workspace Your workspace is at: {self.workspace} +Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) When you have completed the task, provide a clear summary of your findings or actions.""" diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index ec0d2cd..9f1ecdb 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -50,6 +50,10 @@ class CronTool(Tool): "type": "string", "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" }, + "at": { + "type": "string", + "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" + }, "job_id": { "type": "string", "description": "Job ID (for remove)" @@ -64,30 +68,38 @@ class CronTool(Tool): message: str = "", every_seconds: int | None = None, cron_expr: str | None = None, + at: str | None = None, job_id: str | None = None, **kwargs: Any ) -> str: if action == "add": - return self._add_job(message, every_seconds, cron_expr) + return self._add_job(message, every_seconds, cron_expr, at) elif action == "list": return self._list_jobs() elif action == "remove": return self._remove_job(job_id) return f"Unknown action: {action}" - def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str: + def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str: if not message: return "Error: message is required for add" if not self._channel or not self._chat_id: return "Error: no session context (channel/chat_id)" # Build schedule + delete_after = False if every_seconds: schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) elif cron_expr: schedule = CronSchedule(kind="cron", expr=cron_expr) + elif at: + from datetime import datetime + dt = datetime.fromisoformat(at) + at_ms = int(dt.timestamp() * 1000) + schedule = CronSchedule(kind="at", at_ms=at_ms) + delete_after = True else: - return "Error: either every_seconds or cron_expr is required" + return "Error: either every_seconds, cron_expr, or at is required" job = self._cron.add_job( name=message[:30], @@ -96,6 +108,7 @@ class CronTool(Tool): deliver=True, channel=self._channel, to=self._chat_id, + delete_after_run=delete_after, ) return f"Created job '{job.name}' (id: {job.id})" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py new file mode 100644 index 0000000..bcef4aa --- /dev/null +++ b/nanobot/agent/tools/mcp.py @@ -0,0 +1,82 @@ +"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" + +from contextlib import AsyncExitStack +from typing import Any + +from loguru import logger + +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + + +class MCPToolWrapper(Tool): + """Wraps a single MCP server tool as a nanobot Tool.""" + + def __init__(self, session, server_name: str, tool_def): + self._session = session + self._server = server_name + self._name = f"mcp_{server_name}_{tool_def.name}" + self._description = tool_def.description or tool_def.name + self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + @property + def parameters(self) -> dict[str, Any]: + return self._parameters + + async def execute(self, **kwargs: Any) -> str: + from mcp import types + result = await self._session.call_tool( + self._name.removeprefix(f"mcp_{self._server}_"), arguments=kwargs + ) + parts = [] + for block in result.content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + return "\n".join(parts) or "(no output)" + + +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 + + 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") + except Exception as e: + logger.error(f"MCP server '{name}': failed to connect: {e}") diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 72d3afd..4a8cdd9 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -137,8 +137,15 @@ class DingTalkChannel(BaseChannel): logger.info("DingTalk bot started with Stream Mode") - # client.start() is an async infinite loop handling the websocket connection - await self._client.start() + # Reconnect loop: restart stream if SDK exits or crashes + while self._running: + try: + await self._client.start() + except Exception as e: + logger.warning(f"DingTalk stream error: {e}") + if self._running: + logger.info("Reconnecting DingTalk stream in 5 seconds...") + await asyncio.sleep(5) except Exception as e: logger.exception(f"Failed to start DingTalk channel: {e}") diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 1c176a2..23d1415 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -98,12 +98,15 @@ class FeishuChannel(BaseChannel): log_level=lark.LogLevel.INFO ) - # Start WebSocket client in a separate thread + # Start WebSocket client in a separate thread with reconnect loop def run_ws(): - try: - self._ws_client.start() - except Exception as e: - logger.error(f"Feishu WebSocket error: {e}") + while self._running: + try: + self._ws_client.start() + except Exception as e: + logger.warning(f"Feishu WebSocket error: {e}") + if self._running: + import time; time.sleep(5) self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 5964d30..0e8fe66 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -75,12 +75,15 @@ class QQChannel(BaseChannel): logger.info("QQ bot started (C2C private message)") async def _run_bot(self) -> None: - """Run the bot connection.""" - try: - await self._client.start(appid=self.config.app_id, secret=self.config.secret) - except Exception as e: - logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") - self._running = False + """Run the bot connection with auto-reconnect.""" + while self._running: + try: + await self._client.start(appid=self.config.app_id, secret=self.config.secret) + except Exception as e: + logger.warning(f"QQ bot error: {e}") + if self._running: + logger.info("Reconnecting QQ bot in 5 seconds...") + await asyncio.sleep(5) async def stop(self) -> None: """Stop the QQ bot.""" diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ff46c86..1abd600 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from loguru import logger from telegram import BotCommand, Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -121,11 +122,13 @@ class TelegramChannel(BaseChannel): self._running = True - # Build the application - builder = Application.builder().token(self.config.token) + # Build the application with larger connection pool to avoid pool-timeout on long runs + req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) + builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) if self.config.proxy: builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() + self._app.add_error_handler(self._on_error) # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) @@ -386,6 +389,10 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug(f"Typing indicator stopped for {chat_id}: {e}") + async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + """Log polling / handler errors instead of silently swallowing them.""" + logger.error(f"Telegram error: {context.error}") + def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" if mime_type: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aa99d55..45d5d3f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -329,6 +329,7 @@ def gateway( cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, + mcp_servers=config.tools.mcp_servers, ) # Set cron callback (needs agent) @@ -431,6 +432,7 @@ def agent( brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, + mcp_servers=config.tools.mcp_servers, ) # Show spinner when logs are off (no output to miss); skip when logs are on @@ -447,6 +449,7 @@ def agent( with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id) _print_agent_response(response, render_markdown=markdown) + await agent_loop._close_mcp() asyncio.run(run_once()) else: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f6c861d..2a206e1 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,7 +1,7 @@ """Configuration schema using Pydantic.""" from pathlib import Path -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from pydantic_settings import BaseSettings @@ -213,11 +213,20 @@ class ExecToolConfig(BaseModel): timeout: int = 60 +class MCPServerConfig(BaseModel): + """MCP server connection configuration (stdio or HTTP).""" + command: str = "" # Stdio: command to run (e.g. "npx") + args: list[str] = Field(default_factory=list) # Stdio: command arguments + env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars + url: str = "" # HTTP: streamable HTTP endpoint URL + + class ToolsConfig(BaseModel): """Tools configuration.""" web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory + mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) class Config(BaseSettings): @@ -281,6 +290,7 @@ class Config(BaseSettings): return spec.default_api_base return None - class Config: - env_prefix = "NANOBOT_" - env_nested_delimiter = "__" + model_config = ConfigDict( + env_prefix="NANOBOT_", + env_nested_delimiter="__" + ) diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md index c8beecb..7db25d8 100644 --- a/nanobot/skills/cron/SKILL.md +++ b/nanobot/skills/cron/SKILL.md @@ -7,10 +7,11 @@ description: Schedule reminders and recurring tasks. Use the `cron` tool to schedule reminders or recurring tasks. -## Two Modes +## Three Modes 1. **Reminder** - message is sent directly to user 2. **Task** - message is a task description, agent executes and sends result +3. **One-time** - runs once at a specific time, then auto-deletes ## Examples @@ -24,6 +25,11 @@ Dynamic task (agent executes each time): cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600) ``` +One-time scheduled task (compute ISO datetime from current time): +``` +cron(action="add", message="Remind me about the meeting", at="") +``` + List/remove: ``` cron(action="list") @@ -38,3 +44,4 @@ cron(action="remove", job_id="abc123") | every hour | every_seconds: 3600 | | every day at 8am | cron_expr: "0 8 * * *" | | weekdays at 5pm | cron_expr: "0 17 * * 1-5" | +| at a specific time | at: ISO datetime string (compute from current time) | diff --git a/pyproject.toml b/pyproject.toml index b1b3c81..bdccbf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "qq-botpy>=1.0.0", "python-socks[asyncio]>=2.4.0", "prompt-toolkit>=3.0.0", + "mcp>=1.0.0", ] [project.optional-dependencies] From e89afe61f1ab87018d488e4677d7d0de0d10bcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 12 Feb 2026 10:01:30 +0100 Subject: [PATCH 02/11] feat(tools): add mcp support --- README.md | 2 +- nanobot/agent/context.py | 6 ++- nanobot/agent/loop.py | 30 ++++++++++++++ nanobot/agent/subagent.py | 15 +++++-- nanobot/agent/tools/mcp.py | 82 ++++++++++++++++++++++++++++++++++++++ nanobot/cli/commands.py | 3 ++ nanobot/config/schema.py | 18 +++++++-- pyproject.toml | 1 + 8 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 nanobot/agent/tools/mcp.py diff --git a/README.md b/README.md index fed25c8..ea606de 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚑️ Delivers core agent functionality in just **~4,000** lines of code β€” **99% smaller** than Clawdbot's 430k+ lines. -πŸ“ Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index d807854..b9c0790 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -73,7 +73,9 @@ Skills with available="false" need dependencies installed first - you can try in def _get_identity(self) -> str: """Get the core identity section.""" from datetime import datetime + import time as _time now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = _time.strftime("%Z") or "UTC" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" @@ -88,7 +90,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you - Spawn subagents for complex background tasks ## Current Time -{now} +{now} ({tz}) ## Runtime {runtime} @@ -103,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). For normal conversation, just respond with text - do not call the message tool. -Always be helpful, accurate, and concise. When using tools, explain what you're doing. +Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. When remembering something, write to {workspace_path}/memory/MEMORY.md""" def _load_bootstrap_files(self) -> str: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b764c3d..a3ab678 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1,6 +1,7 @@ """Agent loop: the core processing engine.""" import asyncio +from contextlib import AsyncExitStack import json from pathlib import Path from typing import Any @@ -46,6 +47,7 @@ class AgentLoop: cron_service: "CronService | None" = None, restrict_to_workspace: bool = False, session_manager: SessionManager | None = None, + mcp_servers: dict | None = None, ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService @@ -73,6 +75,9 @@ class AgentLoop: ) self._running = False + self._mcp_servers = mcp_servers or {} + self._mcp_stack: AsyncExitStack | None = None + self._mcp_connected = False self._register_default_tools() def _register_default_tools(self) -> None: @@ -107,9 +112,20 @@ class AgentLoop: if self.cron_service: self.tools.register(CronTool(self.cron_service)) + async def _connect_mcp(self) -> None: + """Connect to configured MCP servers (one-time, lazy).""" + if self._mcp_connected or 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) + 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: @@ -136,6 +152,15 @@ class AgentLoop: except asyncio.TimeoutError: 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 + def stop(self) -> None: """Stop the agent loop.""" self._running = False @@ -225,6 +250,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) + # Interleaved CoT: reflect before next action + messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: # No tool calls, we're done final_content = response.content @@ -330,6 +357,8 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) + # Interleaved CoT: reflect before next action + messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: final_content = response.content break @@ -367,6 +396,7 @@ class AgentLoop: Returns: The agent's response. """ + await self._connect_mcp() msg = InboundMessage( channel=channel, sender_id="user", diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 6113efb..9e0cd7c 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool +from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool @@ -101,6 +101,7 @@ class SubagentManager: allowed_dir = self.workspace if self.restrict_to_workspace else None tools.register(ReadFileTool(allowed_dir=allowed_dir)) tools.register(WriteFileTool(allowed_dir=allowed_dir)) + tools.register(EditFileTool(allowed_dir=allowed_dir)) tools.register(ListDirTool(allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), @@ -210,12 +211,17 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" + from datetime import datetime + import time as _time + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = _time.strftime("%Z") or "UTC" + return f"""# Subagent -You are a subagent spawned by the main agent to complete a specific task. +## Current Time +{now} ({tz}) -## Your Task -{task} +You are a subagent spawned by the main agent to complete a specific task. ## Rules 1. Stay focused - complete only the assigned task, nothing else @@ -236,6 +242,7 @@ You are a subagent spawned by the main agent to complete a specific task. ## Workspace Your workspace is at: {self.workspace} +Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) When you have completed the task, provide a clear summary of your findings or actions.""" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py new file mode 100644 index 0000000..bcef4aa --- /dev/null +++ b/nanobot/agent/tools/mcp.py @@ -0,0 +1,82 @@ +"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" + +from contextlib import AsyncExitStack +from typing import Any + +from loguru import logger + +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + + +class MCPToolWrapper(Tool): + """Wraps a single MCP server tool as a nanobot Tool.""" + + def __init__(self, session, server_name: str, tool_def): + self._session = session + self._server = server_name + self._name = f"mcp_{server_name}_{tool_def.name}" + self._description = tool_def.description or tool_def.name + self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + @property + def parameters(self) -> dict[str, Any]: + return self._parameters + + async def execute(self, **kwargs: Any) -> str: + from mcp import types + result = await self._session.call_tool( + self._name.removeprefix(f"mcp_{self._server}_"), arguments=kwargs + ) + parts = [] + for block in result.content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + return "\n".join(parts) or "(no output)" + + +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 + + 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") + except Exception as e: + logger.error(f"MCP server '{name}': failed to connect: {e}") diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aa99d55..45d5d3f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -329,6 +329,7 @@ def gateway( cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, + mcp_servers=config.tools.mcp_servers, ) # Set cron callback (needs agent) @@ -431,6 +432,7 @@ def agent( brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, + mcp_servers=config.tools.mcp_servers, ) # Show spinner when logs are off (no output to miss); skip when logs are on @@ -447,6 +449,7 @@ def agent( with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id) _print_agent_response(response, render_markdown=markdown) + await agent_loop._close_mcp() asyncio.run(run_once()) else: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f6c861d..2a206e1 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -1,7 +1,7 @@ """Configuration schema using Pydantic.""" from pathlib import Path -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from pydantic_settings import BaseSettings @@ -213,11 +213,20 @@ class ExecToolConfig(BaseModel): timeout: int = 60 +class MCPServerConfig(BaseModel): + """MCP server connection configuration (stdio or HTTP).""" + command: str = "" # Stdio: command to run (e.g. "npx") + args: list[str] = Field(default_factory=list) # Stdio: command arguments + env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars + url: str = "" # HTTP: streamable HTTP endpoint URL + + class ToolsConfig(BaseModel): """Tools configuration.""" web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory + mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) class Config(BaseSettings): @@ -281,6 +290,7 @@ class Config(BaseSettings): return spec.default_api_base return None - class Config: - env_prefix = "NANOBOT_" - env_nested_delimiter = "__" + model_config = ConfigDict( + env_prefix="NANOBOT_", + env_nested_delimiter="__" + ) diff --git a/pyproject.toml b/pyproject.toml index b1b3c81..bdccbf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "qq-botpy>=1.0.0", "python-socks[asyncio]>=2.4.0", "prompt-toolkit>=3.0.0", + "mcp>=1.0.0", ] [project.optional-dependencies] From 61e9f7f58ad3ce21df408615adadeac43db0205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 12 Feb 2026 10:16:52 +0100 Subject: [PATCH 03/11] chore: revert unrelated changes, keep only MCP support --- README.md | 2 +- nanobot/agent/context.py | 6 ++---- nanobot/agent/loop.py | 4 ---- nanobot/agent/subagent.py | 15 ++++----------- nanobot/agent/tools/cron.py | 19 +++---------------- nanobot/channels/dingtalk.py | 11 ++--------- nanobot/channels/feishu.py | 13 +++++-------- nanobot/channels/qq.py | 15 ++++++--------- nanobot/channels/telegram.py | 11 ++--------- nanobot/skills/cron/SKILL.md | 9 +-------- 10 files changed, 26 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index ea606de..fed25c8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚑️ Delivers core agent functionality in just **~4,000** lines of code β€” **99% smaller** than Clawdbot's 430k+ lines. -πŸ“ Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index b9c0790..d807854 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -73,9 +73,7 @@ Skills with available="false" need dependencies installed first - you can try in def _get_identity(self) -> str: """Get the core identity section.""" from datetime import datetime - import time as _time now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = _time.strftime("%Z") or "UTC" workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" @@ -90,7 +88,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you - Spawn subagents for complex background tasks ## Current Time -{now} ({tz}) +{now} ## Runtime {runtime} @@ -105,7 +103,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). For normal conversation, just respond with text - do not call the message tool. -Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool. +Always be helpful, accurate, and concise. When using tools, explain what you're doing. When remembering something, write to {workspace_path}/memory/MEMORY.md""" def _load_bootstrap_files(self) -> str: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a3ab678..b15803a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -250,8 +250,6 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) - # Interleaved CoT: reflect before next action - messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: # No tool calls, we're done final_content = response.content @@ -357,8 +355,6 @@ class AgentLoop: messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result ) - # Interleaved CoT: reflect before next action - messages.append({"role": "user", "content": "Reflect on the results and decide next steps."}) else: final_content = response.content break diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 9e0cd7c..6113efb 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMProvider from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool +from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool @@ -101,7 +101,6 @@ class SubagentManager: allowed_dir = self.workspace if self.restrict_to_workspace else None tools.register(ReadFileTool(allowed_dir=allowed_dir)) tools.register(WriteFileTool(allowed_dir=allowed_dir)) - tools.register(EditFileTool(allowed_dir=allowed_dir)) tools.register(ListDirTool(allowed_dir=allowed_dir)) tools.register(ExecTool( working_dir=str(self.workspace), @@ -211,18 +210,13 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" - from datetime import datetime - import time as _time - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = _time.strftime("%Z") or "UTC" - return f"""# Subagent -## Current Time -{now} ({tz}) - You are a subagent spawned by the main agent to complete a specific task. +## Your Task +{task} + ## Rules 1. Stay focused - complete only the assigned task, nothing else 2. Your final response will be reported back to the main agent @@ -242,7 +236,6 @@ You are a subagent spawned by the main agent to complete a specific task. ## Workspace Your workspace is at: {self.workspace} -Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) When you have completed the task, provide a clear summary of your findings or actions.""" diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9f1ecdb..ec0d2cd 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -50,10 +50,6 @@ class CronTool(Tool): "type": "string", "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" }, - "at": { - "type": "string", - "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" - }, "job_id": { "type": "string", "description": "Job ID (for remove)" @@ -68,38 +64,30 @@ class CronTool(Tool): message: str = "", every_seconds: int | None = None, cron_expr: str | None = None, - at: str | None = None, job_id: str | None = None, **kwargs: Any ) -> str: if action == "add": - return self._add_job(message, every_seconds, cron_expr, at) + return self._add_job(message, every_seconds, cron_expr) elif action == "list": return self._list_jobs() elif action == "remove": return self._remove_job(job_id) return f"Unknown action: {action}" - def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str: + def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str: if not message: return "Error: message is required for add" if not self._channel or not self._chat_id: return "Error: no session context (channel/chat_id)" # Build schedule - delete_after = False if every_seconds: schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) elif cron_expr: schedule = CronSchedule(kind="cron", expr=cron_expr) - elif at: - from datetime import datetime - dt = datetime.fromisoformat(at) - at_ms = int(dt.timestamp() * 1000) - schedule = CronSchedule(kind="at", at_ms=at_ms) - delete_after = True else: - return "Error: either every_seconds, cron_expr, or at is required" + return "Error: either every_seconds or cron_expr is required" job = self._cron.add_job( name=message[:30], @@ -108,7 +96,6 @@ class CronTool(Tool): deliver=True, channel=self._channel, to=self._chat_id, - delete_after_run=delete_after, ) return f"Created job '{job.name}' (id: {job.id})" diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4a8cdd9..72d3afd 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -137,15 +137,8 @@ class DingTalkChannel(BaseChannel): logger.info("DingTalk bot started with Stream Mode") - # Reconnect loop: restart stream if SDK exits or crashes - while self._running: - try: - await self._client.start() - except Exception as e: - logger.warning(f"DingTalk stream error: {e}") - if self._running: - logger.info("Reconnecting DingTalk stream in 5 seconds...") - await asyncio.sleep(5) + # client.start() is an async infinite loop handling the websocket connection + await self._client.start() except Exception as e: logger.exception(f"Failed to start DingTalk channel: {e}") diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 23d1415..1c176a2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -98,15 +98,12 @@ class FeishuChannel(BaseChannel): log_level=lark.LogLevel.INFO ) - # Start WebSocket client in a separate thread with reconnect loop + # Start WebSocket client in a separate thread def run_ws(): - while self._running: - try: - self._ws_client.start() - except Exception as e: - logger.warning(f"Feishu WebSocket error: {e}") - if self._running: - import time; time.sleep(5) + try: + self._ws_client.start() + except Exception as e: + logger.error(f"Feishu WebSocket error: {e}") self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 0e8fe66..5964d30 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -75,15 +75,12 @@ class QQChannel(BaseChannel): logger.info("QQ bot started (C2C private message)") async def _run_bot(self) -> None: - """Run the bot connection with auto-reconnect.""" - while self._running: - try: - await self._client.start(appid=self.config.app_id, secret=self.config.secret) - except Exception as e: - logger.warning(f"QQ bot error: {e}") - if self._running: - logger.info("Reconnecting QQ bot in 5 seconds...") - await asyncio.sleep(5) + """Run the bot connection.""" + try: + await self._client.start(appid=self.config.app_id, secret=self.config.secret) + except Exception as e: + logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") + self._running = False async def stop(self) -> None: """Stop the QQ bot.""" diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 1abd600..ff46c86 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING from loguru import logger from telegram import BotCommand, Update from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes -from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -122,13 +121,11 @@ class TelegramChannel(BaseChannel): self._running = True - # Build the application with larger connection pool to avoid pool-timeout on long runs - req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) - builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) + # Build the application + builder = Application.builder().token(self.config.token) if self.config.proxy: builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() - self._app.add_error_handler(self._on_error) # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) @@ -389,10 +386,6 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug(f"Typing indicator stopped for {chat_id}: {e}") - async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: - """Log polling / handler errors instead of silently swallowing them.""" - logger.error(f"Telegram error: {context.error}") - def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" if mime_type: diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md index 7db25d8..c8beecb 100644 --- a/nanobot/skills/cron/SKILL.md +++ b/nanobot/skills/cron/SKILL.md @@ -7,11 +7,10 @@ description: Schedule reminders and recurring tasks. Use the `cron` tool to schedule reminders or recurring tasks. -## Three Modes +## Two Modes 1. **Reminder** - message is sent directly to user 2. **Task** - message is a task description, agent executes and sends result -3. **One-time** - runs once at a specific time, then auto-deletes ## Examples @@ -25,11 +24,6 @@ Dynamic task (agent executes each time): cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600) ``` -One-time scheduled task (compute ISO datetime from current time): -``` -cron(action="add", message="Remind me about the meeting", at="") -``` - List/remove: ``` cron(action="list") @@ -44,4 +38,3 @@ cron(action="remove", job_id="abc123") | every hour | every_seconds: 3600 | | every day at 8am | cron_expr: "0 8 * * *" | | weekdays at 5pm | cron_expr: "0 17 * * 1-5" | -| at a specific time | at: ISO datetime string (compute from current time) | From d30523f460b26132091c96ea9ef73003a53e2e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 12 Feb 2026 10:44:25 +0100 Subject: [PATCH 04/11] fix(mcp): clean up connections on exit in interactive and gateway modes --- nanobot/cli/commands.py | 45 +++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 45d5d3f..cab4d41 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -387,6 +387,8 @@ def gateway( ) except KeyboardInterrupt: console.print("\nShutting down...") + finally: + await agent._close_mcp() heartbeat.stop() cron.stop() agent.stop() @@ -465,30 +467,33 @@ def agent( signal.signal(signal.SIGINT, _exit_on_sigint) async def run_interactive(): - while True: - try: - _flush_pending_tty_input() - user_input = await _read_interactive_input_async() - command = user_input.strip() - if not command: - continue + try: + while True: + try: + _flush_pending_tty_input() + user_input = await _read_interactive_input_async() + command = user_input.strip() + if not command: + continue - if _is_exit_command(command): + if _is_exit_command(command): + _restore_terminal() + console.print("\nGoodbye!") + break + + with _thinking_ctx(): + response = await agent_loop.process_direct(user_input, session_id) + _print_agent_response(response, render_markdown=markdown) + except KeyboardInterrupt: _restore_terminal() console.print("\nGoodbye!") break - - with _thinking_ctx(): - response = await agent_loop.process_direct(user_input, session_id) - _print_agent_response(response, render_markdown=markdown) - except KeyboardInterrupt: - _restore_terminal() - console.print("\nGoodbye!") - break - except EOFError: - _restore_terminal() - console.print("\nGoodbye!") - break + except EOFError: + _restore_terminal() + console.print("\nGoodbye!") + break + finally: + await agent_loop._close_mcp() asyncio.run(run_interactive()) From 153c83e340c518209cfe879f296dc99f742ad778 Mon Sep 17 00:00:00 2001 From: Ahwei Date: Sat, 14 Feb 2026 10:23:54 +0800 Subject: [PATCH 05/11] fix(cron): add timezone support for accurate next run time calculation When schedule.tz is present, use the specified timezone to calculate the next execution time, ensuring scheduled tasks trigger correctly across different timezones. --- nanobot/cron/service.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index d1965a9..6fea4de 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -4,6 +4,7 @@ import asyncio import json import time import uuid +from datetime import datetime from pathlib import Path from typing import Any, Callable, Coroutine @@ -30,9 +31,18 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: if schedule.kind == "cron" and schedule.expr: try: from croniter import croniter - cron = croniter(schedule.expr, time.time()) - next_time = cron.get_next() - return int(next_time * 1000) + from zoneinfo import ZoneInfo + base_time = time.time() + if schedule.tz: + tz = ZoneInfo(schedule.tz) + base_dt = datetime.fromtimestamp(base_time, tz=tz) + cron = croniter(schedule.expr, base_dt) + next_dt = cron.get_next(datetime) + return int(next_dt.timestamp() * 1000) + else: + cron = croniter(schedule.expr, base_time) + next_time = cron.get_next() + return int(next_time * 1000) except Exception: return None From d3f6c95cebaf17d04f0d04655a98c0e795777bb1 Mon Sep 17 00:00:00 2001 From: Ahwei Date: Sat, 14 Feb 2026 10:27:09 +0800 Subject: [PATCH 06/11] refactor(cron): simplify timezone logic and merge conditional branches With tz: Use the specified timezone (e.g., "Asia/Shanghai"). Without tz: Use the local timezone (datetime.now().astimezone().tzinfo) instead of defaulting to UTC --- nanobot/cron/service.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 6fea4de..4da845a 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -33,16 +33,11 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: from croniter import croniter from zoneinfo import ZoneInfo base_time = time.time() - if schedule.tz: - tz = ZoneInfo(schedule.tz) - base_dt = datetime.fromtimestamp(base_time, tz=tz) - cron = croniter(schedule.expr, base_dt) - next_dt = cron.get_next(datetime) - return int(next_dt.timestamp() * 1000) - else: - cron = croniter(schedule.expr, base_time) - next_time = cron.get_next() - return int(next_time * 1000) + tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo + base_dt = datetime.fromtimestamp(base_time, tz=tz) + cron = croniter(schedule.expr, base_dt) + next_dt = cron.get_next(datetime) + return int(next_dt.timestamp() * 1000) except Exception: return None From e2ef1f9d4846246472d2c8454b40d4dbf239dd4c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 06:02:45 +0000 Subject: [PATCH 07/11] docs: add custom provider guideline --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index e73beb5..47702c1 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,7 @@ Config file: `~/.nanobot/config.json` | Provider | Purpose | Get API Key | |----------|---------|-------------| +| `custom` | Any OpenAI-compatible endpoint | β€” | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | @@ -612,6 +613,31 @@ Config file: `~/.nanobot/config.json` | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | β€” | +
+Custom Provider (Any OpenAI-compatible API) + +If your provider is not listed above but exposes an **OpenAI-compatible API** (e.g. Together AI, Fireworks, Azure OpenAI, self-hosted endpoints), use the `custom` provider: + +```json +{ + "providers": { + "custom": { + "apiKey": "your-api-key", + "apiBase": "https://api.your-provider.com/v1" + } + }, + "agents": { + "defaults": { + "model": "your-model-name" + } + } +} +``` + +> The `custom` provider routes through LiteLLM's OpenAI-compatible path. It works with any endpoint that follows the OpenAI chat completions API format. The model name is passed directly to the endpoint without any prefix. + +
+
Adding a New Provider (Developer Guide) From 52cf1da30a408ac4ec91fac5e9249a72f01ee1d2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 07:00:27 +0000 Subject: [PATCH 08/11] fix: store original MCP tool name, make close_mcp public --- README.md | 36 +++++++++++++++++++++++++++++++++++- nanobot/agent/loop.py | 2 +- nanobot/agent/tools/mcp.py | 6 ++---- nanobot/cli/commands.py | 6 +++--- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 47702c1..c08d3af 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚑️ Delivers core agent functionality in just **~4,000** lines of code β€” **99% smaller** than Clawdbot's 430k+ lines. -πŸ“ Real-time line count: **3,536 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,656 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News @@ -683,6 +683,40 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
+### MCP (Model Context Protocol) + +> [!TIP] +> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README. + +nanobot supports [MCP](https://modelcontextprotocol.io/) β€” connect external tool servers and use them as native agent tools. + +Add MCP servers to your `config.json`: + +```json +{ + "tools": { + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] + } + } + } +} +``` + +Two transport modes are supported: + +| Mode | Config | Example | +|------|--------|---------| +| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | +| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) | + +MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools β€” no extra configuration needed. + + + + ### Security > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index cc7a0d0..7deef59 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -229,7 +229,7 @@ class AgentLoop: except asyncio.TimeoutError: continue - async def _close_mcp(self) -> None: + async def close_mcp(self) -> None: """Close MCP connections.""" if self._mcp_stack: try: diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index bcef4aa..1c8eac4 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -14,7 +14,7 @@ class MCPToolWrapper(Tool): def __init__(self, session, server_name: str, tool_def): self._session = session - self._server = server_name + self._original_name = tool_def.name self._name = f"mcp_{server_name}_{tool_def.name}" self._description = tool_def.description or tool_def.name self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}} @@ -33,9 +33,7 @@ class MCPToolWrapper(Tool): async def execute(self, **kwargs: Any) -> str: from mcp import types - result = await self._session.call_tool( - self._name.removeprefix(f"mcp_{self._server}_"), arguments=kwargs - ) + result = await self._session.call_tool(self._original_name, arguments=kwargs) parts = [] for block in result.content: if isinstance(block, types.TextContent): diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 34bfde8..6a9c92f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -405,7 +405,7 @@ def gateway( except KeyboardInterrupt: console.print("\nShutting down...") finally: - await agent._close_mcp() + await agent.close_mcp() heartbeat.stop() cron.stop() agent.stop() @@ -473,7 +473,7 @@ def agent( with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id) _print_agent_response(response, render_markdown=markdown) - await agent_loop._close_mcp() + await agent_loop.close_mcp() asyncio.run(run_once()) else: @@ -515,7 +515,7 @@ def agent( console.print("\nGoodbye!") break finally: - await agent_loop._close_mcp() + await agent_loop.close_mcp() asyncio.run(run_interactive()) From 49fec3684ad100e44203ded5fa2a3c2113d0094b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 08:11:33 +0000 Subject: [PATCH 09/11] fix: use json_repair for robust LLM response parsing --- README.md | 2 +- nanobot/agent/loop.py | 9 ++++++++- nanobot/providers/litellm_provider.py | 6 ++---- pyproject.toml | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c08d3af..9066d5a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚑️ Delivers core agent functionality in just **~4,000** lines of code β€” **99% smaller** than Clawdbot's 430k+ lines. -πŸ“ Real-time line count: **3,656 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,663 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 7deef59..6342f56 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -3,6 +3,7 @@ import asyncio from contextlib import AsyncExitStack import json +import json_repair from pathlib import Path from typing import Any @@ -420,9 +421,15 @@ Respond with ONLY valid JSON, no markdown fences.""" model=self.model, ) text = (response.content or "").strip() + if not text: + logger.warning("Memory consolidation: LLM returned empty response, skipping") + return if text.startswith("```"): text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() - result = json.loads(text) + result = json_repair.loads(text) + if not isinstance(result, dict): + logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}") + return if entry := result.get("history_entry"): memory.append_history(entry) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index a39893b..ed4cf49 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -1,6 +1,7 @@ """LiteLLM provider implementation for multi-provider support.""" import json +import json_repair import os from typing import Any @@ -173,10 +174,7 @@ class LiteLLMProvider(LLMProvider): # Parse arguments from JSON string if needed args = tc.function.arguments if isinstance(args, str): - try: - args = json.loads(args) - except json.JSONDecodeError: - args = {"raw": args} + args = json_repair.loads(args) tool_calls.append(ToolCallRequest( id=tc.id, diff --git a/pyproject.toml b/pyproject.toml index 17c739f..147e799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "python-socks[asyncio]>=2.4.0", "prompt-toolkit>=3.0.0", "mcp>=1.0.0", + "json-repair>=0.30.0", ] [project.optional-dependencies] From 82074a7715cd7e3b8c4810f861401926b64139cf Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 14:03:51 +0000 Subject: [PATCH 10/11] docs: update news section --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 9066d5a..e75f080 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ ## πŸ“’ News +- **2026-02-14** πŸ”Œ nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. - **2026-02-13** πŸŽ‰ Released v0.1.3.post7 β€” includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system β€” Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! +- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support! - **2026-02-10** πŸŽ‰ Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** πŸ’¬ Added Slack, Email, and QQ support β€” nanobot now supports multiple chat platforms! - **2026-02-08** πŸ”§ Refactored Providersβ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). From a5265c263d1ea277dc3197e63364105be0503d79 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 16:41:27 +0000 Subject: [PATCH 11/11] docs: update readme structure --- README.md | 90 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index e75f080..c1b7e46 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,22 @@ nanobot onboard **2. Configure** (`~/.nanobot/config.json`) -For OpenRouter - recommended for global users: +Add or merge these **two parts** into your config (other options have defaults). + +*Set your API key* (e.g. OpenRouter, recommended for global users): ```json { "providers": { "openrouter": { "apiKey": "sk-or-v1-xxx" } - }, + } +} +``` + +*Set your model*: +```json +{ "agents": { "defaults": { "model": "anthropic/claude-opus-4-5" @@ -128,48 +136,11 @@ For OpenRouter - recommended for global users: **3. Chat** ```bash -nanobot agent -m "What is 2+2?" +nanobot agent ``` That's it! You have a working AI assistant in 2 minutes. -## πŸ–₯️ Local Models (vLLM) - -Run nanobot with your own local models using vLLM or any OpenAI-compatible server. - -**1. Start your vLLM server** - -```bash -vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 -``` - -**2. Configure** (`~/.nanobot/config.json`) - -```json -{ - "providers": { - "vllm": { - "apiKey": "dummy", - "apiBase": "http://localhost:8000/v1" - } - }, - "agents": { - "defaults": { - "model": "meta-llama/Llama-3.1-8B-Instruct" - } - } -} -``` - -**3. Chat** - -```bash -nanobot agent -m "Hello from my local LLM!" -``` - -> [!TIP] -> The `apiKey` can be any non-empty string for local servers that don't require authentication. - ## πŸ’¬ Chat Apps Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ β€” anytime, anywhere. @@ -640,6 +611,43 @@ If your provider is not listed above but exposes an **OpenAI-compatible API** (e +
+vLLM (local / OpenAI-compatible) + +Run your own model with vLLM or any OpenAI-compatible server, then add to config: + +**1. Start the server** (example): +```bash +vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000 +``` + +**2. Add to config** (partial β€” merge into `~/.nanobot/config.json`): + +*Provider (key can be any non-empty string for local):* +```json +{ + "providers": { + "vllm": { + "apiKey": "dummy", + "apiBase": "http://localhost:8000/v1" + } + } +} +``` + +*Model:* +```json +{ + "agents": { + "defaults": { + "model": "meta-llama/Llama-3.1-8B-Instruct" + } + } +} +``` + +
+
Adding a New Provider (Developer Guide) @@ -721,6 +729,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ### Security +> [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. | Option | Default | Description | @@ -815,7 +824,6 @@ PRs welcome! The codebase is intentionally small and readable. πŸ€— **Roadmap** β€” Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)! -- [x] **Voice Transcription** β€” Support for Groq Whisper (Issue #13) - [ ] **Multi-modal** β€” See and hear (images, voice, video) - [ ] **Long-term memory** β€” Never forget important context - [ ] **Better reasoning** β€” Multi-step planning and reflection