From 704ac558f60b55939fb5c43740b78ae60d789738 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 24 May 2026 13:38:37 +0800 Subject: [PATCH] feat(mcp): add preset setup and capability mentions --- .gitignore | 1 + nanobot/agent/context.py | 30 + nanobot/agent/loop.py | 32 +- nanobot/agent/tools/mcp.py | 280 ++- nanobot/bus/events.py | 7 +- nanobot/channels/websocket.py | 158 +- nanobot/config/schema.py | 2 + nanobot/session/manager.py | 33 + nanobot/webui/mcp_presets_api.py | 1172 ++++++++++ nanobot/webui/mcp_presets_runtime.py | 5 + nanobot/webui/settings_api.py | 75 +- nanobot/webui/transcript.py | 64 +- tests/agent/test_mcp_connection.py | 176 ++ tests/agent/test_session_manager_history.py | 14 + tests/channels/test_websocket_channel.py | 29 +- tests/channels/test_websocket_http_routes.py | 150 ++ tests/tools/test_mcp_tool.py | 35 +- tests/webui/test_mcp_presets_api.py | 363 +++ tests/webui/test_mcp_presets_runtime.py | 80 + tests/webui/test_settings_api.py | 67 + webui/src/components/CliAppMentionText.tsx | 161 +- webui/src/components/MessageBubble.tsx | 55 +- webui/src/components/SessionSearchDialog.tsx | 69 +- .../src/components/settings/SettingsView.tsx | 2033 +++++++++++++++-- .../thread/AgentActivityCluster.tsx | 1121 +++++++-- .../src/components/thread/ThreadComposer.tsx | 350 ++- .../src/components/thread/ThreadMessages.tsx | 6 +- webui/src/components/thread/ThreadShell.tsx | 124 +- .../src/components/thread/ThreadViewport.tsx | 5 +- webui/src/hooks/useNanobotStream.ts | 3 + webui/src/i18n/locales/en/common.json | 105 +- webui/src/i18n/locales/es/common.json | 155 +- webui/src/i18n/locales/fr/common.json | 155 +- webui/src/i18n/locales/id/common.json | 155 +- webui/src/i18n/locales/ja/common.json | 155 +- webui/src/i18n/locales/ko/common.json | 155 +- webui/src/i18n/locales/vi/common.json | 155 +- webui/src/i18n/locales/zh-CN/common.json | 117 +- webui/src/i18n/locales/zh-TW/common.json | 155 +- webui/src/lib/api.ts | 93 + webui/src/lib/mcp-preset-events.ts | 20 + webui/src/lib/nanobot-client.ts | 8 +- webui/src/lib/provider-brand.ts | 188 ++ webui/src/lib/types.ts | 95 + .../src/tests/agent-activity-cluster.test.tsx | 255 ++- webui/src/tests/api.test.ts | 106 + webui/src/tests/app-layout.test.tsx | 45 +- webui/src/tests/i18n.test.tsx | 1 - webui/src/tests/message-bubble.test.tsx | 42 +- webui/src/tests/nanobot-client.test.ts | 47 + webui/src/tests/provider-brand.test.ts | 37 + .../src/tests/session-search-dialog.test.tsx | 70 + webui/src/tests/thread-composer.test.tsx | 115 +- webui/src/tests/thread-shell.test.tsx | 4 +- 54 files changed, 8425 insertions(+), 708 deletions(-) create mode 100644 nanobot/webui/mcp_presets_api.py create mode 100644 nanobot/webui/mcp_presets_runtime.py create mode 100644 tests/webui/test_mcp_presets_api.py create mode 100644 tests/webui/test_mcp_presets_runtime.py create mode 100644 tests/webui/test_settings_api.py create mode 100644 webui/src/lib/mcp-preset-events.ts create mode 100644 webui/src/lib/provider-brand.ts create mode 100644 webui/src/tests/provider-brand.test.ts create mode 100644 webui/src/tests/session-search-dialog.test.tsx diff --git a/.gitignore b/.gitignore index 17ebbc972..19b129a26 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ tmp/ temp/ *.tmp exp/ +.playwright-mcp/ diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 4ad2124e7..dac5e1d64 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -10,6 +10,10 @@ from typing import Any, Mapping, Sequence from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader +from nanobot.agent.tools import mcp as mcp_tools +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.bus.events import InboundMessage +from nanobot.cli_apps import utils as cli_app_utils from nanobot.session.goal_state import goal_state_runtime_lines from nanobot.utils.helpers import ( current_time_str, @@ -19,6 +23,32 @@ from nanobot.utils.helpers import ( from nanobot.utils.prompt_templates import render_template +def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]: + """Return persisted kwargs for turn-attached capabilities.""" + return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata) + + +def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]: + """Return model-visible runtime annotations for turn-attached capabilities.""" + return [ + *cli_app_utils.runtime_lines(msg, workspace, skip=skip), + *mcp_tools.runtime_lines( + msg, + configured_server_names=set(state._mcp_servers), + connected_server_names=set(state._mcp_stacks), + skip=skip, + ), + ] + + +async def connect_mcp(state: Any, tools: ToolRegistry) -> None: + await mcp_tools.connect_missing_servers(state, tools) + + +async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool: + return await mcp_tools.handle_runtime_control(state, msg, tools) + + class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b9e459af1..e396212ab 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger +from nanobot.agent import context as agent_context from nanobot.agent import model_presets as preset_helpers from nanobot.agent.autocompact import AutoCompact from nanobot.agent.context import ContextBuilder @@ -28,7 +29,6 @@ from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.self import MyTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.cli_apps import utils as cli_app_utils from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.providers.base import LLMProvider @@ -476,26 +476,8 @@ class AgentLoop: logger.info("Registered {} tools: {}", len(registered), registered) async def _connect_mcp(self) -> None: - """Connect to configured MCP servers (one-time, lazy).""" - if self._mcp_connected or self._mcp_connecting or not self._mcp_servers: - return - self._mcp_connecting = True - from nanobot.agent.tools.mcp import connect_mcp_servers - - try: - self._mcp_stacks = await connect_mcp_servers(self._mcp_servers, self.tools) - if self._mcp_stacks: - self._mcp_connected = True - else: - logger.warning("No MCP servers connected successfully (will retry next message)") - except asyncio.CancelledError: - logger.warning("MCP connection cancelled (will retry next message)") - self._mcp_stacks.clear() - except BaseException as e: - logger.warning("Failed to connect MCP servers (will retry next message): {}", e) - self._mcp_stacks.clear() - finally: - self._mcp_connecting = False + """Connect configured MCP servers.""" + await agent_context.connect_mcp(self, self.tools) def _set_tool_context( self, channel: str, chat_id: str, @@ -568,7 +550,7 @@ class AgentLoop: media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p] has_text = isinstance(msg.content, str) and msg.content.strip() if has_text or media_paths: - extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata) + extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata) extra.update(kwargs) text = msg.content if isinstance(msg.content, str) else "" session.add_message("user", text, **extra) @@ -593,7 +575,7 @@ class AgentLoop: chat_id=self._runtime_chat_id(msg), sender_id=msg.sender_id, session_summary=pending_summary, - session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace), + session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace), ) async def _dispatch_command_inline( @@ -811,6 +793,8 @@ class AgentLoop: logger.warning("Error consuming inbound message: {}, continuing...", e) continue + if await agent_context.handle_runtime_control(self, msg, self.tools): + continue raw = msg.content.strip() if self.commands.is_priority(raw): await self._dispatch_command_inline( @@ -1058,7 +1042,7 @@ class AgentLoop: current_role=current_role, sender_id=msg.sender_id, session_summary=pending, - session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent), + session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent), ) t_wall = time.time() final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop( diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 73c0850d5..e26a434db 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -6,13 +6,20 @@ import re import shutil import urllib.parse from contextlib import AsyncExitStack, suppress -from typing import Any +from typing import Any, Mapping +from weakref import WeakKeyDictionary import httpx from loguru import logger from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry +from nanobot.bus.events import ( + INBOUND_META_RUNTIME_CONTROL, + RUNTIME_CONTROL_ACK, + RUNTIME_CONTROL_MCP_RELOAD, + InboundMessage, +) # Transient connection errors that warrant a single retry. # These typically happen when an MCP server restarts or a network @@ -33,6 +40,7 @@ _WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yar # Characters allowed in tool names by model providers (Anthropic, OpenAI, etc.). # Replace anything outside [a-zA-Z0-9_-] with underscore and collapse runs. _SANITIZE_RE = re.compile(r"_+") +_RELOAD_LOCKS: WeakKeyDictionary[Any, asyncio.Lock] = WeakKeyDictionary() def _sanitize_name(name: str) -> str: @@ -503,6 +511,7 @@ async def connect_mcp_servers( command=command, args=args, env=env, + cwd=cfg.cwd or None, ) read, write = await server_stack.enter_async_context(stdio_client(params)) elif transport_type == "sse": @@ -662,3 +671,272 @@ async def connect_mcp_servers( server_stacks[result[0]] = result[1] return server_stacks + + +def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]: + """Return persisted session kwargs for MCP preset attachments.""" + mcp_presets = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None + return {"mcp_presets": mcp_presets} if isinstance(mcp_presets, list) and mcp_presets else {} + + +def runtime_lines( + message: Any, + *, + available_server_names: set[str] | None = None, + configured_server_names: set[str] | None = None, + connected_server_names: set[str] | None = None, + skip: bool = False, +) -> list[str]: + """Return model-visible MCP preset annotations for the current turn.""" + if skip: + return [] + if configured_server_names is None: + configured_server_names = available_server_names + if connected_server_names is None: + connected_server_names = available_server_names + metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None + structured = metadata.get("mcp_presets") if isinstance(metadata, Mapping) else None + if not isinstance(structured, list): + return [] + + lines: list[str] = [] + for item in structured[:8]: + if not isinstance(item, Mapping): + continue + raw_name = str(item.get("name") or "").strip().lower() + if not raw_name: + continue + display = str(item.get("display_name") or raw_name).strip() or raw_name + transport = str(item.get("transport") or "mcp").strip() or "mcp" + prefix = f"mcp_{raw_name}_" + if configured_server_names is not None and raw_name not in configured_server_names: + lines.append( + "MCP Preset Attachment: " + f"@{raw_name} ({display}; transport={transport}) is configured in WebUI Settings, " + "but this gateway has not loaded the latest MCP settings yet. " + f"Tools with prefix `{prefix}` may not be available yet; if they are missing, " + "tell the user to restart nanobot." + ) + continue + if connected_server_names is not None and raw_name not in connected_server_names: + lines.append( + "MCP Preset Attachment: " + f"@{raw_name} ({display}; transport={transport}) is configured, " + "but its MCP connection is not currently live. " + f"Tools with prefix `{prefix}` may be unavailable; tell the user to open Settings, " + "run the preset test, and restart nanobot only if hot reload is unavailable." + ) + continue + lines.append( + "MCP Preset Attachment: " + f"@{raw_name} ({display}; transport={transport}; tool_prefix={prefix}). " + f"Prefer available tools whose names start with `{prefix}` for this request; " + "do not substitute shell commands for this MCP integration unless the user asks." + ) + return lines + + +async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None: + """Connect configured MCP servers that are not currently live.""" + missing_servers = { + name: cfg for name, cfg in state._mcp_servers.items() if name not in state._mcp_stacks + } + if state._mcp_connecting or not missing_servers: + return + state._mcp_connecting = True + try: + connected = await connect_mcp_servers(missing_servers, registry) + state._mcp_stacks.update(connected) + state._mcp_connected = bool(state._mcp_stacks) + if connected: + logger.info("MCP connected servers: {}", sorted(connected)) + else: + logger.warning("No MCP servers connected successfully (will retry next message)") + except asyncio.CancelledError: + logger.warning("MCP connection cancelled (will retry next message)") + state._mcp_connected = bool(state._mcp_stacks) + except BaseException as e: + logger.warning("Failed to connect MCP servers (will retry next message): {}", e) + state._mcp_connected = bool(state._mcp_stacks) + finally: + state._mcp_connecting = False + + +async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]: + """Reconcile live MCP connections with the current config file.""" + async with _reload_lock(state): + try: + from nanobot.config.loader import (load_config, + resolve_config_env_vars) + + config = resolve_config_env_vars(load_config()) + next_servers = dict(config.tools.mcp_servers) + except Exception as exc: + logger.warning("MCP hot reload could not read config: {}", exc) + return { + "ok": False, + "message": "Could not reload MCP config. Restart nanobot to pick up changes.", + "requires_restart": True, + "error": str(exc), + } + + current_servers = dict(state._mcp_servers) + current_names = set(current_servers) + next_names = set(next_servers) + removed = sorted(current_names - next_names) + added = sorted(next_names - current_names) + changed = sorted( + name + for name in current_names & next_names + if _server_signature(current_servers[name]) != _server_signature(next_servers[name]) + ) + + tools_removed = 0 + for name in [*removed, *changed]: + tools_removed += _unregister_server_tools(state, registry, name) + await _close_server(state, name) + + state._mcp_servers = next_servers + retry_missing = sorted( + name + for name in next_names + if name not in state._mcp_stacks and name not in set(added) | set(changed) + ) + to_connect_names = sorted(set(added) | set(changed) | set(retry_missing)) + to_connect = {name: next_servers[name] for name in to_connect_names} + connected: dict[str, AsyncExitStack] = {} + if to_connect: + connected = await connect_mcp_servers(to_connect, registry) + state._mcp_stacks.update(connected) + + state._mcp_connected = bool(state._mcp_stacks) + failed = sorted(set(to_connect) - set(connected)) + unchanged = not removed and not added and not changed and not retry_missing + ok = not failed + if failed: + message = "MCP config reloaded, but some servers did not connect: " + ", ".join(failed) + elif unchanged: + message = "MCP config is already live." + elif retry_missing and not added and not changed and not removed: + message = "MCP connections refreshed without restarting nanobot." + else: + message = "MCP config reloaded without restarting nanobot." + + logger.info( + "MCP hot reload: added={} changed={} removed={} retried={} connected={} failed={} tools_removed={}", + added, + changed, + removed, + retry_missing, + sorted(connected), + failed, + tools_removed, + ) + return { + "ok": ok, + "message": message, + "added": added, + "changed": changed, + "removed": removed, + "retried": retry_missing, + "connected": sorted(state._mcp_stacks), + "configured": sorted(state._mcp_servers), + "failed": failed, + "tools_removed": tools_removed, + "requires_restart": False, + } + + +async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]: + """Ask the running agent loop to reconcile live MCP connections.""" + loop = asyncio.get_running_loop() + ack: asyncio.Future[dict[str, Any]] = loop.create_future() + await bus.publish_inbound( + InboundMessage( + channel="system", + sender_id="webui-settings", + chat_id="runtime", + content=RUNTIME_CONTROL_MCP_RELOAD, + metadata={ + INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_MCP_RELOAD, + RUNTIME_CONTROL_ACK: ack, + }, + ) + ) + try: + result = await asyncio.wait_for(ack, timeout=timeout) + except asyncio.TimeoutError: + return { + "ok": False, + "message": "MCP hot reload timed out. Restart nanobot to pick up changes.", + "requires_restart": True, + } + return result if isinstance(result, dict) else { + "ok": False, + "message": "MCP hot reload returned an unexpected response.", + "requires_restart": True, + } + + +async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool: + metadata = msg.metadata if isinstance(msg.metadata, dict) else {} + control = metadata.get(INBOUND_META_RUNTIME_CONTROL) + if control != RUNTIME_CONTROL_MCP_RELOAD: + return False + + ack = metadata.get(RUNTIME_CONTROL_ACK) + try: + result = await reload_servers(state, registry) + except Exception as exc: + logger.exception("MCP hot reload failed") + result = { + "ok": False, + "message": "MCP hot reload failed. Restart nanobot to pick up changes.", + "requires_restart": True, + "error": str(exc), + } + if isinstance(ack, asyncio.Future) and not ack.done(): + ack.set_result(result) + return True + + +def _reload_lock(state: Any) -> asyncio.Lock: + try: + return _RELOAD_LOCKS[state] + except KeyError: + lock = asyncio.Lock() + _RELOAD_LOCKS[state] = lock + return lock + + +def _server_signature(cfg: Any) -> Any: + if hasattr(cfg, "model_dump"): + return cfg.model_dump(mode="json") + return cfg + + +def _tool_prefix(server_name: str) -> str: + safe_name = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in server_name) + while "__" in safe_name: + safe_name = safe_name.replace("__", "_") + return f"mcp_{safe_name}_" + + +def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int: + prefix = _tool_prefix(server_name) + removed = 0 + for tool_name in list(registry.tool_names): + if tool_name.startswith(prefix): + registry.unregister(tool_name) + removed += 1 + return removed + + +async def _close_server(state: Any, server_name: str) -> None: + stack = state._mcp_stacks.pop(server_name, None) + if stack is None: + return + try: + await stack.aclose() + except (RuntimeError, BaseExceptionGroup): + logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name) diff --git a/nanobot/bus/events.py b/nanobot/bus/events.py index 636f9755f..713fe01d3 100644 --- a/nanobot/bus/events.py +++ b/nanobot/bus/events.py @@ -9,6 +9,12 @@ from typing import Any # render it and other channels may ignore unknown keys. OUTBOUND_META_AGENT_UI = "_agent_ui" +# Internal-only inbound metadata used by in-process channels to ask the agent +# loop to update runtime state without going through a user session. +INBOUND_META_RUNTIME_CONTROL = "_runtime_control" +RUNTIME_CONTROL_ACK = "_ack" +RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload" + @dataclass class InboundMessage: @@ -45,4 +51,3 @@ class OutboundMessage: media: list[str] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) buttons: list[list[str]] = field(default_factory=list) - diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 856274090..87b5fbd3a 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -30,6 +30,7 @@ from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest from websockets.http11 import Response +from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel @@ -46,6 +47,7 @@ from nanobot.utils.media_decode import ( from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.webui.settings_api import ( WebUISettingsError, + create_model_configuration, settings_payload, update_agent_settings, update_image_generation_settings, @@ -57,12 +59,32 @@ from nanobot.webui.cli_apps_api import ( cli_apps_payload, normalize_cli_app_mentions, ) +from nanobot.webui.mcp_presets_api import ( + mcp_presets_settings_action, + normalize_mcp_preset_mentions, +) from nanobot.webui.sidebar_state import ( read_webui_sidebar_state, write_webui_sidebar_state, ) from nanobot.webui.thread_disk import delete_webui_thread -from nanobot.webui.transcript import append_transcript_object, build_webui_thread_response +from nanobot.webui.transcript import ( + append_transcript_object, + build_webui_thread_response, + rewrite_local_markdown_images, +) + +_MCP_PRESET_ACTIONS_BY_PATH = { + "/api/settings/mcp-presets/enable": "enable", + "/api/settings/mcp-presets/remove": "remove", + "/api/settings/mcp-presets/test": "test", + "/api/settings/mcp-presets/custom": "custom", + "/api/settings/mcp-presets/import": "import", + "/api/settings/mcp-presets/import-cursor": "import-cursor", + "/api/settings/mcp-presets/tools": "tools", +} +_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values" +_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024 if TYPE_CHECKING: from nanobot.session.manager import SessionManager @@ -233,6 +255,34 @@ def _parse_query(path_with_query: str) -> dict[str, list[str]]: return _parse_request_path(path_with_query)[1] +def _parse_mcp_settings_query(request: WsRequest) -> dict[str, list[str]]: + query = _parse_query(request.path) + raw = request.headers.get(_MCP_VALUES_HEADER) + if not raw: + return query + if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES: + raise WebUISettingsError("MCP settings payload is too large") + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise WebUISettingsError("invalid MCP settings payload") from exc + if not isinstance(payload, dict): + raise WebUISettingsError("MCP settings payload must be a JSON object") + merged = {key: list(values) for key, values in query.items()} + for key, value in payload.items(): + if not isinstance(key, str) or not key: + raise WebUISettingsError("MCP settings payload contains an invalid key") + if value is None: + continue + if isinstance(value, str): + text = value.strip() + else: + text = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if text: + merged[key] = [text] + return merged + + def _query_first(query: dict[str, list[str]], key: str) -> str | None: """Return the first value for *key*, or None.""" values = query.get(key) @@ -425,18 +475,6 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({ "video/webm", "video/quicktime", }) -_MARKDOWN_LOCAL_IMAGE_RE = re.compile( - r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" -) -_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({ - ".png", - ".jpg", - ".jpeg", - ".webp", - ".gif", -}) - - def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool: """Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``.""" if not configured_secret: @@ -666,6 +704,9 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings/update": return self._handle_settings_update(request) + if got == "/api/settings/model-configurations/create": + return self._handle_settings_model_configuration_create(request) + if got == "/api/settings/provider/update": return self._handle_settings_provider_update(request) @@ -690,6 +731,13 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings/cli-apps/test": return await self._handle_settings_cli_apps_action(request, "test") + if got == "/api/settings/mcp-presets": + return await self._handle_settings_mcp_presets(request) + + mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(got) + if mcp_action is not None: + return await self._handle_settings_mcp_presets(request, mcp_action) + m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -881,6 +929,16 @@ class WebSocketChannel(BaseChannel): self._with_settings_restart_state(payload, section="runtime") ) + def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + query = _parse_query(request.path) + try: + payload = create_model_configuration(query) + except WebUISettingsError as e: + return _http_error(e.status, e.message) + return _http_json_response(self._with_settings_restart_state(payload)) + def _handle_settings_provider_update(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") @@ -937,6 +995,31 @@ class WebSocketChannel(BaseChannel): return _http_error(status, message) return _http_json_response(payload) + async def _handle_settings_mcp_presets( + self, + request: WsRequest, + action: str | None = None, + ) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + try: + payload = await mcp_presets_settings_action( + action, + _parse_mcp_settings_query(request), + reload_mcp=lambda: request_mcp_reload(self.bus), + ) + except Exception as e: + status = getattr(e, "status", 500) + message = getattr(e, "message", str(e)) + if status >= 500: + self.logger.exception("MCP preset action '{}' failed", action or "list") + return _http_error(status, message) + if action is None: + return _http_json_response(payload) + return _http_json_response( + self._with_settings_restart_state(payload, section="runtime") + ) + @staticmethod def _is_websocket_channel_session_key(key: str) -> bool: """True when *key* is a ``websocket:…`` session exposed on this HTTP surface.""" @@ -1028,6 +1111,9 @@ class WebSocketChannel(BaseChannel): cli_apps = meta.get("cli_apps") if isinstance(cli_apps, list) and cli_apps: user_obj["cli_apps"] = cli_apps + mcp_presets = meta.get("mcp_presets") + if isinstance(mcp_presets, list) and mcp_presets: + user_obj["mcp_presets"] = mcp_presets self._try_append_webui_transcript(chat_id, user_obj) await super()._handle_message( sender_id, @@ -1117,45 +1203,12 @@ class WebSocketChannel(BaseChannel): return None return {"url": signed, "name": path.name} - def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None: - url = raw_url.strip() - if url.startswith("<") and url.endswith(">"): - url = url[1:-1].strip() - if not url or url.startswith(("/api/media/", "#")): - return None - parsed = urlparse(url) - if parsed.scheme or parsed.netloc: - return None - if parsed.query or parsed.fragment: - return None - path_text = unquote(url) - if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS: - return None - candidate = Path(path_text).expanduser() - if not candidate.is_absolute(): - candidate = self._workspace_path / candidate - try: - resolved = candidate.resolve(strict=False) - resolved.relative_to(self._workspace_path) - except (OSError, ValueError): - return None - if not resolved.is_file(): - return None - signed = self._sign_or_stage_media_path(resolved) - return signed["url"] if signed else None - def _rewrite_local_markdown_images(self, text: str) -> str: - if "![" not in text: - return text - - def replace(match: re.Match[str]) -> str: - signed_url = self._markdown_image_url_for_local_path(match.group(2)) - if not signed_url: - return match.group(0) - title = match.group(3) or "" - return f"![{match.group(1)}]({signed_url}{title})" - - return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text) + return rewrite_local_markdown_images( + text, + workspace_path=self._workspace_path, + sign_path=self._sign_or_stage_media_path, + ) def _handle_media_fetch(self, sig: str, payload: str) -> Response: """Serve a single media file previously signed via @@ -1531,6 +1584,9 @@ class WebSocketChannel(BaseChannel): cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps")) if cli_apps: metadata["cli_apps"] = cli_apps + mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets")) + if mcp_presets: + metadata["mcp_presets"] = mcp_presets image_generation = envelope.get("image_generation") if isinstance(image_generation, dict) and image_generation.get("enabled") is True: aspect_ratio = image_generation.get("aspect_ratio") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 093e3a7f3..5844faf8c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -92,6 +92,7 @@ FallbackCandidate = str | InlineFallbackConfig class ModelPresetConfig(Base): """A named set of model + generation parameters for quick switching.""" + label: str | None = None model: str provider: str = "auto" max_tokens: int = 8192 @@ -254,6 +255,7 @@ class MCPServerConfig(Base): 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 + cwd: str = "" # Stdio: working directory for MCP server runtime artifacts url: str = "" # HTTP/SSE: endpoint URL headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers tool_timeout: int = 30 # seconds before a tool call is cancelled diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 846ec381f..2b41537a9 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -27,6 +27,8 @@ _MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?") _LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$") _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$') _SESSION_PREVIEW_MAX_CHARS = 120 +_SESSION_LIST_PREVIEW_MAX_RECORDS = 200 +_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000 def _sanitize_assistant_replay_text(content: str) -> str: @@ -182,6 +184,28 @@ class Session: if cli_lines: breadcrumbs = "\n".join(cli_lines) content = f"{content}\n{breadcrumbs}" if content else breadcrumbs + mcp_presets = message.get("mcp_presets") + if ( + role == "user" + and isinstance(mcp_presets, list) + and mcp_presets + and isinstance(content, str) + ): + mcp_lines: list[str] = [] + for item in mcp_presets[:8]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip().lower() + if not name: + continue + transport = str(item.get("transport") or "mcp").strip() or "mcp" + mcp_lines.append( + f"[MCP Preset Attachment: @{name}; tool_prefix=mcp_{name}_; " + f"transport={transport}]" + ) + if mcp_lines: + breadcrumbs = "\n".join(mcp_lines) + content = f"{content}\n{breadcrumbs}" if content else breadcrumbs if include_timestamps: content = self._annotate_message_time(message, content) if role == "assistant" and isinstance(content, str) and not content.strip(): @@ -621,9 +645,18 @@ class SessionManager: title = metadata.get("title") if isinstance(metadata, dict) else None preview = "" fallback_preview = "" + scanned_records = 0 + scanned_chars = 0 for line in f: if not line.strip(): continue + scanned_records += 1 + scanned_chars += len(line) + if ( + scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS + or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS + ): + break item = json.loads(line) if item.get("_type") == "metadata": continue diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py new file mode 100644 index 000000000..d87747d8a --- /dev/null +++ b/nanobot/webui/mcp_presets_api.py @@ -0,0 +1,1172 @@ +"""MCP preset helpers for the WebUI settings and message surfaces.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import shlex +import shutil +import urllib.parse +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal, Mapping + +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.config.loader import load_config, resolve_config_env_vars, save_config +from nanobot.config.paths import get_runtime_subdir +from nanobot.config.schema import MCPServerConfig +from nanobot.utils.helpers import ensure_dir + +QueryParams = dict[str, list[str]] + +_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE) +_SECRET_QUERY_RE = re.compile( + r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+", + re.IGNORECASE, +) +_SECRET_ASSIGNMENT_RE = re.compile( + r"((?:api[_-]?key|token|secret|password|bearer)(?:[=:]|\s+))[^,\s'\"&]+", + re.IGNORECASE, +) +_MCP_ATTACHMENT_KEYS = ( + "name", + "display_name", + "category", + "transport", + "logo_url", + "brand_color", + "status", + "configured", +) +_MAX_TEST_TOOLS = 16 +_DEFAULT_TEST_TIMEOUT = 20 +_DEFAULT_CUSTOM_TIMEOUT = 30 +_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"} + +McpReload = Callable[[], Awaitable[dict[str, Any]]] + + +class McpPresetError(Exception): + """WebUI-facing MCP preset error.""" + + def __init__(self, message: str, status: int = 400): + super().__init__(message) + self.message = message + self.status = status + + +@dataclass(frozen=True) +class McpPresetField: + name: str + label: str + target: tuple[Literal["env", "url_param", "arg", "header"], str] + secret: bool = True + required: bool = True + env_var: str | None = None + placeholder: str = "" + + +@dataclass(frozen=True) +class McpPreset: + name: str + display_name: str + category: str + description: str + docs_url: str + transport: Literal["stdio", "streamableHttp", "sse", "oauth"] + install_supported: bool + brand_domain: str + brand_color: str + server: MCPServerConfig | None = None + fields: tuple[McpPresetField, ...] = () + requires: str = "" + note: str = "" + + +def _favicon_url(domain: str) -> str: + return f"https://www.google.com/s2/favicons?domain={domain}&sz=64" + + +MCP_PRESETS: tuple[McpPreset, ...] = ( + McpPreset( + name="browserbase", + display_name="Browserbase", + category="browser", + description="Cloud browser automation through Browserbase's hosted MCP server.", + docs_url="https://docs.browserbase.com/integrations/mcp/setup", + transport="streamableHttp", + install_supported=True, + brand_domain="browserbase.com", + brand_color="#111827", + requires="Browserbase API key", + server=MCPServerConfig( + type="streamableHttp", + url="https://mcp.browserbase.com/mcp", + tool_timeout=60, + ), + fields=( + McpPresetField( + name="browserbase_api_key", + label="Browserbase API key", + target=("url_param", "browserbaseApiKey"), + env_var="BROWSERBASE_API_KEY", + placeholder="bb_live_...", + ), + ), + ), + McpPreset( + name="playwright", + display_name="Playwright", + category="browser", + description="Local browser inspection and automation with the official Playwright MCP server.", + docs_url="https://playwright.dev/docs/getting-started-mcp", + transport="stdio", + install_supported=True, + brand_domain="playwright.dev", + brand_color="#2EAD33", + requires="Node.js and npx", + server=MCPServerConfig( + type="stdio", + command="npx", + args=["-y", "@playwright/mcp@latest"], + tool_timeout=60, + ), + ), + McpPreset( + name="context7", + display_name="Context7", + category="docs", + description="Fetch current library docs and code examples while the agent works.", + docs_url="https://context7.com/docs/resources/all-clients", + transport="stdio", + install_supported=True, + brand_domain="context7.com", + brand_color="#111827", + requires="Node.js and npx; API key optional", + server=MCPServerConfig( + type="stdio", + command="npx", + args=["-y", "@upstash/context7-mcp@latest"], + tool_timeout=45, + ), + fields=( + McpPresetField( + name="context7_api_key", + label="Context7 API key", + target=("arg", "--api-key"), + env_var="CONTEXT7_API_KEY", + placeholder="ctx7_...", + required=False, + ), + ), + note="Works without a key for basic public docs; add a key for higher limits or private docs.", + ), + McpPreset( + name="firecrawl", + display_name="Firecrawl", + category="web", + description="Scrape, crawl, search, and extract web pages through Firecrawl's MCP server.", + docs_url="https://docs.firecrawl.dev/use-cases/developers-mcp", + transport="stdio", + install_supported=True, + brand_domain="firecrawl.dev", + brand_color="#EB5E28", + requires="Node.js, npx, and Firecrawl API key", + server=MCPServerConfig( + type="stdio", + command="npx", + args=["-y", "firecrawl-mcp"], + tool_timeout=60, + ), + fields=( + McpPresetField( + name="firecrawl_api_key", + label="Firecrawl API key", + target=("env", "FIRECRAWL_API_KEY"), + env_var="FIRECRAWL_API_KEY", + placeholder="fc-...", + ), + ), + ), + McpPreset( + name="exa", + display_name="Exa", + category="web", + description="Search the web and fetch clean page content through Exa's hosted MCP server.", + docs_url="https://exa.ai/mcp", + transport="streamableHttp", + install_supported=True, + brand_domain="exa.ai", + brand_color="#101010", + requires="Network access", + server=MCPServerConfig( + type="streamableHttp", + url="https://mcp.exa.ai/mcp", + tool_timeout=45, + ), + note="Hosted Exa MCP endpoint currently does not require an API key.", + ), + McpPreset( + name="microsoft-learn", + display_name="Microsoft Learn", + category="docs", + description="Search and fetch official Microsoft Learn documentation through Microsoft's hosted MCP server.", + docs_url="https://learn.microsoft.com/en-us/training/support/mcp", + transport="streamableHttp", + install_supported=True, + brand_domain="learn.microsoft.com", + brand_color="#0078D4", + requires="Network access", + server=MCPServerConfig( + type="streamableHttp", + url="https://learn.microsoft.com/api/mcp", + tool_timeout=45, + ), + note="Public documentation only; no authentication required.", + ), + McpPreset( + name="aws-docs", + display_name="AWS Documentation", + category="docs", + description="Search AWS documentation and service guidance through AWS Labs' documentation MCP server.", + docs_url="https://awslabs.github.io/mcp/servers/aws-documentation-mcp-server/", + transport="stdio", + install_supported=True, + brand_domain="aws.amazon.com", + brand_color="#FF9900", + requires="uvx", + server=MCPServerConfig( + type="stdio", + command="uvx", + args=["awslabs.aws-documentation-mcp-server@latest"], + env={"FASTMCP_LOG_LEVEL": "ERROR", "AWS_DOCUMENTATION_PARTITION": "aws"}, + tool_timeout=60, + ), + ), + McpPreset( + name="brave-search", + display_name="Brave Search", + category="web", + description="Run web, news, image, video, and local search through Brave Search.", + docs_url="https://www.npmjs.com/package/@brave/brave-search-mcp-server", + transport="stdio", + install_supported=True, + brand_domain="brave.com", + brand_color="#FB542B", + requires="Node.js, npx, and Brave Search API key", + server=MCPServerConfig( + type="stdio", + command="npx", + args=["-y", "@brave/brave-search-mcp-server@latest", "--transport", "stdio"], + tool_timeout=45, + ), + fields=( + McpPresetField( + name="brave_api_key", + label="Brave Search API key", + target=("env", "BRAVE_API_KEY"), + env_var="BRAVE_API_KEY", + placeholder="BSA...", + ), + ), + ), + McpPreset( + name="postman", + display_name="Postman", + category="api", + description="Inspect and manage Postman APIs, collections, and workspaces through the local MCP server.", + docs_url="https://learning.postman.com/docs/developer/postman-api/postman-mcp-server/postman-mcp-local-server", + transport="stdio", + install_supported=True, + brand_domain="postman.com", + brand_color="#FF6C37", + requires="Node.js, npx, and Postman API key", + server=MCPServerConfig( + type="stdio", + command="npx", + args=["-y", "@postman/postman-mcp-server@latest", "--full"], + tool_timeout=60, + ), + fields=( + McpPresetField( + name="postman_api_key", + label="Postman API key", + target=("env", "POSTMAN_API_KEY"), + env_var="POSTMAN_API_KEY", + placeholder="PMAK-...", + ), + ), + ), + McpPreset( + name="figma", + display_name="Figma", + category="design", + description="Read design context from Figma using the official local Dev Mode MCP server.", + docs_url="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server", + transport="streamableHttp", + install_supported=True, + brand_domain="figma.com", + brand_color="#F24E1E", + requires="Figma desktop app with MCP enabled", + server=MCPServerConfig( + type="streamableHttp", + url="http://127.0.0.1:3845/mcp", + tool_timeout=45, + ), + note="Requires Figma Desktop Dev Mode MCP to be running locally.", + ), + McpPreset( + name="github", + display_name="GitHub", + category="code", + description="Repository, issue, and pull request workflows via GitHub's official MCP server.", + docs_url="https://github.com/github/github-mcp-server", + transport="stdio", + install_supported=True, + brand_domain="github.com", + brand_color="#24292F", + requires="Docker and GitHub token", + server=MCPServerConfig( + type="stdio", + command="docker", + args=[ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server", + ], + tool_timeout=60, + ), + fields=( + McpPresetField( + name="github_token", + label="GitHub token", + target=("env", "GITHUB_PERSONAL_ACCESS_TOKEN"), + env_var="GITHUB_PERSONAL_ACCESS_TOKEN", + placeholder="ghp_...", + ), + ), + ), + McpPreset( + name="supabase", + display_name="Supabase", + category="database", + description="Inspect and manage Supabase projects through the Supabase MCP server.", + docs_url="https://supabase.com/docs/guides/ai-tools/mcp", + transport="stdio", + install_supported=True, + brand_domain="supabase.com", + brand_color="#3ECF8E", + requires="Node.js, npx, and Supabase access token", + server=MCPServerConfig( + type="stdio", + command="npx", + args=["-y", "@supabase/mcp-server-supabase@latest", "--read-only"], + tool_timeout=60, + ), + fields=( + McpPresetField( + name="supabase_access_token", + label="Supabase access token", + target=("env", "SUPABASE_ACCESS_TOKEN"), + env_var="SUPABASE_ACCESS_TOKEN", + placeholder="sbp_...", + ), + ), + note="MVP config starts read-only by default.", + ), +) + + +def _query_first(query: QueryParams, key: str) -> str | None: + values = query.get(key) + return values[0] if values else None + + +def _query_value(query: QueryParams, key: str) -> str | None: + raw = _query_first(query, key) + if raw is None: + return None + value = raw.strip() + return value or None + + +def _preset_by_name(name: str) -> McpPreset: + if not name or _MCP_PRESET_NAME_RE.match(name) is None: + raise McpPresetError("invalid MCP preset name") + for preset in MCP_PRESETS: + if preset.name == name: + return preset + raise McpPresetError("unknown MCP preset", status=404) + + +def _preset_by_name_optional(name: str) -> McpPreset | None: + try: + return _preset_by_name(name) + except McpPresetError: + return None + + +def _known_preset_names() -> set[str]: + return {preset.name for preset in MCP_PRESETS} + + +def _known_mcp_names() -> set[str]: + names = _known_preset_names() + with suppress(Exception): + names.update(load_config().tools.mcp_servers) + return names + + +def _clip_ws_string(value: Any, limit: int = 240) -> str | None: + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + return text[:limit] + + +def normalize_mcp_preset_mentions(raw: Any) -> list[dict[str, Any]]: + """Sanitize structured MCP preset mentions sent by the WebUI.""" + if not isinstance(raw, list): + return [] + known = _known_mcp_names() + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in raw[:8]: + if not isinstance(item, dict): + continue + name = _clip_ws_string(item.get("name"), 64) + if not name or _MCP_PRESET_NAME_RE.match(name) is None: + continue + key = name.lower() + if key in seen or key not in known: + continue + seen.add(key) + row: dict[str, Any] = {"name": key} + for field_name in _MCP_ATTACHMENT_KEYS[1:]: + value = item.get(field_name) + if isinstance(value, bool): + row[field_name] = value + continue + limit = 512 if field_name == "logo_url" else 160 + text = _clip_ws_string(value, limit) + if text: + row[field_name] = text + out.append(row) + return out + + +def _clone_server(server: MCPServerConfig) -> MCPServerConfig: + return MCPServerConfig.model_validate(server.model_dump(mode="json")) + + +def _with_managed_stdio_cwd(name: str, cfg: MCPServerConfig) -> MCPServerConfig: + if cfg.command and (cfg.type in (None, "stdio")) and not cfg.cwd: + cfg.cwd = str(ensure_dir(get_runtime_subdir("mcp") / name)) + return cfg + + +def _url_with_param(url: str, key: str, value: str) -> str: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query = [(k, v) for k, v in query if k != key] + query.append((key, value)) + return urllib.parse.urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urllib.parse.urlencode(query), + parsed.fragment, + ) + ) + + +def _arg_value(args: list[str], flag: str) -> str | None: + prefix = f"{flag}=" + for index, item in enumerate(args): + if item == flag and index + 1 < len(args): + return args[index + 1] + if item.startswith(prefix): + return item[len(prefix):] + return None + + +def _with_arg_value(args: list[str], flag: str, value: str) -> list[str]: + out: list[str] = [] + skip_next = False + prefix = f"{flag}=" + for item in args: + if skip_next: + skip_next = False + continue + if item == flag: + skip_next = True + continue + if item.startswith(prefix): + continue + out.append(item) + out.extend([flag, value]) + return out + + +def _field_value_from_config(field: McpPresetField, cfg: MCPServerConfig | None) -> str | None: + if cfg is None: + return None + target_kind, target_name = field.target + if target_kind == "env": + value = cfg.env.get(target_name) + return value if value else None + if target_kind == "header": + value = cfg.headers.get(target_name) + return value if value else None + if target_kind == "arg": + return _arg_value(list(cfg.args), target_name) + if target_kind == "url_param" and cfg.url: + parsed = urllib.parse.urlsplit(cfg.url) + values = urllib.parse.parse_qs(parsed.query).get(target_name) + if values: + return values[0] + return None + + +def _field_configured(field: McpPresetField, cfg: MCPServerConfig | None) -> bool: + value = _field_value_from_config(field, cfg) + if value: + return True + return bool(field.env_var and os.environ.get(field.env_var)) + + +def _field_payload(field: McpPresetField, cfg: MCPServerConfig | None) -> dict[str, Any]: + return { + "name": field.name, + "label": field.label, + "secret": field.secret, + "required": field.required, + "configured": _field_configured(field, cfg), + "placeholder": field.placeholder, + "env_var": field.env_var, + } + + +def _resolve_field_value( + field: McpPresetField, + query: QueryParams, + existing: MCPServerConfig | None, +) -> str | None: + provided = _query_value(query, field.name) + if provided: + return provided + current = _field_value_from_config(field, existing) + if current: + return current + if field.env_var and os.environ.get(field.env_var): + return f"${{{field.env_var}}}" + return None + + +def _materialize_server( + preset: McpPreset, + query: QueryParams, + existing: MCPServerConfig | None, +) -> MCPServerConfig: + if preset.server is None or not preset.install_supported: + raise McpPresetError(f"{preset.display_name} is not supported yet", status=409) + + cfg = _clone_server(preset.server) + for field_spec in preset.fields: + value = _resolve_field_value(field_spec, query, existing) + if field_spec.required and not value: + raise McpPresetError(f"missing {field_spec.label}") + if not value: + continue + target_kind, target_name = field_spec.target + if target_kind == "env": + cfg.env[target_name] = value + elif target_kind == "header": + cfg.headers[target_name] = value + elif target_kind == "arg": + cfg.args = _with_arg_value(list(cfg.args), target_name, value) + elif target_kind == "url_param": + cfg.url = _url_with_param(cfg.url, target_name, value) + return _with_managed_stdio_cwd(preset.name, cfg) + + +def _command_available(command: str) -> bool: + if not command: + return False + if shutil.which(command): + return True + path = Path(command).expanduser() + return path.exists() and path.is_file() + + +def _config_available(cfg: MCPServerConfig | None) -> bool: + if cfg is None: + return False + if cfg.command: + return _command_available(cfg.command) + if cfg.url: + return True + return False + + +def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str: + if cfg is None: + return "not_installed" if preset.install_supported else "coming_soon" + if any(field.required and not _field_configured(field, cfg) for field in preset.fields): + return "missing_credentials" + if cfg.command and not _command_available(cfg.command): + return "missing_dependency" + return "configured" + + +def _connection_summary(cfg: MCPServerConfig | None) -> str: + if cfg is None: + return "" + if cfg.command: + return " ".join([cfg.command, *cfg.args[:2]]).strip() + if cfg.url: + parsed = urllib.parse.urlsplit(cfg.url) + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + return "" + + +def _tool_allowlist(cfg: MCPServerConfig | None) -> list[str]: + if cfg is None: + return ["*"] + return list(cfg.enabled_tools) + + +def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]: + cfg = configured_servers.get(preset.name) + status = _status_for(preset, cfg) + configured = cfg is not None and status not in {"missing_credentials"} + return { + "name": preset.name, + "display_name": preset.display_name, + "category": preset.category, + "description": preset.description, + "docs_url": preset.docs_url, + "transport": preset.transport, + "requires": preset.requires, + "note": preset.note, + "install_supported": preset.install_supported, + "installed": cfg is not None, + "configured": configured, + "available": configured and _config_available(cfg), + "status": status, + "logo_url": _favicon_url(preset.brand_domain), + "brand_color": preset.brand_color, + "required_fields": [_field_payload(field, cfg) for field in preset.fields], + "connection_summary": _connection_summary(cfg), + "enabled_tools": _tool_allowlist(cfg), + "source": "preset", + } + + +def _custom_payload( + name: str, + cfg: MCPServerConfig, + *, + tool_names: list[str] | None = None, +) -> dict[str, Any]: + transport = cfg.type + if not transport: + transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp") + status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured" + return { + "name": name, + "display_name": name, + "category": "custom", + "description": "Custom MCP server from nanobot config.", + "docs_url": "", + "transport": transport, + "requires": "", + "note": "", + "install_supported": True, + "installed": True, + "configured": True, + "available": _config_available(cfg), + "status": status, + "logo_url": None, + "brand_color": "#64748B", + "required_fields": [], + "connection_summary": _connection_summary(cfg), + "enabled_tools": _tool_allowlist(cfg), + "tool_names": tool_names or [], + "source": "custom", + } + + +def mcp_presets_payload( + *, + last_action: dict[str, Any] | None = None, + tool_preview: Mapping[str, list[str]] | None = None, +) -> dict[str, Any]: + config = load_config() + known = _known_preset_names() + preset_rows = [ + _preset_payload(preset, config.tools.mcp_servers) + | ({"tool_names": tool_preview.get(preset.name, [])} if tool_preview and preset.name in tool_preview else {}) + for preset in MCP_PRESETS + ] + custom_rows = [ + _custom_payload(name, cfg, tool_names=(tool_preview or {}).get(name)) + for name, cfg in sorted(config.tools.mcp_servers.items()) + if name not in known + ] + payload: dict[str, Any] = { + "presets": [*preset_rows, *custom_rows], + "installed_count": len(config.tools.mcp_servers), + } + if last_action is not None: + payload["last_action"] = last_action + return payload + + +def _display_name_for(name: str, preset: McpPreset | None = None) -> str: + return preset.display_name if preset is not None else name + + +def _action_message(action: str, preset: McpPreset, *, ok: bool = True) -> dict[str, Any]: + verb = { + "enable": "Enabled", + "remove": "Removed", + "test": "Checked", + }.get(action, "Updated") + return { + "ok": ok, + "message": f"{verb} MCP preset for {preset.display_name}.", + } + + +def _server_action_message(action: str, name: str, *, ok: bool = True) -> dict[str, Any]: + verb = { + "custom": "Saved", + "import": "Imported", + "import-cursor": "Imported", + "tools": "Updated tools for", + "remove": "Removed", + }.get(action, "Updated") + return { + "ok": ok, + "message": f"{verb} MCP server {name}.", + } + + +def _scrub_test_error(text: str) -> str: + scrubbed = _SECRET_QUERY_RE.sub(r"\1", text.strip()) + scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1", scrubbed) + return scrubbed[:400] if scrubbed else "Connection failed." + + +def _checked_at() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _test_timeout(cfg: MCPServerConfig) -> int: + raw = cfg.tool_timeout or _DEFAULT_TEST_TIMEOUT + return max(5, min(int(raw), _DEFAULT_TEST_TIMEOUT)) + + +async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None: + for stack in stacks.values(): + with suppress(Exception): + await stack.aclose() + + +async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]: + """Connect to an enabled MCP preset and report its tool surface.""" + from nanobot.agent.tools.mcp import connect_mcp_servers + + name = (_query_first(query, "name") or "").strip() + if not name: + raise McpPresetError("missing MCP preset name") + if _MCP_PRESET_NAME_RE.match(name) is None: + raise McpPresetError("invalid MCP server name") + preset = _preset_by_name_optional(name) + display_name = _display_name_for(name, preset) + + try: + config = resolve_config_env_vars(load_config()) + except ValueError as exc: + return mcp_presets_payload(last_action={ + "ok": False, + "message": _scrub_test_error(str(exc)), + "error": _scrub_test_error(str(exc)), + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + }) + + cfg = config.tools.mcp_servers.get(name) + if cfg is None: + raise McpPresetError(f"{display_name} is not enabled", status=404) + + status = _status_for(preset, cfg) if preset is not None else ( + "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured" + ) + if status == "missing_credentials": + last_action = { + "ok": False, + "message": f"{display_name} is missing required credentials.", + "error": "missing credentials", + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + } + return mcp_presets_payload(last_action=last_action) + + if cfg.command and not _command_available(cfg.command): + last_action = { + "ok": False, + "message": f"{display_name} requires '{cfg.command}' on PATH.", + "error": "missing dependency", + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + } + return mcp_presets_payload(last_action=last_action) + + registry = ToolRegistry() + stacks: dict[str, Any] = {} + try: + stacks = await asyncio.wait_for( + connect_mcp_servers({name: cfg}, registry), + timeout=_test_timeout(cfg), + ) + tool_prefix = f"mcp_{name}_" + tool_names = sorted(name for name in registry.tool_names if name.startswith(tool_prefix)) + ok = name in stacks + if ok: + last_action = { + "ok": True, + "message": ( + f"{display_name} connected with {len(tool_names)} tools." + if tool_names + else f"{display_name} connected, but reported no tools." + ), + "tool_count": len(tool_names), + "tool_names": tool_names[:_MAX_TEST_TOOLS], + "checked_at": _checked_at(), + } + else: + last_action = { + "ok": False, + "message": f"{display_name} did not complete an MCP handshake.", + "error": "MCP handshake failed", + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + } + except asyncio.TimeoutError: + last_action = { + "ok": False, + "message": f"{display_name} test timed out.", + "error": "timeout", + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + } + except Exception as exc: + error = _scrub_test_error(str(exc)) + last_action = { + "ok": False, + "message": f"{display_name} could not connect.", + "error": error, + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + } + finally: + await _close_mcp_stacks(stacks) + + preview = {name: last_action.get("tool_names", [])} if last_action.get("tool_names") else None + return mcp_presets_payload(last_action=last_action, tool_preview=preview) + + +def _parse_json_value(raw: str | None, *, fallback: Any) -> Any: + if raw is None or not raw.strip(): + return fallback + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise McpPresetError(f"invalid JSON: {exc.msg}") from exc + + +def _parse_string_list(raw: str | None) -> list[str]: + if raw is None or not raw.strip(): + return [] + parsed = _parse_json_value(raw, fallback=None) + if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed): + return [item for item in parsed if item.strip()] + if isinstance(parsed, str): + return shlex.split(parsed) + raise McpPresetError("expected a JSON string array") + + +def _parse_string_map(raw: str | None) -> dict[str, str]: + parsed = _parse_json_value(raw, fallback={}) + if not isinstance(parsed, dict): + raise McpPresetError("expected a JSON object") + out: dict[str, str] = {} + for key, value in parsed.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise McpPresetError("JSON object values must be strings") + if key.strip(): + out[key.strip()] = value + return out + + +def _parse_enabled_tools(raw: str | None) -> list[str]: + if raw is None or not raw.strip(): + return ["*"] + values = _parse_string_list(raw) + if "*" in values: + return ["*"] + return values + + +def _normalize_transport(value: str | None, *, command: str = "", url: str = "") -> Literal["stdio", "sse", "streamableHttp"]: + raw = (value or "").strip() + if not raw: + if command: + return "stdio" + if url.rstrip("/").endswith("/sse"): + return "sse" + return "streamableHttp" + aliases = { + "stdio": "stdio", + "sse": "sse", + "streamableHttp": "streamableHttp", + "streamable-http": "streamableHttp", + "streamable_http": "streamableHttp", + "http": "streamableHttp", + } + normalized = aliases.get(raw) + if normalized is None: + raise McpPresetError("unsupported MCP transport") + return normalized # type: ignore[return-value] + + +def _validated_server_name(name: str) -> str: + if not name or _MCP_PRESET_NAME_RE.match(name) is None: + raise McpPresetError("invalid MCP server name") + return name.strip().lower() + + +def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]: + name = _validated_server_name((_query_first(query, "name") or "").strip()) + command = (_query_first(query, "command") or "").strip() + url = (_query_first(query, "url") or "").strip() + transport = _normalize_transport(_query_first(query, "transport"), command=command, url=url) + if transport == "stdio" and not command: + raise McpPresetError("stdio MCP servers require a command") + if transport in {"sse", "streamableHttp"} and not url: + raise McpPresetError("remote MCP servers require a URL") + raw_timeout = (_query_first(query, "tool_timeout") or "").strip() + tool_timeout = _DEFAULT_CUSTOM_TIMEOUT + if raw_timeout: + try: + tool_timeout = max(5, min(int(raw_timeout), 600)) + except ValueError as exc: + raise McpPresetError("tool_timeout must be an integer") from exc + cfg = MCPServerConfig( + type=transport, + command=command if transport == "stdio" else "", + args=_parse_string_list(_query_first(query, "args")), + env=_parse_string_map(_query_first(query, "env")), + cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "", + url=url if transport in {"sse", "streamableHttp"} else "", + headers=_parse_string_map(_query_first(query, "headers")), + tool_timeout=tool_timeout, + enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")), + ) + return name, cfg + + +def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]: + server_name = _validated_server_name(name) + if not isinstance(raw, Mapping): + raise McpPresetError(f"MCP server '{server_name}' must be an object") + command = str(raw.get("command") or "").strip() + url = str(raw.get("url") or "").strip() + transport_value = str(raw.get("type", raw.get("transport", "")) or "") + transport = _normalize_transport(transport_value, command=command, url=url) + if transport == "stdio" and not command: + raise McpPresetError(f"MCP server '{server_name}' stdio transport requires a command") + if transport in {"sse", "streamableHttp"} and not url: + raise McpPresetError(f"MCP server '{server_name}' remote transport requires a URL") + args = raw.get("args") or [] + env = raw.get("env") or {} + headers = raw.get("headers") or {} + cwd = str(raw.get("cwd") or "").strip() + enabled_tools = raw.get("enabledTools", raw.get("enabled_tools", ["*"])) + tool_timeout = raw.get("toolTimeout", raw.get("tool_timeout", _DEFAULT_CUSTOM_TIMEOUT)) + try: + timeout_int = max(5, min(int(tool_timeout), 600)) + except (TypeError, ValueError): + timeout_int = _DEFAULT_CUSTOM_TIMEOUT + if not isinstance(args, list) or not all(isinstance(item, str) for item in args): + raise McpPresetError(f"MCP server '{server_name}' args must be a string array") + if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()): + raise McpPresetError(f"MCP server '{server_name}' env must be a string object") + if not isinstance(headers, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()): + raise McpPresetError(f"MCP server '{server_name}' headers must be a string object") + if not isinstance(enabled_tools, list) or not all(isinstance(item, str) for item in enabled_tools): + enabled_tools = ["*"] + return server_name, MCPServerConfig( + type=transport, + command=command if transport == "stdio" else "", + args=args, + env=dict(env), + cwd=cwd if transport == "stdio" else "", + url=url if transport in {"sse", "streamableHttp"} else "", + headers=dict(headers), + tool_timeout=timeout_int, + enabled_tools=list(enabled_tools), + ) + + +def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]: + parsed = _parse_json_value(raw_json, fallback=None) + if not isinstance(parsed, Mapping): + raise McpPresetError("MCP config must be a JSON object") + servers = parsed.get("mcpServers", parsed) + if not isinstance(servers, Mapping): + raise McpPresetError("MCP config must contain mcpServers") + out: dict[str, MCPServerConfig] = {} + for name, raw_server in servers.items(): + if not isinstance(name, str): + raise McpPresetError("MCP server names must be strings") + server_name, cfg = _mcp_server_config(name, raw_server) + out[server_name] = cfg + if not out: + raise McpPresetError("MCP config contains no servers") + return out + + +def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]: + config = load_config() + if action == "custom": + name, cfg = _custom_server_from_query(query) + config.tools.mcp_servers[name] = cfg + save_config(config) + payload = mcp_presets_payload(last_action=_server_action_message(action, name)) + payload["requires_restart"] = True + return payload + + if action in {"import", "import-cursor"}: + servers = _import_mcp_servers(_query_first(query, "config")) + config.tools.mcp_servers.update(servers) + save_config(config) + payload = mcp_presets_payload(last_action={ + "ok": True, + "message": f"Imported {len(servers)} MCP server(s).", + }) + payload["requires_restart"] = True + return payload + + if action == "tools": + name = _validated_server_name((_query_first(query, "name") or "").strip()) + cfg = config.tools.mcp_servers.get(name) + if cfg is None: + raise McpPresetError("unknown MCP server", status=404) + cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools")) + config.tools.mcp_servers[name] = cfg + save_config(config) + payload = mcp_presets_payload(last_action=_server_action_message(action, name)) + payload["requires_restart"] = True + return payload + + raise McpPresetError(f"unknown MCP action '{action}'", status=404) + + +def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]: + name = (_query_first(query, "name") or "").strip() + if not name: + raise McpPresetError("missing MCP preset name") + preset = _preset_by_name_optional(name) + + config = load_config() + existing = config.tools.mcp_servers.get(name) + + if action == "enable": + if preset is None: + raise McpPresetError("unknown MCP preset", status=404) + config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing) + save_config(config) + payload = mcp_presets_payload(last_action=_action_message(action, preset)) + payload["requires_restart"] = True + return payload + + if action == "remove": + if preset is None and name not in config.tools.mcp_servers: + raise McpPresetError("unknown MCP server", status=404) + if name in config.tools.mcp_servers: + del config.tools.mcp_servers[name] + save_config(config) + last_action = ( + _action_message(action, preset) + if preset is not None + else _server_action_message(action, name) + ) + payload = mcp_presets_payload(last_action=last_action) + payload["requires_restart"] = True + return payload + + if action == "test": + raise McpPresetError("MCP preset test must run through the async test action", status=500) + + raise McpPresetError(f"unknown MCP preset action '{action}'", status=404) + + +def attach_mcp_hot_reload_result( + payload: dict[str, Any], + result: dict[str, Any], +) -> dict[str, Any]: + """Merge an agent MCP reload acknowledgement into a WebUI settings payload.""" + payload = dict(payload) + payload["hot_reload"] = result + payload["requires_restart"] = bool(result.get("requires_restart")) + last_action = dict(payload.get("last_action") or {}) + base_message = str(last_action.get("message") or "").strip() + reload_message = str(result.get("message") or "").strip() + if reload_message: + last_action["message"] = ( + f"{base_message} {reload_message}" if base_message else reload_message + ) + if "ok" not in last_action: + last_action["ok"] = bool(result.get("ok", False)) + payload["last_action"] = last_action + return payload + + +async def mcp_presets_settings_action( + action: str | None, + query: QueryParams, + *, + reload_mcp: McpReload | None = None, +) -> dict[str, Any]: + """Run a WebUI MCP preset action and hot-reload the agent when config changes.""" + if action is None: + return mcp_presets_payload() + if action == "test": + return await mcp_presets_test_action(query) + if action in _CUSTOM_ACTIONS: + payload = await asyncio.to_thread(custom_mcp_action, action, query) + else: + payload = await asyncio.to_thread(mcp_presets_action, action, query) + if reload_mcp is not None: + payload = attach_mcp_hot_reload_result(payload, await reload_mcp()) + return payload diff --git a/nanobot/webui/mcp_presets_runtime.py b/nanobot/webui/mcp_presets_runtime.py new file mode 100644 index 000000000..1294ccc85 --- /dev/null +++ b/nanobot/webui/mcp_presets_runtime.py @@ -0,0 +1,5 @@ +"""Compatibility exports for WebUI-attached MCP preset annotations.""" + +from nanobot.agent.tools.mcp import runtime_lines, session_extra + +__all__ = ["runtime_lines", "session_extra"] diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 6d43e22c8..7019597a9 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -6,10 +6,12 @@ settings payload shape and the allowlisted config mutations exposed to WebUI. from __future__ import annotations +import re from typing import Any from zoneinfo import ZoneInfo from nanobot.config.loader import get_config_path, load_config, save_config +from nanobot.config.schema import ModelPresetConfig from nanobot.providers.image_generation import ( get_image_gen_provider, image_gen_provider_names, @@ -41,6 +43,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = { "2:3", "21:9", } +_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") class WebUISettingsError(ValueError): @@ -100,6 +103,32 @@ def _parse_bool(value: str, field: str) -> bool: return normalized in {"1", "true", "yes"} +def _model_configuration_slug(label: str) -> str: + normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower()) + normalized = normalized.strip("-_") + if not normalized: + raise WebUISettingsError("configuration name is required") + if normalized == "default": + raise WebUISettingsError("configuration name is reserved") + if len(normalized) > 48: + normalized = normalized[:48].rstrip("-_") + return normalized + + +def _validate_configured_provider(config: Any, provider: str) -> None: + if provider == "auto": + return + spec = find_by_name(provider) + if spec is None: + raise WebUISettingsError("unknown provider") + provider_config = getattr(config.providers, provider, None) + if ( + provider_config is None + or not _provider_configured_for_settings(spec, provider_config) + ): + raise WebUISettingsError("provider is not configured") + + def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for name in image_gen_provider_names(): @@ -198,7 +227,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]: model_presets.append( { "name": name, - "label": name, + "label": preset.label or name, "active": active_preset_name == name, "is_default": False, "model": preset.model, @@ -321,15 +350,7 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]: provider = provider.strip() if not provider: raise WebUISettingsError("provider is required") - spec = find_by_name(provider) - if spec is None: - raise WebUISettingsError("unknown provider") - provider_config = getattr(config.providers, provider, None) - if ( - provider_config is None - or not _provider_configured_for_settings(spec, provider_config) - ): - raise WebUISettingsError("provider is not configured") + _validate_configured_provider(config, provider) if defaults.provider != provider: defaults.provider = provider changed = True @@ -388,6 +409,40 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]: return settings_payload(requires_restart=restart_required) +def create_model_configuration(query: QueryParams) -> dict[str, Any]: + label = (_query_first_alias(query, "label", "displayName") or "").strip() + raw_name = (_query_first(query, "name") or label).strip() + model = (_query_first(query, "model") or "").strip() + provider = (_query_first(query, "provider") or "").strip() + + if not label: + label = raw_name + if not model: + raise WebUISettingsError("model is required") + if not provider: + raise WebUISettingsError("provider is required") + + name = _model_configuration_slug(raw_name or label) + config = load_config() + if name in config.model_presets: + raise WebUISettingsError("configuration already exists", status=409) + _validate_configured_provider(config, provider) + + base = config.resolve_default_preset() + config.model_presets[name] = ModelPresetConfig( + label=label, + model=model, + provider=provider, + max_tokens=base.max_tokens, + context_window_tokens=base.context_window_tokens, + temperature=base.temperature, + reasoning_effort=base.reasoning_effort, + ) + config.agents.defaults.model_preset = name + save_config(config) + return settings_payload() + + def update_provider_settings(query: QueryParams) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip() if not provider_name: diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 90f2e3b09..be525ac93 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -4,10 +4,12 @@ from __future__ import annotations import json import os +import re import time import uuid from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Mapping +from urllib.parse import unquote, urlparse from loguru import logger @@ -16,6 +18,61 @@ from nanobot.session.manager import SessionManager WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3 _MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024 +_MARKDOWN_LOCAL_IMAGE_RE = re.compile( + r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)" +) +_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", +}) + + +def rewrite_local_markdown_images( + text: str, + *, + workspace_path: Path, + sign_path: Callable[[Path], Mapping[str, Any] | None], +) -> str: + """Rewrite markdown image paths inside the workspace to signed WebUI media URLs.""" + if "![" not in text: + return text + + def resolve_url(raw_url: str) -> str | None: + url = raw_url.strip() + if url.startswith("<") and url.endswith(">"): + url = url[1:-1].strip() + if not url or url.startswith(("/api/media/", "#")): + return None + parsed = urlparse(url) + if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: + return None + path_text = unquote(url) + if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS: + return None + candidate = Path(path_text).expanduser() + if not candidate.is_absolute(): + candidate = workspace_path / candidate + try: + resolved = candidate.resolve(strict=False) + resolved.relative_to(workspace_path) + except (OSError, ValueError): + return None + if not resolved.is_file(): + return None + signed = sign_path(resolved) + return str(signed.get("url")) if signed and signed.get("url") else None + + def replace(match: re.Match[str]) -> str: + signed_url = resolve_url(match.group(2)) + if not signed_url: + return match.group(0) + title = match.group(3) or "" + return f"![{match.group(1)}]({signed_url}{title})" + + return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text) def webui_transcript_path(session_key: str) -> Path: @@ -458,6 +515,11 @@ def replay_transcript_to_ui_messages( cli_apps = rec.get("cli_apps") if isinstance(cli_apps, list) and cli_apps: row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)] + mcp_presets = rec.get("mcp_presets") + if isinstance(mcp_presets, list) and mcp_presets: + row["mcpPresets"] = [ + dict(preset) for preset in mcp_presets if isinstance(preset, dict) + ] messages.append(row) continue diff --git a/tests/agent/test_mcp_connection.py b/tests/agent/test_mcp_connection.py index e7d0a7854..18d118c25 100644 --- a/tests/agent/test_mcp_connection.py +++ b/tests/agent/test_mcp_connection.py @@ -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() diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 0a0909de7..8ea2f24a4 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -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(): diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index f40ec4872..03143d540 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -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" diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 502b50842..18baf700e 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -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 diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index de39d1a67..68fadce44 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py new file mode 100644 index 000000000..6ddde0b47 --- /dev/null +++ b/tests/webui/test_mcp_presets_api.py @@ -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 "" 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"}] diff --git a/tests/webui/test_mcp_presets_runtime.py b/tests/webui/test_mcp_presets_runtime.py new file mode 100644 index 000000000..6abef66f5 --- /dev/null +++ b/tests/webui/test_mcp_presets_runtime.py @@ -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"}]} diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py new file mode 100644 index 000000000..d1b6c175e --- /dev/null +++ b/tests/webui/test_settings_api.py @@ -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"], + } + ) diff --git a/webui/src/components/CliAppMentionText.tsx b/webui/src/components/CliAppMentionText.tsx index d9adbd877..33a9114f2 100644 --- a/webui/src/components/CliAppMentionText.tsx +++ b/webui/src/components/CliAppMentionText.tsx @@ -1,12 +1,17 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; -import type { CliAppInfo } from "@/lib/types"; +import { logoFallbackUrls } from "@/lib/provider-brand"; +import type { CliAppInfo, McpPresetInfo } from "@/lib/types"; import { cn } from "@/lib/utils"; export type CliAppMentionSegment = | { kind: "text"; text: string } | { kind: "cli"; text: string; app: CliAppInfo }; +export type CapabilityMentionSegment = + | CliAppMentionSegment + | { kind: "mcp"; text: string; preset: McpPresetInfo }; + export function cliAppInitials(app: CliAppInfo): string { const value = app.display_name || app.name; return ( @@ -19,6 +24,18 @@ export function cliAppInitials(app: CliAppInfo): string { ); } +export function mcpPresetInitials(preset: Pick): string { + const value = preset.display_name || preset.name; + return ( + value + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || preset.name.slice(0, 2).toUpperCase() + ); +} + export function splitCliAppMentionSegments( value: string, cliApps: CliAppInfo[], @@ -55,22 +72,76 @@ export function splitCliAppMentionSegments( return segments.length ? segments : [{ kind: "text", text: value }]; } +export function splitCapabilityMentionSegments( + value: string, + cliApps: CliAppInfo[], + mcpPresets: McpPresetInfo[] = [], +): CapabilityMentionSegment[] { + if (!value || (cliApps.length === 0 && mcpPresets.length === 0)) { + return value ? [{ kind: "text", text: value }] : []; + } + const cliAppsByName = new Map( + cliApps + .filter((app) => app.installed) + .map((app) => [app.name.toLowerCase(), app]), + ); + const mcpPresetsByName = new Map( + mcpPresets + .filter((preset) => preset.installed && preset.configured) + .map((preset) => [preset.name.toLowerCase(), preset]), + ); + if (cliAppsByName.size === 0 && mcpPresetsByName.size === 0) { + return [{ kind: "text", text: value }]; + } + + const segments: CapabilityMentionSegment[] = []; + const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi; + let cursor = 0; + let match: RegExpExecArray | null; + while ((match = mentionRe.exec(value)) !== null) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const key = name.toLowerCase(); + const app = cliAppsByName.get(key); + const preset = app ? null : mcpPresetsByName.get(key); + if (!app && !preset) continue; + + const mentionStart = match.index + prefix.length; + const mentionEnd = mentionStart + name.length + 1; + if (mentionStart > cursor) { + segments.push({ kind: "text", text: value.slice(cursor, mentionStart) }); + } + if (app) { + segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app }); + } else if (preset) { + segments.push({ kind: "mcp", text: value.slice(mentionStart, mentionEnd), preset }); + } + cursor = mentionEnd; + } + if (cursor < value.length) { + segments.push({ kind: "text", text: value.slice(cursor) }); + } + return segments.length ? segments : [{ kind: "text", text: value }]; +} + export function CliAppMentionText({ text, cliApps, + mcpPresets = [], }: { text: string; cliApps: CliAppInfo[]; + mcpPresets?: McpPresetInfo[]; }) { - const segments = splitCliAppMentionSegments(text, cliApps); - if (!segments.some((segment) => segment.kind === "cli")) return <>{text}; + const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets); + if (!segments.some((segment) => segment.kind === "cli" || segment.kind === "mcp")) return <>{text}; return ( <> {segments.map((segment, index) => { if (segment.kind === "text") { return {segment.text}; } - return ( + if (segment.kind === "cli") return ( ); + return ( + + ); })} ); @@ -94,15 +173,20 @@ export function CliAppMentionToken({ variant: "composer" | "message"; isHero?: boolean; }) { - const [failed, setFailed] = useState(false); + const [logoIndex, setLogoIndex] = useState(0); const color = app.brand_color || "hsl(var(--primary))"; const mentionName = label.startsWith("@") ? label.slice(1) : label; - const showLogo = Boolean(app.logo_url) && !failed; + const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]); + const logoUrl = logoUrls[logoIndex]; + const showLogo = Boolean(logoUrl); const testIdPrefix = variant === "composer" ? "composer" : "message"; + useEffect(() => setLogoIndex(0), [app.logo_url]); + return ( setFailed(true)} + onError={() => setLogoIndex((index) => index + 1)} + /> + + ) : null} + + {mentionName} + + ); +} + +export function McpPresetMentionToken({ + preset, + label, + variant, + isHero = false, +}: { + preset: McpPresetInfo; + label: string; + variant: "composer" | "message"; + isHero?: boolean; +}) { + const [logoIndex, setLogoIndex] = useState(0); + const color = preset.brand_color || "hsl(var(--primary))"; + const mentionName = label.startsWith("@") ? label.slice(1) : label; + const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]); + const logoUrl = logoUrls[logoIndex]; + const showLogo = Boolean(logoUrl); + const testIdPrefix = variant === "composer" ? "composer" : "message"; + + useEffect(() => setLogoIndex(0), [preset.logo_url]); + + return ( + + + @ + {showLogo ? ( + + setLogoIndex((index) => index + 1)} /> ) : null} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 9868ef31f..157d4e291 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -14,13 +14,22 @@ import { ImageLightbox } from "@/components/ImageLightbox"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; import { formatTurnLatency } from "@/lib/format"; -import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types"; +import type { + CliAppInfo, + McpPresetInfo, + UICliAppAttachment, + UIMcpPresetAttachment, + UIImage, + UIMediaAttachment, + UIMessage, +} from "@/lib/types"; interface MessageBubbleProps { message: UIMessage; /** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */ showAssistantCopyAction?: boolean; cliApps?: CliAppInfo[]; + mcpPresets?: McpPresetInfo[]; } /** @@ -36,6 +45,7 @@ export function MessageBubble({ message, showAssistantCopyAction = true, cliApps = [], + mcpPresets = [], }: MessageBubbleProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -45,6 +55,10 @@ export function MessageBubble({ () => mergeCliMentionApps(cliApps, message.cliApps), [cliApps, message.cliApps], ); + const mentionMcpPresets = useMemo( + () => mergeMcpMentionPresets(mcpPresets, message.mcpPresets), + [mcpPresets, message.mcpPresets], + ); useEffect(() => { return () => { @@ -96,7 +110,11 @@ export function MessageBubble({ "text-left text-[16px]/[1.75] whitespace-pre-wrap break-words", )} > - +

) : null} @@ -166,6 +184,39 @@ export function MessageBubble({ ); } +function mergeMcpMentionPresets( + presets: McpPresetInfo[], + attachments: UIMcpPresetAttachment[] | undefined, +): McpPresetInfo[] { + if (!attachments?.length) return presets; + const byName = new Map(presets.map((preset) => [preset.name.toLowerCase(), preset])); + for (const attachment of attachments) { + const name = attachment.name?.trim(); + if (!name) continue; + const existing = byName.get(name.toLowerCase()); + byName.set(name.toLowerCase(), { + name, + display_name: attachment.display_name || existing?.display_name || name, + category: attachment.category || existing?.category || "mcp", + description: existing?.description || "", + docs_url: existing?.docs_url || "", + transport: attachment.transport || existing?.transport || "mcp", + requires: existing?.requires || "", + note: existing?.note || "", + install_supported: existing?.install_supported ?? true, + installed: true, + configured: attachment.configured ?? existing?.configured ?? true, + available: existing?.available ?? true, + status: attachment.status || existing?.status || "configured", + logo_url: attachment.logo_url ?? existing?.logo_url ?? null, + brand_color: attachment.brand_color ?? existing?.brand_color ?? null, + required_fields: existing?.required_fields || [], + connection_summary: existing?.connection_summary || "", + }); + } + return Array.from(byName.values()); +} + function mergeCliMentionApps( cliApps: CliAppInfo[], attachments: UICliAppAttachment[] | undefined, diff --git a/webui/src/components/SessionSearchDialog.tsx b/webui/src/components/SessionSearchDialog.tsx index 1e0f12044..a512f33f5 100644 --- a/webui/src/components/SessionSearchDialog.tsx +++ b/webui/src/components/SessionSearchDialog.tsx @@ -33,6 +33,7 @@ export function SessionSearchDialog({ }: SessionSearchDialogProps) { const { t } = useTranslation(); const inputRef = useRef(null); + const itemRefs = useRef>([]); const [query, setQuery] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); @@ -46,7 +47,6 @@ export function SessionSearchDialog({ ); }, [normalizedQuery, open, sessions, titleOverrides]); const itemCount = sessionResults.length; - const shortcutLabel = useMemo(getSearchShortcutLabel, []); useEffect(() => { if (!open) return; @@ -65,6 +65,18 @@ export function SessionSearchDialog({ ); }, [itemCount]); + useEffect(() => { + itemRefs.current = itemRefs.current.slice(0, itemCount); + }, [itemCount]); + + useEffect(() => { + if (!open) return; + itemRefs.current[highlightedIndex]?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [highlightedIndex, open]); + const handleSelect = (key: string) => { onOpenChange(false); onSelect(key); @@ -107,18 +119,18 @@ export function SessionSearchDialog({ {t("sidebar.searchAria")} {t("sidebar.searchPlaceholder")} -
+
- - {shortcutLabel} -
-
+
-
+
{sectionLabel}
@@ -150,7 +162,7 @@ export function SessionSearchDialog({ {emptyLabel}
) : ( -
    +
      {sessionResults.map((session, index) => { const title = titleOverrides[session.key]?.trim() || session.title?.trim() || @@ -164,15 +176,18 @@ export function SessionSearchDialog({ return (
    • ); @@ -221,13 +236,3 @@ function sessionMatchesTerms( return terms.every((term) => haystack.includes(term)); } - -function getSearchShortcutLabel() { - if (typeof navigator === "undefined") return "Ctrl K"; - const platform = navigator.platform.toLowerCase(); - const apple = - platform.includes("mac") || - platform.includes("iphone") || - platform.includes("ipad"); - return apple ? "⌘K" : "Ctrl K"; -} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index fa74c36a1..1176fe317 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -27,13 +27,13 @@ import { Hexagon, ImageIcon, Info, - KeyRound, Layers, Loader2, LogOut, Moon, Package, PlayCircle, + Plus, Orbit, Palette, Pencil, @@ -59,23 +59,46 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + createModelConfiguration, fetchSettings, fetchCliApps, + fetchMcpPresets, + importMcpConfig, runCliAppAction, + runMcpPresetAction, + saveCustomMcpServer, updateImageGenerationSettings, + updateMcpServerTools, updateProviderSettings, updateSettings, updateWebSearchSettings, } from "@/lib/api"; import { notifyCliAppsChanged } from "@/lib/cli-app-events"; +import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; +import { + logoFallbackUrls, + providerBrand, + providerDisplayLabel, +} from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; import type { CliAppInfo, CliAppsPayload, ImageGenerationSettingsUpdate, + McpPresetInfo, + McpPresetsPayload, SettingsPayload, WebSearchSettingsUpdate, } from "@/lib/types"; @@ -84,10 +107,10 @@ type SettingsSectionKey = | "overview" | "appearance" | "models" - | "providers" | "image" | "web" | "cliApps" + | "mcp" | "runtime" | "advanced"; @@ -111,8 +134,52 @@ interface AgentSettingsDraft { toolHintMaxLength: number; } +interface ModelConfigurationDraft { + label: string; + provider: string; + model: string; +} + type PendingRestartSection = "runtime" | "web" | "image"; type PendingRestartSections = Record; +type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; + +const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png"; + +const FALLBACK_TIMEZONES = [ + "UTC", + "Asia/Shanghai", + "Asia/Hong_Kong", + "Asia/Tokyo", + "Asia/Seoul", + "Asia/Singapore", + "Asia/Taipei", + "Asia/Dubai", + "Asia/Kolkata", + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Europe/Amsterdam", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Toronto", + "America/Sao_Paulo", + "Australia/Sydney", + "Pacific/Auckland", +]; + +interface CustomMcpForm { + name: string; + transport: CustomMcpTransport; + command: string; + args: string; + url: string; + env: string; + headers: string; + toolTimeout: string; +} const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; @@ -138,6 +205,17 @@ const EMPTY_PENDING_RESTART_SECTIONS: PendingRestartSections = { image: false, }; +const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = { + name: "", + transport: "stdio", + command: "", + args: "", + url: "", + env: "", + headers: "", + toolTimeout: "30", +}; + interface SettingsViewProps { theme: "light" | "dark"; onToggleTheme: () => void; @@ -190,10 +268,20 @@ export function SettingsView({ const { token } = useClient(); const [settings, setSettings] = useState(null); const [cliApps, setCliApps] = useState(null); + const [mcpPresets, setMcpPresets] = useState(null); const [loading, setLoading] = useState(true); const [cliAppsLoading, setCliAppsLoading] = useState(true); + const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [saving, setSaving] = useState(false); + const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false); + const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); + const [modelConfigurationForm, setModelConfigurationForm] = useState({ + label: "", + provider: "", + model: "", + }); const [cliAppsAction, setCliAppsAction] = useState(null); + const [mcpPresetAction, setMcpPresetAction] = useState(null); const [providerSaving, setProviderSaving] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); const [imageGenerationSaving, setImageGenerationSaving] = useState(false); @@ -207,6 +295,14 @@ export function SettingsView({ const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); + const [mcpQuery, setMcpQuery] = useState(""); + const [mcpCategory, setMcpCategory] = useState("all"); + const [mcpInstallFilter, setMcpInstallFilter] = useState<"all" | "installed" | "notInstalled">("all"); + const [mcpMessage, setMcpMessage] = useState(null); + const [mcpError, setMcpError] = useState(null); + const [mcpFieldValues, setMcpFieldValues] = useState>>({}); + const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM); + const [mcpConfigImport, setMcpConfigImport] = useState(""); const [providerForms, setProviderForms] = useState>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); @@ -327,6 +423,27 @@ export function SettingsView({ }; }, [token]); + useEffect(() => { + let cancelled = false; + setMcpPresetsLoading(true); + fetchMcpPresets(token) + .then((payload) => { + if (!cancelled) { + setMcpPresets(payload); + setMcpError(null); + } + }) + .catch((err) => { + if (!cancelled) setMcpError((err as Error).message); + }) + .finally(() => { + if (!cancelled) setMcpPresetsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token]); + useEffect(() => { try { window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs)); @@ -383,6 +500,14 @@ export function SettingsView({ ); }, [imageGenerationForm, settings]); + const configuredModelProviderOptions = useMemo( + () => + settings?.providers + .filter((provider) => provider.configured) + .map((provider) => ({ name: provider.name, label: provider.label })) ?? [], + [settings], + ); + const hasPendingRestart = useMemo( () => !!settings?.requires_restart || @@ -413,6 +538,45 @@ export function SettingsView({ } }; + const openModelConfigurationDialog = () => { + if (!settings) return; + const currentProvider = settings.agent.provider; + const provider = + configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ?? + configuredModelProviderOptions[0]?.name ?? + ""; + setModelConfigurationForm({ + label: "", + provider, + model: "", + }); + setModelConfigurationOpen(true); + }; + + const handleCreateModelConfiguration = async () => { + if (modelConfigurationSaving) return; + const label = modelConfigurationForm.label.trim(); + const provider = modelConfigurationForm.provider.trim(); + const model = modelConfigurationForm.model.trim(); + if (!label || !provider || !model) return; + setModelConfigurationSaving(true); + try { + const payload = await createModelConfiguration(token, { + label, + provider, + model, + }); + applyPayload(payload); + onModelNameChange(payload.agent.model || null); + setModelConfigurationOpen(false); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setModelConfigurationSaving(false); + } + }; + const saveRuntimeSettings = async () => { if (!settings || !runtimeDirty || saving) return; setSaving(true); @@ -639,6 +803,105 @@ export function SettingsView({ } }; + const handleMcpPresetAction = async ( + action: "enable" | "remove" | "test", + name: string, + values: Record = {}, + ) => { + const key = `${action}:${name}`; + setMcpPresetAction(key); + setMcpMessage(null); + setMcpError(null); + try { + const payload = await runMcpPresetAction(token, action, name, values); + setMcpPresets(payload); + setMcpMessage(payload.last_action?.message ?? null); + if (action !== "test") { + notifyMcpPresetsChanged(payload); + } + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + if (action === "enable") { + setMcpFieldValues((prev) => ({ ...prev, [name]: {} })); + } + } catch (err) { + setMcpError((err as Error).message); + } finally { + setMcpPresetAction(null); + } + }; + + const handleSaveCustomMcp = async () => { + const name = customMcpForm.name.trim(); + const key = `custom:${name || "new"}`; + setMcpPresetAction(key); + setMcpMessage(null); + setMcpError(null); + try { + const payload = await saveCustomMcpServer(token, { + name, + transport: customMcpForm.transport, + command: customMcpForm.command, + args: customMcpForm.args, + url: customMcpForm.url, + env: customMcpForm.env, + headers: customMcpForm.headers, + tool_timeout: customMcpForm.toolTimeout, + }); + setMcpPresets(payload); + setMcpMessage(payload.last_action?.message ?? null); + notifyMcpPresetsChanged(payload); + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + setCustomMcpForm((prev) => ({ ...DEFAULT_CUSTOM_MCP_FORM, transport: prev.transport })); + } catch (err) { + setMcpError((err as Error).message); + } finally { + setMcpPresetAction(null); + } + }; + + const handleImportMcpConfig = async () => { + setMcpPresetAction("import"); + setMcpMessage(null); + setMcpError(null); + try { + const payload = await importMcpConfig(token, mcpConfigImport); + setMcpPresets(payload); + setMcpMessage(payload.last_action?.message ?? null); + notifyMcpPresetsChanged(payload); + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + setMcpConfigImport(""); + } catch (err) { + setMcpError((err as Error).message); + } finally { + setMcpPresetAction(null); + } + }; + + const handleMcpToolsChange = async (name: string, enabledTools: string[]) => { + setMcpPresetAction(`tools:${name}`); + setMcpMessage(null); + setMcpError(null); + try { + const payload = await updateMcpServerTools(token, name, enabledTools); + setMcpPresets(payload); + setMcpMessage(payload.last_action?.message ?? null); + notifyMcpPresetsChanged(payload); + if (payload.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + } catch (err) { + setMcpError((err as Error).message); + } finally { + setMcpPresetAction(null); + } + }; + const renderSection = () => { if (!settings) return null; switch (activeSection) { @@ -649,6 +912,7 @@ export function SettingsView({ requiresRestart={hasPendingRestart} onRestart={onRestart} isRestarting={isRestarting} + showBrandLogos={localPrefs.brandLogos} onSelectSection={setActiveSection} /> ); @@ -663,47 +927,47 @@ export function SettingsView({ ); case "models": return ( - setActiveSection("providers")} - /> - ); - case "providers": - return ( - - setProviderForms((prev) => ({ - ...prev, - [provider]: { - apiKey: prev[provider]?.apiKey ?? "", - apiBase: prev[provider]?.apiBase ?? "", - ...value, - }, - })) - } - onSaveProvider={saveProvider} - onResetProviderDraft={resetProviderDraft} - imageProviderRestartPending={pendingRestartSections.image} - onRestart={onRestart} - isRestarting={isRestarting} - /> +
      + + + setProviderForms((prev) => ({ + ...prev, + [provider]: { + apiKey: prev[provider]?.apiKey ?? "", + apiBase: prev[provider]?.apiBase ?? "", + ...value, + }, + })) + } + onSaveProvider={saveProvider} + onResetProviderDraft={resetProviderDraft} + imageProviderRestartPending={pendingRestartSections.image} + onRestart={onRestart} + isRestarting={isRestarting} + /> +
      ); case "image": return ( @@ -714,7 +978,8 @@ export function SettingsView({ saving={imageGenerationSaving} onChangeForm={setImageGenerationForm} onSave={saveImageGenerationSettings} - onOpenProviders={() => setActiveSection("providers")} + onOpenProviders={() => setActiveSection("models")} + showBrandLogos={localPrefs.brandLogos} onRestart={onRestart} isRestarting={isRestarting} requiresRestartPending={pendingRestartSections.image} @@ -738,6 +1003,7 @@ export function SettingsView({ }} onReset={resetWebSearchDraft} onSave={saveWebSearch} + showBrandLogos={localPrefs.brandLogos} onRestart={onRestart} isRestarting={isRestarting} requiresRestartPending={pendingRestartSections.web} @@ -763,6 +1029,44 @@ export function SettingsView({ onBackToChat={onBackToChat} /> ); + case "mcp": + return ( + { + setMcpFieldValues((prev) => ({ + ...prev, + [presetName]: { + ...(prev[presetName] ?? {}), + [fieldName]: value, + }, + })); + }} + onAction={handleMcpPresetAction} + onSaveCustom={handleSaveCustomMcp} + onImportConfig={handleImportMcpConfig} + onToolsChange={handleMcpToolsChange} + onRestart={onRestart} + isRestarting={isRestarting} + /> + ); case "runtime": return ( + +
      @@ -835,10 +1150,10 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal { key: "overview", icon: Activity, fallback: "Overview" }, { key: "appearance", icon: Palette, fallback: "Appearance" }, { key: "models", icon: SlidersHorizontal, fallback: "Models" }, - { key: "providers", icon: KeyRound, fallback: "Providers" }, { key: "image", icon: ImageIcon, fallback: "Image" }, { key: "web", icon: Globe2, fallback: "Web" }, { key: "cliApps", icon: Package, fallback: "CLI Apps" }, + { key: "mcp", icon: Layers, fallback: "MCP" }, { key: "runtime", icon: Server, fallback: "Runtime" }, { key: "advanced", icon: ShieldCheck, fallback: "Advanced" }, ]; @@ -924,16 +1239,17 @@ function OverviewSettings({ onRestart, isRestarting, onSelectSection, + showBrandLogos, }: { settings: SettingsPayload; requiresRestart: boolean; onRestart?: () => void; isRestarting?: boolean; onSelectSection: (section: SettingsSectionKey) => void; + showBrandLogos: boolean; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const configuredCount = settings.providers.filter((provider) => provider.configured).length; const activePreset = settings.agent.model_preset || "default"; const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider; const webStatus = settings.web.enable @@ -942,7 +1258,7 @@ function OverviewSettings({ const imageStatus = settings.image_generation.enabled ? tx("settings.values.enabled", "Enabled") : tx("settings.values.disabled", "Disabled"); - const imageCaption = `${providerLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${ + const imageCaption = `${providerDisplayLabel(settings.image_generation.providers, settings.image_generation.provider)} · ${ settings.image_generation.provider_configured ? tx("settings.values.configured", "Configured") : tx("settings.values.notConfigured", "Not configured") @@ -953,9 +1269,7 @@ function OverviewSettings({
      - - - +
      nanobot
      @@ -998,24 +1312,13 @@ function OverviewSettings({ onSelectSection("models")} /> - onSelectSection("providers")} - />
@@ -1024,16 +1327,20 @@ function OverviewSettings({ onSelectSection("web")} /> onSelectSection("image")} /> @@ -1161,6 +1468,7 @@ function AppearanceSettings({ onChangeLocalPrefs((prev) => ({ ...prev, codeWrap }))} + ariaLabel={tx("settings.rows.codeWrap", "Code wrapping")} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} /> @@ -1171,6 +1479,7 @@ function AppearanceSettings({ onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))} + ariaLabel={tx("settings.rows.brandLogos", "Brand logos")} label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} /> @@ -1180,22 +1489,140 @@ function AppearanceSettings({ ); } +function NewModelConfigurationDialog({ + open, + draft, + providers, + saving, + showProviderLogos, + onOpenChange, + onChangeDraft, + onSave, +}: { + open: boolean; + draft: ModelConfigurationDraft; + providers: Array<{ name: string; label: string }>; + saving: boolean; + showProviderLogos: boolean; + onOpenChange: (open: boolean) => void; + onChangeDraft: Dispatch>; + onSave: () => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const canSave = Boolean(draft.label.trim() && draft.provider.trim() && draft.model.trim()); + + return ( + + +
{ + event.preventDefault(); + onSave(); + }} + > + + + {tx("settings.models.newConfiguration", "New model configuration")} + + + {tx("settings.models.newConfigurationHelp", "Save a provider and model as a one-click option.")} + + + +
+ + +
+ +
+ + {tx("settings.rows.provider", "Provider")} + + + onChangeDraft((prev) => ({ ...prev, provider })) + } + /> +
+
+
+ + + + + +
+
+
+ ); +} + function ModelsSettings({ form, setForm, settings, dirty, saving, + showBrandLogos, onSave, - onOpenProviders, + onCreateConfiguration, }: { form: AgentSettingsDraft; setForm: Dispatch>; settings: SettingsPayload; dirty: boolean; saving: boolean; + showBrandLogos: boolean; onSave: () => void; - onOpenProviders: () => void; + onCreateConfiguration: () => void; }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); @@ -1207,45 +1634,24 @@ function ModelsSettings({ const providerValue = providerOptions.some((provider) => provider.name === form.provider) ? form.provider : ""; - const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); return (
- {tx("settings.sections.presets", "Presets")} -
- {settings.model_presets.map((preset) => ( - - ))} -
-
- -
- {t("settings.sections.ai")} - {selectedPreset?.label ?? form.modelPreset} + setForm((prev) => ({ ...prev, modelPreset }))} + onCreateConfiguration={onCreateConfiguration} + /> {form.modelPreset === "default" ? ( <> @@ -1257,6 +1663,7 @@ function ModelsSettings({ providers={providerOptions} value={providerValue} emptyLabel={t("settings.byok.noConfiguredProviders")} + showProviderLogos={showBrandLogos} onChange={(provider) => setForm((prev) => ({ ...prev, provider }))} /> @@ -1271,29 +1678,13 @@ function ModelsSettings({ /> - ) : ( - - - {selectedPreset?.model ?? settings.agent.model} - - - )} + ) : null} - {configuredProviders.length === 0 ? ( - - - - ) : null}
@@ -1480,7 +1871,7 @@ function ProvidersSettings({ disabled={saving || missingRequiredApiKey || missingOptionalCredential} className="rounded-full" > - {saving ? t("settings.actions.saving") : t("settings.actions.save")} + {saving ? t("settings.actions.saving") : tx("settings.providers.saveProvider", "Save provider")}
@@ -1552,6 +1943,7 @@ function ImageGenerationSettings({ onChangeForm, onSave, onOpenProviders, + showBrandLogos, onRestart, isRestarting, requiresRestartPending, @@ -1563,6 +1955,7 @@ function ImageGenerationSettings({ onChangeForm: Dispatch>; onSave: () => void; onOpenProviders: () => void; + showBrandLogos: boolean; onRestart?: () => void; isRestarting?: boolean; requiresRestartPending: boolean; @@ -1595,6 +1988,7 @@ function ImageGenerationSettings({ onChangeForm((prev) => ({ ...prev, enabled }))} + ariaLabel={tx("settings.rows.imageGeneration", "Image generation")} label={form.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} /> @@ -1606,6 +2000,7 @@ function ImageGenerationSettings({ providers={settings.image_generation.providers} value={form.provider} emptyLabel={tx("settings.image.selectProvider", "Select provider")} + showProviderLogos={showBrandLogos} onChange={(provider) => onChangeForm((prev) => ({ ...prev, provider }))} /> @@ -1721,6 +2116,7 @@ function WebSettings({ onToggleKeyEditing, onReset, onSave, + showBrandLogos, onRestart, isRestarting, requiresRestartPending, @@ -1736,6 +2132,7 @@ function WebSettings({ onToggleKeyEditing: () => void; onReset: () => void; onSave: () => void; + showBrandLogos: boolean; onRestart?: () => void; isRestarting?: boolean; requiresRestartPending: boolean; @@ -1781,6 +2178,7 @@ function WebSettings({ providers={settings.web_search.providers} value={form.provider} emptyLabel={t("settings.byok.webSearch.selectProvider")} + showProviderLogos={showBrandLogos} onChange={onChangeProvider} /> @@ -1904,6 +2302,7 @@ function WebSettings({ onChangeForm((prev) => ({ ...prev, useJinaReader }))} + ariaLabel={tx("settings.rows.jinaReader", "Jina reader")} label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} /> @@ -2100,6 +2499,818 @@ function CliAppsSettings({ ); } +function McpPresetsSettings({ + payload, + loading, + query, + category, + installFilter, + actionKey, + message, + error, + fieldValues, + customForm, + configImport, + showBrandLogos, + requiresRestartPending, + onQueryChange, + onCategoryChange, + onInstallFilterChange, + onCustomFormChange, + onConfigImportChange, + onFieldChange, + onAction, + onSaveCustom, + onImportConfig, + onToolsChange, + onRestart, + isRestarting, +}: { + payload: McpPresetsPayload | null; + loading: boolean; + query: string; + category: string; + installFilter: "all" | "installed" | "notInstalled"; + actionKey: string | null; + message: string | null; + error: string | null; + fieldValues: Record>; + customForm: CustomMcpForm; + configImport: string; + showBrandLogos: boolean; + requiresRestartPending: boolean; + onQueryChange: (value: string) => void; + onCategoryChange: (value: string) => void; + onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void; + onCustomFormChange: Dispatch>; + onConfigImportChange: (value: string) => void; + onFieldChange: (presetName: string, fieldName: string, value: string) => void; + onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onSaveCustom: () => void; + onImportConfig: () => void; + onToolsChange: (name: string, enabledTools: string[]) => void; + onRestart?: () => void; + isRestarting?: boolean; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const presets = payload?.presets ?? []; + const categories = useMemo( + () => ["all", ...Array.from(new Set(presets.map((preset) => preset.category))).sort()], + [presets], + ); + const normalizedQuery = query.trim().toLowerCase(); + const filteredPresets = presets.filter((preset) => { + const categoryMatch = category === "all" || preset.category === category; + if (!categoryMatch) return false; + if (installFilter === "installed" && !preset.installed) return false; + if (installFilter === "notInstalled" && preset.installed) return false; + if (!normalizedQuery) return true; + return ( + preset.display_name.toLowerCase().includes(normalizedQuery) || + preset.name.toLowerCase().includes(normalizedQuery) || + preset.description.toLowerCase().includes(normalizedQuery) || + preset.category.toLowerCase().includes(normalizedQuery) + ); + }); + const installFilterOptions = [ + { value: "all", label: tx("settings.mcp.filterAll", "All") }, + { value: "installed", label: tx("settings.mcp.filterInstalled", "Enabled") }, + { value: "notInstalled", label: tx("settings.mcp.filterNotInstalled", "Not enabled") }, + ]; + const categoryLabel = category === "all" ? tx("settings.mcp.allCategories", "All categories") : category; + const visibleStatusMessage = error || message; + const testToolNames = payload?.last_action?.tool_names ?? []; + const testToolCount = payload?.last_action?.tool_count; + const showTestDetails = typeof testToolCount === "number" || testToolNames.length > 0 || !!payload?.last_action?.error; + + return ( +
+
+
+
+ {tx("settings.sections.mcp", "MCP")} +

+ {tx("settings.mcp.summary", "{{installed}} of {{total}} presets enabled") + .replace("{{installed}}", String(payload?.installed_count ?? 0)) + .replace("{{total}}", String(presets.length))} +

+
+ onInstallFilterChange(value as "all" | "installed" | "notInstalled")} + /> +
+ +
+
+ + onQueryChange(event.target.value)} + placeholder={tx("settings.mcp.searchPlaceholder", "Search MCP presets")} + className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]" + /> +
+ + + + + + {categories.map((item) => ( + onCategoryChange(item)}> + {item === "all" ? tx("settings.mcp.allCategories", "All categories") : item} + + ))} + + +
+
+ + + + {requiresRestartPending ? ( +
+ {tx("settings.mcp.restartRequired", "Restart nanobot to connect updated MCP tools.")} + {onRestart ? ( + + ) : null} +
+ ) : null} + + {visibleStatusMessage ? ( +
+ {visibleStatusMessage} +
+ ) : null} + + {showTestDetails ? ( +
+
+ {typeof testToolCount === "number" ? ( + + {tx("settings.mcp.toolsFound", "{{count}} tools").replace("{{count}}", String(testToolCount))} + + ) : null} + {payload?.last_action?.checked_at ? ( + {payload.last_action.checked_at} + ) : null} +
+ {testToolNames.length ? ( +
+ {testToolNames.map((toolName) => ( + + {toolName} + + ))} +
+ ) : null} + {payload?.last_action?.error ? ( +

+ {payload.last_action.error} +

+ ) : null} +
+ ) : null} + + {loading ? ( +
+ + {tx("settings.mcp.loading", "Loading MCP presets...")} +
+ ) : ( +
+
+ {filteredPresets.map((preset) => ( + + ))} +
+ {!filteredPresets.length ? ( +
+ {tx("settings.mcp.empty", "No MCP presets match this filter.")} +
+ ) : null} +
+ )} + +
+ ); +} + +function McpCustomServerPanel({ + form, + configImport, + actionKey, + onFormChange, + onConfigImportChange, + onSave, + onImportConfig, +}: { + form: CustomMcpForm; + configImport: string; + actionKey: string | null; + onFormChange: Dispatch>; + onConfigImportChange: (value: string) => void; + onSave: () => void; + onImportConfig: () => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null); + const [advancedOpen, setAdvancedOpen] = useState(false); + const customBusy = actionKey?.startsWith("custom:") ?? false; + const importBusy = actionKey === "import" || actionKey === "import-cursor"; + const remote = form.transport !== "stdio"; + const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim())); + const update = (key: K, value: CustomMcpForm[K]) => { + onFormChange((prev) => ({ ...prev, [key]: value })); + }; + const transports: Array<{ value: CustomMcpTransport; label: string }> = [ + { value: "stdio", label: "stdio" }, + { value: "streamableHttp", label: "HTTP" }, + { value: "sse", label: "SSE" }, + ]; + + return ( +
+
+
+ + + +
+

+ {tx("settings.mcp.moreOptions", "More MCP options")} +

+

+ {tx("settings.mcp.moreOptionsSubtitle", "Add a custom server or import mcp.json.")} +

+
+
+
+ + +
+
+ + {activeMode === "custom" ? ( +
+
+ +
+ + {tx("settings.mcp.transport", "Transport")} + + update("transport", value as CustomMcpTransport)} + /> +
+ {remote ? ( + + ) : ( + + )} + +
+ + + + {advancedOpen ? ( +
+ {!remote ? ( +