deer-flow/backend/app/gateway/capabilities.py
Wenchao An 42334f26d7
feat(capabilities): unify catalog, plugin configuration and agent selection (#5497)
* feat(capabilities): unify catalog, plugin configuration and agent selection

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

* fix(capabilities): preserve concurrent selections and guide launcher repair
2026-09-19 12:14:24 +08:00

217 lines
9.9 KiB
Python

"""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))