feat(plugins): run portable MCP components

This commit is contained in:
Xubin Ren
2026-08-10 15:33:06 +09:00
parent 833c85f373
commit 9d8a6cc9bb
22 changed files with 901 additions and 79 deletions
+14 -6
View File
@@ -2306,16 +2306,17 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. | | `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
### Agent Plugins v1 skills ### Agent Plugins v1
nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets `<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
Agent Plugins v1 and one or more direct-child skills: Agent Plugins v1 and may provide skills, MCP servers, or both:
```text ```text
plugins/ plugins/
└── release-tools/ └── release-tools/
├── plugin.json ├── plugin.json
├── mcp.json
└── skills/ └── skills/
└── release-notes/ └── release-notes/
└── SKILL.md └── SKILL.md
@@ -2326,10 +2327,17 @@ skills. A workspace skill wins when it has the same name as a plugin skill; plug
over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and
paths that resolve outside the plugin root are ignored. paths that resolve outside the plugin root are ignored.
This initial compatibility layer loads the portable `skills/` component only. Agent Plugins Portable MCP servers declared in `mcp.json` appear in **Apps**, but are never started merely
`mcp.json` is not started automatically: local MCP servers execute third-party processes and because a package exists. Enabling a plugin there is the explicit trust decision that activates
need an explicit trust and approval flow. Configure a reviewed MCP server through **Apps** or its executable components. The host expands `PLUGIN_ROOT` and an isolated `PLUGIN_DATA`, checks
`tools.mcpServers` for now. package paths before launch, and hot-reloads MCP connections. Explicit `tools.mcpServers`
configuration wins over a plugin server if their host names collide. The v1 host currently
supports plugin `stdio` servers; unsupported remote transports are skipped independently.
Plugins may optionally declare a shell-free `extensions.dev.nanobot.installCommand` array. The
local WebUI runs it once per plugin version before first enable; remote WebUI clients cannot run
plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does
not define a registry, so package distribution remains separate from discovery and execution.
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates
+383 -28
View File
@@ -3,15 +3,22 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
import yaml import yaml
from loguru import logger from loguru import logger
from nanobot.config.loader import get_config_path
from nanobot.config.schema import MCPServerConfig
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") _PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") _SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
@@ -30,6 +37,10 @@ _MANIFEST_FIELDS = {
} }
_STRING_FIELDS = {"version", "description", "homepage", "repository", "license"} _STRING_FIELDS = {"version", "description", "homepage", "repository", "license"}
_AUTHOR_FIELDS = {"name", "email", "url"} _AUTHOR_FIELDS = {"name", "email", "url"}
_MCP_SERVER_FIELDS = {
"stdio": {"type", "command", "args", "env", "cwd"},
}
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -41,6 +52,52 @@ class AgentPluginSkill:
plugin: str plugin: str
@dataclass(frozen=True)
class AgentPlugin:
"""A validated Agent Plugins v1 package installed in the workspace."""
name: str
root: Path
version: str
description: str
repository: str
display_name: str
category: str
accent_color: str | None
permissions: tuple[str, ...]
install_command: tuple[str, ...]
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return valid packages from ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
plugins_root = workspace / "plugins"
if not plugins_root.is_dir():
return []
try:
root = plugins_root.resolve(strict=True)
except OSError:
return []
if not root.is_relative_to(workspace):
logger.warning("Ignoring Agent Plugins directory outside the workspace")
return []
try:
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
return []
plugins: list[AgentPlugin] = []
for candidate in candidates:
plugin_root = _contained_directory(candidate, root)
if plugin_root is None:
continue
plugin = _load_manifest(plugin_root)
if plugin is not None:
plugins.append(plugin)
return plugins
def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]: def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
"""Discover direct-child skills under ``<workspace>/plugins/*``. """Discover direct-child skills under ``<workspace>/plugins/*``.
@@ -48,37 +105,13 @@ def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
workspace ``plugins`` directory so packages stay explicit and portable workspace ``plugins`` directory so packages stay explicit and portable
with the rest of the agent workspace. with the rest of the agent workspace.
""" """
workspace = workspace.expanduser().resolve()
plugins_root = workspace / "plugins"
if not plugins_root.is_dir():
return []
try:
resolved_plugins_root = plugins_root.resolve(strict=True)
except OSError:
return []
if not resolved_plugins_root.is_relative_to(workspace):
logger.warning("Ignoring Agent Plugins directory outside the workspace")
return []
try:
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
return []
skills: list[AgentPluginSkill] = [] skills: list[AgentPluginSkill] = []
for candidate in candidates: for plugin in discover_agent_plugins(workspace):
plugin_root = _contained_directory(candidate, resolved_plugins_root) skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
if plugin_root is None:
continue
plugin_name = _load_manifest_name(plugin_root)
if plugin_name is None:
continue
skills.extend(_discover_plugin_skills(plugin_name, plugin_root))
return skills return skills
def _load_manifest_name(plugin_root: Path) -> str | None: def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
manifest = _contained_file(plugin_root / "plugin.json", plugin_root) manifest = _contained_file(plugin_root / "plugin.json", plugin_root)
if manifest is None: if manifest is None:
return None return None
@@ -110,7 +143,95 @@ def _load_manifest_name(plugin_root: Path) -> str | None:
logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest) logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest)
if "extensions" in payload and not isinstance(payload["extensions"], dict): if "extensions" in payload and not isinstance(payload["extensions"], dict):
logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest) logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest)
return name extension = payload.get("extensions")
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
nanobot_value = extension_payload.get("dev.nanobot")
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
return AgentPlugin(
name=name,
root=plugin_root,
version=_string(payload.get("version")),
description=_string(payload.get("description")),
repository=_string(payload.get("repository")),
display_name=_string(nanobot.get("displayName")) or name,
category=_string(nanobot.get("category")) or "Plugin",
accent_color=_accent_color(nanobot.get("accentColor")),
permissions=_string_tuple(nanobot.get("permissions")),
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
)
def agent_plugin_mcp_servers(
workspace: Path,
configured: dict[str, MCPServerConfig] | None = None,
) -> dict[str, MCPServerConfig]:
"""Merge explicitly enabled plugin MCP servers with user configuration.
User configuration wins on the unlikely event of a namespaced collision.
"""
servers: dict[str, MCPServerConfig] = {}
for plugin in discover_agent_plugins(workspace):
if not _enabled(workspace, plugin.name):
continue
plugin_servers = _plugin_mcp_servers(workspace, plugin)
for name, server in plugin_servers.items():
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
servers[host_name] = server
for name, server in (configured or {}).items():
if name in servers:
logger.warning("Configured MCP server '{}' overrides an Agent Plugin server", name)
servers[name] = server
return servers
def agent_plugins_payload(workspace: Path) -> dict[str, Any]:
"""Return installed Agent Plugins for the WebUI Apps surface."""
plugins: list[dict[str, Any]] = []
enabled_count = 0
for plugin in discover_agent_plugins(workspace):
mcp_servers = sorted(_plugin_mcp_servers(workspace, plugin))
if not mcp_servers and not plugin.install_command:
continue
enabled = _enabled(workspace, plugin.name)
enabled_count += int(enabled)
plugins.append(
{
"name": plugin.name,
"display_name": plugin.display_name,
"version": plugin.version,
"description": plugin.description,
"category": plugin.category,
"repository": plugin.repository,
"accent_color": plugin.accent_color,
"permissions": list(plugin.permissions),
"mcp_servers": mcp_servers,
"enabled": enabled,
"setup_required": bool(plugin.install_command)
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
}
)
return {"plugins": plugins, "enabled_count": enabled_count}
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> dict[str, Any]:
"""Enable or disable one installed plugin's executable MCP components."""
plugin = next((item for item in discover_agent_plugins(workspace) if item.name == name), None)
if plugin is None:
raise ValueError(f"unknown Agent Plugin '{name}'")
data = _plugin_data_dir(workspace, plugin.name, create=True)
if enabled:
if plugin.install_command and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"):
_run_install(plugin, data)
_write_state(data / "setup-version", plugin.version or "unknown")
_write_state(data / "enabled", "1")
else:
(data / "enabled").unlink(missing_ok=True)
payload = agent_plugins_payload(workspace)
payload["last_action"] = {
"ok": True,
"message": f"{plugin.display_name} {'enabled' if enabled else 'disabled'}.",
}
return payload
def _valid_optional_fields(payload: dict[str, Any]) -> bool: def _valid_optional_fields(payload: dict[str, Any]) -> bool:
@@ -133,6 +254,240 @@ def _valid_optional_fields(payload: dict[str, Any]) -> bool:
) )
def _string(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _string_tuple(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
items = cast(list[object], value)
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
def _accent_color(value: object) -> str | None:
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
"""Validate nanobot's optional, shell-free setup command extension."""
if not isinstance(value, list):
return ()
items = cast(list[object], value)
if not 1 <= len(items) <= 32 or not all(
isinstance(item, str) and 0 < len(item) <= 4096 for item in items
):
return ()
command = cast(str, items[0])
if not command.startswith("./"):
logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root)
return ()
executable = _contained_file(plugin_root / command[2:], plugin_root)
if executable is None:
logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root)
return ()
return (str(executable), *(cast(str, item) for item in items[1:]))
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
path = _contained_file(plugin.root / "mcp.json", plugin.root)
if path is None:
return {}
try:
value = cast(object, json.loads(path.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}': {}", plugin.name, exc)
return {}
if not isinstance(value, dict):
return {}
payload = cast(dict[str, Any], value)
raw_servers = payload.get("mcpServers")
if (
payload.keys() != {"$schema", "mcpServers"}
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
or not isinstance(raw_servers, dict)
):
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
return {}
data = _plugin_data_dir(workspace, plugin.name, create=True)
servers: dict[str, MCPServerConfig] = {}
for name, raw in cast(dict[str, object], raw_servers).items():
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
continue
server = _plugin_mcp_server(raw, plugin.root, data)
if server is None:
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
continue
servers[name] = server
return servers
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
if not isinstance(raw, dict):
return None
payload = cast(dict[str, Any], raw)
transport = payload.get("type")
allowed = _MCP_SERVER_FIELDS.get(transport) if isinstance(transport, str) else None
if allowed is None or payload.keys() - allowed:
return None
if transport == "stdio":
command = _stdio_command(payload.get("command"), root)
args = payload.get("args", [])
env = payload.get("env", {})
cwd = _stdio_cwd(payload.get("cwd"), root, data)
if (
command is None
or not isinstance(args, list)
or not all(isinstance(item, str) for item in cast(list[object], args))
or not isinstance(env, dict)
or cwd is None
):
return None
env_payload = cast(dict[object, object], env)
if any(
not isinstance(key, str)
or key in {"PLUGIN_ROOT", "PLUGIN_DATA"}
or not isinstance(value, str)
for key, value in env_payload.items()
):
return None
string_env = cast(dict[str, str], env)
replacements = {"${PLUGIN_ROOT}": str(root), "${PLUGIN_DATA}": str(data)}
return MCPServerConfig(
type="stdio",
command=command,
args=[_expand(item, replacements) for item in cast(list[str], args)],
env={
**{key: _expand(value, replacements) for key, value in string_env.items()},
"PLUGIN_ROOT": str(root),
"PLUGIN_DATA": str(data),
},
cwd=str(cwd),
)
return None
def _stdio_command(value: object, root: Path) -> str | None:
if not isinstance(value, str) or not value:
return None
if value.startswith("./"):
executable = _contained_file(root / value[2:], root)
return str(executable) if executable is not None else None
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
return None
return value
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
if value is None:
return root
if not isinstance(value, str):
return None
if value.startswith("./"):
return _contained_directory(root / value[2:], root)
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
if value == placeholder or value.startswith(f"{placeholder}/"):
relative = value[len(placeholder):].lstrip("/")
candidate = (base / relative).resolve()
if not candidate.is_relative_to(base):
return None
if base == data:
candidate.mkdir(parents=True, exist_ok=True)
candidate.chmod(0o700)
return candidate if candidate.is_dir() else None
return None
def _expand(value: str, replacements: dict[str, str]) -> str:
for token, replacement in replacements.items():
value = value.replace(token, replacement)
return value
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
config_root = get_config_path().expanduser().resolve().parent
plugin_data_root = config_root / "plugin-data"
if create:
plugin_data_root.mkdir(parents=True, exist_ok=True)
try:
resolved_plugin_data = plugin_data_root.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin data root is unavailable") from exc
if not resolved_plugin_data.is_relative_to(config_root):
raise RuntimeError("Agent Plugin data root escapes the nanobot config directory")
state_root = resolved_plugin_data / workspace_id
if create:
resolved_plugin_data.chmod(0o700)
state_root.mkdir(parents=True, exist_ok=True)
try:
resolved_state = state_root.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin state directory is unavailable") from exc
if not resolved_state.is_relative_to(config_root):
raise RuntimeError("Agent Plugin state directory escapes the nanobot config directory")
if create:
resolved_state.chmod(0o700)
data = resolved_state / name
if create:
data.mkdir(exist_ok=True)
resolved_data = data.resolve(strict=True)
if not resolved_data.is_relative_to(resolved_state):
raise RuntimeError("Agent Plugin data directory escapes its state directory")
resolved_data.chmod(0o700)
return resolved_data
return data
def _enabled(workspace: Path, name: str) -> bool:
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
def _setup_version(workspace: Path, name: str) -> str:
try:
return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text(
encoding="utf-8"
).strip()
except (OSError, UnicodeError):
return ""
def _write_state(path: Path, value: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.parent.chmod(0o700)
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_text(value, encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(path)
path.chmod(0o600)
def _run_install(plugin: AgentPlugin, data: Path) -> None:
env = {
**{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None},
"PLUGIN_ROOT": str(plugin.root),
"PLUGIN_DATA": str(data),
}
try:
result = subprocess.run(
plugin.install_command,
cwd=plugin.root,
env=env,
capture_output=True,
text=True,
timeout=600,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"{plugin.display_name} setup timed out") from exc
if result.returncode:
output = (result.stderr or result.stdout).strip()[-2000:]
raise RuntimeError(output or f"{plugin.display_name} setup failed")
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]: def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
skills_root = plugin_root / "skills" skills_root = plugin_root / "skills"
if not skills_root.exists(): if not skills_root.exists():
+3 -1
View File
@@ -480,6 +480,8 @@ class AgentLoop:
config, config,
provider_snapshot_loader, provider_snapshot_loader,
) )
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
return cls( return cls(
bus=bus, bus=bus,
provider=provider, provider=provider,
@@ -494,7 +496,7 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode, provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length, tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace, restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers, mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
channels_config=config.channels, channels_config=config.channels,
timezone=defaults.timezone, timezone=defaults.timezone,
unified_session=defaults.unified_session, unified_session=defaults.unified_session,
+11 -4
View File
@@ -5,11 +5,12 @@ import os
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import TYPE_CHECKING, Any, cast
import yaml import yaml
from nanobot.agent.agent_plugins import discover_agent_plugin_skills if TYPE_CHECKING:
from nanobot.agent.agent_plugins import AgentPluginSkill
# Default builtin skills directory (relative to this file) # Default builtin skills directory (relative to this file)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
@@ -35,7 +36,7 @@ class SkillsLoader:
self.workspace_skills = workspace / "skills" self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set() self.disabled_skills = disabled_skills or set()
self.plugin_skills = discover_agent_plugin_skills(workspace) self.plugin_skills: list[AgentPluginSkill] = []
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]: def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists(): if not base.exists():
@@ -63,6 +64,8 @@ class SkillsLoader:
Returns: Returns:
List of skill info dicts with 'name', 'path', 'source'. List of skill info dicts with 'name', 'path', 'source'.
""" """
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
self.plugin_skills = discover_agent_plugin_skills(self.workspace) self.plugin_skills = discover_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace") skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills} seen_names = {entry["name"] for entry in skills}
@@ -103,8 +106,12 @@ class SkillsLoader:
workspace_path = self.workspace_skills / name / "SKILL.md" workspace_path = self.workspace_skills / name / "SKILL.md"
if workspace_path.exists(): if workspace_path.exists():
return workspace_path.read_text(encoding="utf-8") return workspace_path.read_text(encoding="utf-8")
if not self.plugin_skills:
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
for plugin_skill in self.plugin_skills: for plugin_skill in self.plugin_skills:
if plugin_skill.name == name: if plugin_skill.name == name and plugin_skill.path.is_file():
return plugin_skill.path.read_text(encoding="utf-8") return plugin_skill.path.read_text(encoding="utf-8")
if self.builtin_skills: if self.builtin_skills:
builtin_path = self.builtin_skills / name / "SKILL.md" builtin_path = self.builtin_skills / name / "SKILL.md"
+5 -1
View File
@@ -1286,10 +1286,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True, "requires_restart": True,
} }
try: try:
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config()) config = resolve_config_env_vars(load_config())
next_servers = dict(config.tools.mcp_servers) next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
except Exception as exc: except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc) logger.warning("MCP hot reload could not read config: {}", exc)
return { return {
+70 -2
View File
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Literal, Mapping, cast from typing import Any, Literal, Mapping, cast
from nanobot.agent.agent_plugins import agent_plugins_payload, set_agent_plugin_enabled
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
@@ -837,6 +838,44 @@ def _custom_payload(
} }
def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]:
enabled = bool(plugin.get("enabled"))
permissions = plugin.get("permissions")
mcp_servers = plugin.get("mcp_servers")
permission_names = (
[str(item) for item in cast(list[object], permissions)]
if isinstance(permissions, list)
else []
)
server_names = (
[str(item) for item in cast(list[object], mcp_servers)]
if isinstance(mcp_servers, list)
else []
)
return {
"name": f"plugin-{plugin['name']}",
"display_name": str(plugin.get("display_name") or plugin["name"]),
"category": str(plugin.get("category") or "plugin"),
"description": str(plugin.get("description") or "Agent Plugin"),
"docs_url": str(plugin.get("repository") or ""),
"transport": "stdio",
"requires": ", ".join(permission_names),
"note": "",
"install_supported": True,
"installed": True,
"configured": enabled,
"available": enabled,
"status": "configured" if enabled else "not_installed",
"logo_url": None,
"brand_color": plugin.get("accent_color"),
"required_fields": [],
"connection_summary": ", ".join(server_names),
"enabled_tools": ["*"],
"tool_names": [],
"source": "agent-plugin",
}
def mcp_presets_payload( def mcp_presets_payload(
*, *,
last_action: dict[str, Any] | None = None, last_action: dict[str, Any] | None = None,
@@ -854,9 +893,17 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items()) for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known if name not in known
] ]
plugin_state = agent_plugins_payload(config.workspace_path)
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
plugin_rows = [
row
for plugin in plugin_state["plugins"]
if (row := _agent_plugin_payload(plugin))["name"] not in existing_names
]
payload: dict[str, Any] = { payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows], "presets": [*preset_rows, *custom_rows, *plugin_rows],
"installed_count": len(config.tools.mcp_servers), "installed_count": len(config.tools.mcp_servers)
+ sum(int(row["configured"]) for row in plugin_rows),
} }
if last_action is not None: if last_action is not None:
payload["last_action"] = last_action payload["last_action"] = last_action
@@ -1343,6 +1390,27 @@ async def mcp_presets_settings_action(
"""Run a WebUI MCP preset action and hot-reload the agent when config changes.""" """Run a WebUI MCP preset action and hot-reload the agent when config changes."""
if action is None: if action is None:
return mcp_presets_payload() return mcp_presets_payload()
name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"):
config = load_config()
plugin_name = name.removeprefix("plugin-")
installed = {
str(plugin["name"])
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
}
if name not in config.tools.mcp_servers and plugin_name in installed:
if action not in {"enable", "remove"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
state = await asyncio.to_thread(
set_agent_plugin_enabled,
config.workspace_path,
plugin_name,
action == "enable",
)
payload = mcp_presets_payload(last_action=state.get("last_action"))
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
if action == "test": if action == "test":
return await mcp_presets_test_action(query) return await mcp_presets_test_action(query)
if action in _CUSTOM_ACTIONS: if action in _CUSTOM_ACTIONS:
+22 -3
View File
@@ -18,6 +18,7 @@ from urllib.parse import unquote
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
from nanobot.agent.agent_plugins import agent_plugins_payload
from nanobot.agent.tools.image_generation import request_image_generation_reload from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
@@ -222,12 +223,12 @@ class WebUISettingsRouter:
if path == "/api/settings/pairing/deny": if path == "/api/settings/pairing/deny":
return self._handle_settings_pairing_action(request, "deny") return self._handle_settings_pairing_action(request, "deny")
if path == "/api/settings/mcp-presets": if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request) return await self._handle_settings_mcp_presets(connection, request)
if path == "/api/settings/version-check": if path == "/api/settings/version-check":
return await self._handle_settings_version_check(request) return await self._handle_settings_version_check(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path) mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
if mcp_action is not None: if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action) return await self._handle_settings_mcp_presets(connection, request, mcp_action)
return None return None
def _query(self, request: WsRequest) -> QueryParams: def _query(self, request: WsRequest) -> QueryParams:
@@ -1144,15 +1145,33 @@ class WebUISettingsRouter:
async def _handle_settings_mcp_presets( async def _handle_settings_mcp_presets(
self, self,
connection: Any,
request: WsRequest, request: WsRequest,
action: str | None = None, action: str | None = None,
) -> Response: ) -> Response:
if not self._authorized(request): if not self._authorized(request):
return self._unauthorized() return self._unauthorized()
try: try:
query = self._parse_mcp_settings_query(request)
name = (_query_first(query, "name") or "").strip()
if action == "enable" and name.startswith("plugin-"):
config = load_config()
plugin_names = {
f"plugin-{plugin['name']}"
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
}
if (
name not in config.tools.mcp_servers
and name in plugin_names
and not self._allow_feature_package_install(connection, request)
):
return self._error_response(
403,
"Agent Plugin setup is restricted to the local WebUI",
)
payload = await mcp_presets_settings_action( payload = await mcp_presets_settings_action(
action, action,
self._parse_mcp_settings_query(request), query,
reload_mcp=lambda: request_mcp_reload(self.bus), reload_mcp=lambda: request_mcp_reload(self.bus),
) )
except Exception as e: except Exception as e:
+149 -1
View File
@@ -1,10 +1,20 @@
import json import json
import shutil import shutil
import subprocess
from pathlib import Path from pathlib import Path
from typing import Any, cast
import pytest import pytest
from nanobot.agent.agent_plugins import AGENT_PLUGIN_SCHEMA, discover_agent_plugin_skills from nanobot.agent import agent_plugins
from nanobot.agent.agent_plugins import (
AGENT_PLUGIN_MCP_SCHEMA,
AGENT_PLUGIN_SCHEMA,
agent_plugin_mcp_servers,
agent_plugins_payload,
discover_agent_plugin_skills,
set_agent_plugin_enabled,
)
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
@@ -170,3 +180,141 @@ def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
pytest.skip(f"directory symlink unavailable: {exc}") pytest.skip(f"directory symlink unavailable: {exc}")
assert discover_agent_plugin_skills(tmp_path) == [] assert discover_agent_plugin_skills(tmp_path) == []
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
plugin = _write_plugin(tmp_path, "desktop")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
(plugin / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"desktop": {
"type": "stdio",
"command": "./bin/server",
"args": ["--data", "${PLUGIN_DATA}/state"],
"cwd": "${PLUGIN_ROOT}",
}
},
}
),
encoding="utf-8",
)
assert agent_plugin_mcp_servers(tmp_path) == {}
set_agent_plugin_enabled(tmp_path, "desktop", True)
servers = agent_plugin_mcp_servers(tmp_path)
server = servers["desktop"]
assert server.command == str(executable)
assert server.cwd == str(plugin)
assert server.env["PLUGIN_ROOT"] == str(plugin)
assert server.args[0] == "--data"
assert server.args[1].endswith("/state")
set_agent_plugin_enabled(tmp_path, "desktop", False)
assert agent_plugin_mcp_servers(tmp_path) == {}
def test_plugin_setup_command_runs_once_per_version(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit")
plugin = _write_plugin(
tmp_path,
"desktop",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "desktop",
"version": "1.2.3",
"extensions": {"dev.nanobot": {"installCommand": ["./bin/install"]}},
},
)
executable = plugin / "bin" / "install"
executable.parent.mkdir()
executable.write_text("setup", encoding="utf-8")
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
calls.append((command, cast(dict[str, str], kwargs["env"])))
return subprocess.CompletedProcess(command, 0, "ok", "")
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
set_agent_plugin_enabled(tmp_path, "desktop", True)
set_agent_plugin_enabled(tmp_path, "desktop", False)
set_agent_plugin_enabled(tmp_path, "desktop", True)
assert len(calls) == 1
assert calls[0][0] == (str(executable),)
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
assert agent_plugins_payload(tmp_path)["plugins"][0]["setup_required"] is False
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
plugin = _write_plugin(tmp_path, "network")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
(plugin / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
"local": {"type": "stdio", "command": "./bin/server"},
"escape": {"type": "stdio", "command": "../outside"},
},
}
),
encoding="utf-8",
)
set_agent_plugin_enabled(tmp_path, "network", True)
assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"]
def test_plugin_state_symlink_cannot_escape_config_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = tmp_path / "config"
outside = tmp_path / "outside"
config.mkdir()
outside.mkdir()
try:
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: config / "config.json",
)
_write_plugin(tmp_path, "desktop")
with pytest.raises(RuntimeError, match="escapes the nanobot config directory"):
set_agent_plugin_enabled(tmp_path, "desktop", True)
+8 -10
View File
@@ -66,16 +66,14 @@ def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy.parent.mkdir(parents=True) legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8") legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
lines = runtime_lines( lines = runtime_lines_for_request(
SimpleNamespace( "please use @unimol_tools",
content="please use @unimol_tools", {
metadata={ "cli_apps": [{
"cli_apps": [{ "name": "unimol_tools",
"name": "unimol_tools", "entry_point": "cli-anything-unimol-tools",
"entry_point": "cli-anything-unimol-tools", }],
}], },
},
),
tmp_path, tmp_path,
) )
+104 -1
View File
@@ -1,22 +1,66 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from pathlib import Path
import pytest import pytest
from nanobot.agent.agent_plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import ( from nanobot.webui.mcp_presets_api import (
McpPresetError, McpPresetError,
custom_mcp_action, custom_mcp_action,
mcp_presets_action, mcp_presets_action,
mcp_presets_payload, mcp_presets_payload,
mcp_presets_settings_action,
mcp_presets_test_action, mcp_presets_test_action,
normalize_mcp_preset_mentions, normalize_mcp_preset_mentions,
) )
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json") config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def _write_agent_plugin(workspace: Path) -> None:
root = workspace / "plugins" / "desktop"
command = root / "bin" / "server"
command.parent.mkdir(parents=True, exist_ok=True)
command.write_text("#!/bin/sh\n", encoding="utf-8")
(root / "plugin.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "desktop",
"description": "Control the local desktop.",
"extensions": {
"dev.nanobot": {
"displayName": "Desktop Control",
"accentColor": "#ff7a1a",
"permissions": ["screen-recording"],
}
},
}
),
encoding="utf-8",
)
(root / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"desktop": {"type": "stdio", "command": "./bin/server"},
},
}
),
encoding="utf-8",
)
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -55,6 +99,65 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
assert manifest["trust"]["review_status"] == "builtin_preset" assert manifest["trust"]["review_status"] == "builtin_preset"
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
_write_agent_plugin(load_config().workspace_path)
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
assert row["name"] == "plugin-desktop"
assert row["display_name"] == "Desktop Control"
assert row["installed"] is True
assert row["configured"] is False
async def reload() -> dict[str, object]:
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
enabled = asyncio.run(
mcp_presets_settings_action(
"enable",
{"name": ["plugin-desktop"]},
reload_mcp=reload,
)
)
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
assert enabled_row["configured"] is True
assert enabled["requires_restart"] is False
disabled = asyncio.run(
mcp_presets_settings_action(
"remove",
{"name": ["plugin-desktop"]},
reload_mcp=reload,
)
)
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
assert disabled_row["configured"] is False
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
config_path = tmp_path / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["tools"] = {
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
}
config_path.write_text(json.dumps(config), encoding="utf-8")
_write_agent_plugin(load_config().workspace_path)
rows = [
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
]
assert len(rows) == 1
assert rows[0]["source"] == "custom"
def test_enable_browserbase_writes_scrubbed_config_payload( def test_enable_browserbase_writes_scrubbed_config_payload(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
+42 -22
View File
@@ -7383,7 +7383,11 @@ function AppsCatalogSettings({
] ]
.filter((item) => { .filter((item) => {
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery); if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
return filter === "ready" ? appsReady(item) : item.kind === filter; if (filter === "ready") return appsReady(item);
if (filter === "cli") {
return item.kind === "cli" || item.preset.source === "agent-plugin";
}
return item.kind === "mcp" && item.preset.source !== "agent-plugin";
}) })
.sort((left, right) => { .sort((left, right) => {
const rank = Number(!appsReady(left)) - Number(!appsReady(right)); const rank = Number(!appsReady(left)) - Number(!appsReady(right));
@@ -7623,6 +7627,7 @@ function McpAppsCatalogRow({
const testBusy = actionKey === `test:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`;
const busy = enableBusy || removeBusy || testBusy || toolsBusy; const busy = enableBusy || removeBusy || testBusy || toolsBusy;
const agentPlugin = preset.source === "agent-plugin";
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0; const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0; const needsSetupInput = missingFields.length > 0;
@@ -7634,8 +7639,13 @@ function McpAppsCatalogRow({
const enabledTools = preset.enabled_tools ?? ["*"]; const enabledTools = preset.enabled_tools ?? ["*"];
const allowAllTools = enabledTools.includes("*"); const allowAllTools = enabledTools.includes("*");
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
const description = preset.description || preset.note || preset.requires || preset.name; const description = preset.description || preset.note || preset.name;
const statusLabel = mcpPresetStatusLabel(preset.status, tx); const detail = agentPlugin && preset.requires
? `${description} · ${preset.requires}`
: description || preset.requires;
const statusLabel = agentPlugin
? tx("settings.apps.pluginEnabled", "Plugin enabled")
: mcpPresetStatusLabel(preset.status, tx);
useEffect(() => { useEffect(() => {
if (preset.configured || !preset.install_supported) setSetupOpen(false); if (preset.configured || !preset.install_supported) setSetupOpen(false);
@@ -7668,9 +7678,13 @@ function McpAppsCatalogRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3> <h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "Integration")}</AppsTypeBadge> <AppsTypeBadge>
{agentPlugin
? tx("settings.apps.pluginLabel", "Plugin")
: tx("settings.apps.mcpLabel", "Integration")}
</AppsTypeBadge>
</div> </div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p> <p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{readyInstalled ? ( {readyInstalled ? (
@@ -7687,35 +7701,41 @@ function McpAppsCatalogRow({
</AppsActionButton> </AppsActionButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}> {!agentPlugin ? (
<PlayCircle aria-hidden /> <DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
{tx("settings.mcp.test", "Test")} <PlayCircle aria-hidden />
</DropdownMenuItem> {tx("settings.mcp.test", "Test")}
{toolNames.length ? ( </DropdownMenuItem>
) : null}
{!agentPlugin && toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}> <DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden /> <SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")} {tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem
tone="destructive" tone={agentPlugin ? undefined : "destructive"}
disabled={busy} disabled={busy}
onClick={() => onAction("remove", preset.name)} onClick={() => onAction("remove", preset.name)}
> >
<Trash2 aria-hidden /> {agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{tx("settings.mcp.remove", "Remove")} {agentPlugin
? tx("settings.apps.pluginDisable", "Disable")
: tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AppsActionButton {!agentPlugin ? (
ariaLabel={tx("settings.mcp.remove", "Remove")} <AppsActionButton
busy={removeBusy} ariaLabel={tx("settings.mcp.remove", "Remove")}
disabled={busy && !removeBusy} busy={removeBusy}
tone="danger" disabled={busy && !removeBusy}
onClick={() => onAction("remove", preset.name)} tone="danger"
> onClick={() => onAction("remove", preset.name)}
<Trash2 className="h-4 w-4" aria-hidden /> >
</AppsActionButton> <Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : null}
</> </>
) : preset.installed && !preset.configured ? ( ) : preset.installed && !preset.configured ? (
<AppsActionButton <AppsActionButton
+3
View File
@@ -584,6 +584,9 @@
"description": "Add tools to nanobot, then @ them in chat.", "description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App", "cliLabel": "App",
"mcpLabel": "Integration", "mcpLabel": "Integration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin enabled",
"pluginDisable": "Disable",
"channelLabel": "Channel", "channelLabel": "Channel",
"featureLabel": "Feature", "featureLabel": "Feature",
"filterAll": "Ready", "filterAll": "Ready",
+3
View File
@@ -571,6 +571,9 @@
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.", "description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "Aplicación", "cliLabel": "Aplicación",
"mcpLabel": "Integración", "mcpLabel": "Integración",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activado",
"pluginDisable": "Desactivar",
"channelLabel": "Canal", "channelLabel": "Canal",
"featureLabel": "Función", "featureLabel": "Función",
"filterAll": "Listo", "filterAll": "Listo",
+3
View File
@@ -570,6 +570,9 @@
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.", "description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "Application", "cliLabel": "Application",
"mcpLabel": "Intégration", "mcpLabel": "Intégration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activé",
"pluginDisable": "Désactiver",
"channelLabel": "Canal", "channelLabel": "Canal",
"featureLabel": "Fonction", "featureLabel": "Fonction",
"filterAll": "Prêts", "filterAll": "Prêts",
+3
View File
@@ -570,6 +570,9 @@
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.", "description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi", "cliLabel": "Aplikasi",
"mcpLabel": "Integrasi", "mcpLabel": "Integrasi",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin aktif",
"pluginDisable": "Nonaktifkan",
"channelLabel": "Kanal", "channelLabel": "Kanal",
"featureLabel": "Fitur", "featureLabel": "Fitur",
"filterAll": "Siap", "filterAll": "Siap",
+3
View File
@@ -570,6 +570,9 @@
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。", "description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ", "cliLabel": "アプリ",
"mcpLabel": "連携", "mcpLabel": "連携",
"pluginLabel": "プラグイン",
"pluginEnabled": "プラグインは有効です",
"pluginDisable": "無効にする",
"channelLabel": "チャンネル", "channelLabel": "チャンネル",
"featureLabel": "機能", "featureLabel": "機能",
"filterAll": "使用可能", "filterAll": "使用可能",
+3
View File
@@ -570,6 +570,9 @@
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.", "description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "앱", "cliLabel": "앱",
"mcpLabel": "연동", "mcpLabel": "연동",
"pluginLabel": "플러그인",
"pluginEnabled": "플러그인 활성화됨",
"pluginDisable": "비활성화",
"channelLabel": "채널", "channelLabel": "채널",
"featureLabel": "기능", "featureLabel": "기능",
"filterAll": "사용 가능", "filterAll": "사용 가능",
+3
View File
@@ -584,6 +584,9 @@
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.", "description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"cliLabel": "Aplicativo", "cliLabel": "Aplicativo",
"mcpLabel": "Integração", "mcpLabel": "Integração",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin ativado",
"pluginDisable": "Desativar",
"channelLabel": "Canal", "channelLabel": "Canal",
"featureLabel": "Recurso", "featureLabel": "Recurso",
"filterAll": "Prontos", "filterAll": "Prontos",
+3
View File
@@ -570,6 +570,9 @@
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.", "description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"cliLabel": "Ứng dụng", "cliLabel": "Ứng dụng",
"mcpLabel": "Tích hợp", "mcpLabel": "Tích hợp",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin đã bật",
"pluginDisable": "Tắt",
"channelLabel": "Kênh", "channelLabel": "Kênh",
"featureLabel": "Tính năng", "featureLabel": "Tính năng",
"filterAll": "Sẵn sàng", "filterAll": "Sẵn sàng",
+3
View File
@@ -584,6 +584,9 @@
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。", "description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"cliLabel": "应用", "cliLabel": "应用",
"mcpLabel": "集成", "mcpLabel": "集成",
"pluginLabel": "插件",
"pluginEnabled": "插件已启用",
"pluginDisable": "停用",
"channelLabel": "渠道", "channelLabel": "渠道",
"featureLabel": "能力", "featureLabel": "能力",
"filterAll": "可用", "filterAll": "可用",
+3
View File
@@ -570,6 +570,9 @@
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。", "description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"cliLabel": "應用程式", "cliLabel": "應用程式",
"mcpLabel": "整合", "mcpLabel": "整合",
"pluginLabel": "外掛",
"pluginEnabled": "外掛已啟用",
"pluginDisable": "停用",
"channelLabel": "通訊管道", "channelLabel": "通訊管道",
"featureLabel": "功能", "featureLabel": "功能",
"filterAll": "就緒", "filterAll": "就緒",
+60
View File
@@ -579,6 +579,66 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
}); });
it("sets up and enables an installed Agent Plugin explicitly", async () => {
const plugin = {
name: "plugin-nanobot-computer-use",
display_name: "Computer Use",
description: "Control the desktop with a live preview.",
category: "Productivity",
docs_url: "https://github.com/nanobot-dev/nanobot-computer-use",
transport: "stdio",
requires: "screen-recording, accessibility",
note: "",
install_supported: true,
installed: true,
configured: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#ff7a1a",
required_fields: [],
connection_summary: "computer-use",
source: "agent-plugin",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [plugin], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets/enable?name=plugin-nanobot-computer-use") {
return jsonResponse({
presets: [{ ...plugin, configured: true, available: true, status: "configured" }],
installed_count: 1,
last_action: { ok: true, message: "Computer Use enabled." },
});
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
expect(screen.getByText("Plugin")).toBeInTheDocument();
expect(
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/mcp-presets/enable?name=plugin-nanobot-computer-use",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plugin enabled" })).toBeInTheDocument();
});
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => { it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);