feat(capabilities): unify catalog, plugin configuration and agent selection (#5497)

* feat(capabilities): unify catalog, plugin configuration and agent selection

* fix(capabilities): address review isolation, validation and demo issues

* fix(capabilities): preserve concurrent selections and guide launcher repair
This commit is contained in:
Wenchao An 2026-09-19 12:14:24 +08:00 committed by GitHub
parent 2ff006b0c0
commit 42334f26d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 5761 additions and 242 deletions

View File

@ -555,6 +555,20 @@ For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_
MCP tool names are prefixed with `<server_name>_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers.
Signed-in users' notification toggle, default model, conversation mode, and reasoning effort are saved to their account and restored on other browsers or after clearing browser storage. Browser notification permission still needs to be granted on each device. Changes retry after network failures; unsent changes survive a reload in the same tab. Concurrent edits to different fields are preserved; for the same field, the last server write wins. Existing unscoped browser preferences are not uploaded automatically because they have no account owner; reselect those settings once after upgrading. Static demos and auth-disabled development keep browser-local settings. Thread-specific model overrides and other display preferences remain local.
Capability Center groups plugins by office collaboration, documents and knowledge, search and research, business and data, and development and operations. The directory includes setup references alongside existing MCP configurations and Lark. Recommended integrations and built-in support do not imply an installed or verified connection; the Installed filter shows configured MCP entries and installed Lark only.
For plugin manifests, adapter registration, and Agent capability selection, see
[Capability Center integration contract](docs/capability-center.md).
DingTalk and WeCom group notifications and HubSpot CRM are bundled configurable
plugins. Administrators supply robot credentials or a HubSpot private app token;
Agents can then send requested group notifications, list companies, or create
contacts. Saving configuration performs no external write. These plugins reuse
the existing MCP lifecycle and require no separate plugin service. See the
integration contract above for required fields, scopes, and feature boundaries.
Plugin brand icons are bundled locally. When adding or editing one MCP plugin, administrators can upload a PNG, JPG, or WebP image (up to 2 MB), preview it, or restore the default icon. Changes take effect only after Save; custom icons persist across browsers as a normalized 128px PNG in the server entry's display-only `presentation.icon` metadata. They are not sent to the MCP transport.
Capability Center > Plugins adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI.
Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers.
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written.

View File

@ -1,5 +1,13 @@
### Gateway API (`app/gateway/`)
Capability Center's `business` adapter validates only the bundled provider's
credential fields and creates a normal MCP connection. The MCP API accepts the
exact isolated interpreter/module/provider launcher generated by
`deerflow.capabilities.business`, including the exact credential environment key
set. Do not allow arbitrary Python commands, trust manifest metadata to bypass
execution policy, or put credentials into tool schemas. Existing admin checks,
masked edits, atomic configuration writes and MCP cache reloads remain owners.
Memory shutdown resolves hot-reloaded config and the backend, flushes, then
closes as one `await_drained` operation. Keep config resolution inside the
best-effort error handler and off the event loop so malformed config edits do
@ -190,6 +198,16 @@ archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LI
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
filtering before pagination, owner isolation, and activity-time preservation.
Capability installation IDs must be unique for MCP create/replace/state writes.
Single-server DELETE keeps schema validation but allows residual identity
collisions, so legacy duplicate groups can be repaired incrementally. Runtime
explicit selections still exclude ambiguous identities until repaired.
Capability skill discovery (`/api/capabilities/installations/skills`) reuses
`routers/skills.py::_filter_visible_skills` after its off-thread storage read.
It must preserve the ordinary skill listing's caller visibility and provider
failure policy rather than exposing the unfiltered user-scoped catalog.
### Route and skill-listing authorization
Gateway route authorization uses `authz.py::resolve_route_permissions()` as the single provider integration point for both `AuthMiddleware` and decorator-only authentication. When enabled, it evaluates the six registered `threads:*` / `runs:*` permissions as `resource="route"` requests whose targets are the full `resource:action` strings. Decisions use the async provider API and are cached for the request in `AuthContext`; decorators do not call the provider again. Provider resolution or decision errors follow `authorization.fail_closed`, scoped per permission for decision errors. When authorization is disabled, the legacy complete permission set is returned without resolving a provider. Existing `owner_check` enforcement and `require_admin_user()` management gates remain independent and unchanged. Tests: `tests/test_authorization_route_permissions.py`, `tests/test_auth.py`, and `tests/test_auth_middleware.py`.

View File

@ -20,6 +20,7 @@ from app.gateway.routers import (
assistants_compat,
auth,
browser,
capabilities,
channel_connections,
channels,
console,
@ -913,6 +914,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
app.include_router(console.router)
# MCP API is mounted at /api/mcp
app.include_router(capabilities.router)
app.include_router(mcp.router)
# Durable MCP tasks are scoped to their owning thread.

View File

@ -0,0 +1,216 @@
"""Adapters over existing integration services; no duplicate credential/config store."""
import asyncio
from dataclasses import dataclass
from typing import Any, Protocol
from uuid import uuid4
from fastapi import HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from app.gateway.deps import is_admin_user
from app.gateway.routers import integrations, mcp, skills
from deerflow.capabilities.business import connection_config
from deerflow.capabilities.catalog import PluginManifest
from deerflow.capabilities.runtime import ambiguous_installation_ids, installation_id
from deerflow.config.app_config import AppConfig
from deerflow.integrations.lark_cli import get_lark_integration_status
from deerflow.runtime.user_context import get_effective_user_id
class CapabilityInstallation(BaseModel):
id: str
plugin_id: str | None = None
adapter: str
name: str
description: str = ""
selectable: bool = True
installed: bool = True
enabled: bool | None = None
version: str | None = None
scope: str = "deployment"
auth_status: str = "unknown"
health: str = "unknown"
reference: str
category: str | None = None
icon: str | None = None
class InstallationList(BaseModel):
items: list[CapabilityInstallation] = Field(default_factory=list)
can_manage: bool = False
@dataclass(frozen=True)
class AdapterContext:
request: Request
config: AppConfig
user_id: str
class CapabilityAdapter(Protocol):
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]: ...
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None: ...
class MCPAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
servers = await asyncio.to_thread(mcp._load_raw_mcp_server_responses)
result = []
ambiguous = ambiguous_installation_ids({name: server.model_dump() for name, server in servers.items()})
for name, server in servers.items():
raw = server.model_dump()
metadata = raw.get("capability") or {}
metadata = metadata if isinstance(metadata, dict) else {}
auth = server.user_auth
if auth and auth.enabled:
auth_status = "configured" if auth.users.get(context.user_id) else "required"
elif server.oauth or server.headers or server.env:
auth_status = "configured"
else:
auth_status = "not_required"
presentation = raw.get("presentation")
icon = presentation.get("icon") if isinstance(presentation, dict) else None
# Public discovery projects explicit safe fields, never connection
# URLs, commands, env, OAuth configuration, or another user's IDs.
identity = installation_id(name, raw)
result.append(
CapabilityInstallation(
id=f"ambiguous:{installation_id(name, {})}" if identity in ambiguous else identity,
selectable=identity not in ambiguous,
health="ambiguous" if identity in ambiguous else "unknown",
plugin_id=metadata.get("plugin_id") if isinstance(metadata.get("plugin_id"), str) else None,
adapter="mcp",
name=name,
reference=name,
description=server.description or "",
enabled=server.enabled,
version=metadata.get("version") if isinstance(metadata.get("version"), str) else None,
auth_status=auth_status,
icon=icon if isinstance(icon, str) and icon.startswith("data:image/png;base64,") and len(icon) <= 100_000 else None,
)
)
return result
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
if not name.strip():
raise HTTPException(422, "Installation name is required")
if manifest.adapter == "mcp":
# The manifest form is normalized to a transport definition by the
# UI. Validate that wire contract, not form-only name/auth fields.
from urllib.parse import urlsplit
transport = configuration.get("type", configuration.get("transport", "stdio"))
if not isinstance(transport, str):
raise HTTPException(422, "Supply a supported MCP transport")
if transport in {"http", "sse"}:
url = configuration.get("url")
try:
parsed = urlsplit(url) if isinstance(url, str) else None
valid = parsed is not None and parsed.scheme in {"https", "http"} and bool(parsed.hostname) and parsed.username is None and parsed.password is None and not parsed.fragment and not any(c.isspace() for c in url)
if parsed is not None:
_ = parsed.port # Validate malformed ports too.
except ValueError:
valid = False
if not valid:
raise HTTPException(422, "Supply an HTTP(S) MCP server URL without embedded credentials")
elif transport != "stdio" or not isinstance(configuration.get("command"), str) or not configuration["command"].strip():
raise HTTPException(422, "Supply a supported MCP transport and its required connection fields")
definition = {**configuration, "capability": {"id": str(uuid4()), "plugin_id": manifest.id, "version": manifest.version}}
try:
body = mcp.McpConfigUpdateRequest(mcp_servers={name: mcp.McpServerConfigResponse.model_validate(definition)})
except ValidationError as error:
raise HTTPException(422, "Invalid MCP configuration") from error
await mcp.create_mcp_servers(context.request, body)
class BusinessAdapter(MCPAdapter):
"""Build only bundled providers; reuse the MCP store, lifecycle and discovery."""
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
from deerflow.capabilities.business import CREDENTIALS
return [item for item in await super().list_installations(context) if item.plugin_id in CREDENTIALS]
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
try:
definition = connection_config(manifest.id, configuration)
except ValueError as error:
raise HTTPException(422, str(error)) from None
definition["description"] = manifest.description.get("en-US", "")
await super().install(context, manifest, name, definition)
class LarkAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
status = await asyncio.to_thread(get_lark_integration_status, context.user_id, context.config)
return [
CapabilityInstallation(
id="lark",
plugin_id="lark",
adapter="lark",
name="Lark / Feishu",
reference="lark",
installed=status.installed,
version=status.manifest_version,
scope="user",
auth_status="connected" if status.auth.status == "authenticated" and status.auth.verified else "configured" if status.auth.status == "authenticated" else "required",
health="unknown",
)
]
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
await integrations.install_lark(context.request, context.config)
class SkillAdapter:
async def list_installations(self, context: AdapterContext) -> list[CapabilityInstallation]:
response = await asyncio.to_thread(lambda: skills._get_user_skill_storage(context.config).load_skills(enabled_only=False))
response = await skills._filter_visible_skills(context.request, context.config, response)
return [
CapabilityInstallation(
id=f"skill:{skill.category}:{skill.name}",
adapter="skills",
name=skill.name,
reference=skill.name,
description=skill.description,
category=str(skill.category),
enabled=skill.enabled,
scope="user" if str(skill.category) == "custom" else "deployment",
auth_status="not_required",
)
for skill in response
]
async def install(self, context: AdapterContext, manifest: PluginManifest, name: str, configuration: dict[str, Any]) -> None:
raise HTTPException(422, "Use the existing skill archive upload API")
class AdapterRegistry:
def __init__(self) -> None:
self._adapters: dict[str, CapabilityAdapter] = {}
def register(self, name: str, adapter: CapabilityAdapter) -> None:
if name in self._adapters:
raise ValueError(f"Adapter already registered: {name}")
self._adapters[name] = adapter
def get(self, name: str) -> CapabilityAdapter:
adapter = self._adapters.get(name)
if adapter is None:
raise HTTPException(422, "This capability only has a setup guide; no installation adapter is available")
return adapter
registry = AdapterRegistry()
registry.register("mcp", MCPAdapter())
registry.register("business", BusinessAdapter())
registry.register("lark", LarkAdapter())
registry.register("skills", SkillAdapter())
async def list_installations(adapter: str, request: Request, config: AppConfig) -> InstallationList:
context = AdapterContext(request, config, get_effective_user_id())
items = await registry.get(adapter).list_installations(context)
return InstallationList(items=items, can_manage=await is_admin_user(request))

View File

@ -45,6 +45,7 @@ class AgentResponse(BaseModel):
description: str = Field(default="", description="Agent description")
model: str | None = Field(default=None, description="Optional model override")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Subagent allowlist (None=all enabled, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
@ -67,6 +68,7 @@ class AgentCreateRequest(BaseModel):
description: str = Field(default="", description="Agent description")
model: str | None = Field(default=None, description="Optional model override")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Subagent allowlist (None=all enabled, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
@ -82,6 +84,7 @@ class AgentUpdateRequest(BaseModel):
description: str | None = Field(default=None, description="Updated description")
model: str | None = Field(default=None, description="Updated model override")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
mcp_plugins: list[str] | None = Field(default=None, description="MCP installation selection (None=all, []=none)")
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Updated subagent allowlist (None=all, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Updated per-agent sampling overrides")
@ -198,6 +201,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
model=agent_cfg.model,
tool_groups=agent_cfg.tool_groups,
skills=agent_cfg.skills,
mcp_plugins=agent_cfg.mcp_plugins,
allowed_subagents=agent_cfg.allowed_subagents,
model_settings=agent_cfg.model_settings,
thinking_enabled=agent_cfg.thinking_enabled,
@ -339,6 +343,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
config_data["description"] = request.description
if request.tool_groups is not None:
config_data["tool_groups"] = request.tool_groups
if request.mcp_plugins is not None:
config_data["mcp_plugins"] = request.mcp_plugins
if request.skills is not None:
config_data["skills"] = request.skills
if request.allowed_subagents is not None:
@ -422,7 +428,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
# This is critical for skills where None means "inherit all" (not "don't change").
fields_set = request.model_fields_set
config_changed = bool(fields_set & ({"display_name", "description", "tool_groups", "skills", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))
config_changed = bool(fields_set & ({"display_name", "description", "tool_groups", "skills", "mcp_plugins", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))
updated: dict | None = None
if config_changed:
@ -437,6 +443,9 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
if new_tool_groups is not None:
updated["tool_groups"] = new_tool_groups
if "mcp_plugins" in fields_set:
updated["mcp_plugins"] = request.mcp_plugins
# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
if "skills" in fields_set:
new_skills = request.skills

View File

@ -0,0 +1,44 @@
"""Capability discovery is public to authenticated users; mutations reuse existing policy."""
import asyncio
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from app.gateway.capabilities import AdapterContext, InstallationList, list_installations, registry
from app.gateway.deps import get_config, require_admin_user
from deerflow.capabilities.catalog import PluginManifest, load_catalog
from deerflow.config.app_config import AppConfig
from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api/capabilities", tags=["capabilities"])
class InstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
name: str = Field(default="", max_length=128)
configuration: dict[str, Any] = Field(default_factory=dict)
@router.get("/catalog", response_model=list[PluginManifest])
async def catalog() -> list[PluginManifest]:
return await asyncio.to_thread(load_catalog)
@router.get("/installations/{adapter}", response_model=InstallationList)
async def installations(adapter: str, request: Request, config: AppConfig = Depends(get_config)) -> InstallationList:
return await list_installations(adapter, request, config)
@router.post("/installations", response_model=InstallationList)
async def install(body: InstallRequest, request: Request, config: AppConfig = Depends(get_config)) -> InstallationList:
await require_admin_user(request, detail="Admin privileges required to install capabilities.")
entries = await asyncio.to_thread(load_catalog)
manifest = next((entry for entry in entries if entry.id == body.plugin_id), None)
if manifest is None:
raise HTTPException(404, "Plugin not found")
context = AdapterContext(request, config, get_effective_user_id())
await registry.get(manifest.adapter).install(context, manifest, body.name, body.configuration)
return await list_installations(manifest.adapter, request, config)

View File

@ -2,6 +2,7 @@ import asyncio
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Literal, NamedTuple, NoReturn
@ -781,6 +782,21 @@ def _validate_mcp_update_request(
if transport_type != "stdio":
continue
from deerflow.capabilities.business import is_bundled_connection
# This exact isolated interpreter/module/provider tuple is generated by
# our bundled adapter, never an arbitrary API-supplied executable path.
if is_bundled_connection(server.command, server.args, server.env):
continue
if enforce_execution_policy and is_bundled_connection(sys.executable, server.args, server.env):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Bundled MCP server '{name}' uses a different Python interpreter. "
f"Edit this server's JSON and set 'command' to {sys.executable!r}. "
"Keep its capability metadata and credentials unchanged to preserve Agent selections; do not delete and reinstall it."
),
)
command_name = _stdio_command_name(server.command, server_name=name)
if enforce_execution_policy:
if command_name not in allowed_commands:
@ -1102,6 +1118,11 @@ def _merge_preserving_secrets(
for key, value in (existing.model_extra or {}).items():
if key not in (incoming.model_extra or {}):
update[key] = value
# Installation identity belongs to the registry, not the editable transport
# settings. Older clients omit it; neither bulk nor targeted edits may drop
# it and silently detach an Agent's capability selection.
if isinstance(existing_extra.get("capability"), dict):
update["capability"] = existing_extra["capability"]
merged = incoming.model_copy(update=update)
_ensure_no_masked_secrets(merged)
return merged
@ -1163,12 +1184,16 @@ def _mcp_server_response_from_raw(server_name: str, raw_server: Any) -> McpServe
_raise_invalid_mcp_configuration(f"mcpServers.{server_name}: {_validation_error_summary(exc)}", cause=exc)
def _validate_extensions_config_candidate(raw_data: dict) -> None:
def _validate_extensions_config_candidate(raw_data: dict, *, check_installation_ids: bool = True) -> None:
"""Reject a runtime-invalid candidate without changing its placeholders."""
from deerflow.capabilities.runtime import ambiguous_installation_ids
try:
validate_raw_extensions_config(raw_data)
except ValidationError as exc:
_raise_invalid_mcp_configuration(_validation_error_summary(exc), cause=exc)
if check_installation_ids and ambiguous_installation_ids(_raw_mcp_servers(raw_data)):
_raise_invalid_mcp_configuration("Duplicate MCP installation IDs; remove conflicting entries or assign unique capability IDs in the deployment configuration")
def _apply_mcp_config_update(body: McpConfigUpdateRequest) -> dict:
@ -1406,7 +1431,9 @@ def _apply_mcp_server_delete(server_name: str) -> dict:
del raw_servers[server_name]
raw_data["mcpServers"] = raw_servers
_validate_extensions_config_candidate(raw_data)
# Removal cannot introduce an ID collision; permit incremental recovery
# even when another legacy collision pair remains. Keep schema validation.
_validate_extensions_config_candidate(raw_data, check_installation_ids=False)
atomic_write_extensions_config(config_path, raw_data)
logger.info("Deleted MCP server: %s", server_name)

View File

@ -1017,6 +1017,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
"is_plan_mode": is_plan_mode,
"subagent_enabled": subagent_enabled,
"tool_groups": agent_config.tool_groups if agent_config else None,
"mcp_plugins": getattr(agent_config, "mcp_plugins", None),
"available_skills": sorted(available_skills) if available_skills is not None else None,
"allowed_subagents": list(allowed_subagents) if allowed_subagents is not None else None,
"memory_enabled": memory_enabled,
@ -1179,6 +1180,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
raw_tools = get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
mcp_plugins=getattr(agent_config, "mcp_plugins", None),
subagent_enabled=subagent_enabled,
include_conversation_reader=callable(cfg.get(CONVERSATION_READER_CONTEXT_KEY)) and not bool(cfg.get("is_subagent")),
app_config=resolved_app_config,

View File

@ -0,0 +1 @@
"""Declarative capability discovery; execution stays with the owning runtime."""

View File

@ -0,0 +1,732 @@
[
{
"id": "lark",
"category": "office",
"name": {
"en-US": "Lark / Feishu",
"zh-CN": "飞书 / Lark"
},
"description": {
"en-US": "Connect documents, messages, calendars, and tables.",
"zh-CN": "连接文档、消息、日历与多维表格。"
},
"setup": {
"en-US": "Install the managed integration and connect your account.",
"zh-CN": "安装集成并连接自己的账号。"
},
"kind": "cli",
"source": "https://github.com/larksuite/cli",
"aliases": [
"lark",
"feishu",
"飞书"
],
"schema_version": 1,
"version": "1",
"adapter": "lark",
"auth_methods": [
"oauth"
],
"icon": "/images/plugins/lark.ico",
"contributions": [
"tools",
"skills"
],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "dingtalk",
"category": "office",
"name": {
"en-US": "DingTalk group notifications",
"zh-CN": "钉钉群通知"
},
"description": {
"en-US": "Send text and Markdown notifications to a configured DingTalk group robot.",
"zh-CN": "向配置的钉钉群机器人发送文本或 Markdown 通知。"
},
"kind": "mcp",
"setup": {
"en-US": "Create a signed custom group robot. Enter its access_token and signing secret. This does not read chats, documents or calendars.",
"zh-CN": "创建启用加签的自定义群机器人,填写 Webhook 的 access_token 和加签密钥。此插件不读取聊天、文档或日历。"
},
"source": "https://open.dingtalk.com/document/orgapp/custom-robot-access",
"aliases": [
"dingtalk",
"钉钉"
],
"schema_version": 1,
"version": "2",
"adapter": "business",
"auth_methods": [
"api_key"
],
"icon": "/images/plugins/dingtalk.ico",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Connection name"
},
"access_token": {
"type": "string",
"format": "password",
"title": "access_token"
},
"sign_secret": {
"type": "string",
"format": "password",
"title": "sign_secret"
}
},
"required": [
"name",
"access_token",
"sign_secret"
],
"additionalProperties": false
}
},
{
"id": "wecom",
"category": "office",
"name": {
"en-US": "WeCom group notifications",
"zh-CN": "企业微信群通知"
},
"description": {
"en-US": "Send text and Markdown notifications to a configured WeCom group robot.",
"zh-CN": "向配置的企业微信群机器人发送文本或 Markdown 通知。"
},
"kind": "mcp",
"setup": {
"en-US": "Create a group robot and copy the key parameter from its webhook URL. This does not connect the incoming chat channel or access documents.",
"zh-CN": "创建群机器人,复制 Webhook 地址中的 key 参数。此插件用于主动通知,不接入收消息通道或读取文档。"
},
"source": "https://developer.work.weixin.qq.com/document/path/91770",
"aliases": [
"wecom",
"企业微信",
"企微"
],
"schema_version": 1,
"version": "2",
"adapter": "business",
"auth_methods": [
"api_key"
],
"icon": "/images/plugins/wecom.png",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Connection name"
},
"webhook_key": {
"type": "string",
"format": "password",
"title": "webhook_key"
}
},
"required": [
"name",
"webhook_key"
],
"additionalProperties": false
}
},
{
"id": "tencent-docs",
"category": "knowledge",
"name": {
"en-US": "Tencent Docs",
"zh-CN": "腾讯文档"
},
"description": {
"en-US": "Find and update shared documents and spreadsheets.",
"zh-CN": "查找与更新在线文档、表格,协同整理团队资料。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://docs.qq.com/open/auth/mcp.html",
"aliases": [
"tencent-docs",
"腾讯文档"
],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": [
"api_key",
"oauth"
],
"icon": "/images/plugins/tencent-docs.ico",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"url": {
"type": "string",
"title": "Server URL"
},
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": [
"name",
"url"
],
"additionalProperties": false
}
},
{
"id": "notion",
"category": "knowledge",
"name": {
"en-US": "Notion",
"zh-CN": "Notion"
},
"description": {
"en-US": "Search your team's wiki, notes, and project knowledge.",
"zh-CN": "搜索团队知识库、笔记与项目文档,沉淀工作信息。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/makenotion/notion-mcp-server",
"aliases": [
"notion"
],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": [
"api_key",
"oauth"
],
"icon": "/images/plugins/notion.svg",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"url": {
"type": "string",
"title": "Server URL"
},
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": [
"name",
"url"
],
"additionalProperties": false
}
},
{
"id": "openviking",
"category": "knowledge",
"name": {
"en-US": "OpenViking",
"zh-CN": "OpenViking"
},
"description": {
"en-US": "Organize long-term memory and reusable agent resources.",
"zh-CN": "统一管理长期记忆与资源,为 Agent 提供工作上下文。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/volcengine/OpenViking",
"aliases": [
"openviking",
"openviking context"
],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": [
"api_key",
"oauth"
],
"icon": "/images/plugins/openviking.png",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"url": {
"type": "string",
"title": "Server URL"
},
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": [
"name",
"url"
],
"additionalProperties": false
}
},
{
"id": "web-search",
"category": "research",
"name": {
"en-US": "Web search",
"zh-CN": "网页搜索"
},
"description": {
"en-US": "Search the web with your configured search provider.",
"zh-CN": "搜索公开网页,为研究和决策补充信息来源。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/blob/main/config.example.yaml",
"aliases": [
"web-search",
"duckduckgo",
"tavily",
"brave",
"brave search",
"serper"
],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": [
"none",
"api_key"
],
"icon": null,
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "web-fetch",
"category": "research",
"name": {
"en-US": "Web reader",
"zh-CN": "网页读取"
},
"description": {
"en-US": "Read web pages and extract useful content with Jina.",
"zh-CN": "读取网页正文,提取资料,供 Agent 进一步分析。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/jina_ai",
"aliases": [
"web-fetch",
"jina"
],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": [
"none",
"api_key"
],
"icon": null,
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "exa",
"category": "research",
"name": {
"en-US": "Exa",
"zh-CN": "Exa"
},
"description": {
"en-US": "Find relevant sources with semantic web search.",
"zh-CN": "通过语义搜索找到相关网页,开展行业与公司研究。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/exa",
"aliases": [
"exa"
],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": [
"none",
"api_key"
],
"icon": "/images/plugins/exa.svg",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "firecrawl",
"category": "research",
"name": {
"en-US": "Firecrawl",
"zh-CN": "Firecrawl"
},
"description": {
"en-US": "Search and extract content from websites at scale.",
"zh-CN": "搜索与抓取网站内容,将网页转为可分析的资料。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/firecrawl",
"aliases": [
"firecrawl"
],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": [
"none",
"api_key"
],
"icon": "/images/plugins/firecrawl.svg",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "database",
"category": "business",
"name": {
"en-US": "SQL databases",
"zh-CN": "SQL 数据库"
},
"description": {
"en-US": "Connect business data in PostgreSQL, MySQL, and more.",
"zh-CN": "连接 PostgreSQL、MySQL 等数据库,查询与分析业务数据。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/googleapis/genai-toolbox",
"aliases": [
"postgres",
"postgresql",
"mysql",
"database",
"mcp-toolbox"
],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": [
"api_key",
"oauth"
],
"icon": null,
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"url": {
"type": "string",
"title": "Server URL"
},
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": [
"name",
"url"
],
"additionalProperties": false
}
},
{
"id": "hubspot",
"category": "business",
"name": {
"en-US": "HubSpot CRM",
"zh-CN": "HubSpot CRM"
},
"description": {
"en-US": "Read companies and create contacts with a private app access token.",
"zh-CN": "使用私有应用令牌查询公司、创建联系人。"
},
"kind": "mcp",
"setup": {
"en-US": "Provide a private app access token with crm.objects.companies.read and crm.objects.contacts.write. Reading companies does not create records; contacts are created only when the tool is called.",
"zh-CN": "填写私有应用令牌,并授予 crm.objects.companies.read 和 crm.objects.contacts.write 权限。查询公司不会创建数据;只有调用创建联系人工具时才写入。"
},
"source": "https://developers.hubspot.com/docs/apps/legacy-apps/authentication/intro-to-auth",
"aliases": [
"hubspot",
"crm"
],
"schema_version": 1,
"version": "2",
"adapter": "business",
"auth_methods": [
"api_key"
],
"icon": "/images/plugins/hubspot.svg",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Connection name"
},
"access_token": {
"type": "string",
"format": "password",
"title": "access_token"
}
},
"required": [
"name",
"access_token"
],
"additionalProperties": false
}
},
{
"id": "github",
"category": "development",
"name": {
"en-US": "GitHub",
"zh-CN": "GitHub"
},
"description": {
"en-US": "Find code, triage issues, and review pull requests.",
"zh-CN": "检索代码与仓库,跟进 Issue协助审查 Pull Request。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/github/github-mcp-server",
"aliases": [
"github"
],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": [
"api_key",
"oauth"
],
"icon": "/images/plugins/github.svg",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"url": {
"type": "string",
"title": "Server URL"
},
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": [
"name",
"url"
],
"additionalProperties": false
}
},
{
"id": "atlassian",
"category": "development",
"name": {
"en-US": "Jira / Confluence",
"zh-CN": "Jira / Confluence"
},
"description": {
"en-US": "Connect project issues, team pages, and delivery plans.",
"zh-CN": "连接项目需求、团队文档与交付计划,追踪研发进展。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/sooperset/mcp-atlassian",
"aliases": [
"atlassian",
"jira",
"confluence",
"mcp-atlassian"
],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": [
"api_key",
"oauth"
],
"icon": "/images/plugins/jira.svg",
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name"
},
"url": {
"type": "string",
"title": "Server URL"
},
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": [
"name",
"url"
],
"additionalProperties": false
}
},
{
"id": "browser",
"category": "development",
"name": {
"en-US": "Browser automation",
"zh-CN": "浏览器自动化"
},
"description": {
"en-US": "Open pages and complete browser tasks with Playwright.",
"zh-CN": "基于 Playwright 打开网页、提取内容与执行页面操作。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/browser_automation",
"aliases": [
"browser",
"playwright"
],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": [
"none",
"api_key"
],
"icon": null,
"contributions": [
"tools"
],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
]

View File

@ -0,0 +1,178 @@
"""Bundled business tools served through the existing stdio MCP lifecycle.
These clients implement the documented provider APIs independently. Credentials
stay in MCP process environment, never in tool arguments or discovery results.
"""
import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import sys
import time
from typing import Annotated, Any, Literal
import httpx
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
from pydantic import Field
MODULE = "deerflow.capabilities.business"
CREDENTIALS = {
"dingtalk": {"access_token": "DEERFLOW_DINGTALK_ACCESS_TOKEN", "sign_secret": "DEERFLOW_DINGTALK_SIGN_SECRET"},
"wecom": {"webhook_key": "DEERFLOW_WECOM_WEBHOOK_KEY"},
"hubspot": {"access_token": "DEERFLOW_HUBSPOT_ACCESS_TOKEN"},
}
def connection_config(provider: str, configuration: dict[str, Any]) -> dict[str, Any]:
fields = CREDENTIALS.get(provider)
if fields is None or set(configuration) != set(fields):
raise ValueError("Supply the required credentials only")
env = {}
for field, variable in fields.items():
value = configuration[field]
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9._~+/=-]{1,4096}", value):
raise ValueError(f"Invalid credential: {field}")
env[variable] = value
return {"type": "stdio", "command": sys.executable, "args": ["-I", "-m", MODULE, provider], "env": env, "enabled": True}
def is_bundled_connection(command: str | None, args: list[str], env: dict[str, str]) -> bool:
"""Narrow exception to the executable allowlist, including edits/toggles.
Never trust catalog metadata to grant execution. The interpreter, module,
provider, flags and allowed environment keys must all match our own launcher.
"""
return command == sys.executable and len(args) == 4 and args[:3] == ["-I", "-m", MODULE] and args[3] in CREDENTIALS and set(env) == set(CREDENTIALS[args[3]].values())
class BusinessClient:
def __init__(self, provider: str, credentials: dict[str, str], http: httpx.AsyncClient | None = None):
connection_config(provider, credentials)
self.provider = provider
self.credentials = credentials
self.http = http
async def _request(self, method: str, url: str, **kwargs: Any) -> dict[str, Any]:
async def perform(client: httpx.AsyncClient) -> dict[str, Any]:
try:
async with client.stream(method, url, follow_redirects=False, timeout=20, **kwargs) as response:
if not 200 <= response.status_code < 300:
raise ValueError(f"{self.provider}: HTTP {response.status_code}; check credentials, permissions and provider limits")
chunks = bytearray()
async for chunk in response.aiter_bytes():
chunks.extend(chunk)
if len(chunks) > 2_000_000:
raise ValueError("Provider response exceeds the size limit")
try:
data = json.loads(chunks)
except (ValueError, UnicodeError):
raise ValueError("Provider returned an invalid JSON response") from None
if not isinstance(data, dict):
raise ValueError("Provider returned an invalid response")
return data
except httpx.RequestError:
# Request exceptions contain token-bearing webhook URLs.
raise ValueError(f"{self.provider}: network request failed; delivery may be unknown, check before retrying") from None
if self.http is not None:
return await perform(self.http)
async with httpx.AsyncClient() as client:
return await perform(client)
async def send_message(self, content: str, message_type: Literal["text", "markdown"] = "text", title: str = "Notification") -> dict[str, Any]:
if self.provider not in ("dingtalk", "wecom"):
raise ValueError("This provider has no group notification tool")
limit = 2048 if message_type == "text" or self.provider == "dingtalk" else 4096
if message_type not in ("text", "markdown") or not content.strip() or len(content.encode("utf-8")) > limit:
raise ValueError(f"Message must contain 1{limit} UTF-8 bytes")
if not title.strip() or len(title) > 100:
raise ValueError("Title must contain 1100 characters")
if self.provider == "dingtalk":
timestamp = str(int(time.time() * 1000))
secret = self.credentials["sign_secret"]
signature = hmac.new(secret.encode(), f"{timestamp}\n{secret}".encode(), hashlib.sha256).digest()
params = {"access_token": self.credentials["access_token"], "timestamp": timestamp, "sign": base64.b64encode(signature).decode()}
url = "https://oapi.dingtalk.com/robot/send"
body = {"text": content, "title": title} if message_type == "markdown" else {"content": content}
else:
params = {"key": self.credentials["webhook_key"]}
url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send"
body = {"content": content}
data = await self._request("POST", url, params=params, json={"msgtype": message_type, message_type: body})
code = data.get("errcode")
if type(code) is not int or code != 0:
safe_code = str(code) if type(code) is int else "unknown"
raise ValueError(f"{self.provider}: provider rejected the message (code {safe_code}); check robot settings and limits")
return {"sent": True}
async def get_companies(self, limit: int = 10, after: str | None = None) -> dict[str, Any]:
if not 1 <= limit <= 100 or (after is not None and len(after) > 512):
raise ValueError("Invalid page size or cursor")
params = {"limit": str(limit), "properties": "name,domain,industry,phone,city,country", "archived": "false"}
if after:
params["after"] = after
data = await self._hubspot("GET", "/crm/v3/objects/companies", params=params)
if not isinstance(data.get("results"), list):
raise ValueError("HubSpot returned an invalid company list")
return {"companies": data["results"], "next_after": data.get("paging", {}).get("next", {}).get("after")}
async def create_contact(self, email: str, firstname: str = "", lastname: str = "", phone: str = "", company: str = "", jobtitle: str = "") -> dict[str, Any]:
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) or len(email) > 254:
raise ValueError("Supply a valid contact email")
fields = {"email": email, "firstname": firstname, "lastname": lastname, "phone": phone, "company": company, "jobtitle": jobtitle}
if any(len(value) > 1000 for value in fields.values()):
raise ValueError("Contact fields must not exceed 1000 characters")
data = await self._hubspot("POST", "/crm/v3/objects/contacts", json={"properties": {key: value for key, value in fields.items() if value}})
if not data.get("id"):
raise ValueError("HubSpot did not return a created contact ID")
return {"id": data["id"], "properties": data.get("properties", {})}
async def _hubspot(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
if self.provider != "hubspot":
raise ValueError("This provider has no CRM tools")
return await self._request(method, "https://api.hubapi.com" + path, headers={"Authorization": f"Bearer {self.credentials['access_token']}"}, **kwargs)
def build_server(provider: str) -> FastMCP:
if provider not in CREDENTIALS:
raise ValueError("Unknown business provider")
server = FastMCP(f"deerflow-{provider}", log_level="WARNING")
def client() -> BusinessClient:
return BusinessClient(provider, {field: os.environ.get(variable, "") for field, variable in CREDENTIALS[provider].items()})
if provider == "hubspot":
@server.tool(annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, openWorldHint=True))
async def get_companies(limit: Annotated[int, Field(ge=1, le=100)] = 10, after: str | None = None) -> dict[str, Any]:
"""Read a page of HubSpot companies; pass next_after to retrieve the next page."""
return await client().get_companies(limit, after)
@server.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
async def create_contact(email: str, firstname: str = "", lastname: str = "", phone: str = "", company: str = "", jobtitle: str = "") -> dict[str, Any]:
"""Create a real HubSpot contact when requested. Do not retry an uncertain write without checking for duplicates."""
return await client().create_contact(email, firstname, lastname, phone, company, jobtitle)
else:
@server.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
async def send_message(content: str, message_type: Literal["text", "markdown"] = "text", title: str = "Notification") -> dict[str, Any]:
"""Send a real notification to the configured group robot when requested. Does not read chats. Do not automatically retry uncertain delivery."""
return await client().send_message(content, message_type, title)
return server
def main() -> None:
parser = argparse.ArgumentParser(description="DeerFlow bundled business MCP tools")
parser.add_argument("provider", choices=CREDENTIALS)
build_server(parser.parse_args().provider).run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,51 @@
"""Validated manifests, independent from HTTP, account policy and UI components."""
import json
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class PluginManifest(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal[1] = 1
id: str = Field(pattern=r"^[a-z0-9][a-z0-9.-]*$", max_length=128)
version: str = Field(min_length=1, max_length=80)
name: dict[str, str]
description: dict[str, str]
setup: dict[str, str]
category: Literal["office", "knowledge", "research", "business", "development", "custom"]
kind: Literal["mcp", "cli", "native"]
adapter: str = Field(pattern=r"^[a-z][a-z0-9_-]*$")
source: str
icon: str | None = None
aliases: list[str] = Field(default_factory=list)
auth_methods: list[Literal["none", "api_key", "oauth"]] = Field(default_factory=list)
contributions: list[Literal["tools", "skills"]] = Field(default_factory=list)
config_schema: dict[str, Any] = Field(default_factory=lambda: {"type": "object"})
@field_validator("source")
@classmethod
def safe_source(cls, value: str) -> str:
if not value.startswith("https://"):
raise ValueError("Plugin source must use HTTPS")
return value
@field_validator("icon")
@classmethod
def local_icon(cls, value: str | None) -> str | None:
if value is not None and (not value.startswith("/images/plugins/") or ".." in value or "?" in value or "#" in value):
raise ValueError("Catalog icons must be bundled plugin assets")
return value
def load_catalog(path: Path | None = None) -> list[PluginManifest]:
"""An operator may supply another manifest file; never load executable code."""
source = path or Path(__file__).with_name("builtin.json")
items = [PluginManifest.model_validate(item) for item in json.loads(source.read_text(encoding="utf-8"))]
ids = [item.id for item in items]
if len(ids) != len(set(ids)):
raise ValueError("Duplicate plugin identifiers in catalog")
return items

View File

@ -0,0 +1,38 @@
"""Stable installation references and agent tool selection, not an authorization system."""
from collections.abc import Mapping
from typing import Any
from uuid import NAMESPACE_URL, uuid5
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool
def installation_id(server_name: str, server: Mapping[str, Any]) -> str:
metadata = server.get("capability")
if isinstance(metadata, dict) and isinstance(metadata.get("id"), str) and metadata["id"]:
return metadata["id"]
# Existing configs are adopted without rewriting them during a GET. Names
# are existing immutable runtime keys; future saves retain this identity.
return str(uuid5(NAMESPACE_URL, f"deerflow:mcp:{server_name}"))
def ambiguous_installation_ids(servers: Mapping[str, Mapping[str, Any]]) -> set[str]:
"""Count disabled entries too: enabling one must never widen another selection."""
seen: set[str] = set()
ambiguous: set[str] = set()
for name, server in servers.items():
identity = installation_id(name, server)
if identity in seen:
ambiguous.add(identity)
seen.add(identity)
return ambiguous
def filter_mcp_plugins(tools: list[Any], selected: list[str] | None, config: ExtensionsConfig) -> list[Any]:
if selected is None:
return tools
ambiguous = ambiguous_installation_ids({name: server.model_dump() for name, server in config.mcp_servers.items()})
wanted = set(selected) - ambiguous
servers = {name for name, server in config.get_enabled_mcp_servers().items() if installation_id(name, server.model_dump()) in wanted}
return [tool for tool in tools if not is_mcp_tool(tool) or (get_mcp_source(tool) or {}).get("server_name") in servers]

View File

@ -217,6 +217,9 @@ class AgentConfig(BaseModel):
# - [] (explicit empty list): disable all skills
# - ["skill1", "skill2"]: load only the specified skills
skills: list[str] | None = None
# Stable MCP installation IDs. None inherits all; [] selects none.
# This is tool selection, not a replacement for host authorization.
mcp_plugins: list[str] | None = None
# Controls which deployment-level subagents this custom agent may invoke:
# None = all currently enabled definitions, [] = none, list = allowlist.
# The default Lead Agent has no AgentConfig and therefore keeps access to

View File

@ -201,6 +201,7 @@ class SubagentBatchService:
tools = await run_assembly(
get_available_tools,
groups=spec.get("tool_groups"),
mcp_plugins=spec.get("mcp_plugins"),
model_name=effective_model,
subagent_enabled=False,
include_upload_tool=False,

View File

@ -206,6 +206,7 @@ async def batch_task(
"subagent_config": asdict(config),
"parent_model": metadata.get("model_name"),
"tool_groups": metadata.get("tool_groups"),
"mcp_plugins": metadata.get("mcp_plugins"),
"user_role": context.get("user_role"),
"oauth_provider": context.get("oauth_provider"),
"oauth_id": context.get("oauth_id"),

View File

@ -904,6 +904,8 @@ async def task_tool(
"subagent_enabled": False,
"include_upload_tool": upload_state_available,
}
if metadata.get("mcp_plugins") is not None:
available_tools_kwargs["mcp_plugins"] = metadata["mcp_plugins"]
if resolved_app_config is not None:
available_tools_kwargs["app_config"] = resolved_app_config
# Assemble off-loop: tool assembly may block on MCP cache initialization,

View File

@ -76,6 +76,7 @@ def get_available_tools(
model_name: str | None = None,
subagent_enabled: bool = False,
*,
mcp_plugins: list[str] | None = None,
include_upload_tool: bool = True,
include_conversation_reader: bool = False,
app_config: AppConfig | None = None,
@ -187,6 +188,10 @@ def get_available_tools(
# policy-filtered list because their skills load at startup.
for t in mcp_tools:
tag_mcp_tool(t)
if mcp_plugins is not None:
from deerflow.capabilities.runtime import filter_mcp_plugins
mcp_tools = filter_mcp_plugins(mcp_tools, mcp_plugins, extensions_config)
except ImportError:
logger.warning("MCP module not available. Install 'langchain-mcp-adapters' package to enable MCP tools.")
except Exception as e:

View File

@ -0,0 +1,27 @@
"""Capability discovery must offload manifest and integration storage reads."""
from types import SimpleNamespace
import pytest
from app.gateway.capabilities import AdapterContext, MCPAdapter
from app.gateway.routers import capabilities
from deerflow.config.extensions_config import ExtensionsConfig
@pytest.mark.asyncio
async def test_catalog_reads_files_off_loop():
result = await capabilities.catalog()
assert any(entry.id == "github" for entry in result)
@pytest.mark.asyncio
async def test_installation_discovery_reads_files_off_loop(tmp_path, monkeypatch):
path = tmp_path / "extensions_config.json"
import asyncio
await asyncio.to_thread(path.write_text, '{"mcpServers":{"sample":{"enabled":false}},"skills":{}}')
monkeypatch.setattr(ExtensionsConfig, "resolve_config_path", lambda *args: path)
context = AdapterContext(SimpleNamespace(), SimpleNamespace(), "default")
result = await MCPAdapter().list_installations(context)
assert result[0].name == "sample"

View File

@ -155,3 +155,16 @@ async def test_allowed_subagents_round_trip_and_explicit_null_clears(_agent_env)
unrestricted = await update_agent("delegator", AgentUpdateRequest(allowed_subagents=None))
assert unrestricted.allowed_subagents is None
async def test_plugin_selection_persists_empty_omitted_and_null(_agent_env):
created = await create_agent_endpoint(AgentCreateRequest(name="selected", mcp_plugins=["stable-installation"], skills=["research"]))
assert created.mcp_plugins == ["stable-installation"]
fetched = await get_agent("selected")
assert fetched.mcp_plugins == ["stable-installation"]
assert (await update_agent("selected", AgentUpdateRequest(description="changed"))).mcp_plugins == ["stable-installation"]
assert (await update_agent("selected", AgentUpdateRequest(mcp_plugins=[]))).mcp_plugins == []
assert (await get_agent("selected")).mcp_plugins == []
cleared = await update_agent("selected", AgentUpdateRequest(mcp_plugins=None))
assert cleared.mcp_plugins is None
assert cleared.skills == ["research"]

View File

@ -31,6 +31,7 @@ def _runtime():
"model_name": "model-a",
"allowed_subagents": ["general-purpose"],
"tool_groups": ["web"],
"mcp_plugins": ["stable-plugin"],
},
"configurable": {"thread_id": "thread-1"},
},
@ -82,6 +83,7 @@ async def test_batch_task_is_explicit_idempotent_submission(monkeypatch) -> None
message = _message(command)
request = submitter.submit.await_args.args[0]
assert request.submission_key == "run-1:call-1"
assert request.execution_spec["mcp_plugins"] == ["stable-plugin"]
assert request.user_id == "user-1"
assert [item["key"] for item in request.items] == ["record-1", "record-2"]
assert request.max_live_items == 20

View File

@ -0,0 +1,168 @@
"""Provider contracts: actual HTTP serialization, business errors and MCP tools."""
import json
import httpx
import pytest
from deerflow.capabilities.business import BusinessClient, build_server, connection_config
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider,credentials,host",
[
("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-sign"}, "oapi.dingtalk.com"),
("wecom", {"webhook_key": "robot-key"}, "qyapi.weixin.qq.com"),
],
)
async def test_robot_wire_contract(provider, credentials, host):
requests = []
def handle(request):
requests.append(request)
return httpx.Response(200, json={"errcode": 0, "errmsg": "ok"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
client = BusinessClient(provider, credentials, http)
assert await client.send_message("通知", "markdown", "日报") == {"sent": True}
request = requests[0]
assert request.url.host == host
body = json.loads(request.content)
assert body["msgtype"] == "markdown"
if provider == "dingtalk":
assert request.url.params["access_token"] == "robot-token"
assert request.url.params["timestamp"] and request.url.params["sign"]
assert body["markdown"] == {"text": "通知", "title": "日报"}
else:
assert request.url.params["key"] == "robot-key"
assert body["markdown"] == {"content": "通知"}
@pytest.mark.asyncio
@pytest.mark.parametrize("status,body", [(200, {"errcode": 40014, "errmsg": "secret-token"}), (401, {"message": "secret-token"}), (302, {})])
async def test_errors_never_claim_success_or_expose_response_secrets(status, body):
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(status, json=body))) as http:
client = BusinessClient("wecom", {"webhook_key": "secret-token"}, http)
with pytest.raises(ValueError) as error:
await client.send_message("hello")
assert "secret-token" not in str(error.value)
@pytest.mark.asyncio
async def test_hubspot_pagination_and_contact_creation():
requests = []
def handle(request):
requests.append(request)
if request.method == "GET":
return httpx.Response(200, json={"results": [{"id": "12"}], "paging": {"next": {"after": "next-page"}}})
return httpx.Response(201, json={"id": "42", "properties": {"email": "person@example.test"}})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
client = BusinessClient("hubspot", {"access_token": "private-token"}, http)
assert (await client.get_companies(2, "cursor"))["next_after"] == "next-page"
assert (await client.create_contact("person@example.test", firstname="A"))["id"] == "42"
assert requests[0].url.params["after"] == "cursor"
assert requests[0].url.params["limit"] == "2"
assert requests[0].headers["Authorization"] == "Bearer private-token"
assert requests[1].url.path == "/crm/v3/objects/contacts"
assert json.loads(requests[1].content) == {"properties": {"email": "person@example.test", "firstname": "A"}}
@pytest.mark.asyncio
async def test_mcp_discovery_does_not_need_or_expose_credentials():
server = build_server("hubspot")
tools = await server.list_tools()
assert {tool.name for tool in tools} == {"get_companies", "create_contact"}
for tool in tools:
assert "access_token" not in json.dumps(tool.inputSchema)
assert tool.annotations.readOnlyHint == (tool.name == "get_companies")
@pytest.mark.parametrize("provider,values", [("wecom", {}), ("dingtalk", {"access_token": "x"}), ("hubspot", {"access_token": "x", "url": "http://localhost"}), ("hubspot", {"access_token": "***"})])
def test_invalid_configuration_rejected(provider, values):
with pytest.raises(ValueError):
connection_config(provider, values)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider,credentials,tool,arguments",
[
("dingtalk", {"access_token": "fixture-token", "sign_secret": "fixture-secret"}, "send_message", {"content": ""}),
("wecom", {"webhook_key": "fixture-key"}, "send_message", {"content": ""}),
("hubspot", {"access_token": "fixture-token"}, "create_contact", {"email": "invalid"}),
],
)
async def test_exact_installed_launcher_discovers_and_rejects_invalid_calls(provider, credentials, tool, arguments):
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
config = connection_config(provider, credentials)
params = StdioServerParameters(command=config["command"], args=config["args"], env=config["env"])
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
await session.initialize()
discovered = await session.list_tools()
assert tool in {item.name for item in discovered.tools}
result = await session.call_tool(tool, arguments)
assert result.isError
for secret in credentials.values():
assert secret not in result.model_dump_json()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider,credentials,tool,arguments,expected",
[
("dingtalk", {"access_token": "fixture-token", "sign_secret": "fixture-secret"}, "send_message", {"content": "Report ready"}, "sent"),
("wecom", {"webhook_key": "fixture-key"}, "send_message", {"content": "Report ready"}, "sent"),
("hubspot", {"access_token": "fixture-token"}, "get_companies", {"limit": 1}, "Acme"),
("hubspot", {"access_token": "fixture-token"}, "create_contact", {"email": "person@example.test"}, "contact-42"),
],
)
async def test_real_mcp_tool_invocation_with_simulated_provider(tmp_path, provider, credentials, tool, arguments, expected):
"""Real stdio protocol and client code; only external HTTP is simulated."""
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
script = tmp_path / "business_fixture.py"
script.write_text(
"""import httpx
from deerflow.capabilities.business import build_server
import sys
original = httpx.AsyncClient
def handle(request):
if request.url.host == "api.hubapi.com":
if request.method == "GET":
return httpx.Response(200, json={"results": [{"id": "1", "properties": {"name": "Acme"}}]})
return httpx.Response(201, json={"id": "contact-42", "properties": {"email": "person@example.test"}})
return httpx.Response(200, json={"errcode": 0})
httpx.AsyncClient = lambda: original(transport=httpx.MockTransport(handle))
build_server(sys.argv[1]).run()
""",
encoding="utf-8",
)
params = StdioServerParameters(command=sys.executable, args=["-I", str(script), provider], env=connection_config(provider, credentials)["env"])
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool, arguments)
assert not result.isError, result
assert expected in result.model_dump_json()
@pytest.mark.asyncio
async def test_robot_missing_business_status_is_failure_and_network_exception_is_redacted():
async def missing(_):
return httpx.Response(200, json={"ok": True})
def broken(request):
raise httpx.ConnectError("https://example.test?key=super-secret", request=request)
for handler in (missing, broken):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.raises(ValueError) as error:
await BusinessClient("wecom", {"webhook_key": "super-secret"}, http).send_message("hello")
assert "super-secret" not in str(error.value)

View File

@ -0,0 +1,257 @@
"""Exercise catalog installation through HTTP and the existing on-disk MCP store."""
import json
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway import capabilities
from app.gateway.deps import get_config
from app.gateway.routers import capabilities as router
from app.gateway.routers import mcp
from deerflow.config.extensions_config import ExtensionsConfig
@pytest.fixture
def capability_client(tmp_path, monkeypatch):
path = tmp_path / "extensions_config.json"
path.write_text(json.dumps({"mcpServers": {"legacy": {"enabled": True, "type": "http", "url": "https://private.example/mcp", "headers": {"Authorization": "Bearer private-secret"}, "capability": {"plugin_id": 42}}}, "skills": {}}))
monkeypatch.setattr(ExtensionsConfig, "resolve_config_path", lambda *args: path)
monkeypatch.setattr(mcp, "reload_extensions_config", lambda: None)
monkeypatch.setattr(mcp, "reset_mcp_tools_cache", lambda: None)
app = FastAPI()
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
@app.middleware("http")
async def identity(request, call_next):
request.state.user = SimpleNamespace(system_role=request.headers.get("test-role", "admin"))
return await call_next(request)
app.include_router(router.router)
app.include_router(mcp.router)
with TestClient(app) as client:
yield client, path
def test_discovery_never_exposes_connection_secrets_and_tolerates_legacy_extras(capability_client):
client, _ = capability_client
result = client.get("/api/capabilities/installations/mcp", headers={"test-role": "user"})
assert result.status_code == 200
assert result.json()["can_manage"] is False
item = result.json()["items"][0]
assert item["id"] and item["plugin_id"] is None
assert item["auth_status"] == "configured" and item["health"] == "unknown"
for excluded in ("private-secret", "private.example", "Authorization", "headers"):
assert excluded not in result.text
def test_catalog_install_edit_disable_delete_preserves_existing_store_and_identity(capability_client):
client, path = capability_client
body = {"plugin_id": "github", "name": "team-code", "configuration": {"enabled": True, "type": "http", "url": "https://example.test/mcp", "headers": {"Authorization": "Bearer team-secret"}}}
response = client.post("/api/capabilities/installations", json=body)
assert response.status_code == 200, response.text
installed = next(item for item in response.json()["items"] if item["name"] == "team-code")
identity = installed["id"]
assert installed["plugin_id"] == "github"
raw = json.loads(path.read_text())
assert raw["mcpServers"]["legacy"]["headers"]["Authorization"] == "Bearer private-secret"
assert raw["mcpServers"]["team-code"]["headers"]["Authorization"] == "Bearer team-secret"
# Old clients do not know about capability metadata; editing must retain identity.
result = client.put("/api/mcp/config/server", json={"server_name": "team-code", "server": {"enabled": False, "type": "http", "url": "https://example.test/new", "headers": {"Authorization": "***"}}})
assert result.status_code == 200, result.text
saved = json.loads(path.read_text())["mcpServers"]["team-code"]
assert saved["capability"]["id"] == identity
assert saved["capability"]["plugin_id"] == "github"
assert saved["headers"]["Authorization"] == "Bearer team-secret"
assert saved["enabled"] is False
duplicate = client.post("/api/capabilities/installations", json=body)
assert duplicate.status_code == 409
assert client.delete("/api/mcp/config/servers/team-code").status_code == 200
assert set(json.loads(path.read_text())["mcpServers"]) == {"legacy"}
recreated = client.post("/api/capabilities/installations", json=body)
replacement = next(item for item in recreated.json()["items"] if item["name"] == "team-code")
assert replacement["id"] != identity
def test_only_admin_may_install_and_bad_configuration_is_422(capability_client):
client, path = capability_client
before = path.read_bytes()
payload = {"plugin_id": "github", "name": "new", "configuration": {"headers": "invalid-secret"}}
assert client.post("/api/capabilities/installations", json=payload, headers={"test-role": "user"}).status_code == 403
invalid = client.post("/api/capabilities/installations", json=payload)
assert invalid.status_code == 422
assert "invalid-secret" not in invalid.text
assert path.read_bytes() == before
def test_adapter_failure_is_isolated_and_lark_configured_is_not_verified(capability_client, monkeypatch):
client, _ = capability_client
monkeypatch.setattr(capabilities, "get_lark_integration_status", lambda *args: SimpleNamespace(installed=True, manifest_version="1", auth=SimpleNamespace(status="authenticated", verified=False)))
assert client.get("/api/capabilities/installations/lark").json()["items"][0]["auth_status"] == "configured"
assert client.get("/api/capabilities/installations/unknown").status_code == 422
assert client.get("/api/capabilities/catalog").status_code == 200
assert client.get("/api/capabilities/installations/mcp").status_code == 200
@pytest.mark.parametrize(
"provider,credentials",
[
("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-secret"}),
("wecom", {"webhook_key": "webhook-secret"}),
("hubspot", {"access_token": "private-token"}),
],
)
def test_business_install_uses_existing_mcp_lifecycle(capability_client, provider, credentials):
client, path = capability_client
payload = {"plugin_id": provider, "name": "team-" + provider, "configuration": credentials}
assert client.post("/api/capabilities/installations", json=payload, headers={"test-role": "user"}).status_code == 403
response = client.post("/api/capabilities/installations", json=payload)
assert response.status_code == 200, response.text
assert len(response.json()["items"]) == 1
installed = response.json()["items"][0]
assert installed["adapter"] == "mcp" and installed["plugin_id"] == provider
saved = json.loads(path.read_text())["mcpServers"][payload["name"]]
assert saved["args"][-1] == provider
assert set(saved["env"].values()) == set(credentials.values())
for secret in credentials.values():
assert secret not in response.text
assert secret not in client.get("/api/mcp/config").text
# Masked edits and toggles must continue to work without opening Python execution.
masked = client.get("/api/mcp/config").json()["mcp_servers"][payload["name"]]
masked["enabled"] = False
assert client.put("/api/mcp/config/server", json={"server_name": payload["name"], "server": masked}).status_code == 200
masked["enabled"] = True
assert client.put("/api/mcp/config/server", json={"server_name": payload["name"], "server": masked}).status_code == 200
assert client.delete("/api/mcp/config/servers/" + payload["name"]).status_code == 200
assert set(json.loads(path.read_text())["mcpServers"]) == {"legacy"}
def test_business_config_rejects_arbitrary_execution_and_preserves_store(capability_client):
from deerflow.capabilities.business import connection_config
client, path = capability_client
before = path.read_bytes()
payload = {"plugin_id": "hubspot", "name": "bad", "configuration": {"access_token": "private-token", "command": "evil"}}
assert client.post("/api/capabilities/installations", json=payload).status_code == 422
definition = connection_config("hubspot", {"access_token": "private-token"})
for change in ({"args": ["-c", "print(1)"]}, {"args": ["-I", "-m", "other.module", "hubspot"]}, {"env": {**definition["env"], "PYTHONPATH": "/tmp"}}):
response = client.post("/api/mcp/config/servers", json={"mcp_servers": {"bad": {**definition, **change}}})
assert response.status_code == 400
assert path.read_bytes() == before
@pytest.mark.parametrize("configuration", [{"type": "http"}, {"type": "http", "url": ""}, {"type": "http", "url": "file:///tmp/a"}, {"type": "http", "url": "https://user:secret@example.test"}, {"type": "http", "url": "not-a-url"}])
def test_catalog_rejects_incomplete_or_unsafe_http_configuration(capability_client, configuration):
client, path = capability_client
before = path.read_bytes()
response = client.post("/api/capabilities/installations", json={"plugin_id": "github", "name": "bad", "configuration": configuration})
assert response.status_code == 422
assert "secret" not in response.text
assert path.read_bytes() == before
@pytest.mark.parametrize("route", ["/api/mcp/config/servers", "/api/mcp/config"])
@pytest.mark.parametrize("fallback", [False, True])
def test_mcp_writes_reject_colliding_installation_ids(capability_client, route, fallback):
from deerflow.capabilities.runtime import installation_id
client, path = capability_client
raw = json.loads(path.read_text())
identity = installation_id("legacy", {}) if fallback else "shared"
if not fallback:
raw["mcpServers"]["legacy"]["capability"] = {"id": identity}
path.write_text(json.dumps(raw))
before = path.read_bytes()
method = client.post if route.endswith("/servers") else client.put
candidate = {"other": {"type": "http", "url": "https://example.test", "capability": {"id": identity}}}
if route == "/api/mcp/config":
candidate["legacy"] = raw["mcpServers"]["legacy"]
response = method(route, json={"mcp_servers": candidate})
assert response.status_code == 400
assert path.read_bytes() == before
def test_targeted_edit_enable_and_delete_handle_identity_collisions(capability_client):
from deerflow.capabilities.runtime import installation_id
client, path = capability_client
raw = json.loads(path.read_text())
raw["mcpServers"]["second"] = {"type": "http", "url": "https://example.test", "enabled": False}
path.write_text(json.dumps(raw))
before = path.read_bytes()
response = client.put("/api/mcp/config/server", json={"server_name": "second", "server": {"type": "http", "url": "https://example.test", "capability": {"id": installation_id("legacy", {})}}})
assert response.status_code == 400
assert path.read_bytes() == before
raw["mcpServers"]["second"]["capability"] = {"id": installation_id("legacy", {})}
path.write_text(json.dumps(raw))
before = path.read_bytes()
response = client.patch("/api/mcp/config", json={"server_name": "second", "enabled": True})
assert response.status_code == 400
assert path.read_bytes() == before
discovery = client.get("/api/capabilities/installations/mcp").json()["items"]
assert len({item["id"] for item in discovery}) == 2
assert all(item["selectable"] is False for item in discovery)
assert client.delete("/api/mcp/config/servers/second").status_code == 200
assert client.get("/api/capabilities/installations/mcp").json()["items"][0]["selectable"] is True
@pytest.mark.parametrize("provider", ["dingtalk", "wecom", "hubspot"])
def test_stale_bundled_interpreter_can_be_repaired_without_reinstall(capability_client, provider):
import sys
from deerflow.capabilities.business import CREDENTIALS, connection_config
client, path = capability_client
configuration = connection_config(provider, {field: "private-token" for field in CREDENTIALS[provider]})
configuration.update(command="/removed/venv/bin/python", enabled=False, capability={"id": "keep-agent-selection", "plugin_id": provider, "version": "2"})
raw = json.loads(path.read_text())
raw["mcpServers"]["team"] = configuration
path.write_text(json.dumps(raw))
before = path.read_bytes()
response = client.patch("/api/mcp/config", json={"server_name": "team", "enabled": True})
assert response.status_code == 400
assert "different Python interpreter" in response.json()["detail"]
assert sys.executable in response.json()["detail"]
assert "private-token" not in response.text
assert path.read_bytes() == before
masked = client.get("/api/mcp/config").json()["mcp_servers"]["team"]
assert set(masked["env"].values()) == {"***"}
masked.update(command=sys.executable, enabled=True)
response = client.put("/api/mcp/config/server", json={"server_name": "team", "server": masked})
assert response.status_code == 200
repaired = json.loads(path.read_text())["mcpServers"]["team"]
assert repaired["capability"] == configuration["capability"]
assert repaired["env"] == configuration["env"]
assert repaired["command"] == sys.executable
assert client.patch("/api/mcp/config", json={"server_name": "team", "enabled": True}).status_code == 200
def test_delete_recovers_multiple_legacy_identity_collisions(capability_client):
from deerflow.capabilities.runtime import installation_id
client, path = capability_client
raw = json.loads(path.read_text())
raw["mcpServers"].update(
{
"legacy-peer": {"type": "http", "url": "https://example.test", "capability": {"id": installation_id("legacy", {})}},
"pair-two-a": {"type": "http", "url": "https://example.test", "capability": {"id": "pair-two"}},
"pair-two-b": {"type": "http", "url": "https://example.test", "capability": {"id": "pair-two"}, "enabled": False},
"unrelated": {"type": "http", "url": "https://example.test"},
}
)
path.write_text(json.dumps(raw))
before = path.read_bytes()
assert client.delete("/api/mcp/config/servers/legacy-peer", headers={"test-role": "user"}).status_code == 403
assert path.read_bytes() == before
for removed in ["unrelated", "legacy-peer", "pair-two-b"]:
before_raw = json.loads(path.read_text())
assert client.delete(f"/api/mcp/config/servers/{removed}").status_code == 200
del before_raw["mcpServers"][removed]
assert json.loads(path.read_text()) == before_raw
items = client.get("/api/capabilities/installations/mcp").json()["items"]
ambiguous = [item for item in items if not item["selectable"]]
assert len(ambiguous) == {"unrelated": 4, "legacy-peer": 2, "pair-two-b": 0}[removed]
assert client.patch("/api/mcp/config", json={"server_name": "legacy", "enabled": False}).status_code == 200
assert client.post("/api/mcp/config/servers", json={"mcp_servers": {"recovered": {"type": "http", "url": "https://example.test"}}}).status_code == 200

View File

@ -0,0 +1,90 @@
"""The directory is data; installation identity and execution are not display names."""
from types import SimpleNamespace
import pytest
from deerflow.capabilities.catalog import load_catalog
from deerflow.capabilities.runtime import filter_mcp_plugins, installation_id
from deerflow.config.extensions_config import ExtensionsConfig
def test_catalog_has_separate_transport_auth_and_contributions():
catalog = load_catalog()
assert len({entry.id for entry in catalog}) == len(catalog)
github = next(entry for entry in catalog if entry.id == "github")
assert github.adapter == "mcp"
assert "api_key" in github.auth_methods
assert github.version
assert github.config_schema["type"] == "object"
assert next(entry for entry in catalog if entry.id == "lark").adapter == "lark"
def test_installation_identity_ignores_display_and_credentials():
assert installation_id("example", {}) == installation_id("example", {"headers": {"Authorization": "secret"}})
assert installation_id("one", {"capability": {"id": "persistent"}}) == installation_id("renamed", {"capability": {"id": "persistent"}})
assert installation_id("one", {}) != installation_id("two", {})
def test_agent_selection_filters_by_source_not_tool_name():
config = ExtensionsConfig.model_validate({"mcpServers": {"one": {"enabled": True}, "two": {"enabled": True}}})
ordinary = SimpleNamespace(name="one_fake", metadata={})
first = SimpleNamespace(name="search", metadata={"deerflow_mcp": True, "deerflow_mcp_source": {"server_name": "one"}})
second = SimpleNamespace(name="one_search", metadata={"deerflow_mcp": True, "deerflow_mcp_source": {"server_name": "two"}})
unknown = SimpleNamespace(name="legacy", metadata={"deerflow_mcp": True})
tools = [ordinary, first, second, unknown]
assert filter_mcp_plugins(tools, None, config) == tools
assert filter_mcp_plugins(tools, [], config) == [ordinary]
assert filter_mcp_plugins(tools, [installation_id("one", {})], config) == [ordinary, first]
config.mcp_servers["one"].enabled = False
assert filter_mcp_plugins(tools, [installation_id("one", {})], config) == [ordinary]
def test_duplicate_manifest_ids_fail_instead_of_shadowing(tmp_path):
manifest = load_catalog()[0].model_dump()
import json
source = tmp_path / "catalog.json"
source.write_text(json.dumps([manifest, manifest]))
with pytest.raises(ValueError, match="Duplicate"):
load_catalog(source)
@pytest.mark.asyncio
async def test_selected_mcp_executes_real_stdio_tool_without_mutating_shared_catalog(tmp_path, monkeypatch):
"""No network or LLM: discover and invoke an actual MCP subprocess."""
import sys
from deerflow.config.app_config import AppConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.mcp.tools import get_mcp_tools
from deerflow.tools import get_available_tools
from deerflow.tools.mcp_metadata import is_mcp_tool
server = tmp_path / "server.py"
server.write_text('from mcp.server.fastmcp import FastMCP\nmcp = FastMCP("fixture")\n@mcp.tool()\ndef add(a: int, b: int) -> int:\n """Add two numbers."""\n return a + b\nmcp.run()\n')
config = ExtensionsConfig.model_validate({"mcpServers": {"fixture": {"enabled": True, "command": sys.executable, "args": [str(server)]}}})
monkeypatch.setattr(ExtensionsConfig, "from_file", lambda *args: config)
discovered = await get_mcp_tools()
assert len(discovered) == 1
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: discovered)
app_config = AppConfig(models=[], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
selected = get_available_tools(app_config=app_config, mcp_plugins=[installation_id("fixture", {})])
tool = next(tool for tool in selected if is_mcp_tool(tool))
# Without a thread ID, the existing wrapper uses a temporary connection.
result = await tool.ainvoke({"a": 19, "b": 23})
assert "42" in str(result)
assert not any(is_mcp_tool(tool) for tool in get_available_tools(app_config=app_config, mcp_plugins=[]))
assert any(is_mcp_tool(tool) for tool in get_available_tools(app_config=app_config))
@pytest.mark.parametrize("collision", ["explicit", "fallback", "disabled"])
def test_ambiguous_installation_selection_fails_closed(collision):
identity = installation_id("two", {}) if collision == "fallback" else "same"
servers = {"one": {"enabled": True, "capability": {"id": identity}}, "two": {"enabled": collision != "disabled"}}
if collision != "fallback":
servers["two"]["capability"] = {"id": identity}
config = ExtensionsConfig.model_validate({"mcpServers": servers})
tools = [SimpleNamespace(name=name, metadata={"deerflow_mcp": True, "deerflow_mcp_source": {"server_name": name}}) for name in servers]
assert filter_mcp_plugins(tools, [identity], config) == []
assert filter_mcp_plugins(tools, None, config) == tools

View File

@ -659,7 +659,7 @@ def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch):
"reasoning_effort": "high",
"app_config": app_config,
}
get_available_tools.assert_called_once_with(model_name="context-model", groups=None, subagent_enabled=True, include_conversation_reader=False, app_config=app_config)
get_available_tools.assert_called_once_with(model_name="context-model", groups=None, subagent_enabled=True, mcp_plugins=None, include_conversation_reader=False, app_config=app_config)
assert result["model"] is not None
@ -1489,10 +1489,12 @@ def test_request_thinking_overrides_agent_default(monkeypatch):
assert captured["thinking_enabled"] is True # request wins over agent's False
def test_empty_allowed_subagents_disables_requested_delegation(monkeypatch):
@pytest.mark.parametrize("mcp_plugins", [None, [], ["installed-plugin"]])
def test_empty_allowed_subagents_disables_requested_delegation(monkeypatch, mcp_plugins):
"""A request switch cannot widen an explicit Custom Agent hard deny."""
app_config = _make_app_config([_make_model("agent-model", supports_thinking=False)])
agent_config = _make_agent_config(model="agent-model", allowed_subagents=[])
agent_config.mcp_plugins = mcp_plugins
import deerflow.tools as tools_module
@ -1514,6 +1516,7 @@ def test_empty_allowed_subagents_disables_requested_delegation(monkeypatch):
get_available_tools.assert_called_once_with(
model_name="agent-model",
groups=None,
mcp_plugins=mcp_plugins,
subagent_enabled=False,
include_conversation_reader=False,
app_config=app_config,
@ -1521,6 +1524,7 @@ def test_empty_allowed_subagents_disables_requested_delegation(monkeypatch):
assert config["context"]["subagent_enabled"] is False
assert config["configurable"]["subagent_enabled"] is False
assert config["metadata"]["allowed_subagents"] == []
assert config["metadata"]["mcp_plugins"] == mcp_plugins
def test_make_lead_agent_no_agent_settings_passes_none_overrides(monkeypatch):

View File

@ -469,3 +469,39 @@ def test_get_skill_provider_error_fail_closed_vs_open(monkeypatch, fail_closed,
response = client.get("/api/skills/pdf-export")
assert response.status_code == expected_status
@pytest.mark.parametrize("mode", ["filtered", "fail_closed", "fail_open", "anonymous", "disabled"])
def test_capability_skill_discovery_matches_skill_listing_policy(monkeypatch, mode):
"""The capability projection must not reopen a filtered skill-list surface."""
from app.gateway import capabilities
from app.gateway.routers import capabilities as capability_router
fail_closed = mode != "fail_open"
provider = _RecordingProvider(denied={"private-skill"}, errors={"skill"} if mode in {"fail_closed", "fail_open"} else set())
_enable_authorization(monkeypatch, provider, fail_closed=fail_closed)
if mode == "disabled":
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: AuthorizationConfig(enabled=False))
config = _make_app_config()
config.authorization.fail_closed = fail_closed
_stub_user(monkeypatch, None if mode == "anonymous" else _user())
_stub_storage(monkeypatch, _FakeStorage([_skill("visible-skill", enabled=False), _skill("private-skill", category=SkillCategory.CUSTOM)]))
monkeypatch.setattr(capabilities, "is_admin_user", AsyncMock(return_value=False))
app = _make_skills_app(config)
app.include_router(capability_router.router)
with TestClient(app) as client:
legacy = client.get("/api/skills")
projection = client.get("/api/capabilities/installations/skills")
assert legacy.status_code == projection.status_code == 200
expected = [] if mode == "fail_closed" else ["visible-skill"] if mode == "filtered" else ["visible-skill", "private-skill"]
assert [skill["name"] for skill in legacy.json()["skills"]] == expected
assert [item["name"] for item in projection.json()["items"]] == expected
assert projection.json()["can_manage"] is False
if "private-skill" not in expected:
assert "private-skill" not in projection.text
if expected:
assert projection.json()["items"][0]["enabled"] is False
if mode in {"anonymous", "disabled"}:
assert provider.filter_requests == []
else:
assert len(provider.filter_requests) == 2

View File

@ -1017,7 +1017,8 @@ def test_task_tool_context_mode_schema_rejects_unknown_mode():
schema.model_validate({"runtime": None, "prompt": "Task", "subagent_type": "general-purpose", "tool_call_id": "tc", "context_mode": "shared"})
def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch):
@pytest.mark.parametrize("mcp_plugins", [None, [], ["stable-plugin"]])
def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch, mcp_plugins):
"""Verify tool_groups from parent metadata are passed to get_available_tools(groups=...)."""
config = _make_subagent_config()
parent_tool_groups = ["file:read", "file:write", "bash"]
@ -1030,7 +1031,7 @@ def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch):
"uploaded_files": [],
},
context={"thread_id": "thread-1"},
config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1", "tool_groups": parent_tool_groups}},
config={"metadata": {"model_name": "ark-model", "trace_id": "trace-1", "tool_groups": parent_tool_groups, "mcp_plugins": mcp_plugins}},
)
events = []
captured = {}
@ -1066,7 +1067,7 @@ def test_task_tool_propagates_tool_groups_to_subagent(monkeypatch):
assert _task_tool_message(output).content == "Task Succeeded. Result: done"
assert captured["uploaded_files"] == []
# The key assertion: groups should be propagated from parent metadata
get_available_tools.assert_called_once_with(model_name="ark-model", groups=parent_tool_groups, subagent_enabled=False, include_upload_tool=True)
get_available_tools.assert_called_once_with(model_name="ark-model", groups=parent_tool_groups, subagent_enabled=False, include_upload_tool=True, **({"mcp_plugins": mcp_plugins} if mcp_plugins is not None else {}))
def test_task_tool_uses_subagent_model_override_for_tool_loading(monkeypatch):

206
docs/capability-center.md Normal file
View File

@ -0,0 +1,206 @@
# Capability Center integration contract
Capability Center is a discovery and configuration layer over DeerFlow's existing
MCP, Lark CLI, and skill services. It uses the existing administrator/user roles.
Agent selections configure the tools and skills assembled for a run; they are not
an authorization boundary. Customer-specific authorization can remain in the
existing policy extension points.
Skill installation discovery applies the same caller visibility policy as
`/api/skills`, including its configured fail-open or fail-closed behavior.
Public host APIs for skill evolution (mutation transactions, completed-task
snapshots, evaluation runners, lifecycle events and command registration) are
deferred to a separate proposal and PR.
## Ownership
| Concern | Owner |
| --- | --- |
| Catalog and localized metadata | `backend/packages/harness/deerflow/capabilities/builtin.json` |
| Validated manifest schema | `deerflow.capabilities.catalog.PluginManifest` |
| Installation/status adapters | `backend/app/gateway/capabilities.py` |
| Catalog and safe discovery HTTP API | `backend/app/gateway/routers/capabilities.py` |
| MCP settings, secrets, enable/delete, cache reload | Existing `/api/mcp/config` services and `extensions_config.json` |
| Lark installation and personal account authorization | Existing `/api/integrations/lark` services |
| Skill archives, enable state, user storage | Existing `/api/skills` services |
| Agent selection | Agent config `mcp_plugins` and existing `skills` |
| Integration-specific UI | `frontend/src/components/workspace/capabilities/plugin-adapters.tsx` |
There is no second credential database, background plugin daemon, executable
package loader, or remote marketplace dependency. The operator Python extension
loader is separate from this user-facing directory.
## Bundled business integrations
Three entries ship working API clients with the harness, using the `business`
configuration adapter and the existing stdio MCP runtime. No separate service,
package download, or Dify runtime is needed. Configure them under **Capability
Center → Plugins** as an administrator:
| Plugin | Configuration | Tools |
| --- | --- | --- |
| DingTalk group notifications | The robot webhook's `access_token` and signing secret; enable signing in the robot settings | `send_message`: text or Markdown to that group |
| WeCom group notifications | The `key` parameter from the group robot webhook URL | `send_message`: text or Markdown to that group |
| HubSpot CRM | A private app access token | `get_companies`: paginated company list; `create_contact`: create a contact by email and optional profile fields |
HubSpot needs `crm.objects.companies.read` for company queries and
`crm.objects.contacts.write` for contact creation. A read-only token can be used
when only company lookup is needed; the provider rejects unauthorized writes.
Notifications do not read chats, documents, or calendars and do not configure
DeerFlow's incoming IM channels. Existing manually configured CLI connections
are not rewritten when the catalog entry changes.
Configuration saves credentials without sending a message or creating a CRM
record. These deployment credentials are shared by runs allowed to use the
configured MCP server; they are not personal OAuth connections. Credentials
remain in the existing MCP `env` configuration and its masked admin editor.
Edit, toggle and delete the configured entry using the existing MCP controls.
An Agent selects these connections through **Plugins and skills**, just like
other MCP servers. New tool selection applies on the next run.
`deerflow.capabilities.business` implements fixed HTTPS provider endpoints,
bounded responses and timeouts, no redirect following or automatic write retries,
and validates the robot's `errcode` even on HTTP 200. Errors omit provider bodies
and credential-bearing URLs. Tool schemas never include credentials. An exact
`sys.executable -I -m deerflow.capabilities.business <provider>` launcher with
only the provider's known credential environment keys is accepted by the MCP
API; arbitrary interpreter paths/modules/flags/environment remain rejected.
The isolated interpreter ignores the working directory and Python environment
injection. If a deployment moves its Python environment, enabling an old launcher
returns a targeted error with the current interpreter path. Edit that connection's JSON
and replace only `command` with the indicated path; preserve its capability
metadata and masked credentials. This repairs the connection in place without
changing its installation ID or existing Agent selections.
The implementation is independently written against the provider contracts;
Dify's plugins informed the feature scope, not the source implementation:
[DingTalk](https://open.dingtalk.com/document/orgapp/custom-robot-access),
[WeCom](https://developer.work.weixin.qq.com/document/path/91770),
[HubSpot companies](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/companies/guide),
[HubSpot contacts](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/create-contact).
## Add a catalog entry
Add one object to `builtin.json` and, optionally, a licensed local image under
`frontend/public/images/plugins/` (record its source in that directory). Ordinary
HTTP MCP entries reuse the `mcp` adapter and form; the gallery needs no changes.
For example:
```json
{
"schema_version": 1,
"id": "example-search",
"version": "1",
"name": {"en-US": "Example Search", "zh-CN": "示例搜索"},
"description": {"en-US": "Search the team knowledge base."},
"setup": {"en-US": "Supply your administrator-provided MCP endpoint."},
"category": "knowledge",
"kind": "mcp",
"adapter": "mcp",
"source": "https://example.com/docs/mcp",
"auth_methods": ["none", "api_key"],
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "title": "Connection name"},
"url": {"type": "string", "title": "Server URL"},
"authorization": {"type": "string", "title": "Authorization header", "format": "password"}
},
"required": ["name", "url"]
}
}
```
`kind` describes the execution mechanism, `auth_methods` describes supported
account mechanisms, and `adapter` selects the integration flow. `guide` entries
only link to setup instructions; listing them never claims they are installed.
The catalog version describes this manifest, not a remotely detected server
version. A catalog listing does not install, enable, or authorize anything.
The first MCP form supports HTTP endpoints and a deployment Authorization header.
The advanced JSON editor retains stdio, SSE, OAuth token configuration, and
per-user credential mappings supported by the existing MCP runtime. The presence
of `oauth` in a manifest is not a new interactive OAuth implementation. Lark
continues to use its existing personal account flow.
For a new integration protocol, implement the `CapabilityAdapter` interface,
register it once in `AdapterRegistry`, and register its settings component in
`pluginSettingsAdapters` when it requires a dedicated flow. Reuse the owning
service's authorization and validation; never add a provider branch to the gallery.
## API and state
- `GET /api/capabilities/catalog`: validated bundled manifests.
- `GET /api/capabilities/installations/{adapter}`: safe installation projections
for `mcp`, `business`, `lark`, and `skills`; separate requests isolate integration failures.
- `POST /api/capabilities/installations`: administrator installation dispatch.
Body: `plugin_id`, `name`, and adapter `configuration`. MCP configuration uses
the existing server definition schema. HTTP/SSE connections require a valid
HTTP(S) URL without embedded credentials; stdio connections require a command.
Invalid transport configuration returns 422 before saving. Duplicate server
names return 409.
- Existing owner APIs perform edit, enable/disable, uninstall, skill import/export,
and account authorization. Query invalidation refreshes discovery after writes.
Discovery excludes MCP endpoints, commands, environment variables, headers,
OAuth secrets, and per-user credential mappings. `configured` means credentials
are present; it does not mean a connection test succeeded. `health: unknown`
is deliberate when no live check has run. Lark's existing configuration dialog
owns live account verification.
## Compatibility and execution
Catalog-installed MCP servers carry `capability: {id, plugin_id, version}` as
non-executable metadata in the existing config. Transport builders ignore it.
Configuration edits preserve installation identity, including edits by older
clients that omit metadata. Old entries get a deterministic ID from the existing
server key without rewriting files on GET. No provider identity is inferred from
a display name, including when choosing brand icons. Multiple installations of
one provider remain separate rows.
Installation IDs must be unique across all servers, including disabled entries
and legacy derived IDs. Creation, replacement and state changes reject collisions
before saving. Deletion still validates the configuration schema but permits
remaining ID collisions: administrators can remove entries one at a time, even
with multiple independent collision pairs. Other writes remain blocked until
those collisions are repaired; deletion never changes surviving connection IDs.
Old ambiguous configurations remain visible with `selectable: false` and `health: ambiguous`;
explicit Agent selections load none of the colliding connections. Remove a
conflicting entry or repair the deployment configuration before selecting it.
The inherited-all mode retains its previous behavior.
`mcp_plugins: null` (or omitted) keeps the previous behavior: all enabled MCP
servers. `[]` selects none. A list selects installation IDs. Missing or disabled
installations provide no tools; their IDs stay in Agent configuration so editing
other settings cannot silently broaden the selection. The same semantics apply
to the existing skill-name selection. A selection never enables a disabled tool
or grants access to another user's account.
The lead Agent filters tools by their MCP source metadata. Ordinary delegated
and durable batch tasks carry the selection in their execution metadata. Each
Agent run gets a filtered list without altering the shared MCP tool cache.
The settings dialog omits unchanged plugin and skill selections on save, so an
unrelated edit does not overwrite a concurrent capability selection.
Existing skill policy, user-scoped MCP authentication, and tool execution guards
continue to run. Changes take effect on subsequent runs.
## Static demo
The read-only demo bundles a generated snapshot of the gateway catalog. After
editing `builtin.json`, run `cd frontend && pnpm catalog:sync`; the frontend unit
tests check that the snapshot matches the source. This keeps Docker and standalone
frontend builds independent of the backend source tree. Installation
projections come from existing same-origin MCP, Lark and Skills mock fixtures;
they do not require a running gateway. Demo projections omit credentials and
connection details, set `can_manage: false`, and do not imply live verification.
Writes still return 405 locally.
## Validation
Focused backend coverage includes real HTTP install/edit/delete against a
temporary config, role checks, secret-free discovery, identity preservation,
Agent persistence, delegated selection, and a real local stdio MCP invocation.
Browser tests cover catalog installation, Agent selection save/reopen, existing
icon editing, filtering, and desktop/mobile settings layouts. Browser fixture
screenshots show sample integrations; they are not production default settings.

View File

@ -122,8 +122,9 @@ Leave these unset for the standard `make dev` / Docker flow, where nginx serves
`make build-static` creates a standalone read-only demo and copies `.next/static`
and `public` into the output. In static mode, `core/api/static-response.ts`
resolves Gateway REST reads with empty capability/catalog responses or existing
same-origin `/mock/api` fixtures; writes and unknown API routes fail locally.
resolves Gateway REST reads with the bundled capability catalog and safe
installation projections from existing same-origin `/mock/api` fixtures; writes
and unknown API routes fail locally.
The homepage client counter calls `/github-stars`, outside the Gateway proxy.
That dynamic route reads the server-only `GITHUB_OAUTH_TOKEN` at runtime, caches
GitHub data for one hour, and returns 204 when the count is unavailable. Start
@ -204,3 +205,45 @@ mutation permissions, and cache ownership remain in the existing hooks. Skill di
metadata; runtime names and full descriptions remain unchanged. Public, custom,
integration, and legacy sources must stay distinct. Community currently offers
archive import, not a remote marketplace. Screenshot E2E fixtures are demo data.
`backend/packages/harness/deerflow/capabilities/builtin.json` owns localized
catalog manifests. Refresh the generated demo snapshot with `pnpm catalog:sync`
after changing the catalog; unit tests enforce equality with the source. Demo
business projections derive provider IDs from the catalog adapter metadata. The
sync script uses decoded filesystem paths for formatter configuration lookup.
`plugin-catalog.ts` only resolves localized text and explicit
installation metadata; never infer provider identity from server display names.
`plugin-directory.tsx` groups rows and applies search/category/installed filters.
`core/capabilities` consumes catalog and safe status projections; MCP secrets and
raw settings remain in the administrator-only editor. `plugin-adapters.tsx`
registers integration-specific settings flows once, independent of catalog size.
The `business` form uses manifest credential fields without asking for an MCP URL;
its backend adapter generates bundled DingTalk/WeCom notification or HubSpot CRM
connections. These also appear in MCP discovery, so deduplicate projections by
installation ID. Keep their labels as configuration, not package installation.
Keep installation, enabled state, configured credentials, and verified authorization
distinct. Agent `mcp_plugins` uses stable installation IDs; null means all, [] means
none. The settings dialog submits only selections changed from its opening
snapshot, preserving concurrent updates on unrelated saves and treating restored
selections as unchanged. It is runtime selection, not a replacement authorization policy. See
`docs/capability-center.md` for the complete contract and extension example.
`PluginIcon` is shared by recommendations, configured entries, and the editor;
brand assets and their provenance live in `public/images/plugins/`. Brand icons
require explicit catalog metadata; a custom server name never selects a brand.
Ambiguous installation IDs remain visible but cannot be selected for an Agent. The icon picker
accepts local PNG/JPEG/WebP up to 2 MiB, checks the signature, decodes and contains
the image in a 128px PNG, and stages changes until the existing targeted MCP save.
`presentation.icon` is a bounded PNG data URL carried by the API's existing extra
metadata support; it must never enter transport parameters. Preserve sibling
presentation fields and masked credentials; cancel/reset/unmount must fence stale
image-decoding results. Uploaded remote URLs and SVG are never rendered. Existing
shared-MCP administrator checks remain authoritative; this adds no personal scope.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"catalog:sync": "node scripts/sync-capability-catalog.mjs",
"demo:save": "node scripts/save-demo.js",
"build": "next build",
"check": "eslint . --ext .ts,.tsx && tsc --noEmit",

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,14 @@
# Plugin brand assets
These bundled assets identify the corresponding services; no third-party image requests are made at runtime.
Brand names and logos remain the property of their respective owners.
- `github.svg`, `notion.svg`, `hubspot.svg`, `jira.svg`, `postgresql.svg`, `brave.svg`: Simple Icons 16.31.0, https://github.com/simple-icons/simple-icons (CC0). Brand colors added to the SVG root.
- `exa.svg`, `firecrawl.svg`: @lobehub/icons-static-svg 1.95.0, https://github.com/lobehub/lobe-icons (MIT).
- `lark.ico`: Feishu website favicon, https://www.feishu.cn/favicon.ico
- `dingtalk.ico`: favicon linked by https://www.dingtalk.com
- `wecom.png`: 48px favicon linked by https://work.weixin.qq.com
- `tencent-docs.ico`: favicon linked by https://docs.qq.com
- `openviking.png`: https://github.com/volcengine/OpenViking/blob/main/docs/images/ov-logo-icon.png
Generic capabilities use the existing Lucide icons instead of inventing service logos.

View File

@ -0,0 +1,30 @@
# CC0 1.0 Universal
## Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”).
Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following:
1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
2. moral rights retained by the original author(s) and/or performer(s);
3. publicity and privacy rights pertaining to a persons image or likeness depicted in a Work;
4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below;
5. rights protecting the extraction, dissemination, use and reuse of data in a Work;
6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmers Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmers heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmers express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmers express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmers Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmers express Statement of Purpose.
4. Limitations and Disclaimers.
1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any persons Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
For more information, please see <https://creativecommons.org/publicdomain/zero/1.0>.

View File

@ -0,0 +1 @@
<svg fill="#FB542B" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Brave</title><path d="M15.68 0l2.096 2.38s1.84-.512 2.709.358c.868.87 1.584 1.638 1.584 1.638l-.562 1.381.715 2.047s-2.104 7.98-2.35 8.955c-.486 1.919-.818 2.66-2.198 3.633-1.38.972-3.884 2.66-4.293 2.916-.409.256-.92.692-1.38.692-.46 0-.97-.436-1.38-.692a185.796 185.796 0 01-4.293-2.916c-1.38-.973-1.712-1.714-2.197-3.633-.247-.975-2.351-8.955-2.351-8.955l.715-2.047-.562-1.381s.716-.768 1.585-1.638c.868-.87 2.708-.358 2.708-.358L8.321 0h7.36zm-3.679 14.936c-.14 0-1.038.317-1.758.69-.72.373-1.242.637-1.409.742-.167.104-.065.301.087.409.152.107 2.194 1.69 2.393 1.866.198.175.489.464.687.464.198 0 .49-.29.688-.464.198-.175 2.24-1.759 2.392-1.866.152-.108.254-.305.087-.41-.167-.104-.689-.368-1.41-.741-.72-.373-1.617-.69-1.757-.69zm0-11.278s-.409.001-1.022.206-1.278.46-1.584.46c-.307 0-2.581-.434-2.581-.434S4.119 7.152 4.119 7.849c0 .697.339.881.68 1.243l2.02 2.149c.192.203.59.511.356 1.066-.235.555-.58 1.26-.196 1.977.384.716 1.042 1.194 1.464 1.115.421-.08 1.412-.598 1.776-.834.364-.237 1.518-1.19 1.518-1.554 0-.365-1.193-1.02-1.413-1.168-.22-.15-1.226-.725-1.247-.95-.02-.227-.012-.293.284-.851.297-.559.831-1.304.742-1.8-.089-.495-.95-.753-1.565-.986-.615-.232-1.799-.671-1.947-.74-.148-.068-.11-.133.339-.175.448-.043 1.719-.212 2.292-.052.573.16 1.552.403 1.632.532.079.13.149.134.067.579-.081.445-.5 2.581-.541 2.96-.04.38-.12.63.288.724.409.094 1.097.256 1.333.256s.924-.162 1.333-.256c.408-.093.329-.344.288-.723-.04-.38-.46-2.516-.541-2.961-.082-.445-.012-.45.067-.579.08-.129 1.059-.372 1.632-.532.573-.16 1.845.009 2.292.052.449.042.487.107.339.175-.148.069-1.332.508-1.947.74-.615.233-1.476.49-1.565.986-.09.496.445 1.241.742 1.8.297.558.304.624.284.85-.02.226-1.026.802-1.247.95-.22.15-1.413.804-1.413 1.169 0 .364 1.154 1.317 1.518 1.554.364.236 1.355.755 1.776.834.422.079 1.08-.4 1.464-1.115.384-.716.039-1.422-.195-1.977-.235-.555.163-.863.355-1.066l2.02-2.149c.341-.362.68-.546.68-1.243 0-.697-2.695-3.96-2.695-3.96s-2.274.436-2.58.436c-.307 0-.972-.256-1.585-.461-.613-.205-1.022-.206-1.022-.206z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Exa</title><path clip-rule="evenodd" d="M3 0h19v1.791L13.892 12 22 22.209V24H3V0zm9.62 10.348l6.589-8.557H6.03l6.59 8.557zM5.138 3.935v7.17h5.52l-5.52-7.17zm5.52 8.96h-5.52v7.17l5.52-7.17zM6.03 22.21l6.59-8.557 6.589 8.557H6.03z" fill="#1F40ED" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Firecrawl</title><path d="M18.183 7.67c-.939.278-1.647.905-2.166 1.586-.11.146-.343.036-.299-.143.993-4.058-.318-7.432-4.407-9.092a.272.272 0 00-.368.317C12.803 7.76 4.98 7.135 5.969 15.55a.17.17 0 01-.266.159c-.37-.265-.784-.817-1.068-1.205a.17.17 0 00-.302.054A8.631 8.631 0 004 16.9a8.43 8.43 0 003.843 7.07c.133.086.303-.038.258-.189a4.533 4.533 0 01-.133-2.041c.097-.637.32-1.244.694-1.797 1.283-1.914 3.854-3.763 3.443-6.273-.026-.16.162-.264.281-.155 1.812 1.645 2.17 3.858 1.873 5.844-.026.172.192.264.302.129.277-.345.615-.647.983-.875a.17.17 0 01.25.088c.204.592.508 1.148.796 1.704a4.528 4.528 0 01.307 3.375.17.17 0 00.257.192A8.43 8.43 0 0021 16.9a8.746 8.746 0 00-.524-2.98c-.718-1.982-2.54-3.47-2.08-6.053a.17.17 0 00-.213-.195z" fill="#ff4d00"></path></svg>

After

Width:  |  Height:  |  Size: 897 B

View File

@ -0,0 +1 @@
<svg fill="#181717" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 837 B

View File

@ -0,0 +1 @@
<svg fill="#FF7A59" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>HubSpot</title><path d="M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z"/></svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@ -0,0 +1 @@
<svg fill="#0052CC" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Jira</title><path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.001 1.001 0 0 0 23.013 0Z"/></svg>

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1 @@
<svg fill="#000000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Notion</title><path d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"/></svg>

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,23 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { format, resolveConfig } from "prettier";
const source = new URL(
"../../backend/packages/harness/deerflow/capabilities/builtin.json",
import.meta.url,
);
const destination = new URL(
"../src/core/capabilities/builtin.demo.json",
import.meta.url,
);
const catalog = JSON.parse(await readFile(source, "utf8"));
await writeFile(
destination,
await format(JSON.stringify(catalog), {
...(await resolveConfig(
fileURLToPath(new URL("../package.json", import.meta.url)),
)),
parser: "json",
}),
);

View File

@ -0,0 +1,97 @@
"use client";
import { capabilityCopy } from "@/core/capabilities/copy";
import { useCapabilityInstallations } from "@/core/capabilities/hooks";
import { useI18n } from "@/core/i18n/hooks";
function Selection({
adapter,
value,
onChange,
}: {
adapter: "mcp" | "skills";
value: string[] | null;
onChange: (value: string[] | null) => void;
}) {
const { locale, t } = useI18n();
const copy = capabilityCopy(locale);
const query = useCapabilityInstallations(adapter);
const items = (query.data?.items ?? []).filter(
(item) => item.installed && item.selectable !== false,
);
const options = new Map(
items.map((item) => [
adapter === "skills" ? item.reference : item.id,
item.name,
]),
);
for (const id of value ?? [])
if (!options.has(id)) options.set(id, `${id} (${copy.unavailable})`);
return (
<fieldset className="space-y-2 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">
{adapter === "mcp" ? copy.plugins : copy.skills}
</legend>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value === null}
onChange={(event) => onChange(event.target.checked ? null : [])}
/>
{copy.all}
</label>
{query.isLoading && (
<p className="text-muted-foreground text-xs">{t.common.loading}</p>
)}
{query.isError && (
<p role="alert" className="text-destructive text-xs">
{copy.adapterError}
</p>
)}
{value !== null && (
<div className="max-h-44 space-y-2 overflow-y-auto">
{[...options].map(([id, name]) => (
<label key={id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={value.includes(id)}
onChange={(event) =>
onChange(
event.target.checked
? [...value, id]
: value.filter((item) => item !== id),
)
}
/>
{name}
</label>
))}
</div>
)}
</fieldset>
);
}
export function AgentCapabilitySelection({
plugins,
skills,
onPluginsChange,
onSkillsChange,
}: {
plugins: string[] | null;
skills: string[] | null;
onPluginsChange: (value: string[] | null) => void;
onSkillsChange: (value: string[] | null) => void;
}) {
const { locale } = useI18n();
const copy = capabilityCopy(locale);
return (
<details className="space-y-3 rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">
{copy.selectionTitle}
</summary>
<p className="text-muted-foreground text-xs leading-5">{copy.hint}</p>
<Selection adapter="mcp" value={plugins} onChange={onPluginsChange} />
<Selection adapter="skills" value={skills} onChange={onSkillsChange} />
</details>
);
}

View File

@ -27,6 +27,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useSubagents } from "@/core/subagents";
import { AgentCapabilitySelection } from "./agent-capability-selection";
import {
allowedSubagentsToSelection,
DEFAULT_MODEL_VALUE,
@ -40,6 +41,15 @@ import {
thinkingEnabledToSelection,
} from "./agent-settings-dialog-helpers";
function sameSelection(left: string[] | null, right: string[] | null) {
if (left === null || right === null) return left === right;
const selected = new Set(left);
return (
selected.size === new Set(right).size &&
right.every((id) => selected.has(id))
);
}
const REASONING_EFFORTS: ReasoningEffort[] = ["low", "medium", "high"];
interface AgentSettingsDialogProps {
@ -64,6 +74,15 @@ export function AgentSettingsDialog({
const { subagents } = useSubagents();
const subagentDescriptionId = useId();
const updateAgent = useUpdateAgent();
// Keep the opening snapshot even if a background refetch updates agent props.
const [initialSelections] = useState(() => ({
plugins: agent.mcp_plugins ?? null,
skills: agent.skills ?? null,
}));
const [plugins, setPlugins] = useState<string[] | null>(
agent.mcp_plugins ?? null,
);
const [skills, setSkills] = useState<string[] | null>(agent.skills ?? null);
const [displayName, setDisplayName] = useState(agent.display_name ?? "");
const [model, setModel] = useState(agent.model ?? DEFAULT_MODEL_VALUE);
@ -142,6 +161,10 @@ export function AgentSettingsDialog({
name: agent.name,
request: {
display_name: displayName.trim() || null,
...(!sameSelection(plugins, initialSelections.plugins) && {
mcp_plugins: plugins,
}),
...(!sameSelection(skills, initialSelections.skills) && { skills }),
model: model === DEFAULT_MODEL_VALUE ? null : model,
model_settings: parsedSettings.modelSettings,
thinking_enabled: supportsThinking
@ -173,6 +196,12 @@ export function AgentSettingsDialog({
</DialogHeader>
<div className="min-h-0 min-w-0 space-y-4 overflow-y-auto overscroll-contain px-1 py-1">
<AgentCapabilitySelection
plugins={plugins}
skills={skills}
onPluginsChange={setPlugins}
onSkillsChange={setSkills}
/>
<div className="space-y-1.5">
<label htmlFor="agent-display-name" className="text-sm font-medium">
{t.agents.settingsDisplayName}

View File

@ -3,7 +3,7 @@
import { BlocksIcon, SearchIcon, SparklesIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { useState, useSyncExternalStore } from "react";
import { Input } from "@/components/ui/input";
import { SidebarTrigger } from "@/components/ui/sidebar";
@ -17,7 +17,18 @@ const SkillGallery = dynamic(() =>
import("./skill-gallery").then((module) => module.SkillGallery),
);
// Do not accept search input before React can update the directory. A replayed
// change on blur can move the install button between pointerdown and click.
const subscribeHydration = () => () => undefined;
const clientHydrated = () => true;
const serverHydrated = () => false;
export function CapabilityCenter() {
const hydrated = useSyncExternalStore(
subscribeHydration,
clientHydrated,
serverHydrated,
);
const { t } = useI18n();
const params = useSearchParams();
const router = useRouter();
@ -50,6 +61,7 @@ export function CapabilityCenter() {
<div className="relative w-full md:w-72">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-3 left-3 size-4" />
<Input
disabled={!hydrated}
className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none"
aria-label={
tab === "plugins"

View File

@ -21,6 +21,7 @@ import {
useMCPConfig,
useMCPServerMutation,
} from "@/core/mcp/hooks";
import { readPluginIcon, withPluginIcon } from "@/core/mcp/icon";
import {
formatMCPServerDefinition,
MCPServerDefinitionError,
@ -29,48 +30,56 @@ import {
import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env";
import { CapabilityCard, CapabilityIcon } from "./capability-card";
import {
catalogForServer,
type CatalogPlugin,
type PluginCategory,
} from "./plugin-catalog";
import {
PluginDirectory,
PluginRow,
type PluginDirectoryEntry,
} from "./plugin-directory";
import { PluginIcon } from "./plugin-icon";
import { PluginIconPicker } from "./plugin-icon-picker";
type MCPPluginManagerProps = {
query?: string;
children?: ReactNode;
catalog?: PluginDirectoryEntry[];
category?: PluginCategory | "all";
installedOnly?: boolean;
toolbar?: ReactNode;
definitions?: CatalogPlugin[];
};
export function MCPPluginManager(props: MCPPluginManagerProps) {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
if (isLoading || error) {
return (
<div className="space-y-4">
{props.toolbar}
{isLoading ? (
<p role="status" className="text-muted-foreground text-sm">
{t.common.loading}
</p>
) : (
<p role="alert" className="text-muted-foreground text-sm">
{error instanceof MCPConfigRequestError && error.isAdminRequired
? t.settings.tools.adminRequired
: `${t.common.error} ${error?.message}`}
</p>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{props.children}
</div>
</div>
);
}
return <MCPServerList {...props} servers={config?.mcp_servers} />;
// Keep the directory mounted while MCP discovery completes. Replacing the
// entire subtree can swallow a click on an independently available plugin.
return (
<MCPServerList
{...props}
servers={error ? undefined : config?.mcp_servers}
isLoading={isLoading}
error={error}
/>
);
}
function MCPServerList({
servers,
query = "",
children,
catalog = [],
category = "all",
installedOnly = false,
toolbar,
definitions = [],
isLoading = false,
error,
}: MCPPluginManagerProps & {
servers?: Record<string, MCPServerConfig>;
isLoading?: boolean;
error?: Error | null;
}) {
const { t } = useI18n();
const { isPending, mutate: enableMCPServer } = useEnableMCPServer();
@ -79,16 +88,32 @@ function MCPServerList({
{ mode: "add" } | { mode: "edit"; name: string } | null
>(null);
const [definition, setDefinition] = useState("");
const [draftIcon, setDraftIcon] = useState<string | null | undefined>();
const [iconBusy, setIconBusy] = useState(false);
let previewEntries: [string, MCPServerConfig][] = [];
try {
previewEntries = Object.entries(parseMCPServerDefinition(definition));
} catch {
/* JSON can be incomplete while typing. */
}
const previewEntry = previewEntries[0];
const previewName =
editor?.mode === "edit" ? editor.name : (previewEntry?.[0] ?? "");
const previewMetadata = catalogForServer(
previewName,
previewEntry?.[1],
definitions,
);
const previewIcon =
draftIcon === undefined && previewEntry
? readPluginIcon(previewEntry[1])
: draftIcon;
const [definitionError, setDefinitionError] = useState<string | null>(null);
const [pendingRemoval, setPendingRemoval] = useState<string | null>(null);
const readOnly = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const current = servers ?? {};
const entries = Object.entries(current).filter(([name, config]) =>
`${name} ${config.description ?? ""}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
const entries = Object.entries(current);
const isMutating = isPending || isWriting;
function displayServerName(name: string | null) {
@ -98,25 +123,33 @@ function MCPServerList({
}
function closeEditor() {
setDraftIcon(undefined);
setIconBusy(false);
setEditor(null);
setDefinition("");
setDefinitionError(null);
}
function openAddEditor() {
setDraftIcon(undefined);
setIconBusy(false);
setDefinition("");
setDefinitionError(null);
setEditor({ mode: "add" });
}
function openEditEditor(name: string, config: MCPServerConfig) {
setDefinition(formatMCPServerDefinition(name, config));
setDraftIcon(readPluginIcon(config) ?? null);
setIconBusy(false);
setDefinition(
formatMCPServerDefinition(name, withPluginIcon(config, null)),
);
setDefinitionError(null);
setEditor({ mode: "edit", name });
}
function handleSaveDefinition() {
if (editor === null) {
if (editor === null || iconBusy) {
return;
}
@ -143,6 +176,20 @@ function MCPServerList({
return;
}
if (draftIcon !== undefined) {
const entries = Object.entries(parsed);
if (entries.length !== 1) {
setDefinitionError(
editor.mode === "edit"
? t.settings.tools.editSingleServer
: t.capabilities.icon.singleServer,
);
return;
}
const [name, config] = entries[0]!;
parsed = { [name]: withPluginIcon(config, draftIcon) };
}
if (editor.mode === "add") {
const duplicate = Object.keys(parsed).find((name) =>
Object.hasOwn(current, name),
@ -195,93 +242,110 @@ function MCPServerList({
return (
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-h-9 flex-wrap items-center justify-between gap-3">
{toolbar ?? <span />}
<Button
size="sm"
variant="outline"
disabled={readOnly || isMutating}
onClick={openAddEditor}
>
{t.capabilities.addPlugin}
</Button>
{isLoading && (
<p role="status" className="text-muted-foreground text-sm">
{t.common.loading}
</p>
)}
{!isLoading && !error && (
<Button
size="sm"
variant="outline"
disabled={readOnly || isMutating}
onClick={openAddEditor}
>
{t.capabilities.addPlugin}
</Button>
)}
</div>
{entries.length === 0 && !children ? (
<div className="text-muted-foreground text-sm">
{query ? t.capabilities.noResults : t.settings.tools.empty}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{children}
{entries.map(([name, config]) => {
const displayName = displayServerName(name);
const actions = (
<>
<Switch
checked={config.enabled}
aria-label={`${t.capabilities.enabled} ${displayName}`}
disabled={readOnly || isMutating}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}
/>
<Button
size="icon"
variant="ghost"
aria-label={`${t.common.edit} ${displayName}`}
disabled={readOnly || isMutating}
onClick={() => openEditEditor(name, config)}
>
<PencilIcon className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
aria-label={`${t.common.delete} ${displayName}`}
disabled={readOnly || isMutating}
onClick={() => setPendingRemoval(name)}
>
<Trash2 className="size-4" />
</Button>
</>
);
return (
<CapabilityCard
key={name}
name={displayName}
description={
config.description || t.capabilities.mcpDescription
}
label={t.capabilities.mcpLabel}
icon={<CapabilityIcon name={name} />}
status={
<>
<span
className={
config.enabled
? "size-1.5 rounded-full bg-emerald-500"
: "bg-muted-foreground/40 size-1.5 rounded-full"
}
/>
{config.enabled
? t.capabilities.enabled
: t.capabilities.disabled}
</>
}
onDetails={
readOnly || isMutating
? undefined
: () => openEditEditor(name, config)
}
detailsLabel={`${t.capabilities.details} ${displayName}`}
>
{actions}
</CapabilityCard>
);
})}
</div>
{error && (
<p role="alert" className="text-muted-foreground text-sm">
{error instanceof MCPConfigRequestError && error.isAdminRequired
? t.settings.tools.adminRequired
: `${t.common.error} ${error.message}`}
</p>
)}
<PluginDirectory
query={query}
category={category}
installedOnly={installedOnly}
entries={[
...catalog.filter(
(item) =>
!entries.some(
([name, server]) =>
catalogForServer(name, server, definitions)?.id === item.id,
),
),
...entries.map(([name, config]): PluginDirectoryEntry => {
const displayName = displayServerName(name);
const metadata = catalogForServer(name, config, definitions);
return {
id: `mcp:${name}`,
category: metadata?.category ?? "custom",
search: `${name} ${config.description ?? ""} ${metadata?.aliases.join(" ") ?? ""} ${Object.values(metadata?.name ?? {}).join(" ")} ${Object.values(metadata?.description ?? {}).join(" ")}`,
installed: true,
node: (
<PluginRow
name={displayName}
description={
config.description || t.capabilities.mcpDescription
}
label={
config.enabled
? t.capabilities.enabled
: t.capabilities.disabled
}
icon={
<PluginIcon
name={name}
icon={readPluginIcon(config)}
asset={metadata?.icon}
capabilityId={metadata?.id}
/>
}
onDetails={
readOnly || isMutating
? undefined
: () => openEditEditor(name, config)
}
detailsLabel={`${t.capabilities.details} ${displayName}`}
>
<Switch
checked={config.enabled}
aria-label={`${t.capabilities.enabled} ${displayName}`}
disabled={readOnly || isMutating}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}
/>
<Button
size="icon"
variant="ghost"
aria-label={`${t.common.edit} ${displayName}`}
disabled={readOnly || isMutating}
onClick={() => openEditEditor(name, config)}
>
<PencilIcon className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
aria-label={`${t.common.delete} ${displayName}`}
disabled={readOnly || isMutating}
onClick={() => setPendingRemoval(name)}
>
<Trash2 className="size-4" />
</Button>
</PluginRow>
),
};
}),
]}
/>
<Dialog
open={editor !== null}
@ -304,6 +368,20 @@ function MCPServerList({
)
: t.settings.tools.addServerDescription}
</DialogDescription>
<PluginIconPicker
name={previewName}
asset={previewMetadata?.icon}
capabilityId={previewMetadata?.id}
value={previewIcon}
disabled={isWriting || previewEntries.length > 1}
onChange={setDraftIcon}
onBusyChange={setIconBusy}
/>
{previewEntries.length > 1 && (
<p className="text-muted-foreground text-xs">
{t.capabilities.icon.singleServer}
</p>
)}
<Textarea
className="field-sizing-fixed h-96 min-h-24 resize-none overflow-auto font-mono text-xs"
aria-label={t.settings.tools.serverDefinitionLabel}
@ -329,7 +407,10 @@ function MCPServerList({
>
{t.common.cancel}
</Button>
<Button disabled={isWriting} onClick={handleSaveDefinition}>
<Button
disabled={isWriting || iconBusy}
onClick={handleSaveDefinition}
>
{isWriting ? t.common.loading : t.common.save}
</Button>
</DialogFooter>

View File

@ -0,0 +1,126 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentType } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { capabilityCopy } from "@/core/capabilities/copy";
import { useInstallCapability } from "@/core/capabilities/hooks";
import type { PluginManifest } from "@/core/capabilities/types";
import { useI18n } from "@/core/i18n/hooks";
export type PluginSettingsProps = {
plugin: PluginManifest;
onSaved: () => void;
canManage: boolean;
};
function ConfiguredPluginSettings({
plugin,
onSaved,
canManage,
}: PluginSettingsProps) {
const { locale } = useI18n();
const copy = capabilityCopy(locale);
const install = useInstallCapability();
const [fields, setFields] = useState<Record<string, string>>({
name: plugin.id,
});
const [error, setError] = useState<string | null>(null);
async function save(event: React.FormEvent) {
event.preventDefault();
try {
let configuration: Record<string, unknown>;
if (plugin.adapter === "business") {
configuration = Object.fromEntries(
Object.entries(fields).filter(([key]) => key !== "name"),
);
} else {
const url = new URL(fields.url ?? "");
if (
!["https:", "http:"].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error(copy.invalidUrl);
configuration = {
enabled: true,
type: "http",
url: url.toString(),
description:
plugin.description[locale] ?? plugin.description["en-US"],
...(fields.authorization
? { headers: { Authorization: fields.authorization } }
: {}),
};
}
await install.mutateAsync({
plugin_id: plugin.id,
name: fields.name?.trim() ?? "",
configuration,
});
toast.success(copy.saved);
onSaved();
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
}
}
return (
<form className="space-y-4" onSubmit={(event) => void save(event)}>
<p className="text-muted-foreground text-sm leading-6">
{plugin.setup[locale] ?? plugin.setup["en-US"]}
</p>
{Object.entries(plugin.config_schema.properties ?? {}).map(
([key, schema]) => (
<div key={key} className="space-y-1.5">
<label
htmlFor={`plugin-field-${key}`}
className="text-sm font-medium"
>
{copy[key as keyof typeof copy] ?? schema.title ?? key}
</label>
<Input
id={`plugin-field-${key}`}
type={schema.format === "password" ? "password" : "text"}
autoComplete="off"
required={plugin.config_schema.required?.includes(key)}
value={fields[key] ?? ""}
disabled={!canManage || install.isPending}
onChange={(event) =>
setFields((previous) => ({
...previous,
[key]: event.target.value,
}))
}
/>
</div>
),
)}
<p className="text-muted-foreground text-xs leading-5">
{copy.accountHint}
</p>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<Button type="submit" disabled={!canManage || install.isPending}>
{copy.save}
</Button>
</form>
);
}
const LarkSettings = dynamic(() =>
import("./lark-plugin-settings").then((module) => module.LarkPluginSettings),
);
/** One registration per integration flow, never one conditional per catalog item. */
export const pluginSettingsAdapters: Record<
string,
ComponentType<PluginSettingsProps>
> = {
mcp: ConfiguredPluginSettings,
business: ConfiguredPluginSettings,
lark: () => <LarkSettings />,
};

View File

@ -0,0 +1,27 @@
import type { LocalizedText, PluginManifest } from "@/core/capabilities/types";
import type { MCPServerConfig } from "@/core/mcp/types";
export type { PluginCategory } from "@/core/capabilities/types";
export type CatalogPlugin = PluginManifest;
export const pluginCategories = [
"office",
"knowledge",
"research",
"business",
"development",
"custom",
] as const;
export function catalogText(text: LocalizedText, locale: string) {
return text[locale] ?? text["en-US"] ?? Object.values(text)[0] ?? "";
}
/** Identity is explicit metadata. A display name never claims an official provider. */
export function catalogForServer(
_name: string,
config?: MCPServerConfig,
catalog: PluginManifest[] = [],
) {
const metadata = config?.capability;
if (metadata && typeof metadata === "object" && "plugin_id" in metadata) {
return catalog.find((item) => item.id === metadata.plugin_id);
}
return undefined;
}

View File

@ -0,0 +1,128 @@
"use client";
import { type ReactNode } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { pluginCategories, type PluginCategory } from "./plugin-catalog";
export type PluginDirectoryEntry = {
id: string;
category: PluginCategory;
search: string;
installed: boolean;
node: ReactNode;
};
export function PluginRow({
name,
description,
icon,
label,
onDetails,
detailsLabel,
children,
}: {
name: string;
description: string;
icon: ReactNode;
label?: ReactNode;
onDetails?: () => void;
detailsLabel?: string;
children: ReactNode;
}) {
return (
<article className="hover:bg-muted/40 group flex min-w-0 items-center gap-4 rounded-xl px-3 py-5 transition-colors">
{icon}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h3 className="text-sm font-semibold">
{onDetails ? (
<button
className="text-left underline-offset-4 hover:underline"
onClick={onDetails}
aria-label={detailsLabel}
>
{name}
</button>
) : (
name
)}
</h3>
{label && (
<span className="text-muted-foreground bg-muted/60 rounded px-1.5 py-0.5 text-[10px] leading-4">
{label}
</span>
)}
</div>
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-5">
{description}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">{children}</div>
</article>
);
}
export function PluginDirectory({
entries,
query = "",
category = "all",
installedOnly = false,
}: {
entries: PluginDirectoryEntry[];
query?: string;
category?: PluginCategory | "all";
installedOnly?: boolean;
}) {
const { t } = useI18n();
const copy = t.capabilities.directory;
const visible = entries.filter(
(entry) =>
(!installedOnly || entry.installed) &&
(category === "all" || entry.category === category) &&
entry.search.toLowerCase().includes(query.trim().toLowerCase()),
);
if (!visible.length)
return (
<p
className="text-muted-foreground py-12 text-center text-sm"
role="status"
>
{t.capabilities.noResults}
</p>
);
return (
<div className="space-y-7">
{pluginCategories.map((key) => {
const items = visible.filter((entry) => entry.category === key);
if (!items.length) return null;
return (
<section key={key} aria-labelledby={`plugin-group-${key}`}>
<div className="flex items-baseline gap-3 border-b pb-3">
<h2
id={`plugin-group-${key}`}
className="text-base font-semibold"
>
{copy.categories[key]}
</h2>
<span className="text-muted-foreground/70 text-xs">
{items.length}
</span>
<span className="text-muted-foreground ml-auto hidden text-xs lg:block">
{copy.hints[key]}
</span>
</div>
<div className="grid grid-cols-1 gap-x-9 lg:grid-cols-2">
{items.map((entry) => (
<div key={entry.id} className="min-w-0">
{entry.node}
</div>
))}
</div>
</section>
);
})}
</div>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { CheckIcon, SendIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { ArrowUpRightIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
@ -9,115 +9,295 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/core/auth/AuthProvider";
import { capabilityCopy } from "@/core/capabilities/copy";
import {
installationQuery,
useCapabilityCatalog,
} from "@/core/capabilities/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { useLarkIntegrationStatus } from "@/core/integrations/lark";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { cn } from "@/lib/utils";
import { CapabilityCard, CapabilityIcon } from "./capability-card";
import { MCPPluginManager } from "./mcp-plugin-manager";
const LarkPluginSettings = dynamic(() =>
import("./lark-plugin-settings").then((module) => module.LarkPluginSettings),
);
import { pluginSettingsAdapters } from "./plugin-adapters";
import {
catalogText,
pluginCategories,
type PluginCategory,
} from "./plugin-catalog";
import {
PluginDirectory,
PluginRow,
type PluginDirectoryEntry,
} from "./plugin-directory";
import { PluginIcon } from "./plugin-icon";
export function PluginGallery({ query }: { query: string }) {
const { t } = useI18n();
const lark = useLarkIntegrationStatus();
const { t, locale } = useI18n();
const copy = t.capabilities.directory;
const labels = capabilityCopy(locale);
const { user } = useAuth();
const canManage = user?.system_role === "admin" && !isStaticWebsiteOnly();
const directory = useCapabilityCatalog();
const definitions = directory.data ?? [];
const adapterNames = [
...new Set(
definitions
.map((item) => item.adapter)
.filter((name) => name !== "guide"),
),
];
const states = useQueries({ queries: adapterNames.map(installationQuery) });
const client = useQueryClient();
const [filter, setFilter] = useState("all");
const [localOpen, setLocalOpen] = useState(false);
const [category, setCategory] = useState<PluginCategory | "all">("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const open = localOpen || params.get("plugin") === "lark";
function setOpen(value: boolean) {
setLocalOpen(value);
if (!value && params.has("plugin")) {
const pathname = usePathname();
const selected = definitions.find(
(item) => item.id === (selectedId ?? params.get("plugin")),
);
function close() {
setSelectedId(null);
if (params.has("plugin")) {
const next = new URLSearchParams(params);
next.delete("plugin");
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
router.replace(`${pathname}?${next.toString()}`);
}
void client.invalidateQueries({ queryKey: ["capabilities"] });
}
const showLark =
(filter === "all" || lark.data?.installed) &&
`${t.capabilities.larkName} ${t.capabilities.larkDescription} feishu lark cli`
.toLowerCase()
.includes(query.trim().toLowerCase());
const connected =
lark.data?.auth.status === "authenticated" && lark.data.auth.verified;
const larkCard = showLark ? (
<CapabilityCard
name={t.capabilities.larkName}
description={t.capabilities.larkDescription}
label={t.capabilities.larkTag}
icon={<CapabilityIcon name="lark" icon={SendIcon} />}
status={
lark.isLoading ? (
t.common.loading
) : lark.error ? (
t.common.error
) : (
<>
{lark.data?.installed && <CheckIcon className="size-3.5" />}
{lark.data?.installed
? t.capabilities.installed
: t.capabilities.notInstalled}
</>
)
}
onDetails={() => setOpen(true)}
detailsLabel={`${t.capabilities.configure} ${t.capabilities.larkName}`}
>
<Button
size="sm"
variant="outline"
className="h-8 rounded-lg text-xs shadow-none"
onClick={() => setOpen(true)}
>
{connected
? t.capabilities.manage
: lark.data?.installed
? t.capabilities.connect
: t.common.install}
</Button>
</CapabilityCard>
) : null;
const installations = [
...new Map(
states
.flatMap((state) => state.data?.items ?? [])
.map((item) => [item.id, item]),
).values(),
];
const catalog: PluginDirectoryEntry[] = definitions
.filter(
(plugin) =>
canManage ||
!installations.some(
(item) => item.adapter === "mcp" && item.plugin_id === plugin.id,
),
)
.map((plugin) => {
const status = installations.find(
(item) => item.plugin_id === plugin.id && item.installed,
);
const unavailable = states[adapterNames.indexOf(plugin.adapter)]?.isError;
return {
id: plugin.id,
category: plugin.category,
installed: !!status,
search: `${Object.values(plugin.name).join(" ")} ${Object.values(plugin.description).join(" ")} ${plugin.aliases.join(" ")}`,
node: (
<PluginRow
name={catalogText(plugin.name, locale)}
description={catalogText(plugin.description, locale)}
icon={
<PluginIcon
name={plugin.id}
asset={plugin.icon}
capabilityId={plugin.id}
/>
}
label={
unavailable
? labels.adapterError
: status
? status.auth_status === "connected"
? labels.connected
: status.auth_status === "required"
? labels.required
: status.auth_status === "configured"
? labels.configured
: labels.installed
: plugin.adapter === "guide"
? copy.candidate
: plugin.adapter === "lark"
? t.capabilities.notInstalled
: labels.notConfigured
}
onDetails={() => setSelectedId(plugin.id)}
detailsLabel={`${t.capabilities.details} ${catalogText(plugin.name, locale)}`}
>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
aria-label={`${plugin.adapter === "guide" ? copy.guide : t.capabilities.configure} ${catalogText(plugin.name, locale)}`}
onClick={() => setSelectedId(plugin.id)}
>
{status
? t.capabilities.manage
: plugin.adapter === "guide"
? copy.view
: canManage
? plugin.adapter === "lark"
? t.common.install
: t.capabilities.configure
: copy.view}
</Button>
</PluginRow>
),
};
});
// Non-admins see safe installation projections, not the administrator's raw config editor.
if (!canManage)
for (const item of installations.filter((item) => item.adapter === "mcp")) {
const manifest = definitions.find(
(plugin) => plugin.id === item.plugin_id,
);
catalog.push({
id: item.id,
category: manifest?.category ?? "custom",
search: `${item.name} ${item.description}`,
installed: true,
node: (
<PluginRow
name={item.name}
description={item.description}
icon={
<PluginIcon
name={item.name}
icon={item.icon}
asset={manifest?.icon}
capabilityId={manifest?.id}
/>
}
label={
item.selectable === false
? labels.unavailable
: item.enabled
? t.capabilities.enabled
: t.capabilities.disabled
}
>
<span className="text-muted-foreground text-xs">
{item.auth_status === "required"
? labels.required
: item.auth_status === "configured"
? labels.configured
: labels.unknown}
</span>
</PluginRow>
),
});
}
const toolbar = (
<Tabs value={filter} onValueChange={setFilter}>
<TabsList className="bg-muted/50 h-9 rounded-lg">
<TabsTrigger className="rounded-md px-4 text-xs" value="all">
{t.capabilities.allPlugins}
</TabsTrigger>
<TabsTrigger className="rounded-md px-4 text-xs" value="installed">
{t.capabilities.installed}
</TabsTrigger>
<TabsList>
<TabsTrigger value="all">{t.capabilities.allPlugins}</TabsTrigger>
<TabsTrigger value="installed">{t.capabilities.installed}</TabsTrigger>
</TabsList>
</Tabs>
);
const Settings = selected
? pluginSettingsAdapters[selected.adapter]
: undefined;
return (
<div className="space-y-6">
<div>
<h2 className="text-base font-semibold">
{t.capabilities.availablePlugins}
</h2>
<p className="text-muted-foreground mt-1.5 text-sm">
{t.capabilities.pluginHint}
</p>
<div
className="flex flex-wrap gap-1"
role="group"
aria-label={copy.allCategories}
>
{(
[
"all",
...pluginCategories.filter((key) => key !== "custom"),
] as const
).map((key) => (
<Button
key={key}
variant="ghost"
size="sm"
aria-pressed={category === key}
onClick={() => setCategory(key)}
className={cn(
"rounded-lg px-3 text-xs font-normal",
category === key
? "bg-muted text-foreground font-medium"
: "text-muted-foreground",
)}
>
{key === "all" ? copy.allCategories : copy.categories[key]}
</Button>
))}
</div>
<MCPPluginManager query={query} toolbar={toolbar}>
{larkCard}
</MCPPluginManager>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-3xl"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>{t.capabilities.pluginSettings}</DialogTitle>
</DialogHeader>
<LarkPluginSettings />
{directory.isError && <p role="alert">{labels.catalogError}</p>}
{directory.isLoading && <p role="status">{t.common.loading}</p>}
{canManage ? (
<MCPPluginManager
query={query}
toolbar={toolbar}
category={category}
installedOnly={filter === "installed"}
catalog={catalog}
definitions={definitions}
/>
) : (
<>
{toolbar}
<PluginDirectory
query={query}
category={category}
installedOnly={filter === "installed"}
entries={catalog}
/>
</>
)}
<Dialog open={!!selected} onOpenChange={(value) => !value && close()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
{selected && (
<>
<DialogHeader>
<PluginIcon
name={selected.id}
asset={selected.icon}
capabilityId={selected.id}
/>
<DialogTitle>{catalogText(selected.name, locale)}</DialogTitle>
<DialogDescription>
{catalogText(selected.description, locale)}
</DialogDescription>
</DialogHeader>
{Settings ? (
<Settings
key={selected.id}
plugin={selected}
onSaved={close}
canManage={canManage}
/>
) : (
<p className="text-muted-foreground text-sm leading-6">
{catalogText(selected.setup, locale)}
</p>
)}
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
{labels.version}: {selected.version}
</span>
<a
href={selected.source}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 underline"
>
{copy.source}
<ArrowUpRightIcon className="size-3" />
</a>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>

View File

@ -0,0 +1,138 @@
"use client";
import { RotateCcwIcon, UploadIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import { PluginIconError, preparePluginIcon } from "@/core/mcp/icon";
import { PluginIcon } from "./plugin-icon";
export function PluginIconPicker({
name,
asset,
capabilityId,
value,
disabled = false,
onChange,
onBusyChange,
}: {
name: string;
asset?: string | null;
capabilityId?: string;
value?: string | null;
disabled?: boolean;
onChange: (value: string | null) => void;
onBusyChange: (value: boolean) => void;
}) {
const { t } = useI18n();
const copy = t.capabilities.icon;
const input = useRef<HTMLInputElement>(null);
const generation = useRef(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(
() => () => {
generation.current++;
},
[],
);
async function upload(file: File) {
const current = ++generation.current;
setError(null);
setBusy(true);
onBusyChange(true);
try {
const icon = await preparePluginIcon(file);
if (generation.current === current) onChange(icon);
} catch (error) {
if (generation.current === current)
setError(
error instanceof PluginIconError
? copy.errors[error.code]
: copy.errors.invalid,
);
} finally {
if (generation.current === current) {
setBusy(false);
onBusyChange(false);
}
}
}
function reset() {
generation.current++;
setBusy(false);
onBusyChange(false);
setError(null);
onChange(null);
}
return (
<div className="space-y-2">
<div className="bg-muted/30 flex items-center gap-4 rounded-xl border p-4">
<button
type="button"
aria-label={copy.upload}
disabled={disabled || busy}
onClick={() => input.current?.click()}
className="shrink-0 rounded-xl focus-visible:outline-2 focus-visible:outline-offset-4"
>
<PluginIcon
name={name}
icon={value}
asset={asset}
capabilityId={capabilityId}
className="size-16"
/>
</button>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{copy.title}</p>
<p className="text-muted-foreground mt-1 text-xs leading-5">
{copy.hint}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || busy}
onClick={() => input.current?.click()}
>
<UploadIcon className="size-3.5" />
{busy ? t.common.loading : copy.change}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || (!value && !busy)}
onClick={reset}
>
<RotateCcwIcon className="size-3.5" />
{copy.reset}
</Button>
</div>
</div>
</div>
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
aria-label={copy.upload}
disabled={disabled || busy}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void upload(file);
}}
/>
{error && (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import {
DatabaseIcon,
FileSearchIcon,
FolderIcon,
GlobeIcon,
SearchIcon,
} from "lucide-react";
import { useState } from "react";
import { safePluginIcon } from "@/core/mcp/icon";
import { cn } from "@/lib/utils";
import { CapabilityIcon } from "./capability-card";
const nativeIcons = {
"web-search": SearchIcon,
"web-fetch": FileSearchIcon,
database: DatabaseIcon,
browser: GlobeIcon,
filesystem: FolderIcon,
};
/** Use the same resolver in the catalog, installed rows, and edit previews. */
export function PluginIcon({
name,
icon,
className,
asset,
capabilityId,
}: {
name: string;
icon?: string | null;
className?: string;
asset?: string | null;
capabilityId?: string;
}) {
const custom = safePluginIcon(icon);
const builtin =
asset?.startsWith("/images/plugins/") && !asset.includes("..")
? asset
: undefined;
const [failed, setFailed] = useState<string[]>([]);
const src =
custom && !failed.includes(custom)
? custom
: builtin && !failed.includes(builtin)
? builtin
: undefined;
if (src)
return (
<span
className={cn(
"flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-white p-2",
className,
)}
>
{/* Inline PNGs and local brand assets need no image optimization request. */}
<img
key={src}
src={src}
alt=""
data-plugin-icon={name}
width={32}
height={32}
className="size-full object-contain"
onError={() => setFailed((previous) => [...previous, src])}
/>
</span>
);
return (
<span className={className}>
<CapabilityIcon
name={name}
icon={nativeIcons[capabilityId as keyof typeof nativeIcons]}
/>
</span>
);
}

View File

@ -12,6 +12,7 @@ export interface Agent {
model: string | null;
tool_groups: string[] | null;
skills: string[] | null;
mcp_plugins?: string[] | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
@ -26,6 +27,7 @@ export interface CreateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
mcp_plugins?: string[] | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
@ -39,6 +41,7 @@ export interface UpdateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
mcp_plugins?: string[] | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;

View File

@ -1,3 +1,7 @@
import {
staticCapabilityCatalog,
staticCapabilityInstallations,
} from "@/core/capabilities/static";
import { getBackendBaseURL } from "@/core/config";
import type { FeaturesResponse } from "@/core/features/api";
import type { UserMemory } from "@/core/memory/types";
@ -38,6 +42,21 @@ export async function staticApiResponse(
}
const path = url.pathname.slice(root.pathname.length).replace(/\/$/, "");
if (path === "capabilities/catalog") {
return method === "HEAD"
? new Response(null)
: Response.json(staticCapabilityCatalog);
}
if (path.startsWith("capabilities/installations/")) {
const response = await staticCapabilityInstallations(
path.slice("capabilities/installations/".length),
origin,
init,
);
return method === "HEAD"
? new Response(null, { status: response.status })
: response;
}
// These routes already own the demo settings data; do not maintain a second copy.
if (["skills", "mcp/config", "integrations/lark/status"].includes(path)) {
return globalThis.fetch(new URL(`/mock/api/${path}`, origin).href, init);

View File

@ -0,0 +1,497 @@
[
{
"id": "lark",
"category": "office",
"name": { "en-US": "Lark / Feishu", "zh-CN": "飞书 / Lark" },
"description": {
"en-US": "Connect documents, messages, calendars, and tables.",
"zh-CN": "连接文档、消息、日历与多维表格。"
},
"setup": {
"en-US": "Install the managed integration and connect your account.",
"zh-CN": "安装集成并连接自己的账号。"
},
"kind": "cli",
"source": "https://github.com/larksuite/cli",
"aliases": ["lark", "feishu", "飞书"],
"schema_version": 1,
"version": "1",
"adapter": "lark",
"auth_methods": ["oauth"],
"icon": "/images/plugins/lark.ico",
"contributions": ["tools", "skills"],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "dingtalk",
"category": "office",
"name": { "en-US": "DingTalk group notifications", "zh-CN": "钉钉群通知" },
"description": {
"en-US": "Send text and Markdown notifications to a configured DingTalk group robot.",
"zh-CN": "向配置的钉钉群机器人发送文本或 Markdown 通知。"
},
"kind": "mcp",
"setup": {
"en-US": "Create a signed custom group robot. Enter its access_token and signing secret. This does not read chats, documents or calendars.",
"zh-CN": "创建启用加签的自定义群机器人,填写 Webhook 的 access_token 和加签密钥。此插件不读取聊天、文档或日历。"
},
"source": "https://open.dingtalk.com/document/orgapp/custom-robot-access",
"aliases": ["dingtalk", "钉钉"],
"schema_version": 1,
"version": "2",
"adapter": "business",
"auth_methods": ["api_key"],
"icon": "/images/plugins/dingtalk.ico",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Connection name" },
"access_token": {
"type": "string",
"format": "password",
"title": "access_token"
},
"sign_secret": {
"type": "string",
"format": "password",
"title": "sign_secret"
}
},
"required": ["name", "access_token", "sign_secret"],
"additionalProperties": false
}
},
{
"id": "wecom",
"category": "office",
"name": { "en-US": "WeCom group notifications", "zh-CN": "企业微信群通知" },
"description": {
"en-US": "Send text and Markdown notifications to a configured WeCom group robot.",
"zh-CN": "向配置的企业微信群机器人发送文本或 Markdown 通知。"
},
"kind": "mcp",
"setup": {
"en-US": "Create a group robot and copy the key parameter from its webhook URL. This does not connect the incoming chat channel or access documents.",
"zh-CN": "创建群机器人,复制 Webhook 地址中的 key 参数。此插件用于主动通知,不接入收消息通道或读取文档。"
},
"source": "https://developer.work.weixin.qq.com/document/path/91770",
"aliases": ["wecom", "企业微信", "企微"],
"schema_version": 1,
"version": "2",
"adapter": "business",
"auth_methods": ["api_key"],
"icon": "/images/plugins/wecom.png",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Connection name" },
"webhook_key": {
"type": "string",
"format": "password",
"title": "webhook_key"
}
},
"required": ["name", "webhook_key"],
"additionalProperties": false
}
},
{
"id": "tencent-docs",
"category": "knowledge",
"name": { "en-US": "Tencent Docs", "zh-CN": "腾讯文档" },
"description": {
"en-US": "Find and update shared documents and spreadsheets.",
"zh-CN": "查找与更新在线文档、表格,协同整理团队资料。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://docs.qq.com/open/auth/mcp.html",
"aliases": ["tencent-docs", "腾讯文档"],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": ["api_key", "oauth"],
"icon": "/images/plugins/tencent-docs.ico",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"url": { "type": "string", "title": "Server URL" },
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{
"id": "notion",
"category": "knowledge",
"name": { "en-US": "Notion", "zh-CN": "Notion" },
"description": {
"en-US": "Search your team's wiki, notes, and project knowledge.",
"zh-CN": "搜索团队知识库、笔记与项目文档,沉淀工作信息。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/makenotion/notion-mcp-server",
"aliases": ["notion"],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": ["api_key", "oauth"],
"icon": "/images/plugins/notion.svg",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"url": { "type": "string", "title": "Server URL" },
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{
"id": "openviking",
"category": "knowledge",
"name": { "en-US": "OpenViking", "zh-CN": "OpenViking" },
"description": {
"en-US": "Organize long-term memory and reusable agent resources.",
"zh-CN": "统一管理长期记忆与资源,为 Agent 提供工作上下文。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/volcengine/OpenViking",
"aliases": ["openviking", "openviking context"],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": ["api_key", "oauth"],
"icon": "/images/plugins/openviking.png",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"url": { "type": "string", "title": "Server URL" },
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{
"id": "web-search",
"category": "research",
"name": { "en-US": "Web search", "zh-CN": "网页搜索" },
"description": {
"en-US": "Search the web with your configured search provider.",
"zh-CN": "搜索公开网页,为研究和决策补充信息来源。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/blob/main/config.example.yaml",
"aliases": [
"web-search",
"duckduckgo",
"tavily",
"brave",
"brave search",
"serper"
],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": ["none", "api_key"],
"icon": null,
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "web-fetch",
"category": "research",
"name": { "en-US": "Web reader", "zh-CN": "网页读取" },
"description": {
"en-US": "Read web pages and extract useful content with Jina.",
"zh-CN": "读取网页正文,提取资料,供 Agent 进一步分析。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/jina_ai",
"aliases": ["web-fetch", "jina"],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": ["none", "api_key"],
"icon": null,
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "exa",
"category": "research",
"name": { "en-US": "Exa", "zh-CN": "Exa" },
"description": {
"en-US": "Find relevant sources with semantic web search.",
"zh-CN": "通过语义搜索找到相关网页,开展行业与公司研究。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/exa",
"aliases": ["exa"],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": ["none", "api_key"],
"icon": "/images/plugins/exa.svg",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "firecrawl",
"category": "research",
"name": { "en-US": "Firecrawl", "zh-CN": "Firecrawl" },
"description": {
"en-US": "Search and extract content from websites at scale.",
"zh-CN": "搜索与抓取网站内容,将网页转为可分析的资料。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/firecrawl",
"aliases": ["firecrawl"],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": ["none", "api_key"],
"icon": "/images/plugins/firecrawl.svg",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"id": "database",
"category": "business",
"name": { "en-US": "SQL databases", "zh-CN": "SQL 数据库" },
"description": {
"en-US": "Connect business data in PostgreSQL, MySQL, and more.",
"zh-CN": "连接 PostgreSQL、MySQL 等数据库,查询与分析业务数据。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/googleapis/genai-toolbox",
"aliases": ["postgres", "postgresql", "mysql", "database", "mcp-toolbox"],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": ["api_key", "oauth"],
"icon": null,
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"url": { "type": "string", "title": "Server URL" },
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{
"id": "hubspot",
"category": "business",
"name": { "en-US": "HubSpot CRM", "zh-CN": "HubSpot CRM" },
"description": {
"en-US": "Read companies and create contacts with a private app access token.",
"zh-CN": "使用私有应用令牌查询公司、创建联系人。"
},
"kind": "mcp",
"setup": {
"en-US": "Provide a private app access token with crm.objects.companies.read and crm.objects.contacts.write. Reading companies does not create records; contacts are created only when the tool is called.",
"zh-CN": "填写私有应用令牌,并授予 crm.objects.companies.read 和 crm.objects.contacts.write 权限。查询公司不会创建数据;只有调用创建联系人工具时才写入。"
},
"source": "https://developers.hubspot.com/docs/apps/legacy-apps/authentication/intro-to-auth",
"aliases": ["hubspot", "crm"],
"schema_version": 1,
"version": "2",
"adapter": "business",
"auth_methods": ["api_key"],
"icon": "/images/plugins/hubspot.svg",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Connection name" },
"access_token": {
"type": "string",
"format": "password",
"title": "access_token"
}
},
"required": ["name", "access_token"],
"additionalProperties": false
}
},
{
"id": "github",
"category": "development",
"name": { "en-US": "GitHub", "zh-CN": "GitHub" },
"description": {
"en-US": "Find code, triage issues, and review pull requests.",
"zh-CN": "检索代码与仓库,跟进 Issue协助审查 Pull Request。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/github/github-mcp-server",
"aliases": ["github"],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": ["api_key", "oauth"],
"icon": "/images/plugins/github.svg",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"url": { "type": "string", "title": "Server URL" },
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{
"id": "atlassian",
"category": "development",
"name": { "en-US": "Jira / Confluence", "zh-CN": "Jira / Confluence" },
"description": {
"en-US": "Connect project issues, team pages, and delivery plans.",
"zh-CN": "连接项目需求、团队文档与交付计划,追踪研发进展。"
},
"kind": "mcp",
"setup": {
"en-US": "Follow the provider's setup guide, prepare the required account or API credentials, then add its MCP server using Add MCP plugin. Adding a configuration does not verify the connection.",
"zh-CN": "按提供方文档准备账号或 API 凭据,再通过「添加 MCP 插件」录入服务配置。保存配置后仍需验证连接和权限。"
},
"source": "https://github.com/sooperset/mcp-atlassian",
"aliases": ["atlassian", "jira", "confluence", "mcp-atlassian"],
"schema_version": 1,
"version": "1",
"adapter": "mcp",
"auth_methods": ["api_key", "oauth"],
"icon": "/images/plugins/jira.svg",
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Name" },
"url": { "type": "string", "title": "Server URL" },
"authorization": {
"type": "string",
"title": "Authorization header",
"format": "password"
}
},
"required": ["name", "url"],
"additionalProperties": false
}
},
{
"id": "browser",
"category": "development",
"name": { "en-US": "Browser automation", "zh-CN": "浏览器自动化" },
"description": {
"en-US": "Open pages and complete browser tasks with Playwright.",
"zh-CN": "基于 Playwright 打开网页、提取内容与执行页面操作。"
},
"kind": "native",
"setup": {
"en-US": "This capability is supported by DeerFlow. Ask your administrator to select a provider and configure its credentials in config.yaml. Availability depends on this deployment; this directory does not check its runtime status.",
"zh-CN": "DeerFlow 已支持此能力。请由管理员在 config.yaml 中选择服务并配置所需凭据;能否使用取决于当前部署,本目录尚未检测运行状态。"
},
"source": "https://github.com/bytedance/deer-flow/tree/main/backend/packages/harness/deerflow/community/browser_automation",
"aliases": ["browser", "playwright"],
"schema_version": 1,
"version": "1",
"adapter": "guide",
"auth_methods": ["none", "api_key"],
"icon": null,
"contributions": ["tools"],
"config_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
]

View File

@ -0,0 +1,66 @@
const en = {
install: "Configure plugin",
name: "Connection name",
url: "Server URL",
authorization: "Authorization header (optional)",
access_token: "Access token",
sign_secret: "Robot signing secret",
webhook_key: "Robot webhook key (the key parameter in its URL)",
save: "Save configuration",
accountHint:
"These are deployment credentials managed by the administrator. Personal authorization, when supported, uses the integration's account flow.",
saved: "Configuration saved",
unknown: "Not checked",
required: "Account required",
configured: "Credentials configured",
notConfigured: "Not configured",
connected: "Account connected",
notRequired: "No account configured",
catalogError: "Could not load the plugin directory",
adapterError: "Integration status unavailable",
installed: "Configured",
selectionTitle: "Plugins and skills",
plugins: "MCP plugins",
skills: "Skills (including integration skills)",
all: "Use all enabled",
selected: "Choose capabilities",
unavailable: "Unavailable",
hint: "Choose the plugins and skills this agent can use. Changes apply on the next run, including delegated tasks.",
version: "Catalog version",
noPlugins: "No configured plugins",
invalidUrl: "Enter an HTTP or HTTPS server URL.",
};
const zh: typeof en = {
install: "配置插件",
name: "连接名称",
url: "服务地址",
authorization: "授权请求头(可选)",
access_token: "访问令牌Access Token",
sign_secret: "机器人加签密钥",
webhook_key: "机器人 Webhook 密钥(地址中的 key 参数)",
save: "保存配置",
accountHint:
"这里配置的是由管理员管理的部署凭据。支持个人授权的集成,通过其账号流程连接。",
saved: "配置已保存",
unknown: "未检测",
required: "需要连接账号",
configured: "已配置凭据",
notConfigured: "未配置",
connected: "账号已连接",
notRequired: "未配置账号",
catalogError: "无法加载插件目录",
adapterError: "无法获取集成状态",
installed: "已配置",
selectionTitle: "插件与技能",
plugins: "MCP 插件",
skills: "技能(包含集成技能)",
all: "使用全部已启用能力",
selected: "选择能力",
unavailable: "当前不可用",
hint: "选择此 Agent 使用的插件与技能,下次运行时生效,委派任务也会沿用。",
version: "目录版本",
noPlugins: "暂无已配置插件",
invalidUrl: "请输入 HTTP 或 HTTPS 服务地址。",
};
export const capabilityCopy = (locale: string) =>
locale === "zh-CN" ? zh : en;

View File

@ -0,0 +1,68 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { InstallationList, PluginManifest } from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(
`${getBackendBaseURL()}/api/capabilities/${path}`,
init,
);
if (!response.ok) {
const error = (await response.json().catch(() => null)) as {
detail?: unknown;
} | null;
throw new Error(
typeof error?.detail === "string"
? error.detail
: `Capability request failed (${response.status})`,
);
}
return response.json() as Promise<T>;
}
export function useCapabilityCatalog() {
return useQuery({
queryKey: ["capabilities", "catalog"],
queryFn: () => request<PluginManifest[]>("catalog"),
staleTime: 60_000,
});
}
export function useCapabilityInstallations(adapter: string) {
return useQuery({
queryKey: ["capabilities", "installations", adapter],
queryFn: () =>
request<InstallationList>(`installations/${encodeURIComponent(adapter)}`),
});
}
export function useInstallCapability() {
const client = useQueryClient();
return useMutation({
mutationFn: (body: {
plugin_id: string;
name: string;
configuration: Record<string, unknown>;
}) =>
request<InstallationList>("installations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: ["capabilities"] }),
client.invalidateQueries({ queryKey: ["mcpConfig"] }),
client.invalidateQueries({ queryKey: ["skills"] }),
]);
},
});
}
export function installationQuery(adapter: string) {
return {
queryKey: ["capabilities", "installations", adapter],
queryFn: () =>
request<InstallationList>(`installations/${encodeURIComponent(adapter)}`),
};
}

View File

@ -0,0 +1,114 @@
import catalog from "./builtin.demo.json";
import type { CapabilityInstallation } from "./types";
/** Generated catalog snapshot; refresh with pnpm catalog:sync. */
export const staticCapabilityCatalog = catalog;
export async function staticCapabilityInstallations(
adapter: string,
origin: string,
init?: RequestInit,
): Promise<Response> {
const paths: Record<string, string> = {
mcp: "mcp/config",
business: "mcp/config",
lark: "integrations/lark/status",
skills: "skills",
};
const path = paths[adapter];
if (!path)
return Response.json(
{ detail: "Unknown capability adapter" },
{ status: 404 },
);
const response = await globalThis.fetch(
new URL(`/mock/api/${path}`, origin).href,
{ ...init, method: "GET" },
);
if (!response.ok)
return Response.json(
{ detail: "Demo fixture unavailable" },
{ status: response.status },
);
const data = (await response.json()) as {
mcp_servers?: Record<
string,
{
enabled?: boolean;
description?: string;
capability?: { plugin_id?: string };
}
>;
skills?: {
name: string;
description: string;
enabled: boolean;
category: string;
}[];
installed?: boolean;
manifest_version?: string | null;
};
const base = {
description: "",
installed: true,
enabled: null,
version: null,
scope: "deployment",
auth_status: "unknown",
health: "unknown",
category: null,
icon: null,
};
let items: CapabilityInstallation[];
if (adapter === "skills") {
items = (data.skills ?? []).map((skill) => ({
...base,
id: `skill:${skill.category}:${skill.name}`,
plugin_id: null,
adapter,
name: skill.name,
reference: skill.name,
description: skill.description,
enabled: skill.enabled,
category: skill.category,
auth_status: "not_required",
}));
} else if (adapter === "lark") {
items = [
{
...base,
id: "lark",
plugin_id: "lark",
adapter,
name: "Lark / Feishu",
reference: "lark",
installed: data.installed === true,
version: data.manifest_version ?? null,
scope: "user",
},
];
} else {
const businessPluginIds = new Set(
staticCapabilityCatalog
.filter((plugin) => plugin.adapter === "business")
.map((plugin) => plugin.id),
);
items = Object.entries(data.mcp_servers ?? {})
.filter(
([, server]) =>
adapter !== "business" ||
businessPluginIds.has(server.capability?.plugin_id ?? ""),
)
.map(([name, server]) => ({
...base,
id: `demo:mcp:${encodeURIComponent(name)}`,
plugin_id: server.capability?.plugin_id ?? null,
adapter: "mcp",
name,
reference: name,
description: server.description ?? "",
enabled: server.enabled ?? true,
}));
}
return Response.json({ items, can_manage: false });
}

View File

@ -0,0 +1,53 @@
export type LocalizedText = Record<string, string>;
export type PluginCategory =
| "office"
| "knowledge"
| "research"
| "business"
| "development"
| "custom";
export interface PluginManifest {
schema_version: 1;
id: string;
version: string;
name: LocalizedText;
description: LocalizedText;
setup: LocalizedText;
category: PluginCategory;
kind: "mcp" | "cli" | "native";
adapter: string;
source: string;
icon: string | null;
aliases: string[];
auth_methods: string[];
contributions: ("tools" | "skills")[];
config_schema: {
type: string;
properties?: Record<
string,
{ type: string; title?: string; format?: string }
>;
required?: string[];
};
}
export interface CapabilityInstallation {
id: string;
plugin_id: string | null;
adapter: string;
name: string;
description: string;
reference: string;
selectable?: boolean;
installed: boolean;
enabled: boolean | null;
version: string | null;
scope: string;
auth_status: string;
health: string;
category: string | null;
icon: string | null;
}
export interface InstallationList {
items: CapabilityInstallation[];
can_manage: boolean;
}

View File

@ -18,6 +18,53 @@ export const enUS: Translations = {
},
capabilities: {
icon: {
title: "Plugin icon",
upload: "Upload plugin icon",
change: "Choose image",
reset: "Restore default",
hint: "PNG, JPG or WebP · up to 2 MB. Changes take effect when you save.",
singleServer: "Upload an icon when adding one plugin at a time.",
errors: {
type: "Choose a PNG, JPG or WebP image.",
size: "The image must be 2 MB or smaller.",
invalid:
"Cannot read this image. Choose a valid image up to 16 megapixels.",
},
},
directory: {
categories: {
office: "Office & collaboration",
knowledge: "Documents & knowledge",
research: "Search & research",
business: "Business & data",
development: "Development & operations",
custom: "Custom plugins",
},
hints: {
office: "Keep your team in sync",
knowledge: "Make company knowledge accessible",
research: "Find sources and turn them into insights",
business: "Bring business context to every decision",
development: "Connect the tools your team builds with",
custom: "Your configured MCP servers",
},
connected: "Connected",
native: "Built-in support",
guide: "Setup guide",
candidate: "Suggested",
view: "View",
allCategories: "All categories",
source: "Open setup documentation",
setup: "How to connect",
notice:
"Discover integrations for your team. Connect accounts and configure access when you need them.",
configured: "Configured",
nativeHint: "Supported by DeerFlow · requires deployment configuration",
guideHint: "Setup reference · not connected",
unknownStatus: "Status unavailable",
notConnected: "Not connected",
},
integrationSkills: "From plugins",
sharedSkills: "Shared skills",
title: "Capability Center",

View File

@ -7,6 +7,47 @@ export interface Translations {
};
capabilities: {
icon: {
title: string;
upload: string;
change: string;
reset: string;
hint: string;
singleServer: string;
errors: { type: string; size: string; invalid: string };
};
directory: {
categories: {
office: string;
knowledge: string;
research: string;
business: string;
development: string;
custom: string;
};
hints: {
office: string;
knowledge: string;
research: string;
business: string;
development: string;
custom: string;
};
connected: string;
native: string;
guide: string;
candidate: string;
view: string;
allCategories: string;
source: string;
setup: string;
notice: string;
configured: string;
nativeHint: string;
guideHint: string;
unknownStatus: string;
notConnected: string;
};
integrationSkills: string;
sharedSkills: string;
title: string;

View File

@ -18,6 +18,51 @@ export const zhCN: Translations = {
},
capabilities: {
icon: {
title: "插件图标",
upload: "上传插件图标",
change: "选择图片",
reset: "恢复默认",
hint: "支持 PNG、JPG、WebP最大 2 MB。点击保存后生效。",
singleServer: "上传图标时,请每次只添加一个插件。",
errors: {
type: "请选择 PNG、JPG 或 WebP 图片。",
size: "图片不能超过 2 MB。",
invalid: "无法读取图片,请选择有效且不超过 1600 万像素的图片。",
},
},
directory: {
categories: {
office: "办公协作",
knowledge: "文档与知识",
research: "搜索与研究",
business: "业务与数据",
development: "研发与运维",
custom: "自定义插件",
},
hints: {
office: "让信息流转,让团队协同",
knowledge: "连接分散资料,沉淀团队知识",
research: "从信息搜索到研究洞察",
business: "用业务数据支持决策",
development: "贯通需求、代码与交付",
custom: "管理你添加的 MCP 服务",
},
connected: "已连接",
native: "内置支持",
guide: "接入指南",
candidate: "推荐接入",
view: "查看",
allCategories: "全部分类",
source: "查看接入文档",
setup: "接入方式",
notice: "按工作场景发现插件,需要时再连接账号、配置权限。",
configured: "已配置",
nativeHint: "DeerFlow 已支持 · 需按部署配置",
guideHint: "接入参考 · 尚未连接",
unknownStatus: "状态不可用",
notConnected: "未连接",
},
integrationSkills: "来自插件",
sharedSkills: "共享技能",
title: "能力中心",

View File

@ -29,6 +29,7 @@ export function useInstallLarkIntegration() {
queryKey: larkIntegrationQueryKey,
});
await queryClient.invalidateQueries({ queryKey: ["skills"] });
await queryClient.invalidateQueries({ queryKey: ["capabilities"] });
},
});
}

View File

@ -35,7 +35,12 @@ export function getEnableMCPServerMutationOptions(queryClient: QueryClient) {
return {
mutationFn: ({ serverName, enabled }: EnableMCPServerVariables) =>
updateMCPServerState(serverName, enabled),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
queryClient.invalidateQueries({ queryKey: ["capabilities"] }),
]);
},
onError: (error: Error) => {
toast.error(error.message);
},
@ -74,7 +79,12 @@ export function getMCPServerMutationOptions(queryClient: QueryClient) {
return deleteMCPServer(variables.serverName);
}
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mcpConfig"] }),
queryClient.invalidateQueries({ queryKey: ["capabilities"] }),
]);
},
onError: (error: Error) => {
toast.error(error.message);
},

View File

@ -0,0 +1,112 @@
import type { MCPServerConfig } from "./types";
export const MAX_PLUGIN_ICON_FILE_BYTES = 2 * 1024 * 1024;
export const MAX_PLUGIN_ICON_DATA_LENGTH = 100_000;
const ICON_SIZE = 128;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
export type PluginIconErrorCode = "type" | "size" | "invalid";
export class PluginIconError extends Error {
constructor(readonly code: PluginIconErrorCode) {
super(code);
}
}
/** Only bounded, inlined PNGs are displayed. Never fetch a config-supplied URL. */
export function safePluginIcon(value: unknown): string | undefined {
return typeof value === "string" &&
value.length <= MAX_PLUGIN_ICON_DATA_LENGTH &&
/^data:image\/png;base64,iVBORw0KGgo[A-Za-z0-9+/]*={0,2}$/.test(value)
? value
: undefined;
}
function presentationOf(config: MCPServerConfig): Record<string, unknown> {
const value = config.presentation;
return value && typeof value === "object" && !Array.isArray(value)
? value
: {};
}
export function readPluginIcon(config: MCPServerConfig): string | undefined {
return safePluginIcon(presentationOf(config).icon);
}
/** Preserve all connection fields and unrelated presentation keys on save/reset. */
export function withPluginIcon(
config: MCPServerConfig,
icon: string | null | undefined,
): MCPServerConfig {
if (icon === undefined) return config;
const presentation = { ...presentationOf(config) };
if (icon === null) delete presentation.icon;
else {
const safe = safePluginIcon(icon);
if (!safe) throw new PluginIconError("invalid");
presentation.icon = safe;
}
const result = { ...config };
if (Object.keys(presentation).length) result.presentation = presentation;
else delete result.presentation;
return result;
}
/** Decode a local raster image and persist a small, square PNG without metadata. */
export async function preparePluginIcon(file: File): Promise<string> {
if (!ALLOWED_TYPES.has(file.type)) throw new PluginIconError("type");
if (file.size > MAX_PLUGIN_ICON_FILE_BYTES) throw new PluginIconError("size");
const header = new Uint8Array(await file.slice(0, 12).arrayBuffer());
const png = [137, 80, 78, 71, 13, 10, 26, 10].every(
(byte, index) => header[index] === byte,
);
const jpeg = header[0] === 255 && header[1] === 216 && header[2] === 255;
const webp =
String.fromCharCode(...header.slice(0, 4)) === "RIFF" &&
String.fromCharCode(...header.slice(8, 12)) === "WEBP";
if (
!(file.type === "image/png"
? png
: file.type === "image/jpeg"
? jpeg
: webp)
)
throw new PluginIconError("invalid");
const url = URL.createObjectURL(file);
try {
const image = new Image();
image.src = url;
await image.decode();
if (
!image.naturalWidth ||
!image.naturalHeight ||
image.naturalWidth * image.naturalHeight > 16_000_000
)
throw new PluginIconError("invalid");
const canvas = document.createElement("canvas");
canvas.width = ICON_SIZE;
canvas.height = ICON_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) throw new PluginIconError("invalid");
const scale = Math.min(
ICON_SIZE / image.naturalWidth,
ICON_SIZE / image.naturalHeight,
);
const width = image.naturalWidth * scale;
const height = image.naturalHeight * scale;
ctx.drawImage(
image,
(ICON_SIZE - width) / 2,
(ICON_SIZE - height) / 2,
width,
height,
);
const data = canvas.toDataURL("image/png");
if (!safePluginIcon(data)) throw new PluginIconError("invalid");
return data;
} catch (error) {
if (error instanceof PluginIconError) throw error;
throw new PluginIconError("invalid");
} finally {
URL.revokeObjectURL(url);
}
}

View File

@ -1,6 +1,8 @@
export interface MCPServerConfig extends Record<string, unknown> {
enabled: boolean;
description: string;
/** Display-only metadata; never passed to the MCP transport. */
presentation?: Record<string, unknown>;
}
export interface MCPConfig {

View File

@ -109,7 +109,10 @@ for (const viewport of [
exact: true,
});
await general.check();
const details = dialog.locator("details").first();
const details = dialog
.locator("details")
.filter({ hasText: description })
.first();
const summary = details.locator("summary");
await summary.scrollIntoViewIfNeeded();
const preview = summary.locator("span").first();
@ -152,7 +155,9 @@ for (const viewport of [
}) => {
await page.setViewportSize(viewport);
const { dialog } = await openSettings(page);
const summary = dialog.locator("summary").first();
const summary = dialog.locator(
'summary[aria-label="general-purpose: Delegation description"]',
);
await summary.focus();
await expect(summary).toHaveAccessibleName(
"general-purpose: Delegation description",
@ -166,7 +171,10 @@ for (const viewport of [
}) => {
await page.setViewportSize(viewport);
const { dialog } = await openSettings(page);
const details = dialog.locator("details").first();
const details = dialog
.locator("details")
.filter({ hasText: description })
.first();
const summary = details.locator("summary");
await summary.focus();
// Reproduce scrolling a focused disclosure to the last eight visible

View File

@ -0,0 +1,175 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
for (const plugin of [
{
id: "dingtalk",
name: "DingTalk group notifications",
fields: { access_token: "fixture-token", sign_secret: "fixture-secret" },
},
{
id: "wecom",
name: "WeCom group notifications",
fields: { webhook_key: "fixture-key" },
},
{
id: "hubspot",
name: "HubSpot CRM",
fields: { access_token: "fixture-token" },
},
]) {
test(`${plugin.id} configures bundled tools without a server URL`, async ({
page,
}) => {
mockLangGraphAPI(page);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
let submission: Record<string, unknown> | undefined;
let configured = false;
const connectionName = `team-${plugin.id}`;
await page.route("**/api/mcp/config", (route) =>
route.fulfill({
json: {
mcp_servers: configured
? {
[connectionName]: {
enabled: true,
type: "stdio",
command: "python",
env: { TOKEN: "***" },
capability: {
id: `fixture-${plugin.id}`,
plugin_id: plugin.id,
version: "2",
},
},
}
: {},
},
}),
);
await page.route("**/api/capabilities/installations", (route) => {
submission = route.request().postDataJSON() as Record<string, unknown>;
configured = true;
return route.fulfill({ json: { items: [], can_manage: true } });
});
await page.goto(`/workspace/capabilities?plugin=${plugin.id}`);
const dialog = page.getByRole("dialog");
await expect(
dialog.getByRole("heading", { name: plugin.name }),
).toBeVisible();
await expect(dialog.getByLabel("Server URL")).toHaveCount(0);
await dialog.getByLabel("Connection name").fill(connectionName);
for (const [key, value] of Object.entries(plugin.fields)) {
const field = dialog.locator(`#plugin-field-${key}`);
await expect(field).toHaveAttribute("type", "password");
await field.fill(value);
}
const screenshotDirectory = process.env.CAPABILITY_SCREENSHOT_DIR;
if (plugin.id === "hubspot" && screenshotDirectory) {
await mkdir(screenshotDirectory, { recursive: true });
await page.screenshot({
path: path.join(screenshotDirectory, "hubspot-configuration-en.png"),
});
}
await dialog.getByRole("button", { name: "Save configuration" }).click();
await expect(dialog).toHaveCount(0);
expect(submission).toEqual({
plugin_id: plugin.id,
name: connectionName,
configuration: plugin.fields,
});
await expect(
page.locator("article").filter({ hasText: connectionName }),
).toHaveCount(1);
expect(errors).toEqual([]);
});
}
test("rejected credentials remain editable and are not marked configured", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.route("**/api/capabilities/installations", (route) =>
route.fulfill({
status: 422,
json: { detail: "Invalid credential: access_token" },
}),
);
await page.goto("/workspace/capabilities?plugin=hubspot");
const dialog = page.getByRole("dialog");
await dialog.locator("#plugin-field-access_token").fill("invalid value");
await dialog.getByRole("button", { name: "Save configuration" }).click();
await expect(dialog.getByRole("alert")).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Save configuration" }),
).toBeEnabled();
await expect(dialog.locator("#plugin-field-access_token")).toHaveValue(
"invalid value",
);
});
test("ordinary users see each shared connection once and cannot configure credentials", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: "member",
email: "member@example.test",
system_role: "user",
needs_setup: false,
},
}),
);
const connection = {
id: "shared-hubspot",
plugin_id: "hubspot",
adapter: "mcp",
name: "Team CRM",
reference: "team-crm",
installed: true,
enabled: true,
auth_status: "configured",
health: "unknown",
scope: "deployment",
};
// Both adapters project the same MCP installation, not a second account.
for (const adapter of ["mcp", "business"]) {
await page.route(`**/api/capabilities/installations/${adapter}`, (route) =>
route.fulfill({ json: { items: [connection], can_manage: false } }),
);
}
await page.goto("/workspace/capabilities");
// The local preview starts with an SSR admin. Use the real account-refresh
// event to apply the mocked ordinary-user session after hydration.
await expect(
page.getByRole("textbox", { name: "Search plugins by name or purpose" }),
).toBeEnabled();
await page.evaluate(() =>
document.dispatchEvent(new Event("visibilitychange")),
);
await expect(
page.locator("article").filter({ hasText: "Team CRM" }),
).toHaveCount(1);
await expect(
page.getByRole("button", { name: "Add MCP plugin" }),
).toHaveCount(0);
await page
.getByRole("button", {
name: "Configure WeCom group notifications",
exact: true,
})
.click();
const dialog = page.getByRole("dialog");
await expect(dialog.locator("#plugin-field-webhook_key")).toBeDisabled();
await expect(dialog.locator("#plugin-field-webhook_key")).toHaveValue("");
await expect(
dialog.getByRole("button", { name: "Save configuration" }),
).toBeDisabled();
});

View File

@ -51,37 +51,76 @@ const skills = [
license: "MIT",
}));
async function mockCatalog(page: Page) {
async function mockCatalog(page: Page, locale = "en-US") {
mockLangGraphAPI(page, { skills, threads: [] });
await page.route("**/api/mcp/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
GitHub: {
capability: {
id: "fixture-github",
plugin_id: "github",
version: "1",
},
description:
"搜索代码与仓库,查看 Issue 和 Pull Request协助推进开发工作。",
locale === "zh-CN"
? "搜索代码与仓库,查看 Issue 和 Pull Request协助推进开发工作。"
: "Search code and repositories, review issues, and work with pull requests.",
enabled: true,
type: "http",
url: "https://example.test/github",
},
Notion: {
description: "搜索工作空间里的笔记和文档,整理资料,创建新的页面。",
capability: {
id: "fixture-notion",
plugin_id: "notion",
version: "1",
},
description:
locale === "zh-CN"
? "搜索工作空间里的笔记和文档,整理资料,创建新的页面。"
: "Search workspace notes and documents, organize knowledge, and create pages.",
enabled: true,
type: "http",
url: "https://example.test/notion",
},
"Brave Search": {
description: "搜索互联网上的信息,为研究、写作和决策补充最新资料。",
capability: {
id: "fixture-brave-search",
plugin_id: "brave-search",
version: "1",
},
description:
locale === "zh-CN"
? "搜索互联网上的信息,为研究、写作和决策补充最新资料。"
: "Search the web for information to support research, writing, and decisions.",
enabled: true,
command: "example-search",
},
Filesystem: {
description: "访问已授权的文件夹,读取文件内容并整理本地工作资料。",
capability: {
id: "fixture-filesystem",
plugin_id: "filesystem",
version: "1",
},
description:
locale === "zh-CN"
? "访问已授权的文件夹,读取文件内容并整理本地工作资料。"
: "Access authorized folders, read files, and organize local working documents.",
enabled: true,
command: "example-files",
},
PostgreSQL: {
description: "查询数据库中的业务数据,探索表结构,辅助数据分析。",
capability: {
id: "fixture-postgres",
plugin_id: "database",
version: "1",
},
description:
locale === "zh-CN"
? "查询数据库中的业务数据,探索表结构,辅助数据分析。"
: "Query business data, explore database schemas, and support analysis.",
enabled: false,
command: "example-database",
},
@ -109,12 +148,12 @@ test("catalog navigation, search, details, and migrated settings", async ({
await page
.context()
.addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]);
await mockCatalog(page);
await mockCatalog(page, "zh-CN");
await page.goto("/workspace/capabilities");
await expect(
page.getByRole("heading", { name: "能力中心", exact: true }),
).toBeVisible();
await expect(page.locator("article")).toHaveCount(6);
await expect(page.locator("article")).toHaveCount(17);
await expect(page.locator("a[href='/workspace/capabilities']")).toBeVisible();
await screenshot(page, "capability-center-plugins.png");
@ -239,7 +278,7 @@ test("plugin filters remain usable after an MCP refetch fails", async ({
return route.fallback();
});
await page.goto("/workspace/capabilities");
await expect(page.locator("article")).toHaveCount(6);
await expect(page.locator("article")).toHaveCount(17);
const installed = page.getByRole("tab", { name: "Installed", exact: true });
await installed.click();
await expect(page.locator("article")).toHaveCount(5);
@ -260,3 +299,246 @@ test("plugin filters remain usable after an MCP refetch fails", async ({
.click();
await expect(page.getByRole("dialog")).toBeVisible();
});
test("plugin categories, setup guides, and installed state remain distinct", async ({
page,
baseURL,
}) => {
await page.setViewportSize({ width: 1512, height: 1700 });
await page
.context()
.addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]);
await mockCatalog(page, "zh-CN");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/workspace/capabilities");
await expect(page.locator("article")).toHaveCount(17);
for (const name of [
"办公协作",
"文档与知识",
"搜索与研究",
"业务与数据",
"研发与运维",
]) {
await expect(
page.getByRole("heading", { name, exact: true }),
).toBeVisible();
}
await screenshot(page, "capability-catalog-zh.png");
await page.getByRole("button", { name: "办公协作", exact: true }).click();
await expect(page.locator("article")).toHaveCount(3);
await expect(
page.locator("article").filter({ hasText: "GitHub" }),
).toHaveCount(0);
await page
.getByRole("button", { name: "配置 企业微信群通知", exact: true })
.click();
await expect(page.getByRole("dialog")).toContainText("企业微信");
await expect(page.getByRole("dialog").getByRole("link")).toHaveAttribute(
"href",
"https://developer.work.weixin.qq.com/document/path/91770",
);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "全部分类", exact: true }).click();
await page
.getByRole("textbox", { name: "搜索插件名称或用途" })
.fill("firecrawl");
await expect(page.locator("article")).toHaveCount(1);
await expect(page.locator("article")).toContainText("推荐接入");
await page
.getByRole("button", { name: "接入指南 Firecrawl", exact: true })
.click();
await expect(page.getByRole("dialog")).toContainText("Firecrawl");
await screenshot(page, "capability-catalog-detail-zh.png");
await page.keyboard.press("Escape");
await page.getByRole("tab", { name: "已安装", exact: true }).click();
await expect(page.locator("article")).toHaveCount(0);
await expect(
page.getByText("没有找到匹配的内容", { exact: true }),
).toBeVisible();
await page.getByRole("textbox", { name: "搜索插件名称或用途" }).fill("");
await expect(page.locator("article")).toHaveCount(5);
await page.getByRole("tab", { name: "全部插件", exact: true }).click();
await page.setViewportSize({ width: 390, height: 844 });
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await screenshot(page, "capability-catalog-mobile.png");
expect(errors).toEqual([]);
});
test("English plugin catalog preview", async ({ page }) => {
await page.setViewportSize({ width: 1512, height: 1850 });
await mockCatalog(page);
await page.goto("/workspace/capabilities");
await expect(page.locator("article")).toHaveCount(17);
await screenshot(page, "capability-catalog-en.png");
});
test("manifest installation saves through the adapter and refreshes the catalog", async ({
page,
}) => {
mockLangGraphAPI(page);
let installed: Record<string, unknown> | null = null;
let submission: Record<string, unknown> | null = null;
await page.route("**/api/mcp/config", (route) =>
route.fulfill({
json: { mcp_servers: installed ? { "team-code": installed } : {} },
}),
);
await page.route("**/api/capabilities/installations", (route) => {
submission = route.request().postDataJSON() as Record<string, unknown>;
installed = {
...(submission.configuration as Record<string, unknown>),
capability: { id: "stable-github", plugin_id: "github", version: "1" },
};
return route.fulfill({ json: { items: [], can_manage: true } });
});
await page.goto("/workspace/capabilities");
await page
.getByRole("textbox", { name: "Search plugins by name or purpose" })
.fill("github");
await page
.getByRole("button", { name: "Configure GitHub", exact: true })
.click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("Connection name").fill("team-code");
await dialog.getByLabel("Server URL").fill("https://example.test/mcp");
await dialog
.getByLabel("Authorization header (optional)")
.fill("Bearer fixture-only");
await dialog.getByRole("button", { name: "Save configuration" }).click();
await expect(dialog).toHaveCount(0);
expect(submission).toMatchObject({
plugin_id: "github",
name: "team-code",
configuration: {
type: "http",
headers: { Authorization: "Bearer fixture-only" },
},
});
await page
.getByRole("textbox", { name: "Search plugins by name or purpose" })
.fill("");
await expect(
page.getByRole("button", { name: "Edit team-code", exact: true }),
).toBeVisible();
await expect(
page.locator("article").filter({ hasText: "team-code" }),
).toHaveCount(1);
await page.reload();
await expect(
page.getByRole("button", { name: "Edit team-code", exact: true }),
).toBeVisible();
});
test("agent selection saves explicit plugin IDs and an empty skill list", async ({
page,
}) => {
mockLangGraphAPI(page, {
agents: [{ name: "analyst", description: "Analyze reports" }],
skills,
});
await page.route("**/api/capabilities/installations/mcp", (route) =>
route.fulfill({
json: {
can_manage: false,
items: [
{
id: "stable-github",
name: "Team GitHub",
adapter: "mcp",
reference: "team-code",
installed: true,
enabled: true,
},
],
},
}),
);
let selection: Record<string, unknown> | undefined;
await page.route("**/api/agents", (route) =>
route.fulfill({ json: { agents: [{ name: "analyst", ...selection }] } }),
);
await page.route("**/api/agents/analyst", (route) => {
if (route.request().method() === "PUT")
selection = route.request().postDataJSON() as Record<string, unknown>;
return route.fulfill({ json: { name: "analyst", ...selection } });
});
await page.goto("/workspace/agents");
await page.getByTitle("Agent settings", { exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog
.locator("summary")
.filter({ hasText: "Plugins and skills" })
.click();
const plugins = dialog.getByRole("group", {
name: "MCP plugins",
exact: true,
});
await plugins.getByLabel("Use all enabled", { exact: true }).uncheck();
await plugins.getByLabel("Team GitHub", { exact: true }).check();
await dialog
.getByRole("group", {
name: "Skills (including integration skills)",
exact: true,
})
.getByLabel("Use all enabled", { exact: true })
.uncheck();
await screenshot(page, "agent-capability-selection-en.png");
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(selection).toMatchObject({
mcp_plugins: ["stable-github"],
skills: [],
});
await page.getByTitle("Agent settings", { exact: true }).click();
await dialog
.locator("summary")
.filter({ hasText: "Plugins and skills" })
.click();
await expect(
plugins.getByLabel("Team GitHub", { exact: true }),
).toBeChecked();
});
test("renaming an Agent preserves concurrently updated plugin and skill selections", async ({
page,
}) => {
mockLangGraphAPI(page);
let saved = {
name: "analyst",
display_name: "Analyst",
mcp_plugins: ["old-plugin"],
skills: ["old-skill"],
};
let request: Record<string, unknown> | undefined;
await page.route("**/api/agents", (route) =>
route.fulfill({ json: { agents: [saved] } }),
);
await page.route("**/api/agents/analyst", (route) => {
if (route.request().method() === "PUT") {
request = route.request().postDataJSON() as Record<string, unknown>;
saved = { ...saved, ...request };
}
return route.fulfill({ json: saved });
});
await page.goto("/workspace/agents");
await page.getByTitle("Agent settings", { exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByLabel("Display name")).toHaveValue("Analyst");
// Another editor saves capability selections after this dialog has opened.
saved = { ...saved, mcp_plugins: ["new-plugin"], skills: ["new-skill"] };
await dialog.getByLabel("Display name").fill("Renamed analyst");
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(request).not.toHaveProperty("mcp_plugins");
expect(request).not.toHaveProperty("skills");
expect(saved).toMatchObject({
display_name: "Renamed analyst",
mcp_plugins: ["new-plugin"],
skills: ["new-skill"],
});
});

View File

@ -43,7 +43,7 @@ test.describe("Integrations settings", () => {
await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const dialog = page.getByRole("dialog", { name: "Lark / Feishu" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
});
@ -110,7 +110,7 @@ test.describe("Integrations settings", () => {
);
await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const dialog = page.getByRole("dialog", { name: "Lark / Feishu" });
const popupPromise = page.waitForEvent("popup");
await dialog.getByRole("button", { name: "Connect Lark" }).click();
const popup = await popupPromise;
@ -148,11 +148,11 @@ test.describe("Integrations settings", () => {
// Deep link opens the shared dialog on Integrations.
await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const dialog = page.getByRole("dialog", { name: "Lark / Feishu" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Plugin settings" }),
page.getByRole("dialog", { name: "Lark / Feishu" }),
).toHaveCount(1);
// Close the modal before using the sidebar. While the modal is open, the
@ -160,7 +160,7 @@ test.describe("Integrations settings", () => {
// click sidebar controls there.
await page.keyboard.press("Escape");
await expect(
page.getByRole("dialog", { name: "Plugin settings" }),
page.getByRole("dialog", { name: "Lark / Feishu" }),
).toHaveCount(0);
// Opening again from the nav menu must still use the same shared host, not
@ -173,7 +173,7 @@ test.describe("Integrations settings", () => {
page.getByRole("dialog", { name: "Settings", exact: true }),
).toHaveCount(1);
await expect(
page.getByRole("dialog", { name: "Plugin settings", exact: true }),
page.getByRole("dialog", { name: "Lark / Feishu", exact: true }),
).toHaveCount(0);
});
@ -233,7 +233,7 @@ test.describe("Integrations settings", () => {
});
await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const dialog = page.getByRole("dialog", { name: "Lark / Feishu" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
@ -365,7 +365,7 @@ test.describe("Integrations settings", () => {
await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const dialog = page.getByRole("dialog", { name: "Lark / Feishu" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
@ -429,7 +429,7 @@ test.describe("Integrations settings", () => {
});
await page.goto("/workspace/capabilities?tab=plugins&plugin=lark");
const dialog = page.getByRole("dialog", { name: "Plugin settings" });
const dialog = page.getByRole("dialog", { name: "Lark / Feishu" });
await dialog.getByRole("button", { name: "Calendar" }).click();
await dialog
.getByLabel("Exact OAuth scope")

View File

@ -129,6 +129,11 @@ for (const viewport of [
);
}
const expectWithinViewport = async () => {
// The icon editor adds body content on short screens. Scroll the JSON
// editor into view while keeping the pinned heading/actions visible.
await textbox.evaluate((element) =>
element.scrollIntoView({ block: "center" }),
);
for (const element of [
dialog,
dialog.getByRole("heading"),

View File

@ -0,0 +1,348 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { type MCPServerConfig } from "../../src/core/mcp/types";
import { mockLangGraphAPI } from "./utils/mock-api";
async function screenshot(page: Page, name: string) {
const directory = process.env.CAPABILITY_SCREENSHOT_DIR;
if (!directory) return;
await mkdir(directory, { recursive: true });
await page.screenshot({
path: path.join(directory, name),
fullPage: true,
animations: "disabled",
});
}
async function sampleImage(page: Page, mimeType = "image/png") {
const data = await page.evaluate((mimeType) => {
const canvas = document.createElement("canvas");
canvas.width = 300;
canvas.height = 180;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#3159d8";
ctx.fillRect(0, 0, 300, 180);
ctx.fillStyle = "white";
ctx.font = "bold 78px sans-serif";
ctx.textAlign = "center";
ctx.fillText("AC", 150, 118);
return canvas.toDataURL(mimeType).split(",")[1]!;
}, mimeType);
return {
name: "acme.png",
mimeType,
buffer: Buffer.from(data, "base64"),
};
}
test("brand icons load locally for both recommendations and configured MCP servers", async ({
page,
baseURL,
}) => {
await page.setViewportSize({ width: 1512, height: 1700 });
await page
.context()
.addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]);
mockLangGraphAPI(page);
await page.route("**/api/mcp/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
github: {
capability: {
id: "fixture-github",
plugin_id: "github",
version: "1",
},
enabled: false,
description: "检索代码与仓库,跟进 Issue 与 Pull Request。",
type: "http",
url: "https://example.test/github",
},
postgres: {
capability: {
id: "fixture-postgres",
plugin_id: "database",
version: "1",
},
enabled: false,
description: "查询数据库中的业务数据,辅助分析与决策。",
command: "npx",
},
},
},
}),
);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/workspace/capabilities");
for (const name of [
"lark",
"dingtalk",
"wecom",
"tencent-docs",
"notion",
"openviking",
"exa",
"firecrawl",
"hubspot",
"atlassian",
"github",
]) {
const icon = page.locator(`img[data-plugin-icon="${name}"]`);
await expect(icon).toHaveCount(1);
await expect(icon).toHaveAttribute("src", /^\/images\/plugins\//);
await expect
.poll(() =>
icon.evaluate(
(img: HTMLImageElement) => img.complete && img.naturalWidth > 0,
),
)
.toBe(true);
}
// A generic database capability does not assert a specific vendor brand.
await expect(page.locator('img[data-plugin-icon="postgres"]')).toHaveCount(0);
await screenshot(page, "plugin-brand-icons-zh.png");
await page.setViewportSize({ width: 390, height: 844 });
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
expect(errors).toEqual([]);
});
test("upload previews, cancellation, save, reload, restore default, and create are functional", async ({
page,
baseURL,
}) => {
await page
.context()
.addCookies([{ name: "locale", value: "zh-CN", url: baseURL! }]);
await page.setViewportSize({ width: 1440, height: 1100 });
mockLangGraphAPI(page);
const servers: Record<string, MCPServerConfig> = {
github: {
capability: { id: "fixture-github", plugin_id: "github", version: "1" },
enabled: false,
description: "GitHub repositories",
type: "http",
url: "https://example.test/github",
headers: { Authorization: "***" },
presentation: { display_name: "Engineering" },
},
};
let writes = 0;
await page.route("**/api/mcp/config", (route) =>
route.fulfill({ json: { mcp_servers: servers } }),
);
await page.route("**/api/mcp/config/server", async (route) => {
writes++;
const body = route.request().postDataJSON() as {
server_name: string;
server: MCPServerConfig;
};
servers[body.server_name] = body.server;
await route.fulfill({ json: { mcp_servers: servers } });
});
await page.route("**/api/mcp/config/servers", async (route) => {
writes++;
const body = route.request().postDataJSON() as {
mcp_servers: Record<string, MCPServerConfig>;
};
Object.assign(servers, body.mcp_servers);
await route.fulfill({ json: { mcp_servers: servers } });
});
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/workspace/capabilities");
await page.getByRole("button", { name: "编辑 github", exact: true }).click();
const file = await sampleImage(page);
const dialog = page.getByRole("dialog");
await dialog.locator('input[type="file"]').setInputFiles(file);
await expect(dialog.locator("img")).toHaveAttribute(
"src",
/^data:image\/png;base64,/,
);
expect(writes).toBe(0);
await dialog.getByRole("button", { name: "取消", exact: true }).click();
await expect(
page.locator('article img[data-plugin-icon="github"]'),
).toHaveAttribute("src", "/images/plugins/github.svg");
await page.getByRole("button", { name: "编辑 github", exact: true }).click();
await expect(dialog.locator("img")).toHaveAttribute(
"src",
"/images/plugins/github.svg",
);
await dialog.locator('input[type="file"]').setInputFiles(file);
await expect(dialog.locator("img")).toHaveAttribute(
"src",
/^data:image\/png;base64,/,
);
await screenshot(page, "plugin-icon-editor-zh.png");
await dialog.getByRole("button", { name: "保存", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(writes).toBe(1);
expect(servers.github?.headers).toEqual({ Authorization: "***" });
expect(servers.github?.presentation?.display_name).toBe("Engineering");
expect(servers.github?.enabled).toBe(false);
await page.reload();
await expect(
page.locator('article img[data-plugin-icon="github"]'),
).toHaveAttribute("src", /^data:image\/png;base64,/);
// Normalized output is square; the original aspect ratio is preserved with padding.
await expect
.poll(() =>
page
.locator('article img[data-plugin-icon="github"]')
.evaluate((img: HTMLImageElement) => [
img.naturalWidth,
img.naturalHeight,
]),
)
.toEqual([128, 128]);
await page.getByRole("button", { name: "编辑 github", exact: true }).click();
await dialog.getByRole("button", { name: "恢复默认", exact: true }).click();
await expect(dialog.locator("img")).toHaveAttribute(
"src",
"/images/plugins/github.svg",
);
await dialog.getByRole("button", { name: "保存", exact: true }).click();
await expect(dialog).toHaveCount(0);
await page.reload();
await expect(
page.locator('article img[data-plugin-icon="github"]'),
).toHaveAttribute("src", "/images/plugins/github.svg");
expect(servers.github?.presentation).toEqual({ display_name: "Engineering" });
await page
.getByRole("button", { name: "添加 MCP 插件", exact: true })
.click();
await dialog.getByRole("textbox").fill(
JSON.stringify({
"acme-crm": {
enabled: false,
type: "http",
url: "https://example.test/crm",
description: "企业内部客户关系管理",
},
}),
);
await dialog.locator('input[type="file"]').setInputFiles(file);
await expect(dialog.locator("img")).toHaveAttribute(
"src",
/^data:image\/png;base64,/,
);
await page.setViewportSize({ width: 390, height: 844 });
await expect(
dialog.getByRole("button", { name: "保存", exact: true }),
).toBeInViewport();
await dialog.getByRole("button", { name: "保存", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(servers["acme-crm"]?.presentation?.icon).toMatch(
/^data:image\/png;base64,/,
);
expect(writes).toBe(3);
expect(errors).toEqual([]);
});
test("invalid or oversized uploads do not replace the existing icon", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.route("**/api/mcp/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
github: {
capability: {
id: "fixture-github",
plugin_id: "github",
version: "1",
},
enabled: false,
description: "GitHub",
type: "http",
url: "https://example.test/github",
},
},
},
}),
);
await page.goto("/workspace/capabilities");
await page.getByRole("button", { name: "Edit github", exact: true }).click();
const dialog = page.getByRole("dialog");
const input = dialog.locator('input[type="file"]');
await input.setInputFiles({
name: "bad.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'),
});
await expect(dialog.getByRole("alert")).toContainText("Choose a PNG");
await input.setInputFiles({
name: "large.png",
mimeType: "image/png",
buffer: Buffer.alloc(2 * 1024 * 1024 + 1),
});
await expect(dialog.getByRole("alert")).toContainText("2 MB or smaller");
await input.setInputFiles({
name: "fake.png",
mimeType: "image/png",
buffer: Buffer.from("not an image"),
});
await expect(dialog.getByRole("alert")).toContainText("Cannot read");
await expect(dialog.locator("img")).toHaveAttribute(
"src",
"/images/plugins/github.svg",
);
await expect(
dialog.getByRole("button", { name: "Save", exact: true }),
).toBeEnabled();
for (const mimeType of ["image/jpeg", "image/webp"]) {
await input.setInputFiles(await sampleImage(page, mimeType));
await expect(dialog.locator("img")).toHaveAttribute(
"src",
/^data:image\/png;base64,/,
);
await dialog
.getByRole("button", { name: "Restore default", exact: true })
.click();
await expect(dialog.locator("img")).toHaveAttribute(
"src",
"/images/plugins/github.svg",
);
}
});
test("a custom MCP name does not impersonate a catalog brand in rows or editing", async ({
page,
}) => {
mockLangGraphAPI(page);
await page.route("**/api/mcp/config", (route) =>
route.fulfill({
json: {
mcp_servers: {
github: {
enabled: false,
description: "Private connection without provider metadata",
type: "http",
url: "https://example.test/private",
},
},
},
}),
);
await page.goto("/workspace/capabilities");
const row = page
.locator("article")
.filter({ hasText: "Private connection without provider metadata" });
await expect(row).toBeVisible();
await expect(row.locator("img")).toHaveCount(0);
await row.getByRole("button", { name: "Edit github", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("dialog").locator("img")).toHaveCount(0);
});

View File

@ -6,6 +6,9 @@
* `handleRunStream` from here.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import type { Page, Route } from "@playwright/test";
// ---------------------------------------------------------------------------
@ -1860,6 +1863,60 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
);
// Skills list — capability center and slash autocomplete
void page.route("**/api/capabilities/catalog", (route) =>
route.fulfill({
contentType: "application/json",
body: readFileSync(
path.resolve(
process.cwd(),
"../backend/packages/harness/deerflow/capabilities/builtin.json",
),
"utf8",
),
}),
);
void page.route("**/api/capabilities/installations/*", (route) => {
const adapter = route.request().url().split("/").pop();
const items =
adapter === "lark"
? [
{
id: "lark",
plugin_id: "lark",
adapter: "lark",
name: "Lark / Feishu",
reference: "lark",
installed: larkIntegrationStatus.installed,
enabled: null,
version: null,
auth_status: "required",
health: "unknown",
scope: "user",
category: null,
icon: null,
},
]
: adapter === "skills"
? skills.map((skill) => ({
id: `skill:${skill.category ?? "public"}:${skill.name}`,
plugin_id: null,
adapter: "skills",
name: skill.name,
reference: skill.name,
description: skill.description,
installed: true,
enabled: skill.enabled ?? true,
version: null,
auth_status: "not_required",
health: "unknown",
scope: "deployment",
category: skill.category,
icon: null,
}))
: [];
return route.fulfill({ json: { items, can_manage: true } });
});
void page.route("**/api/skills", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({

View File

@ -23,6 +23,13 @@ rs.mock("@/core/models/hooks", () => ({ useModels: () => ({ models: [] }) }));
rs.mock("@/core/subagents", () => ({
useSubagents: () => ({ subagents: [] }),
}));
rs.mock("@/core/capabilities/hooks", () => ({
useCapabilityInstallations: () => ({
data: { items: [], can_manage: false },
isLoading: false,
isError: false,
}),
}));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } }));
@ -113,3 +120,80 @@ describe("custom agent display names", () => {
);
});
});
describe("capability selection update isolation", () => {
it("omits untouched selections after a concurrent agent refresh", async () => {
const opened = {
...agent,
mcp_plugins: ["old-plugin"],
skills: ["old-skill"],
};
const { rerender } = render(
<AgentSettingsDialog agent={opened} open onOpenChange={rs.fn()} />,
);
rerender(
<AgentSettingsDialog
agent={{
...opened,
mcp_plugins: ["new-plugin"],
skills: ["new-skill"],
}}
open
onOpenChange={rs.fn()}
/>,
);
fireEvent.change(screen.getByLabelText("Display name"), {
target: { value: "Rename only" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const { request } = mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
};
expect(request).not.toHaveProperty("mcp_plugins");
expect(request).not.toHaveProperty("skills");
expect(request.display_name).toBe("Rename only");
});
it.each([
["mcp_plugins", 0, null, []],
["skills", 1, null, []],
["mcp_plugins", 0, [], null],
["skills", 1, [], null],
] as const)(
"saves an intentional %s change at index %s from %s to %s only",
async (field, index, initial, selected) => {
render(
<AgentSettingsDialog
agent={{ ...agent, [field]: initial }}
open
onOpenChange={rs.fn()}
/>,
);
fireEvent.click(screen.getAllByLabelText("Use all enabled")[index]!);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const { request } = mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
};
expect(request[field]).toEqual(selected);
expect(request).not.toHaveProperty(
field === "skills" ? "mcp_plugins" : "skills",
);
},
);
it("omits a selection that was changed and restored", async () => {
render(<AgentSettingsDialog agent={agent} open onOpenChange={rs.fn()} />);
const all = screen.getAllByLabelText("Use all enabled")[0]!;
fireEvent.click(all);
fireEvent.click(all);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const { request } = mutateAsync.mock.calls[0]![0] as {
request: Record<string, unknown>;
};
expect(request).not.toHaveProperty("mcp_plugins");
expect(request).not.toHaveProperty("skills");
});
});

View File

@ -29,6 +29,31 @@ rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
t: {
capabilities: {
icon: {
title: "Plugin icon",
upload: "Upload plugin icon",
change: "Choose image",
reset: "Restore default",
hint: "PNG, JPG or WebP",
singleServer: "One plugin at a time",
errors: {
type: "Invalid type",
size: "Too large",
invalid: "Invalid image",
},
},
noResults: "No matches found",
directory: {
categories: {
office: "Office",
knowledge: "Knowledge",
research: "Research",
business: "Business",
development: "Development",
custom: "Custom",
},
hints: {},
},
enabled: "Enabled",
disabled: "Disabled",
details: "View details",
@ -144,9 +169,18 @@ describe("MCPPluginManager MCP switches", () => {
mcpMockState.error =
state === "error" ? new Error("request failed") : null;
render(
<MCPPluginManager toolbar={<button>All plugins</button>}>
<button>Configure Lark</button>
</MCPPluginManager>,
<MCPPluginManager
toolbar={<button>All plugins</button>}
catalog={[
{
id: "lark",
category: "office",
search: "Lark",
installed: false,
node: <button>Configure Lark</button>,
},
]}
/>,
);
expect(
screen.getByRole("button", { name: "Configure Lark" }),
@ -257,7 +291,7 @@ describe("MCPPluginManager add server", () => {
render(<MCPPluginManager />);
expect(screen.getByText("No tools")).toBeDefined();
expect(screen.getByText("No matches found")).toBeDefined();
expect(
screen
.getByRole("button", { name: "Add server" })

View File

@ -0,0 +1,27 @@
import { afterEach, expect, it } from "@rstest/core";
import { cleanup, render } from "@testing-library/react";
import { PluginIcon } from "@/components/workspace/capabilities/plugin-icon";
afterEach(cleanup);
it("does not infer a provider from a custom server name", () => {
for (const name of ["github", "notion", "feishu", "brave", "postgres"]) {
const { container, unmount } = render(<PluginIcon name={name} />);
expect(container.querySelector("img")).toBeNull();
unmount();
}
});
it("uses explicit catalog assets and rejects remote assets", () => {
const { container, rerender } = render(
<PluginIcon name="team-code" asset="/images/plugins/github.svg" />,
);
expect(container.querySelector("img")?.getAttribute("src")).toBe(
"/images/plugins/github.svg",
);
rerender(
<PluginIcon name="github" asset="https://external.example/icon.png" />,
);
expect(container.querySelector("img")).toBeNull();
});

View File

@ -1,7 +1,11 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { listAgents } from "@/core/agents/api";
import { fetch as apiFetch } from "@/core/api/fetcher";
import { staticCapabilityCatalog } from "@/core/capabilities/static";
import {
listChannelConnections,
listChannelProviders,
@ -197,3 +201,121 @@ describe("static website API requests", () => {
);
});
});
it("serves the canonical capability catalog locally and rejects writes", async () => {
const response = await apiFetch("/api/capabilities/catalog");
expect(response.status).toBe(200);
const catalog = (await response.json()) as { id: string }[];
expect(catalog).toEqual(
JSON.parse(
readFileSync(
path.resolve(
process.cwd(),
"../backend/packages/harness/deerflow/capabilities/builtin.json",
),
"utf8",
),
),
);
expect(network).not.toHaveBeenCalled();
const write = await apiFetch("/api/capabilities/installations", {
method: "POST",
body: "{}",
});
expect(write.status).toBe(405);
expect(network).not.toHaveBeenCalled();
});
it("projects demo installations without copying secrets or calling the Gateway", async () => {
env.NEXT_PUBLIC_BACKEND_BASE_URL = "https://gateway.example/prefix";
network.mockResolvedValueOnce(
Response.json({
mcp_servers: {
github: {
enabled: true,
env: { TOKEN: "private" },
url: "https://private.example",
capability: { plugin_id: "github" },
},
},
}),
);
const response = await apiFetch(
"https://gateway.example/prefix/api/capabilities/installations/mcp",
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toMatchObject({
can_manage: false,
items: [
{ name: "github", plugin_id: "github", adapter: "mcp", installed: true },
],
});
expect(JSON.stringify(data)).not.toContain("private");
expect(network.mock.calls[0]?.[0]).toContain("/mock/api/mcp/config");
});
it("provides Lark, skills and business projections from the owning fixtures", async () => {
for (const [adapter, fixture, count] of [
["lark", { installed: false }, 1],
[
"skills",
{
skills: [
{
name: "research",
category: "public",
enabled: true,
description: "Research",
},
],
},
1,
],
["business", { mcp_servers: {} }, 0],
] as const) {
network.mockResolvedValueOnce(Response.json(fixture));
const response = await apiFetch(
`/api/capabilities/installations/${adapter}`,
);
expect(response.status).toBe(200);
const data = (await response.json()) as {
items: unknown[];
can_manage: boolean;
};
expect(data.items).toHaveLength(count);
expect(data.can_manage).toBe(false);
}
const unknown = await apiFetch("/api/capabilities/installations/unknown");
expect(unknown.status).toBe(404);
});
it("discovers newly cataloged business adapters without a provider allowlist", async () => {
const template = staticCapabilityCatalog.find(
(plugin) => plugin.adapter === "business",
)!;
staticCapabilityCatalog.push({ ...template, id: "future-business" });
try {
network.mockResolvedValueOnce(
Response.json({
mcp_servers: {
future: {
enabled: true,
capability: { plugin_id: "future-business" },
},
github: { enabled: true, capability: { plugin_id: "github" } },
unknown: { enabled: true, capability: { plugin_id: "unknown" } },
},
}),
);
const response = await apiFetch("/api/capabilities/installations/business");
const result = await response.json();
expect(result).toMatchObject({
can_manage: false,
items: [{ name: "future", plugin_id: "future-business" }],
});
expect(result.items).toHaveLength(1);
} finally {
staticCapabilityCatalog.pop();
}
});

View File

@ -0,0 +1,57 @@
import { describe, expect, it } from "@rstest/core";
import {
readPluginIcon,
safePluginIcon,
withPluginIcon,
} from "@/core/mcp/icon";
const png =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6Xl8AAAAASUVORK5CYII=";
describe("plugin icon metadata", () => {
it("never displays a URL, SVG, malformed value, or oversized payload from server config", () => {
for (const value of [
"https://example.test/icon.png",
"javascript:alert(1)",
"data:image/svg+xml,<svg/>",
{},
"data:image/png;base64," + "A".repeat(100_000),
]) {
expect(safePluginIcon(value)).toBeUndefined();
}
expect(safePluginIcon(png)).toBe(png);
});
it("preserves connection settings, secret placeholders, and unrelated metadata when replacing or removing an icon", () => {
const server = {
enabled: false,
description: "Company CRM",
url: "https://example.test/mcp",
headers: { Authorization: "***" },
presentation: { icon: png, display_name: "CRM" },
vendor: { custom: true },
};
const updated = withPluginIcon(server, png);
expect(readPluginIcon(updated)).toBe(png);
expect(withPluginIcon(updated, null)).toEqual({
...server,
presentation: { display_name: "CRM" },
});
expect(server.presentation.icon).toBe(png);
expect(withPluginIcon(server, undefined)).toBe(server);
});
it("removes the empty presentation container on reset and rejects an unsafe replacement", () => {
const server = {
enabled: true,
description: "",
presentation: { icon: png },
};
expect(withPluginIcon(server, null)).toEqual({
enabled: true,
description: "",
});
expect(() => withPluginIcon(server, "https://example.test/logo")).toThrow();
});
});

View File

@ -0,0 +1,56 @@
import { execFileSync } from "node:child_process";
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "@rstest/core";
it("resolves formatter configuration in checkouts containing spaces and Unicode", () => {
const root = mkdtempSync(path.join(tmpdir(), "deerflow 清单 space-"));
try {
const frontend = path.join(root, "frontend");
const scripts = path.join(frontend, "scripts");
const backend = path.join(
root,
"backend/packages/harness/deerflow/capabilities",
);
const output = path.join(frontend, "src/core/capabilities");
for (const directory of [scripts, backend, output])
mkdirSync(directory, { recursive: true });
writeFileSync(path.join(frontend, "package.json"), '{"type":"module"}');
writeFileSync(
path.join(frontend, ".prettierrc.json"),
'{"tabWidth":7,"printWidth":20}',
);
writeFileSync(
path.join(backend, "builtin.json"),
'[{"id":"example","adapter":"business"}]',
);
symlinkSync(
path.resolve("node_modules"),
path.join(frontend, "node_modules"),
"junction",
);
const script = path.join(scripts, "sync-capability-catalog.mjs");
copyFileSync(path.resolve("scripts/sync-capability-catalog.mjs"), script);
execFileSync(process.execPath, [script], { cwd: frontend, timeout: 10000 });
const snapshot = readFileSync(
path.join(output, "builtin.demo.json"),
"utf8",
);
expect(JSON.parse(snapshot)).toEqual([
{ id: "example", adapter: "business" },
]);
expect(snapshot.startsWith("[\n {\n")).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});