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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 52cf1da30a408ac4ec91fac5e9249a72f01ee1d2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 15 Feb 2026 07:00:27 +0000 Subject: [PATCH 5/5] 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())