feat(mcp): add slash command listing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1360,6 +1360,7 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot
|
||||
| `/skill uninstall <slug>` | 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 |
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"cmd_persona_list": "/persona list — List available personas",
|
||||
"cmd_persona_set": "/persona set <name> — Switch persona and start a new session",
|
||||
"cmd_skill": "/skill <search|install|uninstall|list|update> ... — 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"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"cmd_persona_list": "/persona list — 查看可用人格",
|
||||
"cmd_persona_set": "/persona set <name> — 切换人格并开始新会话",
|
||||
"cmd_skill": "/skill <search|install|uninstall|list|update> ... — 管理 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": "重启机器人"
|
||||
|
||||
89
tests/test_mcp_commands.py
Normal file
89
tests/test_mcp_commands.py
Normal file
@@ -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
|
||||
@@ -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=["*"])
|
||||
|
||||
Reference in New Issue
Block a user