feat(mcp): add preset setup and capability mentions
This commit is contained in:
@@ -2,12 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools import mcp as mcp_runtime
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import MCPServerConfig
|
||||
|
||||
|
||||
class _FakeMcpTool(Tool):
|
||||
def __init__(self, name: str) -> None:
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "fake MCP tool"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
async def execute(self, **_kwargs: Any) -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
def _make_loop(tmp_path, *, mcp_servers: dict | None = None) -> AgentLoop:
|
||||
@@ -42,3 +69,152 @@ async def test_connect_mcp_retries_when_no_servers_connect(tmp_path, monkeypatch
|
||||
assert attempts == 2
|
||||
assert loop._mcp_connected is False
|
||||
assert loop._mcp_stacks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||
type="stdio",
|
||||
command="browserbase-mcp",
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
closed: list[str] = []
|
||||
|
||||
async def _mark_closed(name: str) -> None:
|
||||
closed.append(name)
|
||||
|
||||
async def _fake_connect(servers, registry):
|
||||
stacks = {}
|
||||
for name in servers:
|
||||
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
stack.push_async_callback(_mark_closed, name)
|
||||
stacks[name] = stack
|
||||
return stacks
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
loop = _make_loop(tmp_path, mcp_servers={})
|
||||
|
||||
added = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert added["ok"] is True
|
||||
assert added["added"] == ["browserbase"]
|
||||
assert loop.tools.has("mcp_browserbase_navigate")
|
||||
assert "browserbase" in loop._mcp_stacks
|
||||
|
||||
config = load_config()
|
||||
del config.tools.mcp_servers["browserbase"]
|
||||
save_config(config)
|
||||
|
||||
removed = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert removed["ok"] is True
|
||||
assert removed["removed"] == ["browserbase"]
|
||||
assert not loop.tools.has("mcp_browserbase_navigate")
|
||||
assert "browserbase" not in loop._mcp_stacks
|
||||
assert closed == ["browserbase"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_mcp_reload_reaches_runtime_control_without_restart(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||
type="stdio",
|
||||
command="browserbase-mcp",
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
closed: list[str] = []
|
||||
|
||||
async def _mark_closed(name: str) -> None:
|
||||
closed.append(name)
|
||||
|
||||
async def _fake_connect(servers, registry):
|
||||
stacks = {}
|
||||
for name in servers:
|
||||
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
stack.push_async_callback(_mark_closed, name)
|
||||
stacks[name] = stack
|
||||
return stacks
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
loop = _make_loop(tmp_path, mcp_servers={})
|
||||
|
||||
async def _handle_one_runtime_control() -> None:
|
||||
msg = await loop.bus.consume_inbound()
|
||||
handled = await mcp_runtime.handle_runtime_control(loop, msg, loop.tools)
|
||||
assert handled is True
|
||||
|
||||
consumer = asyncio.create_task(_handle_one_runtime_control())
|
||||
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
|
||||
await consumer
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["added"] == ["browserbase"]
|
||||
assert result["requires_restart"] is False
|
||||
assert loop.tools.has("mcp_browserbase_navigate")
|
||||
|
||||
config = load_config()
|
||||
del config.tools.mcp_servers["browserbase"]
|
||||
save_config(config)
|
||||
|
||||
consumer = asyncio.create_task(_handle_one_runtime_control())
|
||||
result = await mcp_runtime.request_mcp_reload(loop.bus, timeout=2.0)
|
||||
await consumer
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["removed"] == ["browserbase"]
|
||||
assert result["requires_restart"] is False
|
||||
assert not loop.tools.has("mcp_browserbase_navigate")
|
||||
assert closed == ["browserbase"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||
type="stdio",
|
||||
command="browserbase-mcp",
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
async def _fake_connect(servers, registry):
|
||||
stacks = {}
|
||||
for name in servers:
|
||||
registry.register(_FakeMcpTool(f"mcp_{name}_navigate"))
|
||||
stack = AsyncExitStack()
|
||||
await stack.__aenter__()
|
||||
stacks[name] = stack
|
||||
return stacks
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||
loop = _make_loop(tmp_path, mcp_servers={"browserbase": config.tools.mcp_servers["browserbase"]})
|
||||
|
||||
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["added"] == []
|
||||
assert result["changed"] == []
|
||||
assert result["retried"] == ["browserbase"]
|
||||
assert loop.tools.has("mcp_browserbase_navigate")
|
||||
await loop.close_mcp()
|
||||
|
||||
@@ -56,6 +56,20 @@ def test_list_sessions_includes_user_preview(tmp_path):
|
||||
assert rows[0]["preview"] == "帮我总结一下 OpenAI 的最新硬件计划"
|
||||
|
||||
|
||||
def test_list_sessions_bounds_preview_scan(tmp_path):
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:chat-long-preview")
|
||||
for index in range(220):
|
||||
session.add_message("assistant", f"assistant trace {index}")
|
||||
session.add_message("user", "this should not force a full sidebar scan")
|
||||
manager.save(session)
|
||||
|
||||
rows = manager.list_sessions()
|
||||
|
||||
assert rows[0]["key"] == "websocket:chat-long-preview"
|
||||
assert rows[0]["preview"] == "assistant trace 0"
|
||||
|
||||
|
||||
# --- Original regression test (from PR 2075) ---
|
||||
|
||||
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
||||
|
||||
@@ -1188,6 +1188,30 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
)
|
||||
assert bad_preset.status_code == 400
|
||||
|
||||
created_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert created_preset.status_code == 200
|
||||
created_body = created_preset.json()
|
||||
assert created_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert created_body["agent"]["model"] == "openai/gpt-4.1-mini"
|
||||
created_presets = {
|
||||
preset["name"]: preset for preset in created_body["model_presets"]
|
||||
}
|
||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert duplicate_preset.status_code == 409
|
||||
|
||||
search_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||
@@ -1255,7 +1279,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||
assert saved.agents.defaults.provider == "atomic_chat"
|
||||
assert saved.agents.defaults.model_preset == "deep"
|
||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
||||
assert saved.agents.defaults.bot_name == "Nano"
|
||||
assert saved.agents.defaults.bot_icon == "N"
|
||||
|
||||
@@ -209,6 +209,156 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_presets_routes_require_token_and_return_payload(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_presets_api.mcp_presets_payload",
|
||||
lambda: {
|
||||
"presets": [
|
||||
{
|
||||
"name": "browserbase",
|
||||
"display_name": "Browserbase",
|
||||
"category": "browser",
|
||||
"description": "Cloud browser automation",
|
||||
"docs_url": "https://docs.browserbase.com/integrations/mcp/configuration",
|
||||
"transport": "streamableHttp",
|
||||
"requires": "Browserbase API key",
|
||||
"note": "",
|
||||
"install_supported": True,
|
||||
"installed": False,
|
||||
"configured": False,
|
||||
"available": False,
|
||||
"status": "not_installed",
|
||||
"logo_url": None,
|
||||
"brand_color": "#111827",
|
||||
"required_fields": [],
|
||||
"connection_summary": "",
|
||||
}
|
||||
],
|
||||
"installed_count": 0,
|
||||
},
|
||||
)
|
||||
preset_queries: list[tuple[str, dict[str, list[str]]]] = []
|
||||
custom_queries: list[tuple[str, dict[str, list[str]]]] = []
|
||||
|
||||
def _mcp_preset_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]:
|
||||
preset_queries.append((action, query))
|
||||
return {
|
||||
"presets": [],
|
||||
"installed_count": 1,
|
||||
"requires_restart": action != "test",
|
||||
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
|
||||
}
|
||||
|
||||
def _custom_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]:
|
||||
custom_queries.append((action, query))
|
||||
return {
|
||||
"presets": [],
|
||||
"installed_count": 1,
|
||||
"requires_restart": True,
|
||||
"last_action": {
|
||||
"ok": True,
|
||||
"message": f"{action}:{query.get('name', ['config'])[0]}",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_presets_api.mcp_presets_action",
|
||||
_mcp_preset_action,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.mcp_presets_api.custom_mcp_action",
|
||||
_custom_action,
|
||||
)
|
||||
|
||||
async def _hot_reload(_bus):
|
||||
return {"ok": True, "message": "MCP config reloaded.", "requires_restart": False}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.websocket.request_mcp_reload",
|
||||
_hot_reload,
|
||||
)
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
|
||||
assert deny.status_code == 401
|
||||
|
||||
boot = await _http_get("http://127.0.0.1:29913/webui/bootstrap")
|
||||
token = boot.json()["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
catalog = await _http_get(
|
||||
"http://127.0.0.1:29913/api/settings/mcp-presets",
|
||||
headers=auth,
|
||||
)
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["presets"][0]["name"] == "browserbase"
|
||||
|
||||
enabled = await _http_get(
|
||||
"http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-MCP-Values": json.dumps(
|
||||
{"browserbase_api_key": "bb_live_secret"}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert enabled.status_code == 200
|
||||
assert preset_queries[-1][1]["browserbase_api_key"] == ["bb_live_secret"]
|
||||
body = enabled.json()
|
||||
assert "bb_live_secret" not in enabled.text
|
||||
assert body["last_action"]["message"] == "enable:browserbase MCP config reloaded."
|
||||
assert body["hot_reload"]["ok"] is True
|
||||
assert body["restart_required_sections"] == []
|
||||
|
||||
bad_header = await _http_get(
|
||||
"http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase",
|
||||
headers={**auth, "X-Nanobot-MCP-Values": "[]"},
|
||||
)
|
||||
assert bad_header.status_code == 400
|
||||
|
||||
custom = await _http_get(
|
||||
"http://127.0.0.1:29913/api/settings/mcp-presets/custom",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-MCP-Values": json.dumps(
|
||||
{"name": "docs", "command": "npx"}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert custom.status_code == 200
|
||||
assert custom_queries[-1][1]["command"] == ["npx"]
|
||||
assert custom.json()["last_action"]["message"] == "custom:docs MCP config reloaded."
|
||||
|
||||
imported = await _http_get(
|
||||
"http://127.0.0.1:29913/api/settings/mcp-presets/import",
|
||||
headers={**auth, "X-Nanobot-MCP-Values": json.dumps({"config": "{}"})},
|
||||
)
|
||||
assert imported.status_code == 200
|
||||
assert imported.json()["last_action"]["message"] == "import:config MCP config reloaded."
|
||||
|
||||
tools = await _http_get(
|
||||
"http://127.0.0.1:29913/api/settings/mcp-presets/tools",
|
||||
headers={
|
||||
**auth,
|
||||
"X-Nanobot-MCP-Values": json.dumps(
|
||||
{"name": "docs", "enabled_tools": []}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert tools.status_code == 200
|
||||
assert tools.json()["last_action"]["message"] == "tools:docs MCP config reloaded."
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
bus: MagicMock, tmp_path: Path
|
||||
|
||||
@@ -52,10 +52,17 @@ def _fake_mcp_module(
|
||||
)
|
||||
|
||||
class _FakeStdioServerParameters:
|
||||
def __init__(self, command: str, args: list[str], env: dict | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
command: str,
|
||||
args: list[str],
|
||||
env: dict | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> None:
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
|
||||
class _FakeClientSession:
|
||||
def __init__(self, _read: object, _write: object) -> None:
|
||||
@@ -561,6 +568,32 @@ async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
||||
assert captured["env"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_mcp_servers_passes_stdio_cwd(
|
||||
fake_mcp_runtime: dict[str, object | None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def _capturing_stdio_client(params: object):
|
||||
captured["cwd"] = params.cwd
|
||||
yield object(), object()
|
||||
|
||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _capturing_stdio_client)
|
||||
|
||||
registry = ToolRegistry()
|
||||
stacks = await connect_mcp_servers(
|
||||
{"test": MCPServerConfig(command="fake", cwd="/tmp/nanobot-mcp-test")},
|
||||
registry,
|
||||
)
|
||||
for stack in stacks.values():
|
||||
await stack.aclose()
|
||||
|
||||
assert captured["cwd"] == "/tmp/nanobot-mcp-test"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPResourceWrapper tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
McpPresetError,
|
||||
custom_mcp_action,
|
||||
mcp_presets_action,
|
||||
mcp_presets_payload,
|
||||
mcp_presets_test_action,
|
||||
normalize_mcp_preset_mentions,
|
||||
)
|
||||
|
||||
|
||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
|
||||
|
||||
|
||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = mcp_presets_payload()
|
||||
names = {preset["name"] for preset in payload["presets"]}
|
||||
|
||||
assert {
|
||||
"browserbase",
|
||||
"playwright",
|
||||
"github",
|
||||
"figma",
|
||||
"context7",
|
||||
"firecrawl",
|
||||
"exa",
|
||||
"microsoft-learn",
|
||||
"aws-docs",
|
||||
"brave-search",
|
||||
"postman",
|
||||
}.issubset(names)
|
||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||
assert browserbase["installed"] is False
|
||||
assert browserbase["install_supported"] is True
|
||||
assert browserbase["required_fields"][0]["configured"] is False
|
||||
assert "browserbaseApiKey" not in browserbase["connection_summary"]
|
||||
|
||||
|
||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = mcp_presets_action(
|
||||
"enable",
|
||||
{
|
||||
"name": ["browserbase"],
|
||||
"browserbase_api_key": ["bb_live_secret"],
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["requires_restart"] is True
|
||||
assert payload["last_action"]["ok"] is True
|
||||
preset = next(row for row in payload["presets"] if row["name"] == "browserbase")
|
||||
assert preset["installed"] is True
|
||||
assert preset["configured"] is True
|
||||
assert "bb_live_secret" not in str(payload)
|
||||
config = load_config()
|
||||
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
|
||||
|
||||
|
||||
def test_enable_requires_missing_secret(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(McpPresetError) as exc:
|
||||
mcp_presets_action("enable", {"name": ["browserbase"]})
|
||||
|
||||
assert exc.value.status == 400
|
||||
assert "Browserbase API key" in exc.value.message
|
||||
|
||||
|
||||
def test_enable_context7_optional_api_key_appends_arg(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = mcp_presets_action(
|
||||
"enable",
|
||||
{
|
||||
"name": ["context7"],
|
||||
"context7_api_key": ["ctx7_secret"],
|
||||
},
|
||||
)
|
||||
|
||||
assert "ctx7_secret" not in str(payload)
|
||||
row = next(item for item in payload["presets"] if item["name"] == "context7")
|
||||
assert row["configured"] is True
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["context7"].args == [
|
||||
"-y",
|
||||
"@upstash/context7-mcp@latest",
|
||||
"--api-key",
|
||||
"ctx7_secret",
|
||||
]
|
||||
|
||||
|
||||
def test_enable_stdio_preset_uses_config_scoped_cwd(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||
|
||||
config = load_config()
|
||||
cwd = config.tools.mcp_servers["playwright"].cwd
|
||||
assert cwd == str(tmp_path / "mcp" / "playwright")
|
||||
assert (tmp_path / "mcp" / "playwright").is_dir()
|
||||
|
||||
|
||||
def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
mcp_presets_action("enable", {"name": ["microsoft-learn"]})
|
||||
mcp_presets_action("enable", {"name": ["exa"]})
|
||||
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
|
||||
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
|
||||
|
||||
|
||||
def test_enable_firecrawl_writes_scrubbed_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = mcp_presets_action(
|
||||
"enable",
|
||||
{
|
||||
"name": ["firecrawl"],
|
||||
"firecrawl_api_key": ["fc-secret"],
|
||||
},
|
||||
)
|
||||
|
||||
assert "fc-secret" not in str(payload)
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["firecrawl"].env["FIRECRAWL_API_KEY"] == "fc-secret"
|
||||
|
||||
|
||||
def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||
|
||||
payload = mcp_presets_action("remove", {"name": ["playwright"]})
|
||||
|
||||
assert payload["requires_restart"] is True
|
||||
config = load_config()
|
||||
assert "playwright" not in config.tools.mcp_servers
|
||||
|
||||
|
||||
def test_test_mcp_preset_reports_missing_dependency(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||
monkeypatch.setattr("nanobot.webui.mcp_presets_api.shutil.which", lambda _command: None)
|
||||
|
||||
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
||||
|
||||
assert payload["last_action"]["ok"] is False
|
||||
assert "npx" in payload["last_action"]["message"]
|
||||
|
||||
|
||||
def test_test_mcp_preset_connects_and_reports_tools(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||
|
||||
class FakeStack:
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_connect(servers, registry):
|
||||
assert list(servers) == ["playwright"]
|
||||
|
||||
class FakeTool:
|
||||
name = "mcp_playwright_browser_navigate"
|
||||
|
||||
def to_schema(self):
|
||||
return {"name": self.name, "description": "", "parameters": {}}
|
||||
|
||||
registry.register(FakeTool())
|
||||
return {"playwright": FakeStack()}
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
||||
|
||||
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
||||
|
||||
assert payload["last_action"]["ok"] is True
|
||||
assert payload["last_action"]["tool_count"] == 1
|
||||
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
|
||||
|
||||
|
||||
def test_test_mcp_preset_scrubs_connection_errors(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
mcp_presets_action(
|
||||
"enable",
|
||||
{
|
||||
"name": ["browserbase"],
|
||||
"browserbase_api_key": ["bb_live_secret"],
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_connect(_servers, _registry):
|
||||
raise RuntimeError("failed https://mcp.browserbase.com/mcp?browserbaseApiKey=bb_live_secret")
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
||||
|
||||
payload = asyncio.run(mcp_presets_test_action({"name": ["browserbase"]}))
|
||||
|
||||
assert payload["last_action"]["ok"] is False
|
||||
assert "bb_live_secret" not in str(payload)
|
||||
assert "<redacted>" in payload["last_action"]["error"]
|
||||
|
||||
|
||||
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
with pytest.raises(McpPresetError) as exc:
|
||||
mcp_presets_action("enable", {"name": ["linear"]})
|
||||
|
||||
assert exc.value.status == 404
|
||||
|
||||
|
||||
def test_normalize_mcp_preset_mentions_keeps_known_presets_only() -> None:
|
||||
payload = normalize_mcp_preset_mentions([
|
||||
{
|
||||
"name": "browserbase",
|
||||
"display_name": "Browserbase",
|
||||
"transport": "streamableHttp",
|
||||
"configured": True,
|
||||
"logo_url": "https://example.invalid/logo.svg",
|
||||
},
|
||||
{"name": "totally-unknown"},
|
||||
"bad",
|
||||
])
|
||||
|
||||
assert payload == [{
|
||||
"name": "browserbase",
|
||||
"display_name": "Browserbase",
|
||||
"transport": "streamableHttp",
|
||||
"configured": True,
|
||||
"logo_url": "https://example.invalid/logo.svg",
|
||||
}]
|
||||
|
||||
|
||||
def test_custom_mcp_server_writes_config_and_catalog_row(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["internal-docs"],
|
||||
"transport": ["stdio"],
|
||||
"command": ["node"],
|
||||
"args": ['["server.js"]'],
|
||||
"env": ['{"DOCS_TOKEN":"docs-secret-value"}'],
|
||||
"tool_timeout": ["45"],
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["requires_restart"] is True
|
||||
row = next(item for item in payload["presets"] if item["name"] == "internal-docs")
|
||||
assert row["source"] == "custom"
|
||||
assert row["transport"] == "stdio"
|
||||
assert row["connection_summary"] == "node server.js"
|
||||
assert "docs-secret-value" not in str(payload)
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
|
||||
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
|
||||
|
||||
|
||||
def test_import_mcp_config_and_tool_allowlist(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
|
||||
payload = custom_mcp_action(
|
||||
"import",
|
||||
{
|
||||
"config": [
|
||||
(
|
||||
'{"mcpServers":{'
|
||||
'"docs":{"command":"npx","args":["-y","docs-mcp"],"env":{"API_KEY":"config-secret-value"}},'
|
||||
'"remote-docs":{"transport":"sse","url":"https://example.com/sse"}'
|
||||
'}}'
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
|
||||
config = load_config()
|
||||
assert config.tools.mcp_servers["docs"].command == "npx"
|
||||
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
|
||||
assert config.tools.mcp_servers["remote-docs"].type == "sse"
|
||||
assert config.tools.mcp_servers["remote-docs"].url == "https://example.com/sse"
|
||||
assert config.tools.mcp_servers["docs"].env["API_KEY"] == "config-secret-value"
|
||||
assert "config-secret-value" not in str(payload)
|
||||
|
||||
payload = custom_mcp_action(
|
||||
"tools",
|
||||
{
|
||||
"name": ["docs"],
|
||||
"enabled_tools": ['["mcp_docs_search"]'],
|
||||
},
|
||||
)
|
||||
|
||||
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
||||
assert row["enabled_tools"] == ["mcp_docs_search"]
|
||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
|
||||
|
||||
payload = custom_mcp_action(
|
||||
"tools",
|
||||
{
|
||||
"name": ["docs"],
|
||||
"enabled_tools": ["[]"],
|
||||
},
|
||||
)
|
||||
|
||||
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
||||
assert row["enabled_tools"] == []
|
||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
||||
|
||||
|
||||
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
custom_mcp_action(
|
||||
"custom",
|
||||
{
|
||||
"name": ["docs"],
|
||||
"transport": ["streamableHttp"],
|
||||
"url": ["https://example.com/mcp"],
|
||||
},
|
||||
)
|
||||
|
||||
payload = normalize_mcp_preset_mentions([
|
||||
{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"},
|
||||
])
|
||||
|
||||
assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}]
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.webui import mcp_presets_runtime
|
||||
|
||||
|
||||
def test_mcp_preset_runtime_lines_describe_tool_prefix() -> None:
|
||||
msg = SimpleNamespace(
|
||||
content="use @browserbase",
|
||||
metadata={
|
||||
"mcp_presets": [{
|
||||
"name": "browserbase",
|
||||
"display_name": "Browserbase",
|
||||
"transport": "streamableHttp",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
lines = mcp_presets_runtime.runtime_lines(
|
||||
msg,
|
||||
configured_server_names={"browserbase"},
|
||||
connected_server_names={"browserbase"},
|
||||
)
|
||||
|
||||
assert lines
|
||||
assert "@browserbase" in lines[0]
|
||||
assert "mcp_browserbase_" in lines[0]
|
||||
assert "shell commands" in lines[0]
|
||||
|
||||
|
||||
def test_mcp_preset_runtime_lines_warn_when_restart_needed() -> None:
|
||||
msg = SimpleNamespace(
|
||||
content="use @browserbase",
|
||||
metadata={
|
||||
"mcp_presets": [{
|
||||
"name": "browserbase",
|
||||
"display_name": "Browserbase",
|
||||
"transport": "streamableHttp",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
lines = mcp_presets_runtime.runtime_lines(
|
||||
msg,
|
||||
configured_server_names=set(),
|
||||
connected_server_names=set(),
|
||||
)
|
||||
|
||||
assert lines
|
||||
assert "has not loaded the latest MCP settings" in lines[0]
|
||||
|
||||
|
||||
def test_mcp_preset_runtime_lines_warn_when_connection_not_live() -> None:
|
||||
msg = SimpleNamespace(
|
||||
content="use @browserbase",
|
||||
metadata={
|
||||
"mcp_presets": [{
|
||||
"name": "browserbase",
|
||||
"display_name": "Browserbase",
|
||||
"transport": "streamableHttp",
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
lines = mcp_presets_runtime.runtime_lines(
|
||||
msg,
|
||||
configured_server_names={"browserbase"},
|
||||
connected_server_names=set(),
|
||||
)
|
||||
|
||||
assert lines
|
||||
assert "connection is not currently live" in lines[0]
|
||||
|
||||
|
||||
def test_mcp_preset_session_extra_only_persists_structured_mentions() -> None:
|
||||
assert mcp_presets_runtime.session_extra({}) == {}
|
||||
assert mcp_presets_runtime.session_extra({
|
||||
"mcp_presets": [{"name": "browserbase"}],
|
||||
}) == {"mcp_presets": [{"name": "browserbase"}]}
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
|
||||
|
||||
|
||||
def test_create_model_configuration_writes_label_and_selects(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.agents.defaults.model = "openai/gpt-4o"
|
||||
config.agents.defaults.provider = "openai"
|
||||
config.providers.openai.api_key = "sk-test"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = create_model_configuration(
|
||||
{
|
||||
"label": ["Fast writing"],
|
||||
"provider": ["openai"],
|
||||
"model": ["openai/gpt-4.1-mini"],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "fast-writing"
|
||||
assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
|
||||
rows = {row["name"]: row for row in payload["model_presets"]}
|
||||
assert rows["fast-writing"]["label"] == "Fast writing"
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||
|
||||
with pytest.raises(WebUISettingsError) as duplicate:
|
||||
create_model_configuration(
|
||||
{
|
||||
"label": ["Fast writing"],
|
||||
"provider": ["openai"],
|
||||
"model": ["openai/gpt-4.1-mini"],
|
||||
}
|
||||
)
|
||||
assert duplicate.value.status == 409
|
||||
|
||||
|
||||
def test_create_model_configuration_rejects_unconfigured_provider(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="provider is not configured"):
|
||||
create_model_configuration(
|
||||
{
|
||||
"label": ["Deep"],
|
||||
"provider": ["openai"],
|
||||
"model": ["openai/gpt-4.1"],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user