From cfcfb35f81fdb51349635271cd05027f7b3c9436 Mon Sep 17 00:00:00 2001 From: Hua Date: Thu, 19 Mar 2026 13:10:07 +0800 Subject: [PATCH] feat(mcp): add slash command listing --- AGENTS.md | 1 + README.md | 1 + nanobot/agent/i18n.py | 1 + nanobot/agent/loop.py | 65 +++++++++++++++++++++++++ nanobot/channels/telegram.py | 3 +- nanobot/locales/en.json | 7 +++ nanobot/locales/zh.json | 7 +++ tests/test_mcp_commands.py | 89 ++++++++++++++++++++++++++++++++++ tests/test_telegram_channel.py | 9 +++- 9 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 tests/test_mcp_commands.py diff --git a/AGENTS.md b/AGENTS.md index 39ed2e0..0a1db89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Do not commit real API keys, tokens, chat logs, or workspace data. Keep local se - When a slash command changes user-visible wording, update both `nanobot/locales/en.json` and `nanobot/locales/zh.json`. - If a slash command should appear in Telegram's native command menu, also update `nanobot/channels/telegram.py`. - `/skill` currently supports `search`, `install`, `uninstall`, `list`, and `update`. Keep subcommand dispatch in `nanobot/agent/loop.py`. +- `/mcp` supports the default `list` behavior (and explicit `/mcp list`) to show configured MCP servers and registered MCP tools. - `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime. - `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub. - Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user. diff --git a/README.md b/README.md index 658d76f..8160038 100644 --- a/README.md +++ b/README.md @@ -1360,6 +1360,7 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot | `/skill uninstall ` | Remove a ClawHub-managed skill from the active workspace | | `/skill list` | List ClawHub-managed skills in the active workspace | | `/skill update` | Update all ClawHub-managed skills in the active workspace | +| `/mcp [list]` | List configured MCP servers and registered MCP tools | | `/stop` | Stop the current task | | `/restart` | Restart the bot process | | `/help` | Show command help | diff --git a/nanobot/agent/i18n.py b/nanobot/agent/i18n.py index ed03b25..38af024 100644 --- a/nanobot/agent/i18n.py +++ b/nanobot/agent/i18n.py @@ -81,6 +81,7 @@ def help_lines(language: Any) -> list[str]: text(active, "cmd_persona_list"), text(active, "cmd_persona_set"), text(active, "cmd_skill"), + text(active, "cmd_mcp"), text(active, "cmd_stop"), text(active, "cmd_restart"), text(active, "cmd_help"), diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9979866..829056a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -191,6 +191,27 @@ class AgentLoop: text(language, "cmd_lang_set"), ]) + def _mcp_usage(self, language: str) -> str: + """Return MCP command help text.""" + return text(language, "mcp_usage") + + def _group_mcp_tool_names(self) -> dict[str, list[str]]: + """Group registered MCP tool names by configured server name.""" + grouped = {name: [] for name in self._mcp_servers} + server_names = sorted(self._mcp_servers, key=len, reverse=True) + + for tool_name in self.tools.tool_names: + if not tool_name.startswith("mcp_"): + continue + + for server_name in server_names: + prefix = f"mcp_{server_name}_" + if tool_name.startswith(prefix): + grouped[server_name].append(tool_name.removeprefix(prefix)) + break + + return {name: sorted(tools) for name, tools in grouped.items()} + @staticmethod def _decode_subprocess_output(data: bytes) -> str: """Decode subprocess output conservatively for CLI surfacing.""" @@ -363,6 +384,48 @@ class AgentLoop: content = "\n\n".join(notes) if notes else text(language, "skill_command_completed", command=subcommand) return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content) + async def _handle_mcp_command(self, msg: InboundMessage, session: Session) -> OutboundMessage: + """Handle MCP inspection commands.""" + language = self._get_session_language(session) + parts = msg.content.strip().split() + + if len(parts) > 1 and parts[1].lower() != "list": + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=self._mcp_usage(language), + ) + + if not self._mcp_servers: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=text(language, "mcp_no_servers"), + ) + + await self._connect_mcp() + + server_lines = "\n".join(f"- {name}" for name in self._mcp_servers) + sections = [text(language, "mcp_servers_list", items=server_lines)] + + grouped_tools = self._group_mcp_tool_names() + tool_lines = "\n".join( + f"- {server}: {', '.join(tools)}" + for server, tools in grouped_tools.items() + if tools + ) + sections.append( + text(language, "mcp_tools_list", items=tool_lines) + if tool_lines + else text(language, "mcp_no_tools") + ) + + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="\n\n".join(sections), + ) + def _register_default_tools(self) -> None: """Register the default set of tools.""" allowed_dir = self.workspace if self.restrict_to_workspace else None @@ -810,6 +873,8 @@ class AgentLoop: return await self._handle_persona_command(msg, session) if cmd == "/skill": return await self._handle_skill_command(msg, session) + if cmd == "/mcp": + return await self._handle_mcp_command(msg, session) if cmd == "/help": return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)), diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index cad902d..d22ee04 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -165,7 +165,7 @@ class TelegramChannel(BaseChannel): name = "telegram" display_name = "Telegram" - COMMAND_NAMES = ("start", "new", "lang", "persona", "skill", "stop", "help", "restart") + COMMAND_NAMES = ("start", "new", "lang", "persona", "skill", "mcp", "stop", "help", "restart") @classmethod def default_config(cls) -> dict[str, object]: @@ -239,6 +239,7 @@ class TelegramChannel(BaseChannel): self._app.add_handler(CommandHandler("lang", self._forward_command)) self._app.add_handler(CommandHandler("persona", self._forward_command)) self._app.add_handler(CommandHandler("skill", self._forward_command)) + self._app.add_handler(CommandHandler("mcp", self._forward_command)) self._app.add_handler(CommandHandler("stop", self._forward_command)) self._app.add_handler(CommandHandler("restart", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) diff --git a/nanobot/locales/en.json b/nanobot/locales/en.json index eb6c968..5f68679 100644 --- a/nanobot/locales/en.json +++ b/nanobot/locales/en.json @@ -13,6 +13,7 @@ "cmd_persona_list": "/persona list — List available personas", "cmd_persona_set": "/persona set — Switch persona and start a new session", "cmd_skill": "/skill ... — Manage ClawHub skills", + "cmd_mcp": "/mcp [list] — List configured MCP servers and registered tools", "cmd_stop": "/stop — Stop the current task", "cmd_restart": "/restart — Restart the bot", "cmd_help": "/help — Show available commands", @@ -27,6 +28,11 @@ "skill_command_network_failed": "ClawHub could not reach the npm registry. Check your network, proxy, or npm registry configuration and retry.", "skill_command_completed": "ClawHub command completed: {command}", "skill_applied_to_workspace": "Applied to workspace: {workspace}", + "mcp_usage": "Usage:\n/mcp\n/mcp list", + "mcp_no_servers": "No MCP servers are configured for this agent.", + "mcp_servers_list": "Configured MCP servers:\n{items}", + "mcp_tools_list": "Registered MCP tools:\n{items}", + "mcp_no_tools": "No MCP tools are currently registered. Check MCP server connectivity and configuration.", "current_persona": "Current persona: {persona}", "available_personas": "Available personas:\n{items}", "unknown_persona": "Unknown persona: {name}\nAvailable personas: {personas}\nCreate one under {path} and add SOUL.md or USER.md.", @@ -53,6 +59,7 @@ "lang": "Switch language", "persona": "Show or switch personas", "skill": "Search or install skills", + "mcp": "List MCP servers and tools", "stop": "Stop the current task", "help": "Show command help", "restart": "Restart the bot" diff --git a/nanobot/locales/zh.json b/nanobot/locales/zh.json index 0ce6517..69082c3 100644 --- a/nanobot/locales/zh.json +++ b/nanobot/locales/zh.json @@ -13,6 +13,7 @@ "cmd_persona_list": "/persona list — 查看可用人格", "cmd_persona_set": "/persona set — 切换人格并开始新会话", "cmd_skill": "/skill ... — 管理 ClawHub skills", + "cmd_mcp": "/mcp [list] — 查看已配置的 MCP 服务和已注册工具", "cmd_stop": "/stop — 停止当前任务", "cmd_restart": "/restart — 重启机器人", "cmd_help": "/help — 查看命令帮助", @@ -27,6 +28,11 @@ "skill_command_network_failed": "ClawHub 无法连接到 npm registry。请检查网络、代理或 npm registry 配置后重试。", "skill_command_completed": "ClawHub 命令执行完成:{command}", "skill_applied_to_workspace": "已应用到工作区:{workspace}", + "mcp_usage": "用法:\n/mcp\n/mcp list", + "mcp_no_servers": "当前 agent 没有配置任何 MCP 服务。", + "mcp_servers_list": "已配置的 MCP 服务:\n{items}", + "mcp_tools_list": "已注册的 MCP 工具:\n{items}", + "mcp_no_tools": "当前没有已注册的 MCP 工具。请检查 MCP 服务连通性和配置。", "current_persona": "当前人格:{persona}", "available_personas": "可用人格:\n{items}", "unknown_persona": "未知人格:{name}\n可用人格:{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。", @@ -53,6 +59,7 @@ "lang": "切换语言", "persona": "查看或切换人格", "skill": "搜索或安装技能", + "mcp": "查看 MCP 服务和工具", "stop": "停止当前任务", "help": "查看命令帮助", "restart": "重启机器人" diff --git a/tests/test_mcp_commands.py b/tests/test_mcp_commands.py new file mode 100644 index 0000000..2c3f85b --- /dev/null +++ b/tests/test_mcp_commands.py @@ -0,0 +1,89 @@ +"""Tests for /mcp slash command integration.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.bus.events import InboundMessage + + +class _FakeTool: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._name + + @property + def parameters(self) -> dict: + return {"type": "object", "properties": {}} + + async def execute(self, **kwargs) -> str: + return "" + + +def _make_loop(workspace: Path, *, mcp_servers: dict | None = None): + """Create an AgentLoop with a real workspace and lightweight mocks.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + with patch("nanobot.agent.loop.SubagentManager"): + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace, mcp_servers=mcp_servers) + return loop + + +@pytest.mark.asyncio +async def test_mcp_lists_configured_servers_and_tools(tmp_path: Path) -> None: + loop = _make_loop(tmp_path, mcp_servers={"docs": object(), "search": object()}) + loop.tools.register(_FakeTool("mcp_docs_lookup")) + loop.tools.register(_FakeTool("mcp_search_web")) + loop.tools.register(_FakeTool("read_file")) + + with patch.object(loop, "_connect_mcp", AsyncMock()) as connect_mcp: + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/mcp") + ) + + assert response is not None + assert "Configured MCP servers:" in response.content + assert "- docs" in response.content + assert "- search" in response.content + assert "docs: lookup" in response.content + assert "search: web" in response.content + connect_mcp.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_mcp_without_servers_returns_guidance(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/mcp list") + ) + + assert response is not None + assert response.content == "No MCP servers are configured for this agent." + + +@pytest.mark.asyncio +async def test_help_includes_mcp_command(tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + + response = await loop._process_message( + InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/help") + ) + + assert response is not None + assert "/mcp [list]" in response.content diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 77851f6..130acd8 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -1,5 +1,3 @@ -import asyncio -from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -206,6 +204,13 @@ def test_is_allowed_rejects_invalid_legacy_telegram_sender_shapes() -> None: assert channel.is_allowed("not-a-number|alice") is False +def test_build_bot_commands_includes_mcp() -> None: + commands = TelegramChannel._build_bot_commands("en") + descriptions = {command.command: command.description for command in commands} + + assert descriptions["mcp"] == "List MCP servers and tools" + + @pytest.mark.asyncio async def test_send_progress_keeps_message_in_topic() -> None: config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])