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

179 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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