feat(agents): consolidate connectors under mcp_discovery; route web search through google_search

MCP consolidation:
- Route all MCP-capable connectors (Slack, Jira, Linear, ClickUp, Airtable,
  Notion, Confluence, interim Gmail/Calendar, custom MCP) through a single
  `mcp_discovery` subagent. Drive/OneDrive/Dropbox stay native to enrich the KB.
- Deprecate Discord/Teams/Luma: no viable official MCP server.

Google-only web search:
- Remove the main-agent `web_search` tool and the SearXNG platform service;
  all public web search now flows through the `google_search` subagent via task().
- Deprecate the Tavily/SearXNG/Linkup/Baidu search connectors (HTTP 410 on
  create, "Deprecated" badge); guide heavy users to the custom MCP connector.
- Remove web search from anonymous chat (pure Q&A).
- Tear SearXNG out of docker compose + install scripts; drop tavily-python
  and linkup-sdk deps and their config/env vars.

Fix:
- metrics._package_version() now swallows any metadata lookup failure. A
  malformed editable-install distribution with no `Version` field raised
  KeyError deep in importlib.metadata, and since it runs on every
  record_subagent_invoke_duration call it was crashing every task()
  delegation. Verified end-to-end against live GPT-5.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-04 21:06:04 -07:00
parent ff2e5f390f
commit ab747e7a49
206 changed files with 1704 additions and 7223 deletions

View file

@ -4,12 +4,9 @@ from datetime import datetime
from threading import Lock
from typing import Any
import httpx
from linkup import LinkupClient
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from tavily import TavilyClient
from app.config import config
from app.db import (
@ -388,363 +385,6 @@ class ConnectorService:
result = await self.session.execute(query)
return result.scalars().first()
async def search_tavily(
self, user_query: str, workspace_id: int, top_k: int = 20
) -> tuple:
"""
Search using Tavily API and return both the source information and documents
Args:
user_query: The user's query
workspace_id: The workspace ID
top_k: Maximum number of results to return
Returns:
tuple: (sources_info, documents)
"""
# Get Tavily connector configuration
tavily_connector = await self.get_connector_by_type(
SearchSourceConnectorType.TAVILY_API, workspace_id
)
if not tavily_connector:
# Return empty results if no Tavily connector is configured
return {
"id": 3,
"name": "Tavily Search",
"type": "TAVILY_API",
"sources": [],
}, []
# Initialize Tavily client with API key from connector config
tavily_api_key = tavily_connector.config.get("TAVILY_API_KEY")
tavily_client = TavilyClient(api_key=tavily_api_key)
# Perform search with Tavily
try:
response = tavily_client.search(
query=user_query,
max_results=top_k,
search_depth="advanced", # Use advanced search for better results
)
# Extract results from Tavily response
tavily_results = response.get("results", [])
# Early return if no results
if not tavily_results:
return {
"id": 3,
"name": "Tavily Search",
"type": "TAVILY_API",
"sources": [],
}, []
# Process each result and create sources directly without deduplication
sources_list = []
documents = []
async with self.counter_lock:
for _i, result in enumerate(tavily_results):
# Create a source entry
source = {
"id": self.source_id_counter,
"title": result.get("title", "Tavily Result"),
"description": result.get("content", ""),
"url": result.get("url", ""),
}
sources_list.append(source)
# Create a document entry
document = {
"chunk_id": self.source_id_counter,
"content": result.get("content", ""),
"score": result.get("score", 0.0),
"document": {
"id": self.source_id_counter,
"title": result.get("title", "Tavily Result"),
"document_type": "TAVILY_API",
"metadata": {
"url": result.get("url", ""),
"published_date": result.get("published_date", ""),
"source": "TAVILY_API",
},
},
}
documents.append(document)
self.source_id_counter += 1
# Create result object
result_object = {
"id": 3,
"name": "Tavily Search",
"type": "TAVILY_API",
"sources": sources_list,
}
return result_object, documents
except Exception as e:
# Log the error and return empty results
print(f"Error searching with Tavily: {e!s}")
return {
"id": 3,
"name": "Tavily Search",
"type": "TAVILY_API",
"sources": [],
}, []
async def search_searxng(
self,
user_query: str,
workspace_id: int,
top_k: int = 20,
) -> tuple:
"""Search using the platform SearXNG instance.
Delegates to ``WebSearchService`` which handles caching, circuit
breaking, and retries. SearXNG configuration comes from the
docker/searxng/settings.yml file.
"""
from app.services import web_search_service
if not web_search_service.is_available():
return {
"id": 11,
"name": "Web Search",
"type": "SEARXNG_API",
"sources": [],
}, []
return await web_search_service.search(
query=user_query,
top_k=top_k,
)
async def search_baidu(
self,
user_query: str,
workspace_id: int,
top_k: int = 20,
) -> tuple:
"""
Search using Baidu AI Search API and return both sources and documents.
Baidu AI Search provides intelligent search with automatic summarization.
We extract the raw search results (references) from the API response.
Args:
user_query: User's search query
workspace_id: Workspace ID
top_k: Maximum number of results to return
Returns:
tuple: (sources_info_dict, documents_list)
"""
# Get Baidu connector configuration
baidu_connector = await self.get_connector_by_type(
SearchSourceConnectorType.BAIDU_SEARCH_API, workspace_id
)
if not baidu_connector:
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
config = baidu_connector.config or {}
api_key = config.get("BAIDU_API_KEY")
if not api_key:
print("ERROR: Baidu connector is missing BAIDU_API_KEY configuration")
print(f"Connector config: {config}")
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
# Optional configuration parameters
model = config.get("BAIDU_MODEL", "ernie-3.5-8k")
search_source = config.get("BAIDU_SEARCH_SOURCE", "baidu_search_v2")
enable_deep_search = config.get("BAIDU_ENABLE_DEEP_SEARCH", False)
# Baidu AI Search API endpoint
baidu_endpoint = "https://qianfan.baidubce.com/v2/ai_search/chat/completions"
# Prepare request headers
# Note: Baidu uses X-Appbuilder-Authorization instead of standard Authorization header
headers = {
"X-Appbuilder-Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Prepare request payload
# Calculate resource_type_filter top_k values
# Baidu v2 supports max 20 per type
max_per_type = min(top_k, 20)
payload = {
"messages": [{"role": "user", "content": user_query}],
"model": model,
"search_source": search_source,
"resource_type_filter": [
{"type": "web", "top_k": max_per_type},
{"type": "video", "top_k": max(1, max_per_type // 4)}, # Fewer videos
],
"stream": False, # Non-streaming for simpler processing
"enable_deep_search": enable_deep_search,
"enable_corner_markers": True, # Enable reference markers
}
try:
# Baidu AI Search may take longer as it performs search + summarization
# Increase timeout to 90 seconds
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
baidu_endpoint,
headers=headers,
json=payload,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
print(f"ERROR: Baidu API request timeout after 90s: {exc!r}")
print(f"Endpoint: {baidu_endpoint}")
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
except httpx.HTTPStatusError as exc:
print(f"ERROR: Baidu API HTTP Status Error: {exc.response.status_code}")
print(f"Response text: {exc.response.text[:500]}")
print(f"Request URL: {exc.request.url}")
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
except httpx.RequestError as exc:
print(f"ERROR: Baidu API Request Error: {type(exc).__name__}: {exc!r}")
print(f"Endpoint: {baidu_endpoint}")
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
except Exception as exc:
print(
f"ERROR: Unexpected error calling Baidu API: {type(exc).__name__}: {exc!r}"
)
print(f"Endpoint: {baidu_endpoint}")
print(f"Payload: {payload}")
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
try:
data = response.json()
except ValueError as e:
print(f"ERROR: Failed to decode JSON response from Baidu AI Search: {e}")
print(f"Response status: {response.status_code}")
print(f"Response text: {response.text[:500]}") # First 500 chars
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
# Extract references (search results) from the response
baidu_references = data.get("references", [])
if "code" in data or "message" in data:
print(
f"WARNING: Baidu API returned error - Code: {data.get('code')}, Message: {data.get('message')}"
)
if not baidu_references:
print("WARNING: No references found in Baidu API response")
print(f"Response keys: {list(data.keys())}")
return {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": [],
}, []
sources_list: list[dict[str, Any]] = []
documents: list[dict[str, Any]] = []
async with self.counter_lock:
for reference in baidu_references:
# Extract basic fields
title = reference.get("title", "Baidu Search Result")
url = reference.get("url", "")
content = reference.get("content", "")
date = reference.get("date", "")
ref_type = reference.get("type", "web") # web, image, video
# Create a source entry
source = {
"id": self.source_id_counter,
"title": title,
"description": content[:300]
if content
else "", # Limit description length
"url": url,
}
sources_list.append(source)
# Prepare metadata
metadata = {
"url": url,
"date": date,
"type": ref_type,
"source": "BAIDU_SEARCH_API",
"web_anchor": reference.get("web_anchor", ""),
"website": reference.get("website", ""),
}
# Add type-specific metadata
if ref_type == "image" and reference.get("image"):
metadata["image"] = reference["image"]
elif ref_type == "video" and reference.get("video"):
metadata["video"] = reference["video"]
# Create a document entry
document = {
"chunk_id": self.source_id_counter,
"content": content,
"score": 1.0, # Baidu doesn't provide relevance scores
"document": {
"id": self.source_id_counter,
"title": title,
"document_type": "BAIDU_SEARCH_API",
"metadata": metadata,
},
}
documents.append(document)
self.source_id_counter += 1
result_object = {
"id": 12,
"name": "Baidu Search",
"type": "BAIDU_SEARCH_API",
"sources": sources_list,
}
return result_object, documents
async def search_slack(
self,
user_query: str,
@ -1875,127 +1515,6 @@ class ConnectorService:
return result_object, clickup_docs
async def search_linkup(
self,
user_query: str,
workspace_id: int,
mode: str = "standard",
) -> tuple:
"""
Search using Linkup API and return both the source information and documents
Args:
user_query: The user's query
workspace_id: The workspace ID
mode: Search depth mode, can be "standard" or "deep"
Returns:
tuple: (sources_info, documents)
"""
# Get Linkup connector configuration
linkup_connector = await self.get_connector_by_type(
SearchSourceConnectorType.LINKUP_API, workspace_id
)
if not linkup_connector:
# Return empty results if no Linkup connector is configured
return {
"id": 10,
"name": "Linkup Search",
"type": "LINKUP_API",
"sources": [],
}, []
# Initialize Linkup client with API key from connector config
linkup_api_key = linkup_connector.config.get("LINKUP_API_KEY")
linkup_client = LinkupClient(api_key=linkup_api_key)
# Perform search with Linkup
try:
response = linkup_client.search(
query=user_query,
depth=mode, # Use the provided mode ("standard" or "deep")
output_type="searchResults", # Default to search results
)
# Extract results from Linkup response - access as attribute instead of using .get()
linkup_results = response.results if hasattr(response, "results") else []
# Only proceed if we have results
if not linkup_results:
return {
"id": 10,
"name": "Linkup Search",
"type": "LINKUP_API",
"sources": [],
}, []
# Process each result and create sources directly without deduplication
sources_list = []
documents = []
async with self.counter_lock:
for _i, result in enumerate(linkup_results):
# Only process results that have content
if not hasattr(result, "content") or not result.content:
continue
# Create a source entry
source = {
"id": self.source_id_counter,
"title": (
result.name if hasattr(result, "name") else "Linkup Result"
),
"description": (
result.content if hasattr(result, "content") else ""
),
"url": result.url if hasattr(result, "url") else "",
}
sources_list.append(source)
# Create a document entry
document = {
"chunk_id": self.source_id_counter,
"content": result.content if hasattr(result, "content") else "",
"score": 1.0, # Default score since not provided by Linkup
"document": {
"id": self.source_id_counter,
"title": (
result.name
if hasattr(result, "name")
else "Linkup Result"
),
"document_type": "LINKUP_API",
"metadata": {
"url": result.url if hasattr(result, "url") else "",
"type": result.type if hasattr(result, "type") else "",
"source": "LINKUP_API",
},
},
}
documents.append(document)
self.source_id_counter += 1
# Create result object
result_object = {
"id": 10,
"name": "Linkup Search",
"type": "LINKUP_API",
"sources": sources_list,
}
return result_object, documents
except Exception as e:
# Log the error and return empty results
print(f"Error searching with Linkup: {e!s}")
return {
"id": 10,
"name": "Linkup Search",
"type": "LINKUP_API",
"sources": [],
}, []
async def search_discord(
self,
user_query: str,

View file

@ -185,6 +185,46 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = {
),
account_metadata_keys=["user_id", "user_email"],
),
"notion": MCPServiceConfig(
name="Notion",
mcp_url="https://mcp.notion.com/mcp",
connector_type="NOTION_CONNECTOR",
# DCR (RFC 7591): Notion issues its own client credentials. It expires
# DCR registrations, but refresh reuses the original persisted
# ``mcp_oauth.client_id`` (see _refresh_connector_token).
allowed_tools=[
"search",
"fetch",
"create-pages",
"update-page",
],
readonly_tools=frozenset({"search", "fetch"}),
account_metadata_keys=["workspace_name"],
),
"confluence": MCPServiceConfig(
name="Confluence",
# Same Atlassian Rovo server as Jira; tool sets are kept disjoint by
# curation so a workspace can connect both as separate connectors.
mcp_url="https://mcp.atlassian.com/v1/mcp",
connector_type="CONFLUENCE_CONNECTOR",
allowed_tools=[
"getAccessibleAtlassianResources",
"getConfluenceSpaces",
"getConfluencePage",
"searchConfluenceUsingCql",
"createConfluencePage",
"updateConfluencePage",
],
readonly_tools=frozenset(
{
"getAccessibleAtlassianResources",
"getConfluenceSpaces",
"getConfluencePage",
"searchConfluenceUsingCql",
}
),
account_metadata_keys=["cloud_id", "site_name", "base_url"],
),
}
_CONNECTOR_TYPE_TO_SERVICE: dict[str, MCPServiceConfig] = {
@ -205,6 +245,25 @@ LIVE_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset(
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
SearchSourceConnectorType.DISCORD_CONNECTOR,
SearchSourceConnectorType.LUMA_CONNECTOR,
# Migrated to hosted MCP — indexing pipelines deprecated (KB is
# files/notes/uploads only). LIVE membership blocks the index route
# and auto-disables periodic indexing.
SearchSourceConnectorType.NOTION_CONNECTOR,
SearchSourceConnectorType.CONFLUENCE_CONNECTOR,
}
)
# Indexing-only connectors retired with the KB "files, notes, and uploads only"
# shift: their ingestion pipelines are deprecated. Like LIVE membership, this
# blocks the index route and auto-disables periodic indexing — but the message
# frames it as a deprecation, not a real-time-tools swap. Obsidian is
# intentionally excluded (file-like vault content still enriches the KB).
DEPRECATED_INDEXING_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset(
{
SearchSourceConnectorType.GITHUB_CONNECTOR,
SearchSourceConnectorType.BOOKSTACK_CONNECTOR,
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR,
SearchSourceConnectorType.CIRCLEBACK_CONNECTOR,
}
)

View file

@ -1,290 +0,0 @@
"""
Platform-level web search service backed by SearXNG.
Redis is used only for result caching (graceful degradation if unavailable).
The circuit breaker is fully in-process no external dependency, zero
latency overhead.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import threading
import time
from typing import Any
from urllib.parse import urljoin
import httpx
import redis
from app.config import config
logger = logging.getLogger(__name__)
_EMPTY_RESULT: dict[str, Any] = {
"id": 11,
"name": "Web Search",
"type": "SEARXNG_API",
"sources": [],
}
# ---------------------------------------------------------------------------
# Redis — used only for result caching
# ---------------------------------------------------------------------------
_redis_client: redis.Redis | None = None
def _get_redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
return _redis_client
# ---------------------------------------------------------------------------
# In-process Circuit Breaker (no Redis dependency)
# ---------------------------------------------------------------------------
_CB_FAILURE_THRESHOLD = 5
_CB_FAILURE_WINDOW_SECONDS = 60
_CB_COOLDOWN_SECONDS = 30
_cb_lock = threading.Lock()
_cb_failure_count: int = 0
_cb_last_failure_time: float = 0.0
_cb_open_until: float = 0.0
def _circuit_is_open() -> bool:
return time.monotonic() < _cb_open_until
def _record_failure() -> None:
global _cb_failure_count, _cb_last_failure_time, _cb_open_until
now = time.monotonic()
with _cb_lock:
if now - _cb_last_failure_time > _CB_FAILURE_WINDOW_SECONDS:
_cb_failure_count = 0
_cb_failure_count += 1
_cb_last_failure_time = now
if _cb_failure_count >= _CB_FAILURE_THRESHOLD:
_cb_open_until = now + _CB_COOLDOWN_SECONDS
logger.warning(
"Circuit breaker OPENED after %d failures — "
"SearXNG calls paused for %ds",
_cb_failure_count,
_CB_COOLDOWN_SECONDS,
)
def _record_success() -> None:
global _cb_failure_count, _cb_open_until
with _cb_lock:
_cb_failure_count = 0
_cb_open_until = 0.0
# ---------------------------------------------------------------------------
# Result Caching (Redis, graceful degradation)
# ---------------------------------------------------------------------------
_CACHE_TTL_SECONDS = 300 # 5 minutes
_CACHE_PREFIX = "websearch:cache:"
def _cache_key(query: str, engines: str | None, language: str | None) -> str:
raw = f"{query}|{engines or ''}|{language or ''}"
digest = hashlib.sha256(raw.encode()).hexdigest()[:24]
return f"{_CACHE_PREFIX}{digest}"
def _cache_get(key: str) -> dict | None:
try:
data = _get_redis().get(key)
if data:
return json.loads(data)
except (redis.RedisError, json.JSONDecodeError):
pass
return None
def _cache_set(key: str, value: dict) -> None:
with contextlib.suppress(redis.RedisError):
_get_redis().setex(key, _CACHE_TTL_SECONDS, json.dumps(value))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def is_available() -> bool:
"""Return ``True`` when the platform SearXNG host is configured."""
return bool(config.SEARXNG_DEFAULT_HOST)
async def health_check() -> dict[str, Any]:
"""Ping the SearXNG ``/healthz`` endpoint and return status info."""
host = config.SEARXNG_DEFAULT_HOST
if not host:
return {"status": "unavailable", "error": "SEARXNG_DEFAULT_HOST not set"}
healthz_url = urljoin(host if host.endswith("/") else f"{host}/", "healthz")
t0 = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=5.0, verify=False) as client:
resp = await client.get(healthz_url)
resp.raise_for_status()
elapsed_ms = round((time.perf_counter() - t0) * 1000)
return {
"status": "healthy",
"response_time_ms": elapsed_ms,
"circuit_breaker": "open" if _circuit_is_open() else "closed",
}
except Exception as exc:
elapsed_ms = round((time.perf_counter() - t0) * 1000)
return {
"status": "unhealthy",
"error": str(exc),
"response_time_ms": elapsed_ms,
"circuit_breaker": "open" if _circuit_is_open() else "closed",
}
async def search(
query: str,
top_k: int = 20,
*,
engines: str | None = None,
language: str | None = None,
safesearch: int | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Execute a web search against the platform SearXNG instance.
Returns the standard ``(result_object, documents)`` tuple expected by
``ConnectorService.search_searxng``.
"""
host = config.SEARXNG_DEFAULT_HOST
if not host:
return dict(_EMPTY_RESULT), []
if _circuit_is_open():
logger.info("Web search skipped — circuit breaker is open")
result = dict(_EMPTY_RESULT)
result["error"] = "Web search temporarily unavailable (circuit open)"
result["status"] = "degraded"
return result, []
ck = _cache_key(query, engines, language)
cached = _cache_get(ck)
if cached is not None:
logger.debug("Web search cache HIT for query=%r", query[:60])
return cached["result"], cached["documents"]
params: dict[str, Any] = {
"q": query,
"format": "json",
"limit": max(1, min(top_k, 50)),
}
if engines:
params["engines"] = engines
if language:
params["language"] = language
if safesearch is not None and 0 <= safesearch <= 2:
params["safesearch"] = safesearch
searx_endpoint = urljoin(host if host.endswith("/") else f"{host}/", "search")
headers = {"Accept": "application/json"}
data: dict[str, Any] | None = None
last_error: Exception | None = None
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
response = await client.get(
searx_endpoint,
params=params,
headers=headers,
)
response.raise_for_status()
data = response.json()
break
except (httpx.HTTPStatusError, httpx.TimeoutException) as exc:
last_error = exc
if attempt == 0 and (
isinstance(exc, httpx.TimeoutException)
or (
isinstance(exc, httpx.HTTPStatusError)
and exc.response.status_code >= 500
)
):
continue
break
except httpx.HTTPError as exc:
last_error = exc
break
except ValueError as exc:
last_error = exc
break
if data is None:
_record_failure()
logger.warning("Web search failed after retries: %s", last_error)
return dict(_EMPTY_RESULT), []
_record_success()
searx_results = data.get("results", [])
if not searx_results:
return dict(_EMPTY_RESULT), []
sources_list: list[dict[str, Any]] = []
documents: list[dict[str, Any]] = []
for idx, result in enumerate(searx_results):
source_id = 200_000 + idx
description = result.get("content") or result.get("snippet") or ""
sources_list.append(
{
"id": source_id,
"title": result.get("title", "Web Search Result"),
"description": description,
"url": result.get("url", ""),
}
)
documents.append(
{
"chunk_id": source_id,
"content": description or result.get("content", ""),
"score": result.get("score", 0.0),
"document": {
"id": source_id,
"title": result.get("title", "Web Search Result"),
"document_type": "SEARXNG_API",
"metadata": {
"url": result.get("url", ""),
"engines": result.get("engines", []),
"category": result.get("category"),
"source": "SEARXNG_API",
},
},
}
)
result_object: dict[str, Any] = {
"id": 11,
"name": "Web Search",
"type": "SEARXNG_API",
"sources": sources_list,
}
_cache_set(ck, {"result": result_object, "documents": documents})
return result_object, documents