diff --git a/README.md b/README.md index 5b407c2c0..b0667dfe2 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,20 @@ For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_ MCP tool names are prefixed with `_` 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. diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 7f8f5f086..4b9e8c4a7 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -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`. diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 74094e022..4a7e95fb0 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -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. diff --git a/backend/app/gateway/capabilities.py b/backend/app/gateway/capabilities.py new file mode 100644 index 000000000..f9e9d27f8 --- /dev/null +++ b/backend/app/gateway/capabilities.py @@ -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)) diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 5f3069b52..10dd06103 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -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 diff --git a/backend/app/gateway/routers/capabilities.py b/backend/app/gateway/routers/capabilities.py new file mode 100644 index 000000000..ea071ce47 --- /dev/null +++ b/backend/app/gateway/routers/capabilities.py @@ -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) diff --git a/backend/app/gateway/routers/mcp.py b/backend/app/gateway/routers/mcp.py index a1bebcab0..3e6e9f958 100644 --- a/backend/app/gateway/routers/mcp.py +++ b/backend/app/gateway/routers/mcp.py @@ -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) diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index a03c9b2d1..14988500a 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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, diff --git a/backend/packages/harness/deerflow/capabilities/__init__.py b/backend/packages/harness/deerflow/capabilities/__init__.py new file mode 100644 index 000000000..80be650a6 --- /dev/null +++ b/backend/packages/harness/deerflow/capabilities/__init__.py @@ -0,0 +1 @@ +"""Declarative capability discovery; execution stays with the owning runtime.""" diff --git a/backend/packages/harness/deerflow/capabilities/builtin.json b/backend/packages/harness/deerflow/capabilities/builtin.json new file mode 100644 index 000000000..f6c72085b --- /dev/null +++ b/backend/packages/harness/deerflow/capabilities/builtin.json @@ -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 + } + } +] diff --git a/backend/packages/harness/deerflow/capabilities/business.py b/backend/packages/harness/deerflow/capabilities/business.py new file mode 100644 index 000000000..ce4d71715 --- /dev/null +++ b/backend/packages/harness/deerflow/capabilities/business.py @@ -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 1–100 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() diff --git a/backend/packages/harness/deerflow/capabilities/catalog.py b/backend/packages/harness/deerflow/capabilities/catalog.py new file mode 100644 index 000000000..d945a7f95 --- /dev/null +++ b/backend/packages/harness/deerflow/capabilities/catalog.py @@ -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 diff --git a/backend/packages/harness/deerflow/capabilities/runtime.py b/backend/packages/harness/deerflow/capabilities/runtime.py new file mode 100644 index 000000000..960a0ccd7 --- /dev/null +++ b/backend/packages/harness/deerflow/capabilities/runtime.py @@ -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] diff --git a/backend/packages/harness/deerflow/config/agents_config.py b/backend/packages/harness/deerflow/config/agents_config.py index 0b1819159..2b06ca308 100644 --- a/backend/packages/harness/deerflow/config/agents_config.py +++ b/backend/packages/harness/deerflow/config/agents_config.py @@ -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 diff --git a/backend/packages/harness/deerflow/subagents/batch_service.py b/backend/packages/harness/deerflow/subagents/batch_service.py index ba49706f4..659659e7c 100644 --- a/backend/packages/harness/deerflow/subagents/batch_service.py +++ b/backend/packages/harness/deerflow/subagents/batch_service.py @@ -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, diff --git a/backend/packages/harness/deerflow/tools/builtins/batch_task_tool.py b/backend/packages/harness/deerflow/tools/builtins/batch_task_tool.py index 489d4c2bc..075186761 100644 --- a/backend/packages/harness/deerflow/tools/builtins/batch_task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/batch_task_tool.py @@ -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"), diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index d68b801cd..844a68080 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -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, diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 892c9c204..cecb1d96b 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -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: diff --git a/backend/tests/blocking_io/test_capabilities_router.py b/backend/tests/blocking_io/test_capabilities_router.py new file mode 100644 index 000000000..03bee761c --- /dev/null +++ b/backend/tests/blocking_io/test_capabilities_router.py @@ -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" diff --git a/backend/tests/test_agents_router_model_settings.py b/backend/tests/test_agents_router_model_settings.py index cce5e8663..2f073bac0 100644 --- a/backend/tests/test_agents_router_model_settings.py +++ b/backend/tests/test_agents_router_model_settings.py @@ -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"] diff --git a/backend/tests/test_batch_task_tool.py b/backend/tests/test_batch_task_tool.py index 40dd99272..51f2abea1 100644 --- a/backend/tests/test_batch_task_tool.py +++ b/backend/tests/test_batch_task_tool.py @@ -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 diff --git a/backend/tests/test_business_plugins.py b/backend/tests/test_business_plugins.py new file mode 100644 index 000000000..939701c44 --- /dev/null +++ b/backend/tests/test_business_plugins.py @@ -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) diff --git a/backend/tests/test_capability_api.py b/backend/tests/test_capability_api.py new file mode 100644 index 000000000..de273b74b --- /dev/null +++ b/backend/tests/test_capability_api.py @@ -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 diff --git a/backend/tests/test_capability_registry.py b/backend/tests/test_capability_registry.py new file mode 100644 index 000000000..6f9985d0b --- /dev/null +++ b/backend/tests/test_capability_registry.py @@ -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 diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index e60b2694b..657afb2ee 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -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): diff --git a/backend/tests/test_skills_listing_authorization.py b/backend/tests/test_skills_listing_authorization.py index dfc1e1397..1cfe12d26 100644 --- a/backend/tests/test_skills_listing_authorization.py +++ b/backend/tests/test_skills_listing_authorization.py @@ -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 diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 464747c57..29f0ab03c 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -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): diff --git a/docs/capability-center.md b/docs/capability-center.md new file mode 100644 index 000000000..476c11e5a --- /dev/null +++ b/docs/capability-center.md @@ -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 ` 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. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index a367edad0..d39c9a27e 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. + + + +# 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. + + diff --git a/frontend/package.json b/frontend/package.json index 4547f1399..e30326ac3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/images/plugins/LOBE-ICONS-LICENSE.md b/frontend/public/images/plugins/LOBE-ICONS-LICENSE.md new file mode 100644 index 000000000..1dd53d2a9 --- /dev/null +++ b/frontend/public/images/plugins/LOBE-ICONS-LICENSE.md @@ -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. diff --git a/frontend/public/images/plugins/README.md b/frontend/public/images/plugins/README.md new file mode 100644 index 000000000..b5a812954 --- /dev/null +++ b/frontend/public/images/plugins/README.md @@ -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. diff --git a/frontend/public/images/plugins/SIMPLE-ICONS-LICENSE.md b/frontend/public/images/plugins/SIMPLE-ICONS-LICENSE.md new file mode 100644 index 000000000..df601fc90 --- /dev/null +++ b/frontend/public/images/plugins/SIMPLE-ICONS-LICENSE.md @@ -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 person’s 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 Affirmer’s 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 Affirmer’s 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 Affirmer’s 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 Affirmer’s 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 Affirmer’s 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 Affirmer’s 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 person’s 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 . diff --git a/frontend/public/images/plugins/brave.svg b/frontend/public/images/plugins/brave.svg new file mode 100644 index 000000000..3c7f46816 --- /dev/null +++ b/frontend/public/images/plugins/brave.svg @@ -0,0 +1 @@ +Brave \ No newline at end of file diff --git a/frontend/public/images/plugins/dingtalk.ico b/frontend/public/images/plugins/dingtalk.ico new file mode 100644 index 000000000..cc2df9ac2 Binary files /dev/null and b/frontend/public/images/plugins/dingtalk.ico differ diff --git a/frontend/public/images/plugins/exa.svg b/frontend/public/images/plugins/exa.svg new file mode 100644 index 000000000..7b89500db --- /dev/null +++ b/frontend/public/images/plugins/exa.svg @@ -0,0 +1 @@ +Exa \ No newline at end of file diff --git a/frontend/public/images/plugins/firecrawl.svg b/frontend/public/images/plugins/firecrawl.svg new file mode 100644 index 000000000..25e63a037 --- /dev/null +++ b/frontend/public/images/plugins/firecrawl.svg @@ -0,0 +1 @@ +Firecrawl \ No newline at end of file diff --git a/frontend/public/images/plugins/github.svg b/frontend/public/images/plugins/github.svg new file mode 100644 index 000000000..81920ca33 --- /dev/null +++ b/frontend/public/images/plugins/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/frontend/public/images/plugins/hubspot.svg b/frontend/public/images/plugins/hubspot.svg new file mode 100644 index 000000000..1b9ddd2be --- /dev/null +++ b/frontend/public/images/plugins/hubspot.svg @@ -0,0 +1 @@ +HubSpot \ No newline at end of file diff --git a/frontend/public/images/plugins/jira.svg b/frontend/public/images/plugins/jira.svg new file mode 100644 index 000000000..1bb916736 --- /dev/null +++ b/frontend/public/images/plugins/jira.svg @@ -0,0 +1 @@ +Jira \ No newline at end of file diff --git a/frontend/public/images/plugins/lark.ico b/frontend/public/images/plugins/lark.ico new file mode 100644 index 000000000..4a5baac3d Binary files /dev/null and b/frontend/public/images/plugins/lark.ico differ diff --git a/frontend/public/images/plugins/notion.svg b/frontend/public/images/plugins/notion.svg new file mode 100644 index 000000000..69afef322 --- /dev/null +++ b/frontend/public/images/plugins/notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/frontend/public/images/plugins/openviking.png b/frontend/public/images/plugins/openviking.png new file mode 100644 index 000000000..383182f34 Binary files /dev/null and b/frontend/public/images/plugins/openviking.png differ diff --git a/frontend/public/images/plugins/postgresql.svg b/frontend/public/images/plugins/postgresql.svg new file mode 100644 index 000000000..931bdae18 --- /dev/null +++ b/frontend/public/images/plugins/postgresql.svg @@ -0,0 +1 @@ +PostgreSQL \ No newline at end of file diff --git a/frontend/public/images/plugins/tencent-docs.ico b/frontend/public/images/plugins/tencent-docs.ico new file mode 100644 index 000000000..3b9b4a6af Binary files /dev/null and b/frontend/public/images/plugins/tencent-docs.ico differ diff --git a/frontend/public/images/plugins/wecom.png b/frontend/public/images/plugins/wecom.png new file mode 100644 index 000000000..70743b938 Binary files /dev/null and b/frontend/public/images/plugins/wecom.png differ diff --git a/frontend/scripts/sync-capability-catalog.mjs b/frontend/scripts/sync-capability-catalog.mjs new file mode 100644 index 000000000..b157cc456 --- /dev/null +++ b/frontend/scripts/sync-capability-catalog.mjs @@ -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", + }), +); diff --git a/frontend/src/components/workspace/agents/agent-capability-selection.tsx b/frontend/src/components/workspace/agents/agent-capability-selection.tsx new file mode 100644 index 000000000..588c61054 --- /dev/null +++ b/frontend/src/components/workspace/agents/agent-capability-selection.tsx @@ -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 ( +
+ + {adapter === "mcp" ? copy.plugins : copy.skills} + + + {query.isLoading && ( +

{t.common.loading}

+ )} + {query.isError && ( +

+ {copy.adapterError} +

+ )} + {value !== null && ( +
+ {[...options].map(([id, name]) => ( + + ))} +
+ )} +
+ ); +} +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 ( +
+ + {copy.selectionTitle} + +

{copy.hint}

+ + +
+ ); +} diff --git a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx index ba6bba57f..060caa489 100644 --- a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx +++ b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx @@ -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( + agent.mcp_plugins ?? null, + ); + const [skills, setSkills] = useState(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({
+