mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-23 04:56:18 +00:00
* 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
217 lines
9.9 KiB
Python
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))
|