nanobot/nanobot/webui/mcp_presets_api.py
2026-07-14 11:28:48 +08:00

1324 lines
46 KiB
Python

"""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.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, update_config
from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import Config, 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 Playwright's 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="streamableHttp",
install_supported=True,
brand_domain="firecrawl.dev",
brand_color="#EB5E28",
requires="Network access",
server=MCPServerConfig(
type="streamableHttp",
url="https://mcp.firecrawl.dev/v2/mcp",
tool_timeout=60,
),
note=(
"Uses Firecrawl Keyless through the hosted MCP endpoint. No API key is required for "
"the built-in preset; use a custom MCP server URL if you want account-specific limits."
),
),
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 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 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 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 _remove_managed_stdio_cwd(name: str, cfg: MCPServerConfig | None) -> bool:
if cfg is None or not cfg.cwd:
return False
cwd = Path(cfg.cwd).expanduser().resolve(strict=False)
managed = (get_runtime_subdir("mcp") / name).resolve(strict=False)
if cwd != managed or not cwd.exists():
return False
if cwd.is_symlink() or cwd.is_file():
cwd.unlink()
else:
shutil.rmtree(cwd)
return True
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 _managed_mcp_path(name: str, cfg: MCPServerConfig | None) -> list[str]:
if cfg is None or not cfg.command:
return []
return [f"runtime:mcp/{name}"]
def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
server = preset.server
managed_paths = _managed_mcp_path(preset.name, server)
field_specs = [
compact_dict({
"name": field.name,
"target": field.target[0],
"required": field.required,
"secret": field.secret,
"env_var": field.env_var,
})
for field in preset.fields
]
capabilities = [
compact_dict({
"type": "mcp",
"transport": preset.transport,
"command": server.command if server and server.command else None,
"args": list(server.args) if server and server.command else None,
"url": _connection_summary(server) if server and server.url else None,
"fields": field_specs,
})
]
return app_manifest(
app_id=preset.name,
display_name=preset.display_name,
description=preset.description,
category=preset.category,
source="mcp-preset",
docs_url=preset.docs_url,
logo_url=logo_url,
brand_color=preset.brand_color,
capabilities=capabilities,
install=compact_dict({
"supported": preset.install_supported,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_present", "dependency_available"],
}),
remove=compact_dict({
"supported": True,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_absent", "managed_paths_absent"] if managed_paths else ["config_absent"],
}),
trust={
"registry": "mcp-presets",
"level": "builtin",
"review_status": "builtin_preset",
},
)
def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
transport = cfg.type or ("stdio" if cfg.command else "streamableHttp")
managed_paths: list[str] = []
return app_manifest(
app_id=name,
display_name=name,
description="Custom MCP server from nanobot config.",
category="custom",
source="mcp-custom",
brand_color="#64748B",
capabilities=[
compact_dict({
"type": "mcp",
"transport": transport,
"command": cfg.command or None,
"url": _connection_summary(cfg) if cfg.url else None,
})
],
install=compact_dict({
"supported": True,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_present", "dependency_available"],
}),
remove=compact_dict({
"supported": True,
"strategy": "config",
"managed_paths": managed_paths,
"verification": ["config_absent", "managed_paths_absent"] if managed_paths else ["config_absent"],
}),
trust={
"registry": "user-config",
"level": "user",
"review_status": "user_managed",
},
)
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"}
logo_url = _favicon_url(preset.brand_domain)
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": logo_url,
"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",
"manifest": _preset_manifest(preset, logo_url=logo_url),
}
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",
"manifest": _custom_manifest(name, cfg),
}
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")
payload: dict[str, Any] = {
"ok": ok,
"message": f"{verb} MCP preset for {preset.display_name}.",
}
if action == "enable":
payload["installed"] = True
payload["verification"] = ["config_present"]
elif action == "remove":
payload["removed"] = True
payload["verification"] = ["config_absent"]
return payload
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")
payload: dict[str, Any] = {
"ok": ok,
"message": f"{verb} MCP server {name}.",
}
if action in {"custom", "import", "import-cursor"}:
payload["installed"] = True
payload["verification"] = ["config_present"]
elif action == "remove":
payload["removed"] = True
payload["verification"] = ["config_absent"]
return payload
def _scrub_test_error(text: str) -> str:
scrubbed = _SECRET_QUERY_RE.sub(r"\1<redacted>", text.strip())
scrubbed = _SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", 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]:
if action == "custom":
name, cfg = _custom_server_from_query(query)
update_config(lambda config: config.tools.mcp_servers.__setitem__(name, cfg))
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"))
update_config(lambda config: config.tools.mcp_servers.update(servers))
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())
def mutate(config: Config) -> None:
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
update_config(mutate)
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)
if action == "enable":
if preset is None:
raise McpPresetError("unknown MCP preset", status=404)
def mutate(config: Config) -> None:
existing = config.tools.mcp_servers.get(name)
config.tools.mcp_servers[preset.name] = _materialize_server(
preset,
query,
existing,
)
update_config(mutate)
payload = mcp_presets_payload(last_action=_action_message(action, preset))
payload["requires_restart"] = True
return payload
if action == "remove":
removed_runtime_files = False
cleanup_error = ""
removed: dict[str, MCPServerConfig] = {}
def mutate(config: Config) -> None:
existing_cfg = config.tools.mcp_servers.get(name)
if preset is None and existing_cfg is None:
raise McpPresetError("unknown MCP server", status=404)
if existing_cfg is not None:
removed[name] = existing_cfg
del config.tools.mcp_servers[name]
update_config(mutate)
if existing_cfg := removed.get(name):
try:
removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg)
except OSError as exc:
cleanup_error = str(exc)
last_action = (
_action_message(action, preset)
if preset is not None
else _server_action_message(action, name)
)
if removed_runtime_files:
last_action["message"] = f"{last_action['message']} Removed managed runtime files."
last_action["managed_paths_removed"] = [f"runtime:mcp/{name}"]
last_action["verification"] = ["config_absent", "managed_paths_absent"]
if cleanup_error:
last_action["ok"] = False
last_action["message"] = (
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
)
last_action["verification_failed"] = ["managed_paths_absent"]
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