feat(mcp): add preset setup and capability mentions

This commit is contained in:
Xubin Ren
2026-05-24 19:43:20 +08:00
parent 8be258212e
commit 704ac558f6
54 changed files with 8425 additions and 708 deletions
+176
View File
@@ -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():
+28 -1
View File
@@ -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
+34 -1
View File
@@ -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
# ---------------------------------------------------------------------------
+363
View File
@@ -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"}]
+80
View File
@@ -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"}]}
+67
View File
@@ -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"],
}
)