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

@ -1,7 +1,6 @@
"use client";
import { useAuiState } from "@assistant-ui/react";
import { createContext, type FC, type ReactNode, useContext, useMemo } from "react";
import { createContext, type FC, type ReactNode, useContext } from "react";
export interface CitationMeta {
title: string;
@ -10,50 +9,16 @@ export interface CitationMeta {
type CitationMetadataMap = ReadonlyMap<string, CitationMeta>;
const CitationMetadataContext = createContext<CitationMetadataMap>(new Map());
const EMPTY_CITATION_METADATA: CitationMetadataMap = new Map();
interface ToolCallResult {
status?: string;
citations?: Record<string, { title: string; snippet?: string }>;
}
interface MessageContent {
type: string;
toolName?: string;
result?: unknown;
}
const CitationMetadataContext = createContext<CitationMetadataMap>(EMPTY_CITATION_METADATA);
// The previous web-search citation spine (WEB_RESULT hover cards) was removed
// with the multi-engine web_search tool. Agent citation logic is being reworked
// wholesale, so this provider currently yields no web citation metadata.
export const CitationMetadataProvider: FC<{ children: ReactNode }> = ({ children }) => {
const content = useAuiState(
({ message }) => (message as { content?: MessageContent[] })?.content
);
const metadataMap = useMemo<CitationMetadataMap>(() => {
if (!content || !Array.isArray(content)) return new Map();
const merged = new Map<string, CitationMeta>();
for (const part of content) {
if (part.type !== "tool-call" || part.toolName !== "web_search" || !part.result) {
continue;
}
const result = part.result as ToolCallResult;
const citations = result.citations;
if (!citations || typeof citations !== "object") continue;
for (const [url, meta] of Object.entries(citations)) {
if (url.startsWith("http") && meta.title && !merged.has(url)) {
merged.set(url, { title: meta.title, snippet: meta.snippet });
}
}
}
return merged;
}, [content]);
return (
<CitationMetadataContext.Provider value={metadataMap}>
<CitationMetadataContext.Provider value={EMPTY_CITATION_METADATA}>
{children}
</CitationMetadataContext.Provider>
);

View file

@ -23,6 +23,7 @@ interface ConnectorCardProps {
accountCount?: number;
connectorCount?: number;
isIndexing?: boolean;
deprecated?: boolean;
onConnect?: () => void;
onManage?: () => void;
}
@ -52,11 +53,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
accountCount,
connectorCount,
isIndexing = false,
deprecated = false,
onConnect,
onManage,
}) => {
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
// Deprecated connectors can no longer be connected, but existing rows stay
// manageable (so users can disconnect them).
const isDeprecatedForConnect = deprecated && !isConnected;
// Get connector status
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
useConnectorStatus();
@ -73,6 +78,10 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
return null;
}
if (isDeprecatedForConnect) {
return "Deprecated — no longer available to connect.";
}
return description;
};
@ -104,12 +113,19 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
{showWarnings && status.status !== "active" && (
<ConnectorStatusBadge
status={status.status}
statusMessage={statusMessage}
className="flex-shrink-0"
/>
{deprecated ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
Deprecated
</span>
) : (
showWarnings &&
status.status !== "active" && (
<ConnectorStatusBadge
status={status.status}
statusMessage={statusMessage}
className="flex-shrink-0"
/>
)
)}
</div>
{isIndexing ? (
@ -151,18 +167,20 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || !isEnabled}
disabled={isConnecting || !isEnabled || isDeprecatedForConnect}
>
<span className={isConnecting ? "opacity-0" : ""}>
{!isEnabled
? "Unavailable"
: isConnected
? "Manage"
: id === "youtube-crawler"
? "Add"
: connectorType
? "Connect"
: "Add"}
{isDeprecatedForConnect
? "Deprecated"
: !isEnabled
? "Unavailable"
: isConnected
? "Manage"
: id === "youtube-crawler"
? "Add"
: connectorType
? "Connect"
: "Add"}
</span>
{isConnecting && <Spinner size="xs" className="absolute" />}
</Button>

View file

@ -1,5 +1,25 @@
import { EnumConnectorName } from "@/contracts/enums/connector";
/**
* Connectors retired during the MCP migration (no viable official MCP server).
* The catalog card is shown disabled with a "Deprecated" badge so existing
* users understand why; the backend `/add` routes also refuse with HTTP 410.
* Reinstate by removing the type here and in the backend
* `DEPRECATED_CONNECTOR_TYPES` if demand returns.
*/
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.DISCORD_CONNECTOR,
EnumConnectorName.TEAMS_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR,
// Search APIs retired by the Google-only web-search consolidation. Public
// web search now runs through the google_search subagent; Tavily/Linkup can
// still be added via the generic Custom MCP connector (API-key headers).
EnumConnectorName.TAVILY_API,
EnumConnectorName.SEARXNG_API,
EnumConnectorName.LINKUP_API,
EnumConnectorName.BAIDU_SEARCH_API,
]);
/**
* Connectors that operate in real time (no background indexing).
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
@ -17,6 +37,9 @@ export const LIVE_CONNECTOR_TYPES = new Set<string>([
EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
EnumConnectorName.LUMA_CONNECTOR,
// Migrated to hosted MCP: real-time agent tools, no background indexing.
EnumConnectorName.NOTION_CONNECTOR,
EnumConnectorName.CONFLUENCE_CONNECTOR,
]);
// OAuth Connectors (Quick Connect)
@ -55,9 +78,9 @@ export const OAUTH_CONNECTORS = [
{
id: "notion-connector",
title: "Notion",
description: "Search your Notion pages",
description: "Search, read, and create pages",
connectorType: EnumConnectorName.NOTION_CONNECTOR,
authEndpoint: "/api/v1/auth/notion/connector/add",
authEndpoint: "/api/v1/auth/mcp/notion/connector/add/",
},
{
id: "linear-connector",
@ -104,16 +127,16 @@ export const OAUTH_CONNECTORS = [
{
id: "jira-connector",
title: "Jira",
description: "Rework in progress.",
description: "Search, read, and manage issues",
connectorType: EnumConnectorName.JIRA_CONNECTOR,
authEndpoint: "/api/v1/auth/mcp/jira/connector/add/",
},
{
id: "confluence-connector",
title: "Confluence",
description: "Rework in progress.",
description: "Search, read, and create pages",
connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR,
authEndpoint: "/api/v1/auth/confluence/connector/add/",
authEndpoint: "/api/v1/auth/mcp/confluence/connector/add/",
},
{
id: "clickup-connector",

View file

@ -12,6 +12,7 @@ import {
CONNECTOR_CATEGORY_LABELS,
type ConnectorCategory,
CRAWLERS,
DEPRECATED_CONNECTOR_TYPES,
getConnectorCategory,
OAUTH_CONNECTORS,
OTHER_CONNECTORS,
@ -146,6 +147,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount}
accountCount={accountCount}
isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
@ -194,6 +196,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
documentCount={documentCount}
connectorCount={mcpConnectorCount}
isIndexing={isIndexing}
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
onConnect={handleConnect}
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
/>

View file

@ -11,7 +11,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-fetch";
import { getReauthEndpoint } from "@/lib/connector-telemetry";
import { getReauthEndpoint, needsMcpReconnect } from "@/lib/connector-telemetry";
import { buildBackendUrl } from "@/lib/env-config";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
@ -192,6 +192,9 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
const connectorReauthEndpoint = getReauthEndpoint(connector);
const isAuthExpired =
!!connectorReauthEndpoint && connector.config?.auth_expired === true;
// Migrated type still on legacy native config: reconnect via MCP to
// start producing agent tools again.
const needsReconnect = !isAuthExpired && needsMcpReconnect(connector);
const isLive =
LIVE_CONNECTOR_TYPES.has(connector.connector_type) ||
Boolean(connector.config?.server_config);
@ -245,6 +248,19 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
/>
Re-authenticate
</Button>
) : needsReconnect ? (
<Button
size="sm"
className="h-8 text-[11px] px-3 font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={() => handleReauth(connector)}
disabled={reauthingId === connector.id}
title="This connector moved to MCP. Reconnect to use it with the agent."
>
<RefreshCw
className={cn("size-3.5", reauthingId === connector.id && "animate-spin")}
/>
Reconnect via MCP
</Button>
) : isLive && onDisconnect ? (
confirmDisconnectId === connector.id ? (
<div className="flex items-center gap-1.5 shrink-0">

View file

@ -115,9 +115,9 @@ interface UrlCitationProps {
}
/**
* Inline citation for live web search results (URL-based chunk IDs).
* Inline citation for URL-based chunk IDs (e.g. scraped/linked web pages).
* Renders a compact chip with favicon + domain and a hover popover showing the
* page title and snippet (extracted deterministically from web_search tool results).
* page title and snippet when citation metadata is available.
*/
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
const reactId = useId();

View file

@ -14,7 +14,6 @@ import {
ChevronDown,
ChevronRight,
Clipboard,
Globe,
Plus,
Settings2,
SquareIcon,
@ -1057,12 +1056,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
});
}, []);
const hasWebSearchTool = agentTools?.some((t) => t.name === "web_search") ?? false;
const isWebSearchEnabled = hasWebSearchTool && !disabledToolsSet.has("web_search");
const filteredTools = useMemo(
() => agentTools?.filter((t) => t.name !== "web_search"),
[agentTools]
);
const filteredTools = agentTools;
const groupedTools = useMemo(() => {
if (!filteredTools) return [];
const toolsByName = new Map(filteredTools.map((t) => [t.name, t]));
@ -1143,22 +1137,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
<Upload className="size-4" />
Upload Files
</DropdownMenuItem>
{hasWebSearchTool && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
toggleTool("web_search");
}}
>
<Globe className="size-4" />
<span className="flex-1">Web Search</span>
<Switch
checked={isWebSearchEnabled}
tabIndex={-1}
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
<Unplug className="size-4" />
Manage Connectors
@ -1379,26 +1357,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
<Camera className="h-4 w-4" />
Take a screenshot
</DropdownMenuItem>
{hasWebSearchTool && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
toggleTool("web_search");
}}
className={cn(
"hover:bg-accent hover:text-accent-foreground",
isWebSearchEnabled && "text-primary"
)}
>
<Globe className="h-4 w-4" />
<span className="flex-1 min-w-0 truncate">Web Search</span>
<Switch
checked={isWebSearchEnabled}
tabIndex={-1}
className="pointer-events-none shrink-0 origin-right scale-[0.6]"
/>
</DropdownMenuItem>
)}
<DropdownMenuSub
open={toolsPopoverOpen}
onOpenChange={(open) => {

View file

@ -117,7 +117,6 @@ export function FreeChatPage() {
const anonMode = useAnonymousMode();
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
const resetKey = anonMode.isAnonymous ? anonMode.resetKey : 0;
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
const [isRunning, setIsRunning] = useState(false);
@ -173,7 +172,6 @@ export function FreeChatPage() {
model_slug: modelSlug,
messages: messageHistory,
};
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
if (turnstileToken) reqBody.turnstile_token = turnstileToken;
const response = await fetch(buildBackendUrl("/api/v1/public/anon-chat/stream"), {
@ -323,7 +321,7 @@ export function FreeChatPage() {
throw err;
}
},
[modelSlug, tokenUsageStore, webSearchEnabled]
[modelSlug, tokenUsageStore]
);
const onNew = useCallback(

View file

@ -1,13 +1,11 @@
"use client";
import { ComposerPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ArrowUpIcon, Globe, Paperclip, SquareIcon } from "lucide-react";
import { ArrowUpIcon, Paperclip, SquareIcon } from "lucide-react";
import { type FC, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
@ -76,8 +74,6 @@ export const FreeComposer: FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const hasUploadedDoc = anonMode.isAnonymous && anonMode.uploadedDoc !== null;
const webSearchEnabled = anonMode.isAnonymous ? anonMode.webSearchEnabled : true;
const setWebSearchEnabled = anonMode.isAnonymous ? anonMode.setWebSearchEnabled : () => {};
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -208,26 +204,6 @@ export const FreeComposer: FC = () => {
: "Upload a document (text files only)"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-4" />
<Tooltip>
<TooltipTrigger asChild>
<label
htmlFor="free-web-search-toggle"
className="flex cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Globe className="size-3.5" />
<span className="hidden sm:inline">Web</span>
<Switch
id="free-web-search-toggle"
checked={webSearchEnabled}
onCheckedChange={setWebSearchEnabled}
/>
</label>
</TooltipTrigger>
<TooltipContent>Toggle web search</TooltipContent>
</Tooltip>
</div>
<div className="flex min-w-0 items-center gap-1">

View file

@ -1,10 +1,14 @@
---
title: Baidu Search
description: Search the Chinese web with Baidu AI Search in SurfSense
title: Baidu Search (Deprecated)
description: The Baidu Search connector is deprecated; public web search now runs through Google Search
---
# Baidu Search Integration Setup Guide
<Callout type="warn" title="Deprecated">
The Baidu Search connector is **deprecated** and can no longer be connected (the backend refuses new connections with HTTP 410). Public web search in SurfSense now runs through the built-in **Google Search** specialist — see [Web Search](/docs/how-to/web-search). Existing Baidu connector rows remain manageable, and you can add an alternative provider through a Custom MCP connector. The guide below is retained for reference only.
</Callout>
This guide walks you through connecting Baidu AI Search to SurfSense for Chinese web search and AI-powered research.
## How it works

View file

@ -1,189 +1,53 @@
---
title: Web Search
description: How SurfSense web search works and how to configure it for production with residential proxies
description: How SurfSense web search works and how to add Tavily or Linkup via a Custom MCP connector
---
# Web Search
SurfSense uses [SearXNG](https://docs.searxng.org/) as a bundled meta-search engine to provide web search across all search spaces. SearXNG aggregates results from multiple search engines (Google, DuckDuckGo, Brave, Bing, and more) without requiring any API keys.
SurfSense performs public web search through the built-in **Google Search** specialist. When the assistant needs real-time or public information (news, prices, weather, exchange rates, current events, or "who ranks for X"), it delegates the query to the Google Search subagent, which returns ranked results the assistant can cite and hand off for crawling.
You can also add live search connectors such as Baidu Search, Tavily, and Linkup to a search space. When those connectors are active, SurfSense queries them in parallel with SearXNG and merges the results before passing sources to the assistant.
There is nothing to configure — Google web search is available in every workspace out of the box.
<Callout type="info" title="Google-only web search">
SurfSense previously bundled a multi-engine web-search tool backed by SearXNG, Tavily, Linkup, and Baidu. Those have been consolidated: public web search now runs exclusively through the Google Search specialist. The **Tavily**, **Linkup**, **Baidu Search**, and **SearXNG** connectors are deprecated and can no longer be connected.
</Callout>
## How It Works
When a user triggers a web search in SurfSense:
When the assistant decides a request needs the open web:
1. The backend sends a query to the bundled SearXNG instance via its JSON API
2. SearXNG fans out the query to all enabled search engines simultaneously
3. Results are aggregated, deduplicated, and ranked by engine weight
4. If the current search space has live search connectors, the backend queries them in parallel
5. The backend deduplicates the merged results and presents them to the user
1. The main agent delegates the query to the `google_search` specialist.
2. The specialist runs a Google search and returns ranked result items (title, URL, snippet).
3. The assistant summarizes the findings and can follow up by crawling a specific result page.
SearXNG runs as a Docker container alongside the backend. It is never exposed to the internet. Only the backend communicates with it over the internal Docker network.
Google web search is workspace-scoped and is not available in the free / anonymous (no-login) chat, which answers purely from the model's own knowledge.
## Live Search Connectors
## Still want Tavily or Linkup?
Live search connectors are optional API-backed search providers configured per search space. They are useful when you need a specialized index, authenticated search API, or stronger regional coverage.
If you relied on Tavily or Linkup, you can add them back yourself through the generic **Custom MCP connector** (the **MCPs** card in the Connectors dashboard). Both providers expose a hosted MCP server that authenticates with an API key sent as a request header:
| Connector | Best For | Setup |
|-----------|----------|-------|
| Baidu Search | Chinese web search and China-focused current information | [Baidu Search connector](/docs/connectors/baidu-search) |
| Tavily | General web research through Tavily's search API | Add the Tavily connector from the Connectors dashboard |
| Linkup | General web search through Linkup's search API | Add the Linkup connector from the Connectors dashboard |
| Provider | MCP server URL | Auth header |
|----------|----------------|-------------|
| Tavily | `https://mcp.tavily.com/mcp/` | `Authorization: Bearer <your Tavily API key>` |
| Linkup | `https://mcp.linkup.so/mcp` | `Authorization: Bearer <your Linkup API key>` |
<Callout type="info" title="Search Space Scoped">
Live search connectors only run for the search space where they are configured. They do not replace SearXNG globally.
Steps:
1. Open the Connectors dashboard and choose **MCPs** (Custom MCP connector).
2. Enter the provider's MCP server URL from the table above.
3. Add an `Authorization` header with the value `Bearer <your API key>`.
4. Save. The provider's search tools become available to the assistant through the connected-apps specialist.
<Callout type="info" title="No SurfSense API keys needed">
Tavily/Linkup keys live only in your Custom MCP connector configuration. SurfSense no longer ships first-party Tavily, Linkup, Baidu, or SearXNG integrations.
</Callout>
## Docker Setup
## Deprecated connectors
SearXNG is included in both `docker-compose.yml` and `docker-compose.dev.yml` and works out of the box with no configuration needed.
The following connectors are deprecated. Existing rows remain readable/manageable, but new connections are refused (HTTP 410), and their catalog cards show a **Deprecated** badge:
The backend connects to SearXNG automatically via the `SEARXNG_DEFAULT_HOST` environment variable (defaults to `http://searxng:8080`).
### Disabling SearXNG
If you don't need web search, you can skip the SearXNG container entirely:
```bash
docker compose up --scale searxng=0
```
### Using Your Own SearXNG Instance
To point SurfSense at an external SearXNG instance instead of the bundled one, set in your `docker/.env`:
```bash
SEARXNG_DEFAULT_HOST=http://your-searxng:8080
```
## Configuration
SearXNG is configured via `docker/searxng/settings.yml`. The key sections are:
### Engines
SearXNG queries multiple search engines in parallel. Each engine has a **weight** that influences how its results rank in the merged output:
| Engine | Weight | Notes |
|--------|--------|-------|
| Google | 1.2 | Highest priority, best general results |
| DuckDuckGo | 1.1 | Strong privacy-focused alternative |
| Brave | 1.0 | Independent search index |
| Bing | 0.9 | Different index from Google |
| Wikipedia | 0.8 | Encyclopedic results |
| StackOverflow | 0.7 | Technical/programming results |
| Yahoo | 0.7 | Powered by Bing's index |
| Wikidata | 0.6 | Structured data results |
| Currency | default | Currency conversion |
| DDG Definitions | default | Instant answers from DuckDuckGo |
All engines are free. SearXNG scrapes public search pages, no API keys required.
### Engine Suspension
When a search engine returns an error (CAPTCHA, rate limit, access denied), SearXNG suspends it for a configurable duration. After the suspension expires, the engine is automatically retried.
The default suspension times are tuned for use with rotating residential proxies (shorter bans since each retry goes through a different IP):
| Error Type | Suspension | Default (without override) |
|------------|-----------|---------------------------|
| Access Denied (403) | 1 hour | 24 hours |
| CAPTCHA | 1 hour | 24 hours |
| Too Many Requests (429) | 10 minutes | 1 hour |
| Cloudflare CAPTCHA | 2 hours | 15 days |
| Cloudflare Access Denied | 1 hour | 24 hours |
| reCAPTCHA | 2 hours | 7 days |
### Timeouts
| Setting | Value | Description |
|---------|-------|-------------|
| `request_timeout` | 12s | Default timeout per engine request |
| `max_request_timeout` | 20s | Maximum allowed timeout (must be ≥ `request_timeout`) |
| `extra_proxy_timeout` | 10s | Extra seconds added when using a proxy |
| `retries` | 1 | Retries on HTTP error (uses a different proxy IP per retry) |
## Production: Residential Proxies
In production, search engines may rate-limit or block your server's IP. To avoid this, configure a residential proxy so SearXNG's outgoing requests appear to come from rotating residential IPs.
### Step 1: Build the Proxy URL
Use any residential proxy that exposes a standard `http://user:pass@host:port` endpoint. For example, [DataImpulse](https://dataimpulse.com/) encodes country routing as a username suffix (`<token>__cr.<country>`):
```
http://<token>__cr.us:<password>@gw.dataimpulse.com:823
```
The general proxy URL format is:
```
http://<username>:<password>@<hostname>:<port>/
```
### Step 2: Add to SearXNG Settings
In `docker/searxng/settings.yml`, add the proxy URL under `outgoing.proxies`:
```yaml
outgoing:
proxies:
all://:
- http://username:base64password@proxy-host:port/
```
The `all://:` key routes both HTTP and HTTPS requests through the proxy. If you have multiple proxy endpoints, list them and SearXNG will round-robin between them:
```yaml
proxies:
all://:
- http://user:pass@proxy1:port/
- http://user:pass@proxy2:port/
```
### Step 3: Restart SearXNG
```bash
docker compose restart searxng
```
### Verify
Check that SearXNG is healthy:
```bash
curl http://localhost:8888/healthz
```
## Troubleshooting
### SearXNG Fails to Start
**`ValueError: Invalid settings.yml`** - Check the error line above the traceback. Common causes:
- `extra_proxy_timeout` must be an integer (use `10`, not `10.0`)
- `KeyError: 'engine_name'` means an engine was removed but other engines reference its network. Remove all variants (e.g., removing `qwant` also requires removing `qwant news`, `qwant images`, `qwant videos`)
### Engines Getting Suspended
If an engine is suspended (visible in SearXNG logs as `suspended_time=N`), it will automatically recover after the suspension period. With residential proxies, the next request after recovery goes through a different IP and typically succeeds.
### No Web Search Results
1. Check SearXNG health: `curl http://localhost:8888/healthz`
2. Check SearXNG logs: `docker compose logs searxng`
3. Verify the backend can reach SearXNG: the `SEARXNG_DEFAULT_HOST` env var should point to `http://searxng:8080` (Docker) or `http://localhost:8888` (local dev)
### Proxy Not Working
- Verify the base64 password is correctly encoded
- Check that `extra_proxy_timeout` is set (proxies add latency)
- Ensure `max_request_timeout` is high enough to accommodate `request_timeout + extra_proxy_timeout`
## Environment Variables Reference
| Variable | Location | Description | Default |
|----------|----------|-------------|---------|
| `SEARXNG_DEFAULT_HOST` | `docker/.env` | URL of the SearXNG instance | `http://searxng:8080` |
| `SEARXNG_SECRET` | `docker/.env` | Secret key for SearXNG | `surfsense-searxng-secret` |
| `SEARXNG_PORT` | `docker/.env` | Port to expose SearXNG UI on the host | `8888` |
- **Tavily** — add via Custom MCP connector (see above).
- **Linkup** — add via Custom MCP connector (see above).
- **Baidu Search** — no longer bundled; use the built-in Google Search or a Custom MCP connector for a provider of your choice.
- **SearXNG** — the bundled SearXNG container and its `SEARXNG_*` environment variables have been removed from all Docker Compose files.

View file

@ -9,8 +9,6 @@ export interface AnonymousModeContextValue {
setModelSlug: (slug: string) => void;
uploadedDoc: { filename: string; sizeBytes: number } | null;
setUploadedDoc: (doc: { filename: string; sizeBytes: number } | null) => void;
webSearchEnabled: boolean;
setWebSearchEnabled: (enabled: boolean) => void;
resetKey: number;
resetChat: () => void;
}
@ -36,7 +34,6 @@ export function AnonymousModeProvider({
const [uploadedDoc, setUploadedDoc] = useState<{ filename: string; sizeBytes: number } | null>(
null
);
const [webSearchEnabled, setWebSearchEnabled] = useState(true);
const [resetKey, setResetKey] = useState(0);
const resetChat = () => setResetKey((k) => k + 1);
@ -59,12 +56,10 @@ export function AnonymousModeProvider({
setModelSlug,
uploadedDoc,
setUploadedDoc,
webSearchEnabled,
setWebSearchEnabled,
resetKey,
resetChat,
}),
[modelSlug, uploadedDoc, webSearchEnabled, resetKey]
[modelSlug, uploadedDoc, resetKey]
);
return <AnonymousModeContext.Provider value={value}>{children}</AnonymousModeContext.Provider>;

View file

@ -11,7 +11,6 @@ import {
FolderPlus,
FolderTree,
FolderX,
Globe,
ImageIcon,
ListTodo,
type LucideIcon,
@ -45,7 +44,6 @@ const TOOL_ICONS: Record<string, LucideIcon> = {
display_image: ImageIcon,
// Web / search
scrape_webpage: ScanLine,
web_search: Globe,
// Automations
create_automation: AlarmClock,
// Memory
@ -149,7 +147,6 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
display_image: "Show image",
// Web / search
scrape_webpage: "Read webpage",
web_search: "Search the web",
// Automations
create_automation: "Create automation",
// Memory

View file

@ -4,6 +4,15 @@ function asNonEmptyString(v: unknown): string | undefined {
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
}
/**
* Explicit display labels for subagents whose title-cased id reads poorly.
* Legacy per-connector names (gmail, slack, ...) intentionally fall through
* to {@link titleCaseSubagent} so historical conversations keep their labels.
*/
const SUBAGENT_DISPLAY_LABELS: Record<string, string> = {
mcp_discovery: "Connected Apps",
};
/**
* Title-case a subagent identifier:
* "notion" "Notion"
@ -27,7 +36,8 @@ export function titleCaseSubagent(raw: string): string {
export function resolveSubagentTitle(item: ToolCallItem): string | undefined {
if (item.toolName !== "task") return undefined;
const subagent = asNonEmptyString(item.args?.subagent_type);
return subagent ? titleCaseSubagent(subagent) : undefined;
if (!subagent) return undefined;
return SUBAGENT_DISPLAY_LABELS[subagent] ?? titleCaseSubagent(subagent);
}
/**

View file

@ -165,10 +165,9 @@ const DeleteConfluencePageToolUI = dynamic(
* - **Structural primitives** (``task``): the row IS the parent of a
* delegation span; its job is to label the group. Children render
* as their own indented entries.
* - **Suppressed connectors** (``web_search``, ``link_preview``,
* ``multi_link_preview``, ``scrape_webpage``): citations they
* produce render inline in markdown; a separate card would be
* redundant noise.
* - **Suppressed connectors** (``link_preview``, ``multi_link_preview``,
* ``scrape_webpage``): citations they produce render inline in
* markdown; a separate card would be redundant noise.
*/
const NullTimelineBody: TimelineToolComponent = () => null;
@ -218,7 +217,6 @@ const TOOLS_BY_NAME = {
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
web_search: NullTimelineBody,
link_preview: NullTimelineBody,
multi_link_preview: NullTimelineBody,
scrape_webpage: NullTimelineBody,

View file

@ -112,17 +112,49 @@ const LEGACY_REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.DISCORD_CONNECTOR]: "/api/v1/auth/discord/connector/reauth",
};
/**
* Connector types migrated to hosted MCP, mapped to their MCP service key.
* A legacy native row of one of these types (no ``config.server_config``)
* re-authenticates through the MCP flow so the reconnect writes an MCP
* ``server_config`` and the row starts producing agent tools again.
*
* Gmail/Calendar/Drive/Dropbox/OneDrive are intentionally absent: they stay
* on their native OAuth (Google Workspace MCP is not GA yet; file connectors
* remain native for knowledge-base enrichment).
*/
const MIGRATED_TYPE_TO_MCP_SERVICE: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "linear",
[EnumConnectorName.JIRA_CONNECTOR]: "jira",
[EnumConnectorName.NOTION_CONNECTOR]: "notion",
[EnumConnectorName.CONFLUENCE_CONNECTOR]: "confluence",
};
/**
* True when a row is a migrated connector type still on its legacy native
* config (no MCP ``server_config``). Such rows appear "connected" but produce
* no agent tools until reconnected via MCP the UI surfaces a nudge.
*/
export function needsMcpReconnect(connector: SearchSourceConnector): boolean {
if (!(connector.connector_type in MIGRATED_TYPE_TO_MCP_SERVICE)) return false;
return !connector.config?.server_config;
}
/**
* Resolve the reauth endpoint for a connector.
*
* MCP OAuth connectors (those with ``config.mcp_service``) dynamically build
* the URL from the service key. Legacy OAuth connectors fall back to the
* static ``LEGACY_REAUTH_ENDPOINTS`` map.
* the URL from the service key. Migrated native rows are routed to the MCP
* reauth flow so reconnecting converts them in place. Everything else falls
* back to the static ``LEGACY_REAUTH_ENDPOINTS`` map.
*/
export function getReauthEndpoint(connector: SearchSourceConnector): string | undefined {
const mcpService = connector.config?.mcp_service as string | undefined;
if (mcpService) {
return `/api/v1/auth/mcp/${mcpService}/connector/reauth`;
}
const migratedService = MIGRATED_TYPE_TO_MCP_SERVICE[connector.connector_type];
if (migratedService) {
return `/api/v1/auth/mcp/${migratedService}/connector/reauth`;
}
return LEGACY_REAUTH_ENDPOINTS[connector.connector_type];
}

View file

@ -200,14 +200,12 @@ export function trackAnonymousChatMessageSent(options: {
modelSlug: string;
messageLength?: number;
hasUploadedDoc?: boolean;
webSearchEnabled?: boolean;
surface?: "free_chat_page" | "free_model_page";
}) {
safeCapture("anonymous_chat_message_sent", {
model_slug: options.modelSlug,
message_length: options.messageLength,
has_uploaded_doc: options.hasUploadedDoc ?? false,
web_search_enabled: options.webSearchEnabled,
surface: options.surface,
});
}

View file

@ -336,7 +336,6 @@
"add_connector": {
"title": "Connect Your Tools",
"subtitle": "Integrate with your favorite services to enhance your research capabilities.",
"web_search": "Web Search",
"messaging": "Messaging",
"project_management": "Project Management",
"documentation": "Documentation",
@ -759,27 +758,7 @@
"general_reset": "Reset Changes",
"general_save": "Save Changes",
"general_saving": "Saving",
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them.",
"nav_web_search": "Web Search",
"nav_web_search_desc": "Built-in web search settings",
"web_search_title": "Web Search",
"web_search_description": "Web search is powered by a built-in SearXNG instance. All queries are proxied through your server — no data is sent to third parties.",
"web_search_enabled_label": "Enable Web Search",
"web_search_enabled_description": "When enabled, the AI agent can search the web for real-time information like news, prices, and current events.",
"web_search_status_healthy": "Web search service is healthy",
"web_search_status_unhealthy": "Web search service is unavailable",
"web_search_status_not_configured": "Web search service is not configured",
"web_search_engines_label": "Search Engines",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "Comma-separated list of SearXNG engines to use. Leave empty for defaults.",
"web_search_language_label": "Preferred Language",
"web_search_language_placeholder": "en",
"web_search_language_description": "IETF language tag (e.g. en, en-US). Leave empty for auto-detect.",
"web_search_safesearch_label": "SafeSearch Level",
"web_search_safesearch_description": "0 = off, 1 = moderate, 2 = strict",
"web_search_save": "Save Web Search Settings",
"web_search_saving": "Saving...",
"web_search_saved": "Web search settings saved"
"general_unsaved_changes": "You have unsaved changes. Click \"Save Changes\" to apply them."
},
"homepage": {
"hero_title_part1": "The AI Workspace",

View file

@ -336,7 +336,6 @@
"add_connector": {
"title": "Conecta tus herramientas",
"subtitle": "Integra con tus servicios favoritos para mejorar tus capacidades de investigación.",
"web_search": "Búsqueda web",
"messaging": "Mensajería",
"project_management": "Gestión de proyectos",
"documentation": "Documentación",
@ -759,27 +758,7 @@
"general_reset": "Restablecer cambios",
"general_save": "Guardar cambios",
"general_saving": "Guardando",
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos.",
"nav_web_search": "Búsqueda web",
"nav_web_search_desc": "Configuración de búsqueda web integrada",
"web_search_title": "Búsqueda web",
"web_search_description": "La búsqueda web funciona con una instancia SearXNG integrada. Todas las consultas se procesan a través de tu servidor; no se envían datos a terceros.",
"web_search_enabled_label": "Activar búsqueda web",
"web_search_enabled_description": "Cuando está activada, el agente de IA puede buscar en la web información en tiempo real como noticias, precios y eventos actuales.",
"web_search_status_healthy": "El servicio de búsqueda web está funcionando",
"web_search_status_unhealthy": "El servicio de búsqueda web no está disponible",
"web_search_status_not_configured": "El servicio de búsqueda web no está configurado",
"web_search_engines_label": "Motores de búsqueda",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "Lista separada por comas de motores SearXNG a usar. Déjalo vacío para usar los valores predeterminados.",
"web_search_language_label": "Idioma preferido",
"web_search_language_placeholder": "es",
"web_search_language_description": "Etiqueta de idioma IETF (por ejemplo, es, es-ES). Déjalo vacío para detección automática.",
"web_search_safesearch_label": "Nivel de SafeSearch",
"web_search_safesearch_description": "0 = desactivado, 1 = moderado, 2 = estricto",
"web_search_save": "Guardar configuración de búsqueda web",
"web_search_saving": "Guardando...",
"web_search_saved": "Configuración de búsqueda web guardada"
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos."
},
"homepage": {
"hero_title_part1": "El espacio de trabajo con IA",

View file

@ -336,7 +336,6 @@
"add_connector": {
"title": "अपने टूल कनेक्ट करें",
"subtitle": "अपनी शोध क्षमताओं को बढ़ाने के लिए अपनी पसंदीदा सेवाओं के साथ एकीकृत करें।",
"web_search": "वेब खोज",
"messaging": "मैसेजिंग",
"project_management": "प्रोजेक्ट प्रबंधन",
"documentation": "दस्तावेज़ीकरण",
@ -759,27 +758,7 @@
"general_reset": "परिवर्तन रीसेट करें",
"general_save": "परिवर्तन सहेजें",
"general_saving": "सहेजा जा रहा है",
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।",
"nav_web_search": "वेब खोज",
"nav_web_search_desc": "बिल्ट-इन वेब खोज सेटिंग्स",
"web_search_title": "वेब खोज",
"web_search_description": "वेब खोज एक बिल्ट-इन SearXNG इंस्टेंस द्वारा संचालित है। सभी क्वेरी आपके सर्वर के माध्यम से प्रॉक्सी की जाती हैं; कोई डेटा तृतीय पक्षों को नहीं भेजा जाता।",
"web_search_enabled_label": "वेब खोज सक्षम करें",
"web_search_enabled_description": "सक्षम होने पर, AI एजेंट समाचार, कीमतों और वर्तमान घटनाओं जैसी वास्तविक समय की जानकारी के लिए वेब खोज सकता है।",
"web_search_status_healthy": "वेब खोज सेवा स्वस्थ है",
"web_search_status_unhealthy": "वेब खोज सेवा उपलब्ध नहीं है",
"web_search_status_not_configured": "वेब खोज सेवा कॉन्फ़िगर नहीं है",
"web_search_engines_label": "खोज इंजन",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "उपयोग करने के लिए SearXNG इंजनों की कॉमा-सेपरेटेड सूची। डिफ़ॉल्ट के लिए खाली छोड़ें।",
"web_search_language_label": "पसंदीदा भाषा",
"web_search_language_placeholder": "hi",
"web_search_language_description": "IETF भाषा टैग (जैसे hi, hi-IN)। ऑटो-डिटेक्ट के लिए खाली छोड़ें।",
"web_search_safesearch_label": "SafeSearch स्तर",
"web_search_safesearch_description": "0 = बंद, 1 = मध्यम, 2 = सख्त",
"web_search_save": "वेब खोज सेटिंग्स सहेजें",
"web_search_saving": "सहेजा जा रहा है...",
"web_search_saved": "वेब खोज सेटिंग्स सहेजी गईं"
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।"
},
"homepage": {
"hero_title_part1": "AI कार्यक्षेत्र",

View file

@ -336,7 +336,6 @@
"add_connector": {
"title": "Conecte suas ferramentas",
"subtitle": "Integre com seus serviços favoritos para melhorar suas capacidades de pesquisa.",
"web_search": "Pesquisa web",
"messaging": "Mensagens",
"project_management": "Gerenciamento de projetos",
"documentation": "Documentação",
@ -759,27 +758,7 @@
"general_reset": "Redefinir alterações",
"general_save": "Salvar alterações",
"general_saving": "Salvando",
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las.",
"nav_web_search": "Pesquisa na web",
"nav_web_search_desc": "Configurações integradas de pesquisa na web",
"web_search_title": "Pesquisa na web",
"web_search_description": "A pesquisa na web é alimentada por uma instância SearXNG integrada. Todas as consultas passam pelo seu servidor; nenhum dado é enviado a terceiros.",
"web_search_enabled_label": "Ativar pesquisa na web",
"web_search_enabled_description": "Quando ativado, o agente de IA pode pesquisar na web informações em tempo real, como notícias, preços e eventos atuais.",
"web_search_status_healthy": "O serviço de pesquisa na web está saudável",
"web_search_status_unhealthy": "O serviço de pesquisa na web está indisponível",
"web_search_status_not_configured": "O serviço de pesquisa na web não está configurado",
"web_search_engines_label": "Mecanismos de pesquisa",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "Lista separada por vírgulas de mecanismos SearXNG a usar. Deixe vazio para os padrões.",
"web_search_language_label": "Idioma preferido",
"web_search_language_placeholder": "pt",
"web_search_language_description": "Tag de idioma IETF (por exemplo, pt, pt-BR). Deixe vazio para detecção automática.",
"web_search_safesearch_label": "Nível de SafeSearch",
"web_search_safesearch_description": "0 = desativado, 1 = moderado, 2 = rigoroso",
"web_search_save": "Salvar configurações de pesquisa na web",
"web_search_saving": "Salvando...",
"web_search_saved": "Configurações de pesquisa na web salvas"
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las."
},
"homepage": {
"hero_title_part1": "O espaço de trabalho com IA",

View file

@ -335,7 +335,6 @@
"add_connector": {
"title": "连接您的工具",
"subtitle": "集成您喜欢的服务以增强研究能力。",
"web_search": "网络搜索",
"messaging": "即时通讯",
"project_management": "项目管理",
"documentation": "文档协作",
@ -758,27 +757,7 @@
"general_reset": "重置更改",
"general_save": "保存更改",
"general_saving": "保存中...",
"general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。",
"nav_web_search": "网页搜索",
"nav_web_search_desc": "内置网页搜索设置",
"web_search_title": "网页搜索",
"web_search_description": "网页搜索由内置 SearXNG 实例提供支持。所有查询都通过您的服务器代理;不会向第三方发送数据。",
"web_search_enabled_label": "启用网页搜索",
"web_search_enabled_description": "启用后AI 代理可以搜索网页以获取新闻、价格和当前事件等实时信息。",
"web_search_status_healthy": "网页搜索服务运行正常",
"web_search_status_unhealthy": "网页搜索服务不可用",
"web_search_status_not_configured": "网页搜索服务未配置",
"web_search_engines_label": "搜索引擎",
"web_search_engines_placeholder": "google,brave,duckduckgo",
"web_search_engines_description": "要使用的 SearXNG 引擎的逗号分隔列表。留空则使用默认值。",
"web_search_language_label": "首选语言",
"web_search_language_placeholder": "zh",
"web_search_language_description": "IETF 语言标签(例如 zh、zh-CN。留空则自动检测。",
"web_search_safesearch_label": "SafeSearch 级别",
"web_search_safesearch_description": "0 = 关闭1 = 中等2 = 严格",
"web_search_save": "保存网页搜索设置",
"web_search_saving": "保存中...",
"web_search_saved": "网页搜索设置已保存"
"general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。"
},
"homepage": {
"hero_title_part1": "AI 工作空间",

View file

@ -1,21 +1,20 @@
import { expect, confluenceWithChatTest as test } from "../../fixtures";
import { streamChatToCompletion } from "../../helpers/api/chat";
import { listConnectors, triggerIndex } from "../../helpers/api/connectors";
import { getEditorContent, listDocuments } from "../../helpers/api/documents";
import { listConnectors, triggerIndexExpectDisabled } from "../../helpers/api/connectors";
import { listDocuments } from "../../helpers/api/documents";
import { CANARY_TOKENS, FAKE_CONFLUENCE_PAGES } from "../../helpers/canary";
import { openConnectorPopup } from "../../helpers/ui/connector-popup";
import { waitForDocumentByTitle, waitForIndexingComplete } from "../../helpers/waits/indexing";
/**
* Proves Confluence OAuth -> indexed Confluence pages -> stored source_markdown -> chat.
* Proves Confluence MCP OAuth -> live MCP tool discovery/call -> chat.
*
* The external Atlassian provider is faked at the OAuth token/resource and
* Confluence page-fetch boundaries; SurfSense's own add/callback routes,
* encrypted config storage, indexing endpoint, indexing pipeline, and chat
* retrieval remain real.
* Confluence migrated to the hosted Atlassian Rovo MCP server (shared with
* Jira), so it is live-tool only: the public indexing route returns
* indexing_started=false and chat should call Confluence MCP tools
* (`searchConfluenceUsingCql`) instead of retrieving from the KB.
*/
test.describe("Confluence connector journey", () => {
test("user connects Confluence through OAuth, indexes a page, and chats with the canary token", async ({
test("user connects Confluence and chats through live MCP tools with indexing disabled", async ({
page,
request,
apiToken,
@ -23,17 +22,27 @@ test.describe("Confluence connector journey", () => {
confluenceConnector,
chatThread,
}) => {
test.setTimeout(180_000); // worker cold-start + summarize + embed + chunk
test.setTimeout(90_000); // worker cold-start + live tool chat
expect(confluenceConnector.connector_type).toBe("CONFLUENCE_CONNECTOR");
expect(confluenceConnector.is_indexable).toBe(true);
expect(confluenceConnector.is_indexable).toBe(false);
expect(confluenceConnector.config._token_encrypted).toBe(true);
expect(confluenceConnector.config.cloud_id).toBe(FAKE_CONFLUENCE_PAGES.canary.cloudId);
expect(confluenceConnector.config.base_url).toBe(FAKE_CONFLUENCE_PAGES.canary.baseUrl);
expect(confluenceConnector.config.access_token).toBeTruthy();
expect(confluenceConnector.config.CONFLUENCE_BASE_URL).toBeUndefined();
expect(confluenceConnector.config.CONFLUENCE_EMAIL).toBeUndefined();
expect(confluenceConnector.config.CONFLUENCE_API_TOKEN).toBeUndefined();
expect(confluenceConnector.config.mcp_service).toBe("confluence");
expect(confluenceConnector.config.server_config).toMatchObject({
transport: "streamable-http",
url: "https://mcp.atlassian.com/v1/mcp",
});
// Shared Atlassian server issues the token via the same cf.mcp endpoint
// as Jira; assert the endpoint + a live token rather than the (Jira-named)
// shared client id.
expect(confluenceConnector.config.mcp_oauth).toMatchObject({
token_endpoint: "https://cf.mcp.atlassian.com/v1/token",
});
expect(
(confluenceConnector.config.mcp_oauth as Record<string, unknown>).access_token
).toBeTruthy();
expect(confluenceConnector.config.access_token).toBeUndefined();
expect(confluenceConnector.config.refresh_token).toBeUndefined();
await page.goto(`/dashboard/${searchSpace.id}/new-chat`, {
waitUntil: "domcontentloaded",
@ -44,45 +53,17 @@ test.describe("Confluence connector journey", () => {
await connectorDialog.getByPlaceholder("Search").fill("Confluence");
await expect(connectorDialog.getByText("Confluence", { exact: true })).toBeVisible();
await triggerIndex(request, apiToken, confluenceConnector.id, searchSpace.id, {});
const beforeDocs = await listDocuments(request, apiToken, searchSpace.id);
expect(beforeDocs).toHaveLength(0);
await waitForIndexingComplete(request, apiToken, confluenceConnector.id, searchSpace.id, {
timeoutMs: 150_000,
intervalMs: 1_500,
minDocuments: 1,
});
await waitForDocumentByTitle(
const disabledIndex = await triggerIndexExpectDisabled(
request,
apiToken,
searchSpace.id,
FAKE_CONFLUENCE_PAGES.canary.title,
{
timeoutMs: 30_000,
}
confluenceConnector.id,
searchSpace.id
);
const docs = await listDocuments(request, apiToken, searchSpace.id);
const canaryDoc = docs.find((d) => d.title === FAKE_CONFLUENCE_PAGES.canary.title);
expect(canaryDoc, "Confluence canary document must exist after indexing").toBeDefined();
if (!canaryDoc) throw new Error("unreachable: canaryDoc asserted defined above");
expect(canaryDoc.document_type).toBe("CONFLUENCE_CONNECTOR");
const editor = await getEditorContent(request, apiToken, searchSpace.id, canaryDoc.id);
expect(
editor.source_markdown,
`canary token ${CANARY_TOKENS.confluenceCanary} should appear in editor source_markdown; ` +
`got first 200 chars: ${editor.source_markdown.slice(0, 200)}`
).toContain(CANARY_TOKENS.confluenceCanary);
expect(editor.document_type).toBe("CONFLUENCE_CONNECTOR");
expect(editor.chunk_count).toBeGreaterThan(0);
const refreshedConnectors = await listConnectors(request, apiToken, searchSpace.id);
const refreshed = refreshedConnectors.find((c) => c.id === confluenceConnector.id);
expect(refreshed?.connector_type).toBe("CONFLUENCE_CONNECTOR");
expect(refreshed?.is_indexable).toBe(true);
expect(refreshed?.last_indexed_at).not.toBeNull();
expect(disabledIndex.message ?? "").toContain("real-time agent tools");
expect(disabledIndex.message ?? "").toContain("background indexing is disabled");
const chat = await streamChatToCompletion(request, apiToken, {
searchSpaceId: searchSpace.id,
@ -91,7 +72,19 @@ test.describe("Confluence connector journey", () => {
});
expect(
chat.assistantText,
`chat agent should surface Confluence canary token after indexing; got: ${chat.assistantText.slice(0, 200)}`
`chat agent should surface Confluence canary token from live MCP tools; got: ${chat.assistantText.slice(0, 200)}`
).toContain(CANARY_TOKENS.confluenceCanary);
const eventText = JSON.stringify(chat.events);
expect(eventText).toContain("searchConfluenceUsingCql");
const refreshedConnectors = await listConnectors(request, apiToken, searchSpace.id);
const refreshed = refreshedConnectors.find((c) => c.id === confluenceConnector.id);
expect(refreshed?.connector_type).toBe("CONFLUENCE_CONNECTOR");
expect(refreshed?.is_indexable).toBe(false);
expect(refreshed?.last_indexed_at).toBeNull();
const afterDocs = await listDocuments(request, apiToken, searchSpace.id);
expect(afterDocs).toHaveLength(0);
});
});

View file

@ -1,20 +1,19 @@
import { expect, notionWithChatTest as test } from "../../fixtures";
import { streamChatToCompletion } from "../../helpers/api/chat";
import { listConnectors, triggerIndex } from "../../helpers/api/connectors";
import { getEditorContent, listDocuments } from "../../helpers/api/documents";
import { listConnectors, triggerIndexExpectDisabled } from "../../helpers/api/connectors";
import { listDocuments } from "../../helpers/api/documents";
import { CANARY_TOKENS, FAKE_NOTION_PAGES } from "../../helpers/canary";
import { openConnectorPopup } from "../../helpers/ui/connector-popup";
import { waitForDocumentByTitle, waitForIndexingComplete } from "../../helpers/waits/indexing";
/**
* Proves Notion OAuth -> indexed Notion API pages -> stored source_markdown -> chat.
* Proves Notion MCP OAuth -> live MCP tool discovery/call -> chat.
*
* The external Notion provider is faked at the OAuth token/API boundary;
* SurfSense's own add/callback routes, encrypted config storage, connector
* indexing endpoint, indexing pipeline, and chat retrieval remain real.
* Notion migrated from indexed OAuth to the hosted Notion MCP server, so it is
* live-tool only: the public indexing route returns indexing_started=false and
* chat should call Notion MCP tools (`search`) instead of retrieving from the KB.
*/
test.describe("Notion connector journey", () => {
test("user connects Notion through OAuth, indexes a page, and chats with the canary token", async ({
test("user connects Notion and chats through live MCP tools with indexing disabled", async ({
page,
request,
apiToken,
@ -22,16 +21,25 @@ test.describe("Notion connector journey", () => {
notionConnector,
chatThread,
}) => {
test.setTimeout(180_000); // worker cold-start + summarize + embed + chunk
test.setTimeout(90_000); // worker cold-start + live tool chat
expect(notionConnector.connector_type).toBe("NOTION_CONNECTOR");
expect(notionConnector.is_indexable).toBe(true);
expect(notionConnector.is_indexable).toBe(false);
expect(notionConnector.config._token_encrypted).toBe(true);
expect(notionConnector.config.NOTION_INTEGRATION_TOKEN).toBeUndefined();
expect(notionConnector.config.access_token).toBeTruthy();
expect(notionConnector.config.workspace_id).toBe(FAKE_NOTION_PAGES.canary.workspaceId);
expect(notionConnector.config.workspace_name).toBe(FAKE_NOTION_PAGES.canary.workspaceName);
expect(notionConnector.config.bot_id).toBe(FAKE_NOTION_PAGES.canary.botId);
expect(notionConnector.config.mcp_service).toBe("notion");
expect(notionConnector.config.server_config).toMatchObject({
transport: "streamable-http",
url: "https://mcp.notion.com/mcp",
});
expect(notionConnector.config.mcp_oauth).toMatchObject({
client_id: "fake-notion-mcp-client-id",
token_endpoint: "https://mcp.notion.com/token",
});
expect(
(notionConnector.config.mcp_oauth as Record<string, unknown>).access_token
).toBeTruthy();
expect(notionConnector.config.access_token).toBeUndefined();
expect(notionConnector.config.refresh_token).toBeUndefined();
await page.goto(`/dashboard/${searchSpace.id}/new-chat`, {
waitUntil: "domcontentloaded",
@ -39,46 +47,20 @@ test.describe("Notion connector journey", () => {
await openConnectorPopup(page);
const connectorDialog = page.getByRole("dialog", { name: "Manage Connectors" });
await expect(connectorDialog).toBeVisible();
await connectorDialog.getByPlaceholder("Search").fill("Notion");
await expect(connectorDialog.getByText("Notion", { exact: true })).toBeVisible();
await triggerIndex(request, apiToken, notionConnector.id, searchSpace.id, {});
const beforeDocs = await listDocuments(request, apiToken, searchSpace.id);
expect(beforeDocs).toHaveLength(0);
await waitForIndexingComplete(request, apiToken, notionConnector.id, searchSpace.id, {
timeoutMs: 150_000,
intervalMs: 1_500,
minDocuments: 1,
});
await waitForDocumentByTitle(
const disabledIndex = await triggerIndexExpectDisabled(
request,
apiToken,
searchSpace.id,
FAKE_NOTION_PAGES.canary.title,
{
timeoutMs: 30_000,
}
notionConnector.id,
searchSpace.id
);
const docs = await listDocuments(request, apiToken, searchSpace.id);
const canaryDoc = docs.find((d) => d.title === FAKE_NOTION_PAGES.canary.title);
expect(canaryDoc, "Notion canary document must exist after indexing").toBeDefined();
if (!canaryDoc) throw new Error("unreachable: canaryDoc asserted defined above");
expect(canaryDoc.document_type).toBe("NOTION_CONNECTOR");
const editor = await getEditorContent(request, apiToken, searchSpace.id, canaryDoc.id);
expect(
editor.source_markdown,
`canary token ${CANARY_TOKENS.notionCanary} should appear in editor source_markdown; ` +
`got first 200 chars: ${editor.source_markdown.slice(0, 200)}`
).toContain(CANARY_TOKENS.notionCanary);
expect(editor.document_type).toBe("NOTION_CONNECTOR");
expect(editor.chunk_count).toBeGreaterThan(0);
const refreshedConnectors = await listConnectors(request, apiToken, searchSpace.id);
const refreshed = refreshedConnectors.find((c) => c.id === notionConnector.id);
expect(refreshed?.connector_type).toBe("NOTION_CONNECTOR");
expect(refreshed?.is_indexable).toBe(true);
expect(refreshed?.last_indexed_at).not.toBeNull();
expect(disabledIndex.message ?? "").toContain("real-time agent tools");
expect(disabledIndex.message ?? "").toContain("background indexing is disabled");
const chat = await streamChatToCompletion(request, apiToken, {
searchSpaceId: searchSpace.id,
@ -87,7 +69,19 @@ test.describe("Notion connector journey", () => {
});
expect(
chat.assistantText,
`chat agent should surface Notion canary token after indexing; got: ${chat.assistantText.slice(0, 200)}`
`chat agent should surface Notion canary token from live MCP tools; got: ${chat.assistantText.slice(0, 200)}`
).toContain(CANARY_TOKENS.notionCanary);
const eventText = JSON.stringify(chat.events);
expect(eventText).toContain(FAKE_NOTION_PAGES.canary.id);
const refreshedConnectors = await listConnectors(request, apiToken, searchSpace.id);
const refreshed = refreshedConnectors.find((c) => c.id === notionConnector.id);
expect(refreshed?.connector_type).toBe("NOTION_CONNECTOR");
expect(refreshed?.is_indexable).toBe(false);
expect(refreshed?.last_indexed_at).toBeNull();
const afterDocs = await listDocuments(request, apiToken, searchSpace.id);
expect(afterDocs).toHaveLength(0);
});
});

View file

@ -454,12 +454,12 @@ export async function runNativeGoogleCalendarOAuth(
}
/**
* Drives the Notion OAuth flow programmatically.
* Drives the Notion MCP OAuth flow programmatically.
*
* The E2E backend keeps SurfSense's OAuth add/callback routes real and
* patches only Notion's external token endpoint. Notion's authorization
* URL stays off-origin, so this helper extracts the signed state and calls
* the backend callback directly with the deterministic fake code.
* Notion migrated from indexed OAuth to the hosted Notion MCP server, so this
* uses SurfSense's generic MCP OAuth routes. The E2E backend keeps those routes
* real and patches Notion's external discovery/DCR/token/MCP tool boundaries
* (see surfsense_backend/tests/e2e/fakes/notion_mcp_module.py).
*/
export async function runNotionOAuth(
request: APIRequestContext,
@ -471,26 +471,26 @@ export async function runNotionOAuth(
connector: ConnectorRow | null;
}> {
const initiateResp = await request.get(
`${BACKEND_URL}/api/v1/auth/notion/connector/add?space_id=${searchSpaceId}`,
`${BACKEND_URL}/api/v1/auth/mcp/notion/connector/add?space_id=${searchSpaceId}`,
{ headers: authHeaders(token) }
);
if (!initiateResp.ok()) {
throw new Error(
`Notion initiate failed (${initiateResp.status()}): ${await initiateResp.text()}`
`Notion MCP initiate failed (${initiateResp.status()}): ${await initiateResp.text()}`
);
}
const { auth_url } = (await initiateResp.json()) as { auth_url: string };
if (!auth_url) {
throw new Error("Notion initiate response missing auth_url");
throw new Error("Notion MCP initiate response missing auth_url");
}
const state = new URL(auth_url).searchParams.get("state");
if (!state) {
throw new Error(`Notion auth_url missing state: ${auth_url}`);
throw new Error(`Notion MCP auth_url missing state: ${auth_url}`);
}
const callbackResp = await request.get(
`${BACKEND_URL}/api/v1/auth/notion/connector/callback?code=fake-notion-oauth-code&state=${encodeURIComponent(state)}`,
`${BACKEND_URL}/api/v1/auth/mcp/notion/connector/callback?code=fake-notion-oauth-code&state=${encodeURIComponent(state)}`,
{
headers: authHeaders(token),
maxRedirects: 0,
@ -506,10 +506,14 @@ export async function runNotionOAuth(
}
/**
* Drives the Confluence OAuth flow programmatically.
* Drives the Confluence MCP OAuth flow programmatically.
*
* The E2E backend keeps SurfSense's OAuth add/callback routes real and
* patches only Atlassian's external token/resource endpoints.
* Confluence migrated to the hosted Atlassian Rovo MCP server (shared with
* Jira), so this uses SurfSense's generic MCP OAuth routes. The E2E backend
* keeps those routes real and patches the shared Atlassian
* discovery/DCR/token/MCP tool boundaries (see
* surfsense_backend/tests/e2e/fakes/jira_module.py). The OAuth code is the
* shared Atlassian one because both connectors hit the same token endpoint.
*/
export async function runConfluenceOAuth(
request: APIRequestContext,
@ -521,26 +525,26 @@ export async function runConfluenceOAuth(
connector: ConnectorRow | null;
}> {
const initiateResp = await request.get(
`${BACKEND_URL}/api/v1/auth/confluence/connector/add?space_id=${searchSpaceId}`,
`${BACKEND_URL}/api/v1/auth/mcp/confluence/connector/add?space_id=${searchSpaceId}`,
{ headers: authHeaders(token) }
);
if (!initiateResp.ok()) {
throw new Error(
`Confluence initiate failed (${initiateResp.status()}): ${await initiateResp.text()}`
`Confluence MCP initiate failed (${initiateResp.status()}): ${await initiateResp.text()}`
);
}
const { auth_url } = (await initiateResp.json()) as { auth_url: string };
if (!auth_url) {
throw new Error("Confluence initiate response missing auth_url");
throw new Error("Confluence MCP initiate response missing auth_url");
}
const state = new URL(auth_url).searchParams.get("state");
if (!state) {
throw new Error(`Confluence auth_url missing state: ${auth_url}`);
throw new Error(`Confluence MCP auth_url missing state: ${auth_url}`);
}
const callbackResp = await request.get(
`${BACKEND_URL}/api/v1/auth/confluence/connector/callback?code=fake-confluence-oauth-code&state=${encodeURIComponent(state)}`,
`${BACKEND_URL}/api/v1/auth/mcp/confluence/connector/callback?code=fake-jira-oauth-code&state=${encodeURIComponent(state)}`,
{
headers: authHeaders(token),
maxRedirects: 0,